feat(app): Edit Google Drive files at /gdrive through a Pages Functions sign-in relay - #453
Merged
Merged
Conversation
…m views Split the schema sidebar into fragments that read no atom, so a second list can reuse its screen without the local data hooks: - SidebarShell: the nav landmark, header, scrolled list and footer slots, and the bar the sash folds it down to - SidebarSearch: the search field, Escape to clear and ArrowDown into the list - SidebarGroups: the date groups, data-schema-list, No results and a leading row - useSidebarList: the query, the groups and the roving Tab stop - SidebarItemView: the rename field, the row keys and the menu, with menuItems, trailing and an optional remove whose key and menu paths stay apart Sidebar and SidebarItem become the local adapter: they bind the IndexedDB atoms, keep the header buttons, Duplicate, the collaboration trigger and the footer, and pass every label, so the names the e2e specs read are unchanged. SidebarAddItem takes its two labels as props, and the row styles move beside SidebarItemView. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The adapter passes the landmark, search field, name field and remove labels, but SidebarItemView keeps Rename and Actions for <name>, which the e2e locators read, and SidebarGroups and SidebarSearch keep No results and the Search placeholder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SidebarItemView takes renameDisabled, which leaves F2, a double click and the Rename item inert, as the Drive adapter needs for files it cannot rename. The trailing slot sits in a wrapper the row hides and shows, so the fragment's styles no longer name the local adapter's collaboration class. SidebarItemView.test.ts and SidebarSearch.test.ts hold the row and search keys, both remove paths and a row without remove, which no e2e spec presses, and AGENTS.md now says which tests guard what. A useSidebarList case name reads the Tab stop order the code has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ignore .env*.local, where the dev server's Google secrets live. The root program takes functions/, the Pages Functions entry, so the auth handlers are checked without the DOM lib. The app's build task passes VITE_GOOGLE_CLIENT_ID and keys its cache on it, both app tasks read functions/, and the coverage gate names the server, the Drive services and the two utilities still to come. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed refresh cookie
src/server/auth/ holds the relay behind /api/auth/*, on web standard APIs only, so the Pages
Function (functions/api/auth/[[route]].ts re-exports pages.ts) and vp dev (nodeAdapter.ts, added
by the gdriveDevServer plugin) run the same handlers.
- start seals state, PKCE verifier and the opener's attempt id into __Host-erd_oauth (Lax, 10
minutes) and sends the popup to Google with prompt=consent and access_type=offline, so every
browser receives a refresh token of its own (the U1 decision, option C).
- callback checks the state, exchanges the code and seals the refresh token into
__Host-erd_gdrive_rt (Strict, 180 days) with AES-256-GCM, unless the grant lacks drive.file.
Its page reflects nothing from the request: a fixed error enum in escaped data attributes,
one constant nonce script that tells only the attempt sealed with a matching state.
- token trades the cookie for { access_token, expires_in, scope } and renews it; logout revokes
the grant and clears it. Both POSTs need the page's own Origin and X-Requested-With.
- Every answer is JSON or the callback page, never the SPA, and carries no CORS header. The dev
adapter drops the headers Vite's cors middleware set before it.
fetch is bound where the relay is assembled and called without a receiver. The tests run in
Node against a fake Google whose fetch throws on another receiver, and imports.test.ts holds
what pages.ts reaches to relative imports inside src/server with no Node global.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A callback whose state does not match a state cookie that still opens leaves that cookie in place: it belongs to a sign-in still open in another popup, so a stale popup or a stray callback link no longer ends it. The later of two sign-ins now finishes when the earlier popup returns first. A cookie that no longer opens is still cleared, as is one the callback consumes. - The state cookie also seals the login_hint, as plan 2b and S2 list. - The state-mismatch matrix sends a state as long as the real one, from another start, and safeEqual has cases of its own, so a comparison of lengths alone fails the suite. - The fake Google answers 400 to anything but a form POST, and the no-refresh-token case runs through its PKCE-bound exchange with omitRefreshToken. - AGENTS.md and the relay's JSDoc name logout's 200 without configuration and start's 302. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…login hint The fake now binds each code to the authorize request's client_id and redirect_uri as well as its PKCE challenge, and answers invalid_grant when the exchange names others. The start tests open the state cookie with the test key, so a hint that is no longer sealed fails them. The dev server JSDoc no longer claims to precede Vite's request, cors and host checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… across tabs services/gdrive holds the /gdrive client as pure modules, fetch, channels, locks, storage and GIS passed in and every injected function called without a receiver: - availability: not configured without VITE_GOOGLE_CLIENT_ID (empty counts as missing), ready on erd-editor.io and localhost only, isFramed for the in-app clickjacking guard. - authMode: the relay judged by its JSON contract, an unavailable one remembered until the next UTC midnight in localStorage, offline deciding nothing. - oauthPopup: the relay's sign-in popup, taking only its own attempt's oauth-done; a popup that closes, strays onto a page without the callback marker, times out or is cancelled ends with one token request, and a late success after a cancel still signs in. - tokenManager: the access token in memory, shared over a BroadcastChannel and renewed under a lock, once between tabs; userinfo gives sub and email, another sub stops a tab as account-changed, a grant without drive.file is scope-missing. The fallback uses the GIS token client alone and sends the relay nothing until sign-out. Gestures inside <erd-editor> never renew, since its closed shadow root hides its inputs from a window listener. - driveClient: fields on every call, media PATCH with the file's own mimeType, multipart create, one 401 renewal, and withRetry running whole cycles after network errors, 5xx and rate limits, never a POST. - driveFileName and stateParam: the four extensions, <name>.erd.json for new files, rename keeping the extension; Drive's ?state= for open and create. __test-utils__/gdrive.ts has the fakes: channel hub, lock manager, relay with userinfo, GIS token client, popup window and a Drive that answers only the fields asked for. AGENTS.md describes the contracts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… cross A sign-out the relay does not confirm now resolves false, revokes a live token at Google itself and leaves a pending logout in localStorage that goes ahead of the next /api/auth/token of any tab or visit, so a shared computer cannot be signed back in by the surviving refresh cookie. A sign-out also wins over what is in flight: a tab drops a relay or userinfo answer asked for before it, and adopts no token issued before the latest one. Userinfo and the relay logout give up after ten seconds, so a stall no longer holds the refresh lock. The popup keeps an oauth-done that crosses the token request of a close: its failure (scope_missing included) wins over any answer, its success over a 401 asks once more, and a late scope_missing still stops as scope-missing. Sign in, Reconnect Google, a tab shown again and a click in an expired fallback read the shared midnight flag again. A click that asks for a sign-in takes over the renewal the same click started, controls marked with data-gdrive-auth-control renew nothing, and any new token lifts a failed gesture renewal's block. The fake relay enforces the CSRF gate and models Google's revoke, the fake Drive reads metadata only as JSON, and timers and spies are restored in afterEach hooks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… pin Drive fields - A sign-out closes the relay popup in every tab and settles a token client request; a callback that still reports success logs out at the relay again. A renewal's 401 ends no sign-in, and relay logouts run one after another. - With a logout pending, a popup close waits a second for its callback's report, so the logout cannot clear the cookie that callback just set. - A token client answer counts from when it was asked for, and a renewal asked for before this tab's own sign-in finished is dropped, so an account switch cannot stop its tab. - Past the recorded midnight Reconnect Google sends one token request and opens no popup. - Resource keys from ?state= take a Drive id's characters. - The Drive tests spell every fields value out, and the fake Drive answers 400 invalidParameter for a field it lacks; the no-locks test renews for real. - AGENTS.md matches the sign-out, revoke and popup code. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
toFingerprint and the derived-field rules it rests on move verbatim to utils/documentFingerprint.ts, and SchemaService only changes its import. The module adds toDriveFingerprint, which a Drive save compares: the document and every setting but the six a view writes, so a changed database or column order reaches the file while a zoom never saves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…change The document sync the /gdrive editor will run, on injected locks, channels and Drive like the auth client: - fileLeader: the file's lock per account, an ifAvailable probe, a queued handoff, a person's steal and isStillLeader, with leader.ts's abort and no-Web-Locks meanings. - fileChannel: the per-file protocol with epochs, the hello backoff, the follower beforeunload rule and the account's files channel. - saveQueue: the 2 s / 10 s debounce in every tab, one metadata, compare and PATCH cycle at a time, conflict and unconfirmed stops, whole-cycle retries, fencing, saved posted whoever leads, and Check Drive. - documentController: open, reject, lead or follow, snapshots, takeovers, reload, rename through the leader, flush and download. - emptyDocument: a new file's content from a headless replica. The integration tests edit real engines through createPeerStore. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ush after its save - toDriveFingerprint leaves out entity meta, which each replica stamps with its own clock, so a handoff no longer re-saves and two tabs without Web Locks stop PATCHing each other's saves - A follower's flush sends a save-request id and waits for the leader's flushed reply, sent once the cycle ends; the ack alone no longer reports success before the save - A waiting-snapshot follower takes a leader's late snapshot; a load a steal or another load overtook installs nothing - Without Web Locks a tab takes a late snapshot until it edits, saves or hands its load on, and saving and saved of another epoch no longer move the base - The four presence trackers stay off the file channel - An older saved never moves the base back, scope-missing resumes when the token returns, and an unconfirmed whose attempt failed or settled is a conflict - isStillLeader relies on the flag a steal's rejection clears; the held-name check was dead Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hrough a steal's wait - A saved from another tab lifts a conflict or unconfirmed stop only after a metadata GET finds Drive at the new base; a file moved past it stays stopped, as a conflict once no attempt is left. - The tab that stole holds its own saves inside the five-second wait, so no debounce PATCHes before the check on the old leader's attempt. - A cycle that ends after its lock was stolen leaves no stop, and a new leader drops a stop of its own that its predecessor has since seen resolved. - toDriveFingerprint leaves out what no longer hangs off doc, which the element's collector drops in a shared worker long after the baseline, so opening an old file no longer saves it. - A detached editor leaves its document, so one attached again in the same load starts from it. - Without Web Locks, a sidebar rename goes to the first tab to claim it rather than every open tab. - Tests: the PATCH fence with an answer held after Drive applied it (holdAnswer), a stolen leader leading again, a baseline-less queue, and the one ack a follower's flushes share. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s and error reports The gtag snippet now adds its script from code, and never on /gdrive, so Google Analytics does not load there. Sentry traces no request to Google or the auth relay, drops ui breadcrumbs and query-carrying navigations on /gdrive and fetch breadcrumbs to Google everywhere, and scrubs /files/<id>, the query and the fragment from every Drive, /gdrive and /api/auth URL it sends, the request's Referer included (sentryScrub.ts, 100% covered). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A lazy /gdrive route, linked from nowhere in the app. It answers a frame with a link to its own tab, a build without a client id with "not configured" and a preview origin with a notice; otherwise it runs a session (services/gdrive/session.ts) over the token manager, the Drive client and one document controller per open file. The session keeps Drive's ?state= until an account signs in: open replaces the URL with ?file=, create asks for a name in a dialog that names the folder and offers My Drive when Drive refuses it, and another account's userId offers a switch with it as login_hint. Switching files and signing out save first and ask when the save fails; Download my changes keeps the edits. The sidebar is the Drive adapter of the sidebar fragments: New file, Import (documents only, as new .erd.json files, backups refused), rename keeping the extension, the account's email and Sign out. The viewer shows the editor, rebuilt for every load, the save status, and the banners for Reconnect Google, a leader that stopped answering, a conflict, a deleted file and an unconfirmed save with Check Drive. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… Google The app's e2e script now runs Playwright twice and fails if either run failed. Run 1 keeps the eight specs on :5175, now with an empty client id and no reused server, and adds gdrive-not-configured. Run 2 (e2e/gdrive) starts a fake Google token server on :5178 and a configured dev server on :5177 whose real relay exchanges and refreshes against it. The browser context fakes the rest of Google: the authorize page, the GIS token client, userinfo and a Drive that answers only the fields asked for. The relay's start is routed too, since the browser follows its 302 past every route. The four Drive specs cover sign-in, the refresh cookie, one renewal between two tabs, sign-out, the state through sign-in, the account switch, Drive left out, a stray callback link, the fallback to the token client and Reconnect Google, the list, open and create states, rename, import, v2 files, files never saved, the debounced save, conflicts, tab leadership, Check Drive after a lost answer, beforeunload and a frame. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…any casing An INP span goes alone, past beforeSendTransaction, named after the element with its aria-label and title, so a Drive file name or the account's email could reach Sentry. beforeSendSpan now sends it as [Filtered] once the page has shown /gdrive, and a /gdrive pageload drops lcp.* and cls.source.*. The route span's url.full in contexts.trace.data and a span's http.query are scrubbed too. The router matches /gdrive in any case and percent-encoding, so the gtag snippet and the Sentry checks do as well. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…screen shows A sign-in, missing-grant or other-account screen unmounts the editor, and the save queue then read no value: beforeunload stayed silent and a due save passed for saved. The queue now reads the document the detached editor left, a leader's next editor schedules the save it still owes, and those screens offer Download my changes. A follower counts a press or key in the element at once, since its change event comes 200 ms after the edit. Check Drive says so when it cannot reach Drive, New file opens a folded sidebar, a list answer keeps what the tab created or renamed meanwhile, and GdriveEditor attaches only a ready controller instead of reporting the expected race. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… retry The O12 case now closes the leader as soon as its dropped PATCH lands, before its own retry looks, and checks that the new leader found the move itself. A follower closed right after an edit asks before the change event comes. The save, sign-out and fallback checks poll what lands after the screen changes, and the link check refuses only the local app's routes, which leaves room for the policy links. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…follower checks - A save that finds edit access gone shows a stop banner with Download my changes (DocumentSnapshot.accessLost); a failed or paused save offers it in the status. - A new account's sign-in no longer drops the last account's unsaved edits: they wait on an unsaved-changes screen, from a controller closed at once. - A list another account started no longer leaves the new account's list loading. - Account screens offer a download only for unsaved edits, offline and the wait for Google included; a person's sign-out elsewhere closes a file without edits. - A follower's Check Drive waits for the leader's checked answer, so a check that never reached Drive shows a notice. - gtag stays off /api/auth pages; Reconnect Google says when it failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… access - fakeGoogle gains failNext, shows each account only its own files, and refuses an upload without its uploadType or a metadata PATCH that is not JSON. - gdrive-save covers a save Drive kept refusing with Try again, and edit access lost with the edits downloaded. - The renewal spec checks when and from which tab each relay call came; the history spec reads its baseline before the visit; each extension's own table is read; the account switch lists the new account's files alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…debars /privacy and /terms are static HTML in public/, with inline CSS that follows prefers-color-scheme and no script, so they open for Google's review without JavaScript or the service worker. The policy covers drive.file, file content never passing through the relay, the encrypted cookie and no database, a sign-out revoking every device, analytics kept off /gdrive, Limited Use and the effective date. The terms name dineug, as-is, limited liability and the user's ownership, and no governing law. GitHub Issues is the only contact. SidebarShell gains a legal row under the footer, where the local and the Drive sidebar both put Privacy and Terms as plain anchors opening a new tab. Under vp dev, policyPages answers the two extensionless paths with their files, as Pages does. policy.spec reads both pages with JavaScript off; the Drive files spec checks the Drive sidebar's links. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
public/_headers sends frame-ancestors 'none' and X-Frame-Options: DENY for /gdrive and /gdrive/*, where a lured click on Sign out or Reload from Drive could cost unsaved edits. Whether Pages applies it to the SPA fallback is for the first deploy to show; isFramed in the route stays the defense that holds everywhere. / stays frameable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e check google-workspace/ holds the icon at 16 to 256 px (and 120 for the consent screen's logo), the 220x140 card banner and a 1280x800 screenshot of /gdrive, outside public/ so no deploy carries them. assets.json lists them; google-workspace:assets renders them with Playwright on the second e2e run's servers and fake Google (e2e/gdrive/assets.config.ts), and google-workspace:check reads each PNG's IHDR against the list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e assets The app's AGENTS.md gains the policy pages and their dev rewrite, the legal row of the sidebar shell, _headers and what it cannot reach, and the asset scripts. The root one names the Drive editor in the package map, and the paths the Google Cloud console holds (/privacy, /terms, /gdrive, the callback) as a contract outside the repo. e2e/README.md lists policy.spec and the asset config. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s from / without a script The privacy policy described only the refresh cookie; it now also names the 10-minute sign-in cookie and the account ID it holds. index.html links /privacy and /terms in a noscript block, so a homepage check that runs no script finds them on /. policy.spec pins the encrypted-cookie half of point 3 and the file-name half of point 5, grouped by point; checks the theme of both pages; fails on any request to another origin; and checks the noscript links on /. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… _headers, pin the render The privacy policy said local schemas leave the device only through export or collaboration, yet on / their names reach Google Analytics in the tab title and Sentry in click labels. It now says so, keeps the content claim, and names Google Fonts, which every editor page loads, /gdrive included. pagesHeaders.test.ts parses public/_headers as Pages does and holds /gdrive and /gdrive/* to the two framing headers, which nothing checked before. The Marketplace screenshot runs on a page clock installed at one date the seeded files count back from, and is taken once two screenshots match instead of after a fixed second. Re-rendering gives the committed PNGs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
readCookie trimmed each name with String.prototype.trim, which also drops a no-break space, a vertical tab or a form feed. A name padded with one lacks the __Host- prefix, so another host of the site could set it for /api/auth, where the longer Path sends it first, and the relay read it as its own refresh or state cookie: someone else's Drive session, or a login CSRF. Names and values are now trimmed of the header's space and tab alone, and cookie.test.ts holds both cookies to that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The relay keeps no sessions, so a refresh cookie that stays in use renewed
itself for another 180 days on every token call, and a copied one never
ended short of a revocation at Google.
The cookie now seals { rt, iat }, iat being the consent's time in epoch
seconds: the callback stamps it, a renewal keeps it, a rotated refresh token
included, and sets Max-Age to 180 days or what is left of the year when that
is shorter. A year after iat the token route clears the cookie and answers
401 reauth_required without asking Google, which the client already reads as
signed out. Logout still revokes the refresh token of a cookie past its year.
AGENTS.md and the privacy policy describe the year.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…client of its own The production OAuth client listed http://localhost:5175 beside erd-editor.io, and its client id is public in the bundle, so any page served on that port could get Drive tokens under erd-editor's name; its secret also sat in a developer's .env.local. Local sign-in now uses another client, in a Google Cloud project of its own kept in Testing. GOOGLE_CLIENT_SECRET and COOKIE_KEY go on the Pages Production environment only: a preview cannot finish a sign-in, and every pushed branch's build and Function could otherwise read them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rename bullet said renameDriveFile waits for a status within two seconds where Web Locks are missing, but askLeaderToRename resolves on a rename-claim, as the leader bullet above it already says. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The policy pages linked home to /, against the rule that / and /gdrive link to nothing of each other; only the Privacy and Terms links remain. The site name stays as plain text, and policy.spec.ts holds each page's own links to /privacy and /terms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign in, Continue with Google and Try again pass the Drive state's userId as the login_hint, through the relay's popup and the GIS token client alike, and after a 401 the next sign-in hints the account it signed out. A person's sign-out leaves no hint behind. isLoginHint moves from oauthState.ts to contract.ts, which the client already shares with the relay. tokenManager.signIn drops a hint the relay's start would refuse with 400, so Google asks for the account, and account-mismatch says it could not preselect the one Drive used. The unit fakes use numeric subs, as Google's are; the e2e reaches account-mismatch from an earlier sign-in, since the state's own hint now signs in with the account Drive used. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
requestRelayLogout read { ok: true, revoked: false } as a confirmed sign-out, so
the tab's own revoke never ran when the relay cleared its cookie but could not
reach Google. It now carries revoked through, and signOut() resolves
{ confirmed, revoked }: a live token the relay did not revoke is revoked at
Google, and a token client's through GIS, whose callback's successful now
counts, ten seconds at most.
Revoked by nobody, the tab still signs out, and the session's notice says the
account's other devices may stay signed in until access is removed at
myaccount.google.com/permissions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A follower took every change event, another tab's batch and a pan or zoom included, for an edit of its own: beforeunload warned and it sent the leader save-requests for edits the leader was already saving. Its own edit is now a batch its shared store sent with an action outside NON_EDIT_ACTIONS, the view a replica follows and the LWW handshake; the change event only tells the leader, whose fingerprint decides. The 500 ms window after a press stays, and nothing counts on a file the tab may not edit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing outside the token manager read the snapshot's expiresAt, whose timers run on the session's own; and GoogleButton is used by GdriveSignIn alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…one file name isRecord, asRecord and sleep had a copy in five services/gdrive modules, one of them letting arrays through; util.ts holds the one of each. fileChannelName is fileLockName now, so the rule that a file's channel and lock share one name is code rather than two equal prefixes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… channel The sidebar gates a rename on the Drive list entry's canRename, and nothing read the controller's copy, so snapshot and reloaded no longer carry it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only the leader downloads, so isEditorDocument turns a file away after the probe, which is why that refusal lets the lock go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A follower takes the leader's status for its save state alone and never read the time the leader stamped on it, so the status carries the state only and a status without one is refused. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… it once one comes A load that found no token, or one without drive.file, showed "Google Drive didn't answer" and stayed failed after Reconnect Google or a sign-in: the session's switch saw the same file and did nothing, and only a second Try again opened it. Such a load now waits as waiting-token, lets the lock go as a failed one does, and the session opens the file again as soon as the token manager holds a token, so an idle fallback's click on a file or a 401 mid-open needs no second click. The viewer says it waits for Google and adds no button, so Reconnect Google stays the primary action. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a reload downloads it A reload that could not read Drive, or found the file no longer a document, kept the document without a word, and a follower's click posted reload-request and resolved at once, so its spinner stopped over the same conflict banner. A reload now resolves to how it went (ReloadResult): a follower sends a requestId and waits for the leader's reload-done, failed after 30 s, and the tab that asked shows a notice, as Check Drive does. The leader answers with the same askLeader helper that flushes and Check Drive now share. A load from Drive after a takeover, a handoff or Reload from Drive also turns away a trashed file, a Google Doc or one over 64 MB before it downloads anything, as opening does: a reload keeps the document and names the refusal, a first load or a takeover shows the file as rejected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The VS Code extension's displayName and the IntelliJ plugin's name are ERD Editor, so the web app says the same wherever a person reads it: the tab title, the PWA manifest's name and short_name, the noscript line, the /gdrive notices, dialogs and sign-in screen, the update prompt, the OAuth callback page's title and the privacy and terms pages. A sentence that means the website keeps erd-editor.io, and identifiers keep erd-editor: package names, the element, file names, channel, lock and storage keys, the IndexedDB name, the backup format and file name, the callback marker, the popup name and trystero's appId. The e2e assertions, the fakes that stand in for the app's pages and AGENTS.md follow the new strings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The card banner beside the icon now reads ERD Editor, the name the consent screen, the Drive UI integration and the Marketplace listing use. Rendered with google-workspace:assets; the icons and the /gdrive screenshot came out byte for byte the same, and google-workspace:check passes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Marketplace review's Drive app criterion asks for an app-specific folder, never files dumped in My Drive. The sidebar's New file, every Import, Drive's New without a folder and the create dialog's fallback, now Create in the ERD Editor folder, create in a folder named ERD Editor. It is found again by its appProperties marker, so a rename or a move keeps it, is made anew once deleted or in the trash, and the oldest by createdTime wins when several exist. Find-or-create runs under a per-account Web Lock, and the id is cached per account and checked before each use. A file created from Drive's New in a folder the person picked stays there. Both fake Drives model folders, parents, appProperties and the list query, and answer a query term they cannot read with a 400, so an unmatched query lists nothing. The privacy policy, AGENTS.md and the e2e README say where new files go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tab that creates the ERD Editor folder lists again at once and moves to an older one another device made meanwhile, and a folder counts as settled only once a list has shown it, since Drive documents no read-after-write for files.list: until then each use looks again. A folder in the trash or without capabilities.canAddChildren is passed over and replaced, and getFolder checks the cached id with FOLDER_FIELDS. A lookup that another account's sign-in overtook fails with AccountChangedError and caches nothing, so no account's folder is filed under another's sub. Both fake Drives answer canAddChildren and refuse a create into a parent without it with a 403; the unit fake can leave a file out of lists as a lagging search would. The e2e specs create a file after a reload, a rename and a move of the folder, and read the list of a reloaded page. The privacy policy says a move to a shared drive makes a new folder and covers Drive's New without a folder; AGENTS.md and the e2e README follow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…new one to other tabs Every New file and Import now lists the app's folders again under the account lock and takes the oldest open one. An open tab therefore moves to an older folder as soon as a list shows it again, say one taken out of the trash, just as a new page does. The list takes the place of the GET that checked a cached id, so a use still costs one request. Each tab keeps the ids of the folders it used, made or heard of. It reads with getFolder any that a lagging list leaves out, weighs them while they are open, and uses the oldest of them when the list fails. When the chosen folder is one no list shows yet, the tab posts it on the account's files channel before it lets go of the lock, so the browser's next tab uses it and makes no second folder. FOLDER_FIELDS now reads driveId: a folder moved to a shared drive is passed over, as the privacy policy says. Both fake Drives now work out trashed and driveId through a file's parents. A list leaves out a shared drive's files unless it sends includeItemsFromAllDrives, and a request reaches them only with supportsAllDrives. The e2e fake drops its unused createdTime seed, and its README says a create that names no parent lands in My Drive. New tests cover a folder taken back out of the trash, one in the trash through its parent, one moved to a shared drive, the list-failure fallback, the channel handoff, the create dialog's fallback after a 403, a folder named like a document kept out of the list, and the policy's folder paragraph. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…of reach or left behind A check that answers 403 now forgets the folder as a 404 does, and an account's remembered folder ids go when it leaves the tab, so a sign-out that revoked the grant no longer blocks New file and Import until a reload. The lookup's list and check run through withRetry with the session's options; the create still never retries. A folder a lookup made is always posted to the account's other tabs, since one tab's list showing it says nothing of the next lock holder's. Tests cover the per-account memory, forget, the 403, the retries, a queued tab whose list lags, and Drive's New without a folderId in e2e. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The row under the empty viewer's buttons on / moves into one shared ResourceLinks component, and /gdrive's empty viewer, with files and without, shows it too, under New file and Import files, through a footer slot on GdriveNotice. Both links are external, so /gdrive still links to nothing of /. The Drive files spec checks both links' href, target and place under the buttons in both empty states. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… on / too The Editing Guide and GitHub links set rel="noopener", as the /gdrive frame's new-tab link does; the supported browsers already treat a target="_blank" link so, and noreferrer stays off, so docs.erd-editor.io still gets the origin from / as before. The Drive files spec's check moves to support/resourceLinks.ts, pins rel too, and runs on / in policy.spec.ts, so dropping the row from / now fails a spec. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…der for a new one A sign-out, or removing ERD Editor's access in the Google Account, revokes the grant, and drive.file access to each file goes with it, so the next lookup finds no folder and makes another. The privacy policy now lists both among the cases that make a new ERD Editor folder, and says the earlier folder and its files stay in Drive but drop out of the list; policy.spec.ts pins both parts. AGENTS.md adds the same caveat to the folder's reuse, and says that neither fake's revoke takes access away, so only the real Drive checks the premise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ard it A guest edit made right after the leader tab closes goes out on that tab's closed channel before the guest notices, and is lost unless the dying tab still relays it. The spec now waits for a nickname typed after the close to reach the successor's own participants list, as participants.spec.ts does, before the guest adds its table. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ditor starts at 0x0 Opening a schema mounts a new erd-editor, whose viewport starts empty until its ResizeObserver measures the root, so its canvas can be scanned at 0x0. freeCanvasPoint then found no point and failed addTable once in schema-list.spec.ts:27. It now retries the scan with toPass for up to the suite's 15 s expect timeout, and its error names the canvas size it saw. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The folder marker is the app's appProperties, not the person's, so another person's ERD Editor folder shared with the account carries it too, and the default list shows it once drive.file reaches it, say after Drive's New inside it. Being older, it won, and every New file and Import then went into it, taking on its sharing. FOLDER_FIELDS now asks for ownedByMe, parseDriveFolder reads it, and pickAppFolder and the known-id recheck pass over a folder the account does not own, which also drops one whose ownership moved. Drive leaves ownedByMe out in a shared drive, which reads as false. Both fakes answer ownedByMe: the unit fake per file, the e2e fake by an owner, the first of accounts by default. Unit tests cover a shared older folder and a known folder handed to another owner; a gdrive-files spec makes its own folder beside another account's shared one. The privacy policy says a folder shared by someone else is never used and an owned-by-someone-else folder is replaced; policy.spec.ts pins both. AGENTS.md and the e2e README describe the rule and the fakes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t no file lands in one The policy said files made at /gdrive never go in an ERD Editor folder someone else shares, but a file started from Drive's New inside such a folder goes there, as the same paragraph says further on. It now says ERD Editor never picks one, which is what the lookup does, and policy.spec.ts pins the new wording. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n green Every LoaderCircle in /gdrive (saving, waiting for the leader or the snapshot, and Saving before you leave...) now turns with one shared spinning style, a full turn a second. Under reduced motion it takes three seconds a turn instead of stopping, since it is the only sign that work goes on. The check before Saved to Google Drive takes green step 11, which reads 4.7:1 on the light panel and 9.4:1 on the dark one; its label stays gray, and the other states keep their colors. gdrive-save.spec.ts checks the animation while saving and once saved, the reduced-motion duration, and the saved icon's color against what var(--green-11) resolves to. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…et and the screenshot Below 64 px the icon's lines come out thinner than a pixel and blur, and the Google consoles show the icons on HiDPI screens. The user uploaded 2x renders on 2026-09-26 and the console took them, so the repo keeps those alone: icon-<size>@2x.png for 16 to 256 px, each the SVG drawn on a page at deviceScaleFactor 2 in a box of its base size, at the page's origin, which reproduces the uploaded files pixel for pixel. assets.json gives each entry an optional scale, and its width and height stay the PNG's pixels, which google-workspace:check still reads. The Marketplace screenshot is no longer made here: its test, its app servers, its fake Google and the erd-editor build leave google-workspace:assets, which now starts no server. The banner comes out byte for byte as before. AGENTS.md and the e2e README say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
erd-editor.io can now open, create and save ERD files that live in Google Drive. They are edited at a new, separate route,
/gdrive. You get there from Drive's Open with → ERD Editor and New → ERD Editor, or by going straight to/gdrive. That shows a sign-in screen and then the Drive files ERD Editor can see. Edits save back to the same Drive file as you work. When the file changed somewhere else, ERD Editor stops instead of overwriting it. Files made by New file and Import go in an ERD Editor folder in My Drive, never loose in its root.The local app at
/keeps working the way it does now: IndexedDB, the schema list, and live sessions. It gets three changes:/and/gdrivedo not link to each other, and nothing moves local schemas into Drive.How it fits together
/gdrive(lazy route,src/components/gdrive/**)/. The save status icon spins while a save or a wait is under way (slower under reduced motion), and the saved check is green. It reads Drive's?state=for Open with and New, then replaces the URL with?file=<id>.functions/api/auth/[[route]].ts→src/server/auth/**)/api/auth/start,callback,tokenandlogout. The sign-in is a self-built OAuth popup withaccess_type=offline,prompt=consentand PKCE S256. See "Sign-in" below. The refresh token is kept only in an AES-256-GCM__Host-cookie. There is no database, and file contents never pass through the server./api/auth/*stops answering in its JSON shape (free-tier limit, error 1027, an HTML page), the tab switches to the GIS token client (one-hour tokens) until the next UTC midnight.src/services/gdrive/**)drive.filescope, explicitfields=on every request, media uploads. Saves run 2 s after an edit, at most 10 s later. One leader tab per file saves; the other tabs follow it over a BroadcastChannel. The Web Locks lock names include the account id. A save first comparesmodifiedTime, and a mismatch stops saving. The conflict banner offers Reload from Drive, Download my changes and Check Drive..erd,.vuerd,.erd.jsonand.vuerd.json, and judges each file by its content. New files are.erd.json. A file that isn't an ERD Editor document is never saved. View-only files open read-only.appFolder.ts)appPropertiesmarker, so renaming it or moving it within My Drive keeps it. Every use lists the folders again under a Web Lock and takes the oldest one that is open to new files: not in the trash, not in a shared drive,canAddChildren, andownedByMe, so another person's shared ERD Editor folder is never used. That way every tab and device settles on one folder. Drive's New inside a folder still creates the file there./gdriveor/api/auth. Sentry scrubs Drive ids, file names and account ids from URLs, spans and breadcrumbs._headerssendsframe-ancestors 'none'for/gdrive, and when framed the app shows only a notice.public/privacy.htmlandpublic/terms.htmlare static HTML with no script. They are linked from both sidebars and from/'s<noscript>.packages/app/google-workspace/holds the icons for 16–256 px, drawn at 2x (icon-<size>@2x.png), and the 220×140 banner.google-workspace:assetsrenders them from the SVG with no server, andgoogle-workspace:checkchecks their sizes. They are not deployed. The Store Listing's screenshot is made by hand.Sign-in
With the GIS code client, Google issues a refresh token only on the first consent. A second device, cleared cookies or a key change would then be stuck with hourly re-sign-ins. So the relay runs its own popup with
prompt=consent. Every device gets its own refresh token, and Google keeps up to 100 of them per account and client without signing the others out.The cookie:
HttpOnly; Secure; SameSite=Strict; Path=//api/auth/token, which checksOriginandX-Requested-WithThe callback page shows only a fixed error enum and never reflects the query, and it sends a nonce CSP with
form-action 'none'.Changes to
/SidebarShell,SidebarSearch,SidebarGroups,SidebarItemView,SidebarHeaderRowanduseSidebarList. The local adapters keep every accessible name anddata-*attribute, and the 8 existing e2e specs pass unchanged.utils/documentFingerprint.ts.SchemaServicebehaves as before.ResourceLinkscomponent, which/gdriveuses too. Both links now setrel="noopener"; the supported browsers already treated them that way.<title>, the PWAnameandshort_name, and the copy say ERD Editor. Identifiers stay as they are:erd-editor.io,@dineug/erd-editor*,<erd-editor>, storage and channel keys.Toolchain
tsconfig.jsontype-checksfunctions/**/*.tswithout the DOM lib. The app'sbuildandtesttasks listfunctions/**as input, andbuildpassesVITE_GOOGLE_CLIENT_IDthrough.pnpm --filter @dineug/erd-editor-app e2enow runs Playwright twice, one after the other, and fails if either run fails:/with no client id, plus the policy pages/gdriveagainst the real dev relay and a fake Google.env*.localis git-ignored.vp devmounts the same auth handlers at/api/auth, so no wrangler dependency is added./'s specs, both in the tests and not in the app:leadership.spec.ts:59: the guest's edit could leave on the closed leader's data channel before the guest noticed the leader was gone. The guest now waits until the successor lists it.freeCanvasPointcould scan a new editor's canvas while it was still 0×0. It now scans again until it finds a free point.Not in this PR
/gdrivebeyondframe-ancestors.COOKIE_KEY_PREVIOUS): changingCOOKIE_KEYsigns everyone out once, and unsaved edits are kept.erd-editor'skonvaFlip.browser.test.ts:79, an existing flaky test that fails onmaintoo./never had one.Verification
a9c11c0b(spinning save icon, green check), run fresh afterpnpm cache:clear:pnpm checktest:coverage: 1,357 testspnpm build57908af8(2x icons, no screenshot):pnpm checke2e:typecheckgoogle-workspace:checkgoogle-workspace:assetsrun changes no filebdb78e38, after the ERD Editor folder and the links, run fresh afterpnpm cache:clear:pnpm checkpnpm testpnpm -r --no-bail test:coveragepnpm buildpnpm peer-graphpnpm size: 316,248 B gzip/gdrive) passed 42google-workspace:checkd762a98c, run fresh afterpnpm cache:clear:pnpm checkpnpm testpnpm -r --no-bail test:coverage: 80% per file in all 11 packages, with the new modules in the app's coverage includepnpm buildpnpm peer-graphpnpm size: 316,248 B gzip against a budget of 328,242 B/gdrive) passed 38ee1c816d, after the ERD Editor rename, which touched only the app:pnpm checkpnpm test: app 1,307 teststest:coveragepnpm buildpnpm sizegoogle-workspace:checkwrangler pages functions buildcompiled the Functions and generated the route/api/auth/*. The bundle holds onlysrc/server/auth/**.wrangler pages dev packages/app/diston workerd:tokenanswered 401 and 403 and cleared the cookie,_headersand/privacywere served, andstartredirected (302) with PKCE and a Lax state cookie.callbackreached Google's token endpoint without an exception, and an injectederrorwas not reflected.🤖 Generated with Claude Code