Skip to content

Instantly share code, notes, and snippets.

@gemmadlou
Last active September 2, 2026 00:29
Show Gist options
  • Select an option

  • Save gemmadlou/3455277885a32bd5793fc0201a8977cc to your computer and use it in GitHub Desktop.

Select an option

Save gemmadlou/3455277885a32bd5793fc0201a8977cc to your computer and use it in GitHub Desktop.
LLM disk planinto Claude /plan - Does it improve token usage and code output?

Accepting an invitation, from inside the app

Context

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.

The thing that makes this small

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.

Corrections to the notepad

Three of its claims are wrong against the source, and the plan below reflects the code, not the notepad:

  1. TenantSwitching lives in packages/editor-shell/src/host-port.ts:46, not editor-protocol.
  2. GET /v1/invitations answers a bare JSON array (Json<Vec<MyInvitationItem>>, invitations_list_mine.rs:52) — not { invitations: [...] }. Decoding must key off Array.isArray(body), unlike toWorkspaces which reads body.tenants.
  3. The row carries invited_by_user_id (always) and invited_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).

Slices

S1 — send() learns POST-without-body, and a 404

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.

S2 — editor-account learns the three calls

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, through toSession.
  • declineInvitation(serverUrl, token, id) → POST .../decline, same shape, returns void.

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.

S3 — the host port can accept

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.

S4 — Settings → Sync renders the block

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.

Files

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.

Verification

