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/evidence/invitation-journey-uat.json b/docs/evidence/invitation-journey-uat.json new file mode 100644 index 0000000..1f96d28 --- /dev/null +++ b/docs/evidence/invitation-journey-uat.json @@ -0,0 +1,19 @@ +{ + "releaseSha": "f10437bfbe46c4cbcbfb71bc6b599e9551669051", + "at": "2026-10-18T05:34:42.693Z", + "previewRecipient": true, + "previewDoesNotJoin": true, + "wrongAccountDenied": true, + "anonymousDenied": true, + "landingNoDestinationMetadata": true, + "crossServerAcceptDenied": true, + "revokedAcceptDenied": true, + "guestHasNoOrgMembership": true, + "siblingDenied": true, + "nativeInvitationPending": true, + "schema": { + "d1": 5, + "org": 4, + "channel": 5 + } +} \ No newline at end of file diff --git a/docs/spec.md b/docs/spec.md index 94b13ba..7769ddf 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -4,6 +4,8 @@ Canonical delivery specification: https://github.com/BotHarness/tob/issues/1 +> **Current checkpoint, 2026-10-18:** the earlier initialization-only paragraphs below are historical scope. Mobile/CLI login, default and multiple workspaces, channels, recipient-bound workspace/channel invitations, channel Agents and bounded two-Host health-check forwarding now have implementation and UAT records in their linked slice documents. The umbrella remains open for outstanding gates. Next Draft [#25](https://github.com/BotHarness/tob/pull/25) adds invitation locators and recipient-only previews for DeepSeekBot [#2169](https://github.com/BotHarness/DeepSeekBot/pull/2169); its API/CLI and assigned iOS guest invitation acceptance now have [native evidence](https://github.com/BotHarness/DeepSeekBot/blob/8b9c8dba55f13e323274fc728a3116a2df2ad9bb/.github/pr-screenshots/2161-invitation-journey/README.md); sender sharing, new-instance trust/Hosted AuthKit continuation, camera and Android remain pending. A link carries a destination, not permission, and channel guests still get no organization membership. No public reusable invitations, federation or Prod changes are introduced. See [CLI contract](t1-cli-session.md#recipient-bound-invitation-links-next-tracer-validation-in-progress). + 状态:T1 的部署与 WorkOS 登录/首个管理员初始化增量已在真实 UAT 验证。健康/版本、D1 与 Org DO 迁移、JWT 校验、受保护的原子初始化和 `/api/me` 已实现;组织/群命令仍关闭。可信配置文件导入及正式 CLI 登录、刷新、退出也已通过真实 UAT 验证;手机及组织路径仍待完成,T1 保持开放,T2–T5 与 Prod 未启动。 2026-10-18 UAT 证据:有效身份初始化前拒绝 → 已接受邀请的实际用户原子绑定 → 同一身份 `/api/me` 成功;同目标重试不改写授权。准确部署 SHA、测试范围、首次失败与剩余门槛见 [登录与初始化切片](t1-auth-bootstrap.md#evidence-and-remaining-boundaries)。本条记录属于较早的初始化增量;CLI 后续结果见下文,Prod 不变。 diff --git a/docs/t1-cli-session.md b/docs/t1-cli-session.md index 6ebb285..2e99e2c 100644 --- a/docs/t1-cli-session.md +++ b/docs/t1-cli-session.md @@ -87,3 +87,34 @@ 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. This initial source checkpoint is superseded by the UAT and native results below; no Prod change or merge is implied. QR display/save and the unified cloud workspace drawer are follow-up work. + +### UAT API/CLI replay — 2026-10-18 + +Service `f10437bfbe46c4cbcbfb71bc6b599e9551669051` passed [CI](https://github.com/BotHarness/tob/actions/runs/39100874671), was deployed through the existing guarded UAT-only helper, and read back the same version with D1/Org/Channel schemas 5/4/5. [Sanitized replay evidence](evidence/invitation-journey-uat.json) records actual synthetic A/B accounts: recipient preview without joining, wrong-account/anonymous refusal, neutral landing without destination data, explicit server mismatch refusal, revoked acceptance denial, no workspace membership and sibling-channel refusal. The pending native invitation was subsequently exercised in the assigned Simulator run below. + +- [x] Public locator and recipient preview through real UAT CLI/API. +- [x] Wrong-account, no-join, revoked and cross-server boundaries. +- [x] Assigned iOS Simulator before/after evidence: deep-link preview, pending/cold restart, explicit accept and exact guest-channel navigation. +- [x] Native message once, cold reopen, revoked-grant denial/list removal, refresh and isolated session cleanup. +- [ ] Sender native copy/share and new-instance trust/Hosted AuthKit continuation carrying an invitation. +- [ ] QR rendering/save and real camera acceptance, followed by separate platform coverage. + +### Native invitation replay — 2026-10-18 + +[Batch21 evidence](https://github.com/BotHarness/DeepSeekBot/blob/8b9c8dba55f13e323274fc728a3116a2df2ad9bb/.github/pr-screenshots/2161-invitation-journey/README.md) binds the real iOS Simulator run to mobile runtime `1a2c1c385fd842958a078da05bb85c841d69934e`, baseline `9a2b651844d8686a0381cbd2d991da6d5199e924` and this service's `f10437b` UAT deployment. The recipient previews without joining, retains the pending target through cold restart, explicitly accepts, enters only the invited guest channel and sends exactly one message. CLI independently confirms the message, no organization membership and sibling denial. Cold reopen returns to that channel; revoke denies the old session and removes the guest destination. Phone refresh/logout revokes only the synthetic test session and rejects its latest refresh token, preserving others. + +Two first failures were fixed in the mobile client: a misleading saved-session label and absent in-memory identity after restart hiding an already loaded guest channel. The latter has a failing-before/passing-after regression; 32 client tests/typecheck and final runtime CI pass. The recording and original failures remain in the evidence. Synthetic login does not prove Hosted AuthKit interaction. New-instance trust/browser continuation, sender sharing, camera and Android remain separate gates. Original display settings and the shared device window were restored/released; no Prod change or new PR merge. diff --git a/docs/t1-design.md b/docs/t1-design.md index 48199f0..0a74828 100644 --- a/docs/t1-design.md +++ b/docs/t1-design.md @@ -424,3 +424,9 @@ The bounded UAT mobile path, deployment callback opt-in, public QR export and ex The expanded [multi-user collaboration contract](multi-user-collaboration.md) replaces the previous restriction that every application user must be the initialized administrator. Bootstrap is unchanged; a protected UAT operator change enables normal verified WorkOS users. First application sign-in invokes idempotent default-workspace provisioning. Additional organization creation remains independently configurable. CLI `onboard` and mobile session check retry setup with the existing session. Registration, instance administration and organization ownership remain separate permissions. Acceptance: two ordinary synthetic UAT accounts receive distinct default workspaces; repeated/concurrent setup creates no duplicates; A creates/selects another workspace; B cannot list/read A's workspaces even with a claimed org ID. No administrator grant is created. Native evidence, public API/CLI evidence and browser authentication evidence remain distinct. Invitations and channel/Agent work are subsequent slices, not completed by this change. + +### Invitation-first navigation checkpoint + +The next client tracer carries a recipient-bound invitation through service selection/trust and existing WorkOS login, then explicitly accepts it. `GET /api/{invitations|channel-invitations}/:id` only returns destination identity to the verified recipient and never creates membership. The same origin-bound URL works with CLI preview/accept and the native join screen. A pending URL is local navigation state, not an authorization record. Read the [current contract and acceptance](t1-cli-session.md#recipient-bound-invitation-links-next-tracer-validation-in-progress) for actual UAT evidence and unfinished native/QR gates. The earlier admin-only and no-organization paragraphs describe the historical T1 initialization increment; later collaboration slices have their own recorded authorizations and evidence. + +Assigned iOS continuation: [native invitation evidence](https://github.com/BotHarness/DeepSeekBot/blob/8b9c8dba55f13e323274fc728a3116a2df2ad9bb/.github/pr-screenshots/2161-invitation-journey/README.md) verifies pending-target cold restart, exact guest-channel entry without organization membership, one message, revocation and isolated session cleanup. Sender copy/share and new-instance trust/Hosted AuthKit return are still explicit acceptance gates; the synthetic test session does not substitute for those flows. 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=