Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/cli/lib/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,6 +23,8 @@ async function identity(c: InstanceConfig, s: Session) {
return i;
}
export async function execute(args: string[], rt: Runtime): Promise<unknown> {
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");
Expand Down Expand Up @@ -59,12 +62,17 @@ export async function execute(args: string[], rt: Runtime): Promise<unknown> {
const orgCommand = scoped ? args[1] : undefined;
const options: Record<string, string> = {};
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");
Expand Down Expand Up @@ -152,6 +160,12 @@ export async function execute(args: string[], rt: Runtime): Promise<unknown> {
}
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"];
Expand Down
9 changes: 9 additions & 0 deletions apps/service/src/guests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions apps/service/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Join a cloud conversation</title></head><body><main><h1>Join a cloud conversation</h1><p>Service: ${target.server}</p><p>Open this invitation in DeepSeekBot and sign in with the invited account. You will review the destination before joining.</p><p><a href="${appLink}">Open in DeepSeekBot</a></p><p>You can also paste this invitation link into “Join with invitation” in the app.</p></main></body></html>`);
})
.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;
Expand Down
7 changes: 7 additions & 0 deletions apps/service/src/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) }));
Expand Down
19 changes: 19 additions & 0 deletions docs/evidence/invitation-journey-uat.json
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 2 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不变。
Expand Down
31 changes: 31 additions & 0 deletions docs/t1-cli-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<instance>/join?invitation=<invitation-id>`. 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_<id>
node apps/cli/tob.mjs channel-invite preview --server https://groups.example --link 'https://groups.example/join?invitation=ci_<id>'
node apps/cli/tob.mjs channel-invite accept --server https://groups.example --link 'https://groups.example/join?invitation=ci_<id>'
```

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.
6 changes: 6 additions & 0 deletions docs/t1-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions packages/contracts/invitation-link.ts
Original file line number Diff line number Diff line change
@@ -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'); }
}
Loading
Loading