Machine-checkable — each written to fail first (spec-writer), then made green:

  • S1 in packages/editor-account/test/account.unit.test.ts, whose stubFetch fake already records method (:11-30) — the web-sync.test.ts fake 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, not unexpected_response.
  • S2 fetchInvitations decodes a top-level array; a row with invited_by_display_name: null and one with it absent both give invitedByDisplayName: null with invitedByUserId intact.
  • S3 in web-sync.test.ts, over the existing respondWith fake: after accepting, (await webSyncConfig()).getAuthToken() is the accept response's token and activeStoreName() is the new tenant's store — the seam switchTo is 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 verify green (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:

  1. Invite a second account from the account site's team page.
  2. Sign up and verify that account.
  3. Open the browser app, sign in, see the invitation in Settings → Sync.
  4. Accept it. Land in the new workspace with its documents, not the old one's.
  5. Decline a second invitation and watch it leave the list.

Gates

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.

Cost

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.

78 — Accepting an invitation, from inside the app

Superseded plan. This file opened as "fold the account site into the browser app" — move seven pages out of services/web into apps/web, delete the third front-end. plan-refuter returned REFUTED, the plan was revised, and then the licensing question it surfaced was answered the other way.

Decision, 2026-09-01: anything network-served stays AGPL. The fold does not happen. §77 is the permanent answer for the account site.

What survives is the one finding the refuter turned up that was never about folding at all: nobody can accept a workspace invitation from the app.

Plain language first

Someone invites you to their workspace. You get an email, you sign up, you verify. Now you need to say yes.

Today the only "yes" button in the whole repo is on a page of the account website — account.example.com. If you are sitting in the desktop app, signed in, looking at your workspace list, there is no way to accept. You have to know that a separate website exists, and go there.

That is the gap. It has nothing to do with which front-end owns sign-up; it is true whether or not services/web exists. This plan closes it and stops.

Vocabulary:

  • invitation — a pending offer of membership in someone else's workspace. Server-side it is a row; you either accept it (you join) or decline it.
  • workspace switch — the thing the app already does when you pick a different workspace in Settings → Sync. It swaps your session token and re-points local storage at that workspace's data.

The thing that makes this small

Accepting an invitation is a workspace switch.

POST /v1/invitations/{id}/accept (routes.rs:29-30) answers { token, user_id, tenant_id } — byte-for-byte the shape POST /v1/auth/switch-tenant answers, which account.ts:161-172's switchTenant already decodes through toSession, and which web-sync.ts:104-111 already adopts through adopt() + reloadApp().

So there is no new session machinery. The first draft proposed a HostPort.sync.adoptSession(serverUrl, email, token) primitive; that was for emailed verify links landing in apps/web, which is no longer happening. It is not needed and is not in this plan.

What is actually missing

services/web/src/api/invitations.ts:49,78,111 are the only callers of the three endpoints anywhere in the repo, and account-page.vue:5-10,88-107 is their only UI. team-page.vue has create + revoke only (api/tenants.ts:138,195). None of that moves — services/web keeps its copy. The app grows its own.

Where it lives

  • packages/editor-account (ring 2, zero runtime deps) — the three calls.
  • packages/editor-app-vue — the Settings block and its composable.
  • packages/editor-protocol — one port method.

Note this touches no services/ path and adds no MIT→AGPL edge. The block is shipped to the desktop binary as well as the browser, so it sits on the uncontroversial side of the rule the decision above reaffirms.

apps/web remains the open licensing question — a served web app in the MIT tier that docs/LICENSING.md's per-path table does not list at all. This plan does not resolve it and does not make it worse. Resolving it means either adding an apps/web row as AGPL — and tools/check-license-boundary.sh:6's MIT_DIRS=(packages apps tools) keys on top-level directory, so it would need to key on paths — or writing down why a PWA shipped to a browser counts as local-running. Separate decision, separate slice, not blocking here.

Slices

S1 — send() learns to POST without a body

account.ts:87 derives the verb from the body:

method: request.body === null ? 'GET' : 'POST'

Accept and decline are POSTs that carry no body. Today that is unsayable — it would go out as a 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'),

Every existing call site is untouched. 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. Widen it when something needs it.

S2 — editor-account learns the three calls

Hand-written decoding in the shape toWorkspaces/toSession already use — no zod, the package keeps its zero runtime dependencies.

export interface Invitation {
  invitationId: string
  tenantId: string
  tenantName: string
  invitedBy: string | null
}

export async function fetchInvitations(
  serverUrl: string,
  token: string,
): Promise<Invitation[]> {
  return toInvitations(await send({
    serverUrl, path: '/v1/invitations',
    auth: { kind: 'session', token }, body: null,
  }))
}

export async function acceptInvitation(
  serverUrl: string, token: string, invitationId: string,
): Promise<AuthSession> {
  return toSession(await send({
    serverUrl, path: `/v1/invitations/${encodeURIComponent(invitationId)}/accept`,
    auth: { kind: 'session', token }, method: 'POST', body: null,
  }))
}

export async function declineInvitation(
  serverUrl: string, token: string, invitationId: string,
): Promise<void> {
  await send({
    serverUrl, path: `/v1/invitations/${encodeURIComponent(invitationId)}/decline`,
    auth: { kind: 'session', token }, method: 'POST', body: null,
  })
}

invited_by_display_name is null-able and optional server-side, so invitedBy is string | null and the row reads "invited by invitedByUserId" when it is absent — the account site's own fallback.

packages/editor-account/README.md:19-24 currently says invitations "live in services/web". That becomes false here, so it is rewritten here.

S3 — the host port can accept

web-sync.ts:104-111's switchTo is exactly the flow accept needs: call the server, adopt(), reloadApp() if the store changed. Accept differs only in which endpoint mints the session.

 export interface TenantSwitching {
   kind: 'supported'
   switchTo(tenantId: string): Promise<void>
+  accept(invitationId: string): Promise<void>
 }

Desktop stays { kind: 'unsupported' } (tauri.ts:54) — it is pinned to one workspace and cannot switch, so joining a second one is meaningless there.

That is the honest consequence and it must be stated plainly: on desktop, this plan changes nothing. The invitation block renders only where canSwitchWorkspace is true, which today is the browser app alone. Giving desktop multi-workspace membership is its own plan; pretending the block works there would be worse than not showing it.

S4 — Settings → Sync renders the block

settings-page.vue:250-271 already renders the workspace list and already gates on canSwitchWorkspace. The block sits directly under it.

Settings → Sync
  Signed in as gemma@…                       Syncing ✓

  Workspaces
    • Acme                                    (current)
    • Side project                            [Switch]

  Invitations
    • Contoso — invited by ada@…    [Accept]  [Decline]

use-account.ts grows invitations, refreshInvitations, accept, decline, reusing switchingTo/workspaceError's in-flight-and-error discipline rather than inventing a second one. refreshAccount already fetches through host().syncConfig(); invitations ride the same token.

Nothing renders when the list is empty — no header, no empty state. An invitation is rare and the section should not be a permanent reminder that you have none.

What proves it

Machine-checkable:

  • A POST with no body goes out as POST — S1, the thing the old shape could not say. Break it back to the ternary and this goes red.
  • fetchInvitations decodes a row with invited_by_display_name absent, and one with it null, to the same invitedBy: null — S2.
  • accept returns the new session and the host adopts it: after accepting, readSession().token is the accept response's token and the active store is the new tenant's — S3, over the existing web-sync.test.ts fakes.
  • Accepting a workspace whose store differs reloads; accepting one whose store matches does not — same seam switchTo is already tested on at web-sync.test.ts:492-514.
  • The block does not render when tenantSwitching.kind === 'unsupported' — S4.
  • make verify green.

Not machine-checkable, required before this is called done — apps/web in a real browser, not jsdom:

  1. Invite a second account from the account site's team page.
  2. Sign up and verify that account.
  3. Open the browser app, sign in, and see the invitation in Settings → Sync.
  4. Accept it. Land in the new workspace with its documents, not the old one's.
  5. Decline a second invitation and watch it leave the list.

What it costs

  • One optional field on send(), one method on TenantSwitching.
  • ~120 lines in editor-account, ~80 in the Settings block and composable.
  • Nothing deleted. services/web keeps its own invitation page; the two implementations coexist until the apps/web licensing question is answered one way or the other.

What it is worth

  • A user who is invited to a workspace can say yes in the app they are already signed into, which is where the server's own invite email (tenants_invitations_create.rs:208-214) already tells them to look.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment