From f10437bfbe46c4cbcbfb71bc6b599e9551669051 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 14:31:01 +0900 Subject: [PATCH 1/3] Add recipient-bound invitation links and previews Issue: #3 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- apps/cli/lib/commands.ts | 18 ++++++++++++++++-- apps/service/src/guests.ts | 9 +++++++++ apps/service/src/index.ts | 14 ++++++++++++++ apps/service/src/invitations.ts | 7 +++++++ docs/t1-cli-session.md | 14 ++++++++++++++ packages/contracts/invitation-link.ts | 20 ++++++++++++++++++++ tests/cli.node.test.ts | 12 ++++++++++++ tests/service.test.ts | 26 ++++++++++++++++++++++++++ 8 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 packages/contracts/invitation-link.ts diff --git a/apps/cli/lib/commands.ts b/apps/cli/lib/commands.ts index 9602e77..bf47ea5 100644 --- a/apps/cli/lib/commands.ts +++ b/apps/cli/lib/commands.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { invitationLink, parseInvitationLink } from "../../../packages/contracts/invitation-link.ts"; import { agentCommand } from "./agent.ts"; import { parseWorkInput } from "../../../packages/contracts/work.ts"; import { setTimeout as sleep } from "node:timers/promises"; @@ -22,6 +23,8 @@ async function identity(c: InstanceConfig, s: Session) { return i; } export async function execute(args: string[], rt: Runtime): Promise { + if (args[0] === "invitation-link" && args.length === 3) + return { url: invitationLink(args[1], args[2]), recipientBound: true }; if (args[0] === "health" && args[1] === "--server" && args.length === 3) { const url = new URL(args[2]); if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !(url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) throw new ClientError("INVALID_SERVER_ORIGIN"); @@ -59,12 +62,17 @@ export async function execute(args: string[], rt: Runtime): Promise { const orgCommand = scoped ? args[1] : undefined; const options: Record = {}; if (scoped) { - if (!(isWork ? ["request"] : isAgent ? ["list", "create", "revoke"] : isOrg ? ["list", "create", "use", "show", "update"] : (isInvitation || isGuestInvite) ? ["inbox", "list", "send", "accept", "revoke"] : isGuest ? ["list", "show", "history", "send"] : ["list", "create", "show", "history", "send"]).includes(orgCommand ?? "")) throw new ClientError("USAGE"); + if (!(isWork ? ["request"] : isAgent ? ["list", "create", "revoke"] : isOrg ? ["list", "create", "use", "show", "update"] : (isInvitation || isGuestInvite) ? ["inbox", "list", "send", "preview", "accept", "revoke"] : isGuest ? ["list", "show", "history", "send"] : ["list", "create", "show", "history", "send"]).includes(orgCommand ?? "")) throw new ClientError("USAGE"); for (let n = 2; n < args.length; n += 2) { - if (!["--server", "--id", "--name", "--operation", "--description", "--version", "--after", "--org", "--text-file", "--email", "--channel", "--agent", "--expires-at"].includes(args[n]) || args[n+1] === undefined || options[args[n]] !== undefined) throw new ClientError("USAGE"); + if (!["--server", "--id", "--link", "--name", "--operation", "--description", "--version", "--after", "--org", "--text-file", "--email", "--channel", "--agent", "--expires-at"].includes(args[n]) || args[n+1] === undefined || options[args[n]] !== undefined) throw new ClientError("USAGE"); options[args[n]] = args[n+1]; } } + if (options["--link"]) { + const target = parseInvitationLink(options["--link"]); + if (!(isInvitation || isGuestInvite) || !["preview", "accept"].includes(orgCommand ?? "") || options["--id"] || target.server !== options["--server"] || target.guest !== isGuestInvite) throw new ClientError("INVITATION_TARGET_MISMATCH"); + options["--id"] = target.invitationId; + } const command = args[0]; const server = scoped ? options["--server"] : args[2]; if ((!scoped && (!["login", "whoami", "refresh", "logout", "onboard"].includes(command) || args[1] !== "--server" || args.length !== 3)) || !server) throw new ClientError("USAGE"); @@ -152,6 +160,12 @@ export async function execute(args: string[], rt: Runtime): Promise { } return value; }; + if ((isInvitation || isGuestInvite) && orgCommand === "preview") { + const id = options["--id"] ?? ""; + invitationLink(c.server, id); + if (id.startsWith("ci_") !== isGuestInvite) throw new ClientError("INVALID_INVITATION"); + return call("/" + id, {}, isGuestInvite ? "/api/channel-invitations" : "/api/invitations"); + } if (isWork) { const orgId = options["--org"] ?? i.organizationId; const channel = options["--channel"]; diff --git a/apps/service/src/guests.ts b/apps/service/src/guests.ts index 5635a87..f96b60a 100644 --- a/apps/service/src/guests.ts +++ b/apps/service/src/guests.ts @@ -53,6 +53,15 @@ export const ownerGuestInvitations = new Hono<{ Bindings: Env }>() }); export const guestInvitations = new Hono<{ Bindings: Env }>() .use("*", limit) + .get("/:id", async c => { + const user = await recipient(c.env, c.req.header("Authorization")); + const row = await indexed(c.env, c.req.param("id")); + const value = await c.env.CHANNELS.getByName(row.channelId).recipientGuestInvitation(row.invitationId, user.userId, user.email); + if (!value || !await localOrg(c.env, value.organizationId)) throw new AccessError("CHANNEL_INVITATION_ACCESS_DENIED", 403); + // Recipient verification precedes even the workspace identity projection. + const org = await c.env.ORGS.getByName(value.organizationId).read(value.organizationId); + return c.json({ ...value, organizationName: org.name }); + }) .get("/", async c => { const user = await recipient(c.env, c.req.header("Authorization")); const rows = await drizzle(c.env.INSTANCE_DB).select().from(channelInvitationDirectory).where(and(or(eq(channelInvitationDirectory.recipientEmail, user.email), eq(channelInvitationDirectory.acceptedUserId, user.userId)), gt(channelInvitationDirectory.invitationId, cursor(c.req.query("after"))))).orderBy(asc(channelInvitationDirectory.invitationId)).limit(51).all(); diff --git a/apps/service/src/index.ts b/apps/service/src/index.ts index 33f0dad..8e86ca6 100644 --- a/apps/service/src/index.ts +++ b/apps/service/src/index.ts @@ -1,4 +1,6 @@ import { Hono } from "hono"; +import { html } from "hono/html"; +import { invitationLink, parseInvitationLink } from "../../../packages/contracts/invitation-link.ts"; import { drizzle } from "drizzle-orm/d1"; import { admit, AccessError } from "./auth/admission"; import { organizations, createOrganization } from "./organizations"; @@ -27,6 +29,18 @@ app.onError((_error, c) => { return c.json({ error: { code: "STORAGE_UNAVAILABLE" } }, 503); }); const api = app + .get("/join", c => { + const raw = c.env.PUBLIC_BASE_URL + new URL(c.req.url).pathname + new URL(c.req.url).search; + let target; + try { target = parseInvitationLink(raw); } + catch { return c.text("Invalid invitation link", 400); } + const link = invitationLink(target.server, target.invitationId); + const appLink = "ai.botharness.deepseekbot.dev:/cloud-join?link=" + encodeURIComponent(link); + c.header("Referrer-Policy", "no-referrer"); + c.header("Content-Security-Policy", "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); + // No recipient, workspace or channel metadata is exposed before authentication. + return c.html(html`Join a cloud conversation

Join a cloud conversation

Service: ${target.server}

Open this invitation in DeepSeekBot and sign in with the invited account. You will review the destination before joining.

Open in DeepSeekBot

You can also paste this invitation link into “Join with invitation” in the app.

`); + }) .get("/.well-known/open-cloud-groups", (c) => c.json(publicInstance({ instanceId: c.env.INSTANCE_ID, environment: c.env.DEPLOYMENT_ENV, server: c.env.PUBLIC_BASE_URL, clientId: c.env.WORKOS_CLIENT_ID, issuer: c.env.WORKOS_ISSUER, authkitDomain: c.env.WORKOS_AUTH_DOMAIN, mobileRedirectUri: c.env.MOBILE_REDIRECT_URI }))) .post("/api/auth/logout", async (c) => { let identity; diff --git a/apps/service/src/invitations.ts b/apps/service/src/invitations.ts index 2244cb8..9a8b882 100644 --- a/apps/service/src/invitations.ts +++ b/apps/service/src/invitations.ts @@ -110,6 +110,13 @@ export const organizationInvitations = new Hono<{ Bindings: Env }>() }); export const invitations = new Hono<{ Bindings: Env }>() .use("*", limit) + .get("/:id", async c => { + const user = await recipient(c.env, c.req.header("Authorization")); + const value = await get(c.env, c.req.param("id")); + if (value.email.toLowerCase() !== user.email || (value.accepted_user_id && value.accepted_user_id !== user.userId)) throw new AccessError("INVITATION_ACCESS_DENIED", 403); + const org = await c.env.ORGS.getByName(value.organization_id).read(value.organization_id); + return c.json({ ...view(value), organizationName: org.name }); + }) .get("/", async c => { const user = await recipient(c.env, c.req.header("Authorization")); const result = await page(c.env, new URLSearchParams({ email: user.email, limit: "50", ...cursor(c.req.query("after")) })); diff --git a/docs/t1-cli-session.md b/docs/t1-cli-session.md index 6ebb285..585b94e 100644 --- a/docs/t1-cli-session.md +++ b/docs/t1-cli-session.md @@ -87,3 +87,17 @@ The successful final replay used the same Human-approved session, restored from Checks for this source: typecheck/build, 13 workerd tests, 16 Node tests, plus 2 wizard tests in CI. Live configuration-drift, crash/power-loss, cross-device and native-mobile scenarios were not run; their source tests/design must not be described as cloud or Human QA evidence. WorkOS access JWTs may remain valid until expiry after revocation; immediate access-token invalidation remains a later T1 gate. Remaining T1 work: mobile QR/import and native PKCE/secure storage, independent phone/CLI organization access, owner/member setup and organization creation/retry, signed identity-event reconciliation and measured revocation behavior, and native Human QA. T1 stays open; T2–T5 and Production are untouched. + +## Recipient-bound invitation links (next tracer, validation in progress) + +An invitation locator has the form `https:///join?invitation=`. It contains no WorkOS token or Agent credential and does not grant access. The public landing only displays the service and opens the native client. Destination metadata requires a verified invited account through `GET /api/invitations/:id` or `GET /api/channel-invitations/:id`; neither GET accepts the invitation. Existing POST acceptance and revocation remain authoritative. + +```sh +node apps/cli/tob.mjs invitation-link https://groups.example ci_ +node apps/cli/tob.mjs channel-invite preview --server https://groups.example --link 'https://groups.example/join?invitation=ci_' +node apps/cli/tob.mjs channel-invite accept --server https://groups.example --link 'https://groups.example/join?invitation=ci_' +``` + +Use `invite` instead of `channel-invite` for workspace invitations. The explicit server must match the link and an independently imported configuration; link parsing never imports trust or logs into another server. A wrong account gets no destination metadata. A revoked or expired invitation cannot be accepted. Mobile adds a device-local pending target through login/restart, then explicitly accepts and opens the selected guest channel without obtaining workspace membership. + +Local validation: 62 workerd tests and 20 CLI tests passed, including recipient preview/no-join, wrong-account denial, revoked acceptance denial, neutral landing and cross-server credential refusal. This records source/local runtime evidence only. UAT deployment, real invitation replay and native screenshots/recording for this new slice remain pending; no Prod change or merge is implied. QR display/save and the unified cloud workspace drawer are follow-up work. diff --git a/packages/contracts/invitation-link.ts b/packages/contracts/invitation-link.ts new file mode 100644 index 0000000..f59205b --- /dev/null +++ b/packages/contracts/invitation-link.ts @@ -0,0 +1,20 @@ +import { ClientError, serverOrigin } from './instance.ts'; + +// A locator for a recipient-bound invitation. Possession never grants membership. +export function invitationLink(server: string, invitationId: string): string { + serverOrigin(server); + if (!/^(?:invitation_[A-Za-z0-9]{1,120}|ci_[a-f0-9]{64})$/.test(invitationId)) + throw new ClientError('INVALID_INVITATION_LINK'); + return server + '/join?invitation=' + invitationId; +} +export function parseInvitationLink(raw: string) { + try { + if (raw.length > 2048 || raw !== raw.trim()) throw Error(); + const url = new URL(raw); + const id = url.searchParams.get('invitation') ?? ''; + if (url.username || url.password || url.hash || url.pathname !== '/join' || + [...url.searchParams.keys()].length !== 1 || invitationLink(url.origin, id) !== raw) + throw Error(); + return { server: url.origin, invitationId: id, guest: id.startsWith('ci_') }; + } catch { throw new ClientError('INVALID_INVITATION_LINK'); } +} diff --git a/tests/cli.node.test.ts b/tests/cli.node.test.ts index 3e8e5ed..f7e6b71 100644 --- a/tests/cli.node.test.ts +++ b/tests/cli.node.test.ts @@ -296,3 +296,15 @@ test('work request CLI carries only fixed target/expiry and stable operation thr }; assert.deepEqual(await run(),{replayed:false});assert.deepEqual(await run(),{replayed:true}); }); + +test('invitation links preserve target and CLI refuses a different server before sending credentials', async()=>{ + await setup(); + const id='ci_'+'a'.repeat(64), url=config.server+'/join?invitation='+id; + assert.deepEqual(await execute(['invitation-link',config.server,id],rt),{url,recipientBound:true}); + const delegate=globalThis.fetch; + globalThis.fetch=async(input,init)=>String(input)===config.server+'/api/channel-invitations/'+id ? Response.json({invitationId:id,state:'pending'}) : delegate(input,init); + assert.deepEqual(await execute(['channel-invite','preview','--server',config.server,'--link',url],rt),{invitationId:id,state:'pending'}); + const count=requests.length; + await fails(execute(['channel-invite','accept','--server',config.server,'--link',url.replace(config.server,'https://other.example')],rt),'INVITATION_TARGET_MISMATCH'); + assert.equal(requests.length,count); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 2834b2f..4e33cbe 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -933,3 +933,29 @@ it('Agent identity discovery reports canonical channel and actor without exposin await f.call(f.base+'/agents/'+a.actorId+'/revoke','user_a','POST'); expect((await call()).status).toBe(403); }); + +it('invitation preview discloses the destination only to its verified recipient and never joins', async () => { + const f = await guestFixture(); + const v = await (await f.issue()).json() as { invitationId: string }; + const path = '/channel-invitations/' + v.invitationId; + expect((await request('/api' + path)).status).toBe(401); + expect((await f.guest(path, 'user_c')).status).toBe(403); + expect(await (await f.guest(path)).json()).toMatchObject({ invitationId: v.invitationId, channelId: f.channel.channelId, organizationName: expect.any(String), state: 'pending', role: 'guest' }); + expect((await f.guest('/guest-channels/' + f.channel.channelId)).status).toBe(403); + expect(f.members.map(m => m.user_id)).toEqual(['user_a']); + await f.call(f.base + '/guest-invitations/' + v.invitationId + '/revoke', 'user_a', 'POST'); + expect(await (await f.guest(path)).json()).toMatchObject({ state: 'revoked' }); + expect((await f.accept(v.invitationId)).status).toBe(409); + expect((await f.guest('/guest-channels/' + f.sibling.channelId)).status).toBe(403); +}); +it('invitation landing is metadata-free, escaped, and rejects malformed locators', async () => { + const response = await request('/join?invitation=ci_' + 'a'.repeat(64)); + expect(response.status).toBe(200); + expect(response.headers.get('Referrer-Policy')).toBe('no-referrer'); + expect(response.headers.get('Content-Security-Policy')).toContain("frame-ancestors 'none'"); + const page = await response.text(); + expect(page).toContain('ai.botharness.deepseekbot.dev:/cloud-join?link='); + expect(page).not.toContain('organizationId'); + for (const query of ['invitation=