Someone invites you to their workspace. You get an email, you sign up, you verify. Now you need to say yes — and the only "yes" button in the whole repo is on a page of the account website (services/web, account-page.vue). If you are sitting in the browser app, signed in, looking at your workspace list, there is no way to accept. You have to know a separate website exists and go there.
This is the one finding that survived §78's superseded fold-the-account-site plan. It has nothing to do with which front-end owns sign-up; it is true whether or not services/web exists. Notepad: notepad/78-invitations-in-app.md.
Scope, confirmed: browser only. apps/desktop is pinned to one workspace (tauri.ts:54 → tenantSwitching: { kind: 'unsupported' }), so joining a second one is meaningless there. The block renders only where switching is supported. On desktop this plan changes nothing, and that is stated rather than papered over.
Accepting an invitation is a workspace switch.
POST /v1/invitations/{id}/accept returns Json<AuthResponse> — the same type auth_login returns (invitations_accept.rs:23,40), which toSession already decodes (account.ts:113) and web-sync.ts:104-111's switchTo already adopts via adopt() + reloadApp(). No new session machinery. The device the session is stamped with comes from SessionMeta on the server (device.rs:156-179), not from a client header — nothing extra to send.
Three of its claims are wrong against the source, and the plan below reflects the code, not the notepad:
TenantSwitchinglives inpackages/editor-shell/src/host-port.ts:46, noteditor-protocol.GET /v1/invitationsanswers a bare JSON array (Json<Vec<MyInvitationItem>>,invitations_list_mine.rs:52) — not{ invitations: [...] }. Decoding must key offArray.isArray(body), unliketoWorkspaceswhich readsbody.tenants.- The row carries
invited_by_user_id(always) andinvited_by_display_name(Option<String>). Keep both, so the fallback "invited by " is expressible — the account site's own behaviour.
Also verified: decline answers 204 No Content with an empty body, which send() already tolerates (response.json().catch(() => null), account.ts:96).
account.ts:87 derives the verb from the body, so a bodyless POST is unsayable today — accept and decline would go out as GET and 405.
interface Request {
serverUrl: string
path: string
auth: Auth
+ method?: 'GET' | 'POST'
body: unknown
}- method: request.body === null ? 'GET' : 'POST',
+ method: request.method ?? (request.body === null ? 'GET' : 'POST'),Both endpoints answer 404 { error: "invitation_not_found" } when the invitation was revoked or already used, and send() has no 404 branch — that would surface as unexpected_response / http 404. Add one line beside the existing status branches:
if (response.status === NOT_FOUND) throw fail(reportedCode(decoded, 'not_found'))DELETE is deliberately not added: the only DELETE in the account API is invitation revoke, which belongs to the team page and stays in services/web.
packages/editor-account/src/account.ts, hand-decoded in the shape toWorkspaces/toSession already use — no zod, the package keeps zero runtime deps.
export interface Invitation {
invitationId: string
tenantId: string
tenantName: string
invitedByUserId: string
invitedByDisplayName: string | null
}
function toInvitations(body: unknown): Invitation[] {
if (!Array.isArray(body)) return []
return body.map((row) => {
const r = asRecord(row)
const name = r.invited_by_display_name
return {
invitationId: String(r.invitation_id ?? ''),
tenantId: String(r.tenant_id ?? ''),
tenantName: String(r.tenant_name ?? ''),
invitedByUserId: String(r.invited_by_user_id ?? ''),
invitedByDisplayName: typeof name === 'string' && name !== '' ? name : null,
}
})
}fetchInvitations(serverUrl, token)→GET /v1/invitations,body: null.acceptInvitation(serverUrl, token, id)→POST .../accept,method: 'POST',body: null, throughtoSession.declineInvitation(serverUrl, token, id)→POST .../decline, same shape, returnsvoid.
Both paths use encodeURIComponent(invitationId).
Export all three plus Invitation from src/index.ts (knip requires live callers — S3 and S4 supply them).
packages/editor-account/README.md:19-24 says invitations "live in services/web". That becomes false here, so it is rewritten in this slice — the README gate is the home for this orientation, not a comment.
packages/editor-shell/src/host-port.ts:46:
export type TenantSwitching =
| { kind: 'unsupported' }
- | { kind: 'supported'; switchTo(tenantId: string): Promise<void> }
+ | {
+ kind: 'supported'
+ switchTo(tenantId: string): Promise<void>
+ accept(invitationId: string): Promise<void>
+ }apps/web/src/host/web-sync.ts gains accept, mirroring switchTo exactly — read the session, call acceptInvitation, adopt(), reloadApp() if the store changed. Desktop's arm is { kind: 'unsupported' } and needs no edit; the union means it cannot silently omit the method.
Only accept goes through the port, because only it mints a session that must be stored (localStorage vs SQLite — host's business, per the package README). fetch and decline are pure HTTP and are called straight from the composable, the way refreshAccount already calls fetchAccount. That avoids two more port methods for a decision the host has no stake in.
Ceiling check: apps/web/src/host/** sits at the STANDARD complexity: 4, depth: 2 (eslint.config.mjs:9,44) and is not in FROZEN. accept mirrors switchTo's shape, well under.
use-account.ts grows invitations, refreshInvitations, acceptInvitation, declineInvitation, reusing the existing switchingTo/workspaceError in-flight-and-error discipline rather than inventing a second one — one respondingTo ref alongside switchingTo, one shared workspaceError.
refreshInvitations rides the same host().syncConfig() token refreshAccount already fetches through, and is called from the same watch in settings-page.vue:57-63.
New copy in ACCOUNT_ERROR_COPY (use-account.ts:6-21):
not_invited_user→ "That invitation isn't for this account."invitation_not_found→ "That invitation is no longer available."
settings-page.vue, directly under the workspace list (:250-268):
Settings → Sync
Signed in as gemma@… Syncing ✓
Workspaces
• Acme (current)
• Side project [Switch]
Invitations
• Contoso — invited by ada@… [Accept] [Decline]
Row hint is invitedByDisplayName ?? invitedByUserId. Gated on canSwitchWorkspace && invitations.length > 0 — nothing renders when the list is empty, no header and no empty state. An invitation is rare and the section should not be a permanent reminder that you have none.
settings-accordion.test.ts:11-20's useAccount mock must grow the new fields or the component throws on mount.
| File | Change |
|---|---|
packages/editor-account/src/account.ts |
method?, 404 branch, Invitation, toInvitations, three calls |
packages/editor-account/src/index.ts |
export the three + the type |
packages/editor-account/README.md |
invitations no longer "live in services/web" |
packages/editor-account/test/account.unit.test.ts |
S1 + S2 assertions |
packages/editor-shell/src/host-port.ts |
accept on the supported arm |
apps/web/src/host/web-sync.ts |
accept beside switchTo |
apps/web/src/host/web-sync.test.ts |
S3 assertions |
packages/editor-app-vue/src/composables/use-account.ts |
state, actions, error copy |
packages/editor-app-vue/src/components/settings-page.vue |
the block |
packages/editor-app-vue/src/components/settings-accordion.test.ts |
mock fields |
Nothing in services/ is touched and no MIT→AGPL edge is added. services/web keeps its own invitation page; the two coexist. apps/web's own licensing status remains the open question §78 names and is not made worse here.
Machine-checkable — each written to fail first (spec-writer), then made green:
- S1 in
packages/editor-account/test/account.unit.test.ts, whosestubFetchfake already recordsmethod(:11-30) — theweb-sync.test.tsfake does not, so the verb assertion belongs here: a bodyless accept goes out as POST. Restore the bare ternary and this goes red. - S1 a
404 { error: 'invitation_not_found' }rejects with that code, notunexpected_response. - S2
fetchInvitationsdecodes a top-level array; a row withinvited_by_display_name: nulland one with it absent both giveinvitedByDisplayName: nullwithinvitedByUserIdintact. - S3 in
web-sync.test.ts, over the existingrespondWithfake: after accepting,(await webSyncConfig()).getAuthToken()is the accept response's token andactiveStoreName()is the new tenant's store — the seamswitchTois already tested on at:492-514. - S3 accepting into a different store reloads; accepting into the current one does not.
- S4 the block does not render when
tenantSwitching.kind === 'unsupported', and does not render when the list is empty. make verifygreen (this is the done-signal, not "the file looks right").
Not machine-checkable, required before this is called done — apps/web in a real browser against a live API, not jsdom:
- Invite a second account from the account site's team page.
- Sign up and verify that account.
- Open the browser app, sign in, see the invitation in Settings → Sync.
- Accept it. Land in the new workspace with its documents, not the old one's.
- Decline a second invitation and watch it leave the list.
plan-refuter on this file before code; spec-writer turns the verification list into failing tests the implementation must not edit; claim-auditor re-runs the evidence before this is reported done.
One optional field and one status branch on send(), one method on the TenantSwitching supported arm, ~120 lines in editor-account, ~80 across the composable and the Settings block. Nothing deleted.