From 5894a024d3c9e20791b844b8cb25c7db0ba0b0e1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:39:58 -0700 Subject: [PATCH 01/18] feat(chat)!: hard-cut chat tenancy to client-minted Interchange tenants Summary: - New forward migration 0031 drops chat.workbench_tenancy and its index; history untouched, chat_bench_settings kept (product data, no drop). - Delete workbench-tenancy.ts, workbench-tenancy-routes.ts (POST /kinds), POST /workbenches/:id/move, and POST /participants/mint-dm. - Delete mint wrappers (createWorkbenchTenant, mintNativeChildTenant, mintAgentDm, provisionSpaceWorkbench), tenancy compensations, grant planting, addWorkbenchMember conferral, withTenancy annotator, legacy true/false and no_tenancy paths, find-or-reopen. - Retain pure native principal reads in new neutral module src/native-principal.ts (refId projection for DM 1:1 validation); server mints nothing, settings writes take client-minted ids. - Update no-product-tenancy structural allowlist (workbench_tenancy removed) and chat-local README. Verification: - bun run typecheck (packages/chat): exit 0 - bun test (packages/chat): 928 pass, 0 fail (two consecutive full runs) - bunx prettier --check on changed files: pass; bunx eslint packages/chat: exit 0 - bun run check:structural no-product-tenancy: ok --- packages/chat/README.md | 20 +- packages/chat/src/agent-dm-mode.ts | 16 +- packages/chat/src/federation-trust.ts | 8 +- packages/chat/src/index.ts | 28 +- packages/chat/src/migrations.ts | 7 + packages/chat/src/native-principal.test.ts | 22 + packages/chat/src/native-principal.ts | 56 + packages/chat/src/routes.ts | 406 +----- packages/chat/src/schema.ts | 22 +- packages/chat/src/store.ts | 2 +- packages/chat/src/workbench-service.ts | 465 +------ packages/chat/src/workbench-settings.ts | 6 +- packages/chat/src/workbench-share.ts | 6 +- packages/chat/src/workbench-tenancy-routes.ts | 59 - packages/chat/src/workbench-tenancy.ts | 1189 ----------------- .../chat/src/workflow-participant-routes.ts | 156 +-- .../chat/test/agent-dm-warm-mailbox.test.ts | 326 ----- packages/chat/test/agent-turns-routes.test.ts | 29 +- packages/chat/test/migrations.test.ts | 26 +- .../test/provision-space-workbench.test.ts | 88 -- packages/chat/test/room-access.test.ts | 35 +- packages/chat/test/routes.test.ts | 697 ++-------- packages/chat/test/test-support.ts | 39 +- packages/chat/test/workbench-service.test.ts | 32 +- .../test/workbench-tenancy-routes.test.ts | 89 -- packages/chat/test/workbench-tenancy.test.ts | 514 ------- .../test/workflow-participant-routes.test.ts | 217 --- scripts/checks/no-product-tenancy.ts | 3 +- 28 files changed, 358 insertions(+), 4205 deletions(-) create mode 100644 packages/chat/src/native-principal.test.ts create mode 100644 packages/chat/src/native-principal.ts delete mode 100644 packages/chat/src/workbench-tenancy-routes.ts delete mode 100644 packages/chat/src/workbench-tenancy.ts delete mode 100644 packages/chat/test/agent-dm-warm-mailbox.test.ts delete mode 100644 packages/chat/test/provision-space-workbench.test.ts delete mode 100644 packages/chat/test/workbench-tenancy-routes.test.ts delete mode 100644 packages/chat/test/workbench-tenancy.test.ts diff --git a/packages/chat/README.md b/packages/chat/README.md index 2a9242c535..f1d64060ba 100644 --- a/packages/chat/README.md +++ b/packages/chat/README.md @@ -2,13 +2,19 @@ Workbenches as the workbench's chat surface: workbench lifecycle (create, join, invite), message send with mention fan-out, threads, reactions, pins, -poll/form block responses, settings, tenancy (workbenches as child tenants), -cross-tenant workbench sharing gated by bilateral trust, and the SSE stream a +poll/form block responses, settings, cross-tenant workbench sharing +gated by bilateral trust, and the SSE stream a workbench's UI subscribes to. Invited agents are provisioned workflow runs — this package launches and wakes them through Interchange `prepareProvisionedDeployment`, never reimplementing that machinery itself. +Tenancy model: the client creates each conversation tenant through +stock Interchange before initializing chat, and the tenant id is the +workbench id. Chat never mints, links, or drops tenants — there is no +tenancy table (migration `0031` dropped `workbench_tenancy`) and no +server mint route. + ## Composing with `@intx/*` This package consumes Interchange's published surfaces rather than @@ -37,11 +43,11 @@ primitives, `@intx/authz` for grant checks, `@intx/crypto` for signing, - **`src/chat-orchestrator.ts`** — turns invited-agent replies and approval-gate events into workbench messages and in-chat approve blocks. - **`src/workbench-service.ts`** — workbench-level orchestration above the - platform port: joining an agent, sending with mention fan-out, - provisioning a bare space workbench. -- **`src/workbench-tenancy.ts`** / **`src/workbench-share.ts`** — workbenches as - child tenants, and cross-tenant workbench projection gated by - `src/federation-trust.ts`'s bilateral trust. + platform port: joining an agent and sending with mention fan-out. +- **`src/native-principal.ts`** / **`src/workbench-share.ts`** — reads of + native principals (DM counterpart validation) and cross-tenant + workbench projection gated by `src/federation-trust.ts`'s bilateral + trust. - **`src/threads.ts`**, **`src/reactions.ts`**, **`src/pins.ts`**, **`src/block-responses.ts`** — thread identity, message reactions, pinned messages, and poll/form response persistence and aggregation. diff --git a/packages/chat/src/agent-dm-mode.ts b/packages/chat/src/agent-dm-mode.ts index 6e63e19d66..a447ee87b3 100644 --- a/packages/chat/src/agent-dm-mode.ts +++ b/packages/chat/src/agent-dm-mode.ts @@ -1,13 +1,15 @@ // CL-7108: the agent-DM pin — the ONE place that says what an agent DM // is. An agent DM is a `kind: "chat"` conversation carrying a -// `chat/definitionId`: the one 1:1 (tenant, agent) conversation, minted by -// `mintAgentDm` and found-or-reopened by `findExistingAgentChat` (see -// `./workbench-service.ts`) rather than invited like a room agent. +// `chat/definitionId`: the one 1:1 (tenant, agent) conversation. The +// client mints it through stock Interchange and stamps the definition +// id in the settings it writes; the server never mints DMs — this +// module only validates what the client wrote (see +// `definitionIdOfSettings` / `isAgentDmSettings` below). // // Why DMs differ from rooms is chat-layer only — the execution plane is // identical. A DM's agent runs the same interactive warm-mailbox shape a -// room agent does: one standing per-agent run, provisioned once (at mint, -// ahead of the member's first message) from the conversation's +// room agent does: one standing per-agent run, provisioned once (at first +// message, ahead of the member's first turn) from the conversation's // single-definition asset, every inbound mail a header-threaded turn on // that run, the turn rows a durable per-run INBOX verifiable in the hub // replica, and the agent's `mail_wait` wired in the one place the @@ -15,8 +17,8 @@ // never parks awaiting agent mail; dispatch is fire-and-forget and the // agent-turns projection plus the turn-mail correlation carry the trail). // What makes a DM a DM lives entirely here at the chat layer: the 1:1 -// identity this pin names, find-or-reopen instead of invite, the sidebar -// bucket, the greeting, mint-not-invite. +// identity this pin names, the sidebar bucket, the greeting — +// validated, never minted. // // Every settings-level reader and writer of DM-ness goes through this // module's keys and predicate — never a second inline `chat/definitionId` diff --git a/packages/chat/src/federation-trust.ts b/packages/chat/src/federation-trust.ts index bdeb85e341..2b4ab8f229 100644 --- a/packages/chat/src/federation-trust.ts +++ b/packages/chat/src/federation-trust.ts @@ -6,9 +6,8 @@ // — both tenants have separately opted in — which is what // `packages/chat/src/workbench-share.ts` gates workbench projection on. // -// Mirrors `workbench-tenancy.ts`'s shape: a store interface, a drizzle -// implementation, and an in-memory test double with `registerTenant` test -// support. +// Shape: a store interface, a drizzle implementation, and an in-memory +// test double with `registerTenant` test support. import { and, eq, inArray } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { generateId } from "@intx/hub-common"; @@ -176,8 +175,7 @@ export function createDrizzleFederationTrustStore< * An in-memory `FederationTrustStore`, for tests and any host wiring chat * routes without a database. `registerTenant` is test-support only — * standing up a fake native `tenant` row (name + optional parent) without - * a database, mirroring `createInMemoryWorkbenchTenancyStore`'s - * `registerExistingTenant`. `seedDirectionalTrust` lets a test construct + * a database. `seedDirectionalTrust` lets a test construct * the "only one direction exists" case directly, without going through * `establishBilateralTrust` (which always writes both). */ diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index bd000ce4ee..43c08210ce 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -81,7 +81,7 @@ export { } from "./participants"; export type { ParticipantRecord } from "./participants"; -export { createChatRoutes, findExistingAgentChat } from "./routes"; +export { createChatRoutes } from "./routes"; export type { CreateChatRoutesDeps } from "./routes"; export { @@ -250,21 +250,16 @@ export { waitUntilFreeTimeoutMessage, launchAndJoinAgent, KindIsChatError, - mintAgentDm, postCannedGreeting, cannedGreeting, sendWorkbenchMessage, startWorkflowCommand, - provisionSpaceWorkbench, cancelWorkbenchTurn, } from "./workbench-service"; export type { LaunchAndJoinAgentDeps, LaunchAndJoinAgentInput, LaunchAndJoinAgentResult, - MintAgentDmDeps, - MintAgentDmInput, - MintAgentDmResult, PostCannedGreetingDeps, PostCannedGreetingInput, CannedGreetingInput, @@ -275,26 +270,13 @@ export type { StartWorkflowCommandDeps, StartWorkflowCommandInput, StartWorkflowCommandResult, - ProvisionSpaceWorkbenchDeps, - ProvisionSpaceWorkbenchInput, - ProvisionSpaceWorkbenchResult, } from "./workbench-service"; export { - createDrizzleWorkbenchTenancyStore, - createInMemoryWorkbenchTenancyStore, -} from "./workbench-tenancy"; -export type { - WorkbenchTenancyDb, - WorkbenchTenancyRow, - WorkbenchTenancyStore, - CreateWorkbenchTenantInput, - CreateWorkbenchTenantResult, - MoveWorkbenchTenancyInput, -} from "./workbench-tenancy"; - -export { createWorkbenchTenancyRoutes } from "./workbench-tenancy-routes"; -export type { CreateWorkbenchTenancyRoutesDeps } from "./workbench-tenancy-routes"; + createDrizzleNativePrincipalStore, + createInMemoryNativePrincipalStore, +} from "./native-principal"; +export type { NativePrincipal, NativePrincipalStore } from "./native-principal"; export { createDrizzleFederationTrustStore, diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index d8d02da322..2ce3080112 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -588,6 +588,13 @@ export const chatMigrations: readonly ChatMigration[] = [ ("tenant_id", "agent_address", "occurrence"); `, }, + { + name: "0031_drop_workbench_tenancy", + sql: ` + DROP INDEX IF EXISTS "chat"."workbench_tenancy_parent_tenant_id_idx"; + DROP TABLE IF EXISTS "chat"."workbench_tenancy"; + `, + }, ]; // Bookkeeping table for this package's own migrations. Named diff --git a/packages/chat/src/native-principal.test.ts b/packages/chat/src/native-principal.test.ts new file mode 100644 index 0000000000..370ecda30b --- /dev/null +++ b/packages/chat/src/native-principal.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { createInMemoryNativePrincipalStore } from "./native-principal"; + +test("native principal reads stay scoped to the requested tenant", async () => { + const principals = createInMemoryNativePrincipalStore(); + principals.registerPrincipal("tnt_dm", { + id: "prn_member", + kind: "user", + status: "active", + refId: "usr_member", + }); + + expect(await principals.getTenantPrincipal("tnt_dm", "prn_member")).toEqual({ + id: "prn_member", + kind: "user", + status: "active", + refId: "usr_member", + }); + expect( + await principals.getTenantPrincipal("tnt_other", "prn_member"), + ).toBeUndefined(); +}); diff --git a/packages/chat/src/native-principal.ts b/packages/chat/src/native-principal.ts new file mode 100644 index 0000000000..0ba71fefac --- /dev/null +++ b/packages/chat/src/native-principal.ts @@ -0,0 +1,56 @@ +import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { principal } from "@intx/db/schema"; + +export interface NativePrincipal { + readonly id: string; + readonly kind: "user" | "agent" | "workflow"; + readonly status: "active" | "suspended" | "invited" | "deactivated"; + readonly refId: string; +} + +export interface NativePrincipalStore { + getTenantPrincipal( + tenantId: string, + principalId: string, + ): Promise; +} + +export function createDrizzleNativePrincipalStore< + TSchema extends Record, +>(db: PostgresJsDatabase): NativePrincipalStore { + return { + async getTenantPrincipal(tenantId, principalId) { + const [row] = await db + .select({ + id: principal.id, + kind: principal.kind, + status: principal.status, + refId: principal.refId, + }) + .from(principal) + .where( + and(eq(principal.tenantId, tenantId), eq(principal.id, principalId)), + ) + .limit(1); + return row; + }, + }; +} + +export function createInMemoryNativePrincipalStore(): NativePrincipalStore & { + registerPrincipal(tenantId: string, value: NativePrincipal): void; +} { + const principals = new Map(); + const key = (tenantId: string, principalId: string) => + `${tenantId}\u0000${principalId}`; + + return { + registerPrincipal(tenantId, value) { + principals.set(key(tenantId, value.id), value); + }, + async getTenantPrincipal(tenantId, principalId) { + return principals.get(key(tenantId, principalId)); + }, + }; +} diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 58cf3f2bb9..c4ef3dbc89 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -11,7 +11,6 @@ // `./platform-port`, the settings vocabulary in `./workbench-settings`, // join/fan-out orchestration in `./workbench-service`, and the SSE // subscriber registry in `./workbench-events`. -import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { Hono } from "hono"; import type { Context } from "hono"; @@ -75,7 +74,6 @@ import { joinHumanParticipant, KindIsChatError, launchAndJoinAgent, - findExistingAgentChat, removeWorkbenchParticipant, resolveInvitedDisplayName, sendWorkbenchMessage, @@ -111,8 +109,7 @@ import { import type { CommandRegistry, CommandResult } from "@corbits/commands"; import { InferenceResolutionError } from "./model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import { cookiesFromHeader } from "@corbits/hub-api-client"; +import type { NativePrincipalStore } from "./native-principal"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -143,15 +140,8 @@ export type CreateChatRoutesDeps = { platform: ChatPlatform; /** The workbench timeline itself: every message a room holds. */ roomMessages: RoomMessageStore; - /** - * Mints and tracks the native child tenant every workbench is anchored - * as (see `./workbench-tenancy.ts`) — required, never optional: a - * workbench created without a tenancy would be a silent legacy path - * reopened, which "no fallbacks" forbids. Every workbench created - * through this route carries a tenancy link from creation onward; - * only workbenches that predate this rollout lack one. - */ - tenancy: WorkbenchTenancyStore; + /** Native principal reads used to validate a person DM counterpart. */ + principals: NativePrincipalStore; requireGrant: RequireGrant; /** * The host's verdict on whether a deployed definition belongs in the @@ -373,13 +363,6 @@ const CreateWorkbenchBody = type({ "participants?": "string[]", "definitionId?": "string", "principalId?": "string", - /** - * Accepted and ignored. `kind: "chat"` + `definitionId` always - * find-or-reopens via `findExistingAgentChat` (CL-6981); this flag is - * no longer an opt-in. Callers that still send it (Myra land-hop, - * `openAgentDm`) are not 400'd. - */ - "reuseExisting?": "boolean", }); type CreateWorkbenchBodyT = typeof CreateWorkbenchBody.infer; @@ -617,35 +600,30 @@ async function checkGrant( * A bench member may open a workbench its own bench owns when either * the workbench is bench-visible (the default — every member of the * owning bench opens it, which reaching this check at all already - * proves the caller is) or, for a members-only workbench, the caller's - * own auth identity (`refId`) holds an active principal in the - * workbench's own child tenant — the native "invited" signal (CL-6332): - * a workbench IS a tenant and mints one for whoever it's shared with, - * with no separate membership table to fall out of sync with it. A - * legacy workbench with no tenancy link at all (predates workbench - * tenancy) has no such tenant to check membership against, so it stays - * bench-visible regardless of its `chat/visibility` setting. + * proves the caller is) or, for a members-only workbench, the caller + * is its creator (`chat/createdBy`, stamped at creation and immutable + * through PATCH — `validateSettingsPatch` rejects unknown `chat/` + * keys) or holds a human participant row on it: `joinHumanParticipant` + * records a person invite under their bare principal id, which is + * exactly what the caller is compared against, so agent addresses + * (always `local@domain`) can never match. No link table and no + * child-tenant principal lookup — the workbench's own settings row + * carries both signals. */ async function benchCallerCanOpenWorkbench( - deps: CreateChatRoutesDeps, + store: ChatStore, benchTenantId: string, workbenchId: string, - principalRefId: string, + principalId: string, ): Promise { - const settings = await deps.store.getWorkbenchSettings( - benchTenantId, - workbenchId, - ); + const settings = await store.getWorkbenchSettings(benchTenantId, workbenchId); if (settings === undefined || visibilityOf(settings.settings) === "bench") { return true; } - const link = await deps.tenancy.getWorkbenchTenancy(workbenchId); - if (link === undefined) return true; - const member = await deps.tenancy.getTenantPrincipalByRefId( - link.tenantId, - principalRefId, + if (settings.settings["chat/createdBy"] === principalId) return true; + return participantsOf(settings.settings).some( + (participant) => participant.address === principalId, ); - return member !== undefined && member.status === "active"; } async function resolveWorkbenchAccess( @@ -653,15 +631,14 @@ async function resolveWorkbenchAccess( actingTenantId: string, workbenchId: string, principalId: string, - principalRefId: string, ): Promise<{ ownerTenantId: string } | undefined> { if (await workbenchInTenant(deps.store, actingTenantId, workbenchId)) { if ( !(await benchCallerCanOpenWorkbench( - deps, + deps.store, actingTenantId, workbenchId, - principalRefId, + principalId, )) ) { return undefined; @@ -937,36 +914,6 @@ async function dispatchWorkbenchCommand( return undefined; } -const MoveWorkbenchBody = type({ - newParentTenantId: "string", -}); - -export { findExistingAgentChat }; - -/** Annotates a workbench view with its native child-tenancy — the - * `tenancy` field every workbench created after this rollout carries, - * never `null` unless a caller reaches a route that skips the - * annotation (there are none; `GET /workbenches` handles the one place a - * link can be legitimately missing itself, via its own `legacy` - * branch). */ -function withTenancy( - view: ReturnType, - link: { tenantId: string; parentTenantId: string; slug: string }, -): ReturnType & { - tenancy: { tenantId: string; parentTenantId: string; slug: string }; - legacy: false; -} { - return { - ...view, - tenancy: { - tenantId: link.tenantId, - parentTenantId: link.parentTenantId, - slug: link.slug, - }, - legacy: false, - }; -} - export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const app = new Hono(); const registry = @@ -1036,31 +983,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const principal = c.get("principal"); - // kind: chat + definitionId always find-or-reopens (CL-6981): a DM - // is the one 1:1 tenant with that agent. `reuseExisting` is accepted - // and ignored — omitted and `false` reopen the same as `true`. - // Checked before anything is minted, and before the (cheaper, - // in-memory) principal-self-chat validation below, since a found - // match short-circuits the whole handler. - if (isChatWithDefinition(body)) { - const existing = await findExistingAgentChat( - deps, - tenant.id, - body.definitionId, - ); - if (existing !== undefined) { - const link = await deps.tenancy.getWorkbenchTenancy( - existing.workbenchId, - ); - return c.json( - link !== undefined - ? withTenancy(workbenchView(existing), link) - : { ...workbenchView(existing), tenancy: null, legacy: true }, - 200, - ); - } - } - // A person-DM's counterpart is validated before anything is // minted: a caller cannot start a direct chat with themselves // (structurally never a DM — there is no second party), and @@ -1077,7 +999,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 409, ); } - const target = await deps.tenancy.getTenantPrincipal( + const target = await deps.principals.getTenantPrincipal( tenant.id, body.principalId, ); @@ -1097,12 +1019,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } } - const workbenchId = generateId("workflowRun"); - // An unnamed agent chat is titled by its agent's display name - // ("Myra"), resolved before the workbench tenant is minted so the - // tenant row carries the same readable name instead of the raw - // workbench id. An unknown definition leaves this undefined; the - // post-join handle fallback below still names the chat then. + // The client creates the conversation tenant through Interchange before + // initializing chat. The tenant id is therefore the stable workbench id; + // chat never mints or links another tenant behind it. + const workbenchId = tenant.id; + // An unnamed agent chat is titled by its agent's display name. An + // unknown definition leaves this undefined; the post-join handle + // fallback below still names the chat then. const invitable = isChatWithDefinition(body) ? await deps.platform.listInvitableDefinitions(tenant.id) : []; @@ -1114,62 +1037,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ?.description : undefined); - // A workbench is a child tenant of the bench it is created in from - // the moment it exists. Native tenant/role/grant rows are minted - // through POST /api/tenants as this caller; the workbench_tenancy - // link is written after. A later launch failure compensates the - // link only — the native tenant stays, same as a later launch - // failure already lives with. The creator becomes the child - // tenant's native owner exactly as POST /api/tenants seeds its - // own creator. - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: tenant.id, - workbenchId, - name: chatTitle ?? workbenchId, - creatorUserId: principal.refId, - cookies: cookiesFromHeader(c.req.header("cookie")), - }); - - // Compensation can itself fail (a dropped connection, the same - // outage that failed the launch). That must never swallow the - // launch failure that triggered it — compensation failure is its - // own loud log line, tagged with the orphaned tenant id for an - // operator to clean up by hand, and the ORIGINAL launch error - // always propagates to the caller (sync paths) or the log - // (the async mint path below). - async function compensateMint(err: unknown, phase: string) { - log.error( - "Workbench {phase} failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant and settings: " + - "{cause}", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - cause: err instanceof Error ? err.message : String(err), - err, - }, - ); - try { - await deps.store.deleteWorkbenchSettings(tenant.id, workbenchId); - await deps.tenancy.compensateWorkbenchTenant( - workbenchTenant.tenantId, - ); - } catch (compensationErr) { - log.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s {phase} failure; this tenant is now " + - "a privileged orphan and requires manual cleanup", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } - } - const preset = presetForKind(body.kind); // Initial participants arrive as bare addresses; each gets a // handle derived from its own local part, de-duplicated the same @@ -1185,10 +1052,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { "chat/kind": body.kind, "chat/pinned": preset.pinned, "chat/participants": initialParticipants, + // The creator's standing invite: members-only visibility admits + // exactly this principal plus participant-row holders (see + // `benchCallerCanOpenWorkbench`), so creation stamps it here — + // the one write path that knows who the creator is. + "chat/createdBy": principal.id, }; - // Recorded so a later `POST /workbenches` for the same agent can find - // this chat by it directly (see `findExistingAgentChat`) instead of - // reverse-resolving a participant address every time. + // The definition pin identifies the agent bound to this client-minted DM. const withDefinitionId: Record = isChatWithDefinition( body, ) @@ -1237,7 +1107,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }, ); } catch (err) { - await compensateMint(err, "agent mint"); // CL-6357: a definition whose every asset candidate has gone // unresolvable (DB/blob drift) is a named, consumer-facing // 4xx — never an unhandled 500 — with the same compensation @@ -1310,10 +1179,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }); return c.json( - withTenancy( - workbenchView({ workbenchId, settings: finalSettings }), - workbenchTenant, - ), + workbenchView({ workbenchId, settings: finalSettings }), 201, ); } @@ -1330,7 +1196,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // the local-part-of-the-principal-id fallback below only // fires for a bare API call that omits `name` entirely. const memberHandle = handleFromName(body.name ?? "", body.principalId); - const memberPrincipal = await deps.tenancy.getTenantPrincipal( + const memberPrincipal = await deps.principals.getTenantPrincipal( tenant.id, body.principalId, ); @@ -1350,14 +1216,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { store: deps.store, roomMessages: deps.roomMessages, publish, - tenancy: deps.tenancy, }, { tenantId: tenant.id, principalId: principal.id, workbenchId, memberPrincipalId: body.principalId, - memberRefId: memberPrincipal.refId, memberHandle, }, ); @@ -1381,42 +1245,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : joined.settings; return c.json( - withTenancy( - workbenchView({ workbenchId, settings: finalSettings }), - workbenchTenant, - ), + workbenchView({ workbenchId, settings: finalSettings }), 201, ); } catch (err) { - log.error( - "Adding the person-DM participant failed for workbench " + - "{workbenchId} after the host was minted and settings were " + - "written; compensating the workbench tenant and deleting " + - "its settings", - { workbenchId, tenantId: workbenchTenant.tenantId, err }, - ); - try { - await deps.tenancy.compensateWorkbenchTenant( - workbenchTenant.tenantId, - ); - await deps.store.deleteWorkbenchSettings(tenant.id, workbenchId); - } catch (compensationErr) { - log.error( - "Compensation failed after person-DM join failure for " + - "workbench {workbenchId}; the orphaned tenant {tenantId} " + - "and/or its settings require manual cleanup", - { - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } + reportError(err, { + operation: "chat.joinHumanParticipant", + tenantId: tenant.id, + }); throw err; } } - return c.json(withTenancy(workbenchView(row), workbenchTenant), 201); + return c.json(workbenchView(row), 201); }, ); @@ -1427,23 +1268,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const kind = c.req.query("kind"); const rows = await deps.store.listWorkbenchSettings(tenant.id, kind); - // Every workbench_settings row here is scoped to this bench - // already — the tenancy link is annotated on top, never used to - // widen or narrow this query. A moved workbench keeps its - // workbench_settings row in the bench it was created in forever, - // so its link must be read by its own workbench id, never by - // "children of this bench" — that filter goes stale the moment - // a workbench moves elsewhere and would wrongly report it as - // legacy. A row with no link at all is a genuine LEGACY workbench: - // it predates this rollout (created before workbench tenancy - // existed) and carries no native tenant of its own. Legacy rows - // are surfaced here, never silently dropped — "no fallbacks" - // means the gap stays visible until every legacy workbench is - // backfilled a tenancy, at which point this branch and the - // `legacy` field below should both be deleted. - const links = await Promise.all( - rows.map((row) => deps.tenancy.getWorkbenchTenancy(row.workbenchId)), - ); // Message signals (unread count, preview, relative time) in two bulk // calls covering every row — never one per workbench. The caller's @@ -1479,12 +1303,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { roomMessages: deps.roomMessages, }); - const ownItems = rows.map((row, index) => { - const link = links[index]; - const view = - link !== undefined - ? withTenancy(workbenchView(row), link) - : { ...workbenchView(row), tenancy: null, legacy: true }; + const ownItems = rows.map((row) => { + const view = workbenchView(row); const activity = activityByWorkbenchId[row.workbenchId]; const withLiveState = { ...view, @@ -1551,8 +1371,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : `shared · ${owningTenantName ?? "another tenant"}`; items.push({ ...view, - tenancy: null, - legacy: false, sharedLabel, live: "idle", }); @@ -1888,7 +1706,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -1974,7 +1791,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -2052,7 +1868,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2177,7 +1992,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { continue; } - const target = await deps.tenancy.getTenantPrincipal( + const target = await deps.principals.getTenantPrincipal( ownerTenantId, entry.principalId, ); @@ -2200,14 +2015,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { store: deps.store, roomMessages: deps.roomMessages, publish, - tenancy: deps.tenancy, }, { tenantId: ownerTenantId, principalId: principal.id, workbenchId, memberPrincipalId: entry.principalId, - memberRefId: target.refId, memberHandle: handleFromName(entry.name ?? "", entry.principalId), }, ); @@ -2449,7 +2262,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2671,7 +2483,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2729,7 +2540,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2833,7 +2643,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2911,7 +2720,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2961,7 +2769,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -3413,117 +3220,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }, ); - app.post( - "/workbenches/:id/move", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - const body = MoveWorkbenchBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid move body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - - // The move is only ever initiated from the bench that currently - // owns the workbench — `getWorkbenchSettings` scopes by `tenant.id`, - // so a caller cannot move a workbench it does not already see. - const existing = await deps.store.getWorkbenchSettings( - tenant.id, - workbenchId, - ); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const principal = c.get("principal"); - - // The destination is verified and the move is written inside a - // single call: `newParentTenantId` must name a real tenant, and - // the caller must hold an active, manage-granted principal there - // — the same grant machinery `requireGrant` uses, evaluated - // against the destination tenant rather than the caller's own — - // but re-checked from inside the very transaction that performs - // the write, under row locks, rather than as a separate round - // trip beforehand (see `WorkbenchTenancyStore.moveWorkbenchTenancy`). - // A caller with standing only in the workbench's current bench can - // never move it into a tenant it has no authority over, and - // nothing can revoke that authority in the gap between checking - // it and acting on it, because there is no gap. - const outcome = await deps.tenancy.moveWorkbenchTenancy({ - workbenchId, - newParentTenantId: body.newParentTenantId, - callerRefId: principal.refId, - }); - - switch (outcome.kind) { - case "no_tenancy": - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: - "this workbench predates the child-tenancy rollout and carries " + - "no native tenant of its own; it cannot be moved until it " + - "is backfilled a tenancy", - }), - 409, - ); - case "destination_not_found": - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "destination tenant not found", - }), - 404, - ); - case "cycle": - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: - "the destination is this workbench's own tenant, or a " + - "descendant of it; moving it there would make the " + - "workbench its own ancestor", - }), - 409, - ); - case "forbidden": - return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: - "you do not have a manage grant in the destination tenant", - }), - 403, - ); - case "moved": - return c.json( - { - workbenchId, - tenancy: { - tenantId: outcome.row.tenantId, - parentTenantId: outcome.row.parentTenantId, - slug: outcome.row.slug, - }, - }, - 200, - ); - } - }, - ); - const CreateShareBody = type({ projectedTenantId: "string" }); app.post( @@ -3555,7 +3251,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const workbenchId = c.req.param("id"); // A share can only ever be created by the tenant that already - // owns the workbench — the same ownership check `/move` runs. + // owns the workbench — `getWorkbenchSettings` scopes by + // `tenant.id`, so a caller cannot share one it does not own. const existing = await deps.store.getWorkbenchSettings( tenant.id, workbenchId, @@ -4057,7 +3754,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4107,7 +3803,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4147,7 +3842,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4186,7 +3880,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4233,7 +3926,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4257,7 +3949,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ).then((access) => access !== undefined), presence: { registry: presence, principalId: principal.id }, }); @@ -4291,7 +3982,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4326,7 +4016,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4373,7 +4062,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index dc241070ea..ecf8aa9154 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -1,6 +1,6 @@ // Product tables @corbits/chat owns (see scripts/checks/no-product-tenancy // ALLOWLIST): workbench_settings, workbench_read_state, workbench_launch, -// workbench_tenancy, message_reactions, and pinned_messages. These tables +// message_reactions, and pinned_messages. These tables // live in their own `chat` Postgres schema, // fully siloed from the platform's `public` schema — see // docs/package-migrations.md. `tenantId`/`principalId` are plain text @@ -131,26 +131,6 @@ export const workbenchLaunch = chatSchema.table("workbench_launch", { .defaultNow(), }); -/** - * The parent↔child link between a bench and the native tenant a - * workbench was minted as (see `./workbench-tenancy.ts`). No native - * child-tenant listing route exists upstream (`parentId` is stored on - * `tenant` but never queried by any hub-api route), so this table is - * the honest source for "which workbenches are child tenancies of this - * bench" — chat owns it rather than leaving the question unanswerable. - * `tenantId` is unique: a workbench tenant is minted for exactly one - * workbench, never shared. - */ -export const workbenchTenancy = chatSchema.table("workbench_tenancy", { - workbenchId: text("workbench_id").primaryKey(), - tenantId: text("tenant_id").notNull().unique(), - parentTenantId: text("parent_tenant_id").notNull(), - slug: text("slug").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - /** * Every message posted into a workbench — the room's own timeline * (CL-6327). A message is a row here and nowhere else: posting one is a diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts index 3496e110f1..2cdf06a47a 100644 --- a/packages/chat/src/store.ts +++ b/packages/chat/src/store.ts @@ -189,7 +189,7 @@ export interface ChatStore { /** * True when `instanceId` is a workflow instance this tenant launched * (workbench host or invited agent). Agent mailboxes are addressed by - * instance id, not by a `workbench_settings` row, so tenancy gates on + * instance id, not by a `workbench_settings` row, so the tenant gates on * message routes must consult this as well as `getWorkbenchSettings`. */ hasLaunchedInstance(tenantId: string, instanceId: string): Promise; diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 43e3547a2d..d82b989280 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -6,7 +6,6 @@ // caller (like a routine) that names no existing destination. Each // depends only on the platform/store seams it actually calls, not the // full `ChatPlatform`/`ChatStore`. -import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { reportError } from "@corbits/error-sink"; import { encodeParts } from "./codec"; @@ -43,12 +42,7 @@ import { participantsOf, resolveContextWindow, } from "./workbench-settings"; -import { - AGENT_DM_DEFINITION_ID_KEY, - AGENT_DM_KIND, - definitionIdOfSettings, -} from "./agent-dm-mode"; -import { presetForKind } from "./kinds"; +import { AGENT_DM_KIND, definitionIdOfSettings } from "./agent-dm-mode"; import type { WorkbenchLauncher, WorkbenchMail, @@ -74,447 +68,12 @@ import type { TurnMailCorrelationStore } from "./turn-mail-correlation"; import type { WorkbenchSubscriberRegistry } from "./workbench-events"; import type { QueuedTurn, WorkbenchTurnQueue } from "./turn-queue"; import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import type { ChatStore, WorkbenchSettingsRow } from "./store"; +import type { ChatStore } from "./store"; import { withTimeout } from "./with-timeout"; -const provisionLog = getLogger(["chat", "provision-space"]); const removeLog = getLogger(["chat", "remove-participant"]); const greetingLog = getLogger(["chat", "canned-greeting"]); const fanoutLog = getLogger(["chat", "message-fanout"]); - -export type ProvisionSpaceWorkbenchDeps = { - readonly tenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "compensateWorkbenchTenant" - >; - readonly store: Pick; -}; - -export type ProvisionSpaceWorkbenchInput = { - readonly tenantId: string; - readonly tenantDomain: string; - readonly creatorPrincipalId: string; - readonly creatorUserId: string; - readonly name: string; - /** Request cookies forwarded into native `POST /api/tenants`. */ - readonly cookies: string[]; -}; - -export type ProvisionSpaceWorkbenchResult = { - readonly workbenchId: string; - readonly compensate: () => Promise; -}; - -/** - * Provisions a brand-new `kind: "workbench"` space (mint the child - * tenant, write its base settings), the same steps `POST /workbenches` - * runs for a named space — used by a caller (a routine's create route, - * chiefly) that needs to hand a fresh destination to something else in - * the same request rather than collecting one from a picker first. A - * workbench is data: nothing launches or deploys here. - * - * Returns a `compensate` callback rather than compensating on every - * failure itself: the caller may still fail its own next step (e.g. - * writing the row this space is *for*) after this returns - * successfully, and only the caller knows when that's happened. - */ -export async function provisionSpaceWorkbench( - deps: ProvisionSpaceWorkbenchDeps, - input: ProvisionSpaceWorkbenchInput, -): Promise { - const workbenchId = generateId("workflowRun"); - - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: input.tenantId, - workbenchId, - name: input.name, - creatorUserId: input.creatorUserId, - cookies: input.cookies, - }); - - const preset = presetForKind("workbench"); - try { - await deps.store.createWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/pinned": preset.pinned, - "chat/participants": [], - "chat/name": input.name, - }, - updatedBy: input.creatorPrincipalId, - }); - } catch (err) { - provisionLog.error( - "Workbench settings write failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant", - { workbenchId, tenantId: workbenchTenant.tenantId, err }, - ); - try { - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - } catch (compensationErr) { - provisionLog.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s settings failure; this tenant is now " + - "a privileged orphan with no workbench pointing at it and " + - "requires manual cleanup", - { workbenchId, tenantId: workbenchTenant.tenantId, compensationErr }, - ); - } - throw err; - } - - return { - workbenchId, - compensate: async () => { - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - }, - }; -} - -const mintAgentDmLog = getLogger(["chat", "mint-agent-dm"]); - -export type MintAgentDmDeps = { - readonly tenancy: Pick< - WorkbenchTenancyStore, - | "createWorkbenchTenant" - | "compensateWorkbenchTenant" - | "getWorkbenchTenancy" - >; - readonly store: Pick< - ChatStore, - | "createWorkbenchSettings" - | "deleteWorkbenchSettings" - | "updateWorkbenchSettings" - | "listWorkbenchSettings" - | "mutateWorkbenchParticipants" - >; - readonly platform: LaunchAndJoinAgentDeps["platform"]; - readonly roomMessages: LaunchAndJoinAgentDeps["roomMessages"]; - readonly publish: LaunchAndJoinAgentDeps["publish"]; -}; - -export type MintAgentDmInput = { - /** Parent bench tenant id (`scope.tenantId`) — settings and launches - * are scoped here, matching `POST /workbenches` agent-DM mint. */ - readonly tenantId: string; - /** Myra's (caller's) workbench id — receives `chat.workbenches-mutated` - * so the sidebar refreshes without joining the new agent into this DM. */ - readonly callerWorkbenchId: string; - /** Caller's principal id — settings `updatedBy` / launch creator. */ - readonly callerPrincipalId: string; - /** Human auth user `refId` — required by `createWorkbenchTenant`. */ - readonly creatorUserId: string; - /** Request cookies forwarded into native `POST /api/tenants`. */ - readonly cookies: string[]; - readonly definitionId: string; - /** Optional title; else invitable description / definition name. */ - readonly name?: string; -}; - -export type MintAgentDmResult = { - readonly workbenchId: string; - readonly address: string; - readonly definitionId: string; - readonly handle: string; - readonly displayName: string; -}; - -export type FindExistingAgentChatDeps = { - readonly store: Pick; - readonly platform: Pick< - WorkbenchLauncher, - "resolveDefinitionAssetId" | "resolveDefinitionIdByAddress" - >; - readonly tenancy: Pick; -}; - -/** - * Finds an existing chat with the given agent. `POST /workbenches` with - * `kind: "chat"` + `definitionId` always find-or-reopens this way - * (CL-6981), and `mintAgentDm` does the same: a DM is the one 1:1 - * tenant with that agent. Uniqueness is per (bench, definitionId). - * Product reopens; it does not clone. - * - * Matches forward, by the DM pin (`definitionIdOfSettings` in - * `./agent-dm-mode.ts` — the one place that says what an agent DM is) - * every agent chat has carried in its settings since this landed, and - * falls back to `matchesLegacyAgentChat` for a chat minted before that - * key existed. - * The comparison is on the definition's ASSET, not the row id: a - * code-sourced deploy projects a new `workflow_definition` row per - * frozen wire projection, so the id a chat recorded at creation and the - * id the picker offers later are routinely different rows over the one - * asset that IS the agent. - * More than one match (duplicates this same gap already let through) - * resolves to the oldest by its workbench-tenancy `createdAt` — the - * original conversation, not whichever the caller happens to hit first — - * with a workbench that predates workbench tenancy entirely sorting oldest - * of all. - */ -export async function findExistingAgentChat( - deps: FindExistingAgentChatDeps, - tenantId: string, - definitionId: string, -): Promise { - const chats = await deps.store.listWorkbenchSettings(tenantId, "chat"); - const assetId = await deps.platform.resolveDefinitionAssetId(definitionId); - const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = []; - for (const row of chats) { - const storedDefinitionId = definitionIdOfSettings(row.settings); - const isMatch = - storedDefinitionId !== undefined - ? await sameAgent(deps, storedDefinitionId, definitionId, assetId) - : await matchesLegacyAgentChat(deps, row, definitionId); - if (!isMatch) continue; - const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId); - matches.push({ row, createdAt: link?.createdAt ?? new Date(0) }); - } - if (matches.length === 0) return undefined; - matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - return matches[0]?.row; -} - -/** - * Whether two definition ids name the same agent: the same row, or two - * rows projected over the same workflow asset. An unresolvable asset (a - * definition row that no longer exists) never matches by asset, so a - * stale recorded id falls back to plain id equality alone. - */ -async function sameAgent( - deps: Pick, - storedDefinitionId: string, - definitionId: string, - assetId: string | undefined, -): Promise { - if (storedDefinitionId === definitionId) return true; - if (assetId === undefined) return false; - const storedAssetId = - await deps.platform.resolveDefinitionAssetId(storedDefinitionId); - return storedAssetId === assetId; -} - -/** - * A chat minted before `chat/definitionId` was recorded at creation - * carries no forward marker naming its agent — the only way back to its - * definition is the platform's reverse address lookup, run once per - * agent participant the chat has (ordinarily exactly one). - */ -async function matchesLegacyAgentChat( - deps: Pick, - row: WorkbenchSettingsRow, - definitionId: string, -): Promise { - const agentAddresses = participantsOf(row.settings) - .map((participant) => participant.address) - .filter(isAgentAddress); - for (const address of agentAddresses) { - const resolved = await deps.platform.resolveDefinitionIdByAddress(address); - if (resolved === definitionId) return true; - } - return false; -} - -/** - * Mints a `kind: "chat"` 1:1 for `definitionId` under the caller's bench, - * or reopens the existing one for that (bench, definition) — never clones - * a second DM. Launches the agent into THAT workbench (never into - * `callerWorkbenchId`), and on a fresh mint publishes - * `chat.workbenches-mutated` on the caller's workbench so the sidebar - * picks up the new chat. The workflow-run counterpart of `POST - * /workbenches` agent-DM mint — `create_agent`'s default path uses this - * instead of inviting into Myra's own DM (which is itself `kind: chat` - * and rejects additional agents via `KindIsChatError`). - */ -export async function mintAgentDm( - deps: MintAgentDmDeps, - input: MintAgentDmInput, -): Promise { - const existing = await findExistingAgentChat( - deps, - input.tenantId, - input.definitionId, - ); - if (existing !== undefined) { - return reopenAgentDm(deps, input, existing); - } - - const invitable = await deps.platform.listInvitableDefinitions( - input.tenantId, - ); - const matched = invitable.find( - (definition) => definition.id === input.definitionId, - ); - const chatTitle = - input.name ?? matched?.description ?? matched?.name ?? undefined; - - const workbenchId = generateId("workflowRun"); - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: input.tenantId, - workbenchId, - name: chatTitle ?? workbenchId, - creatorUserId: input.creatorUserId, - cookies: input.cookies, - }); - - async function compensateMint(err: unknown, phase: string): Promise { - mintAgentDmLog.error( - "Agent DM {phase} failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant and settings: {cause}", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - cause: err instanceof Error ? err.message : String(err), - err, - }, - ); - try { - await deps.store.deleteWorkbenchSettings(input.tenantId, workbenchId); - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - } catch (compensationErr) { - mintAgentDmLog.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s {phase} failure; this tenant is now " + - "a privileged orphan and requires manual cleanup", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } - } - - const preset = presetForKind("chat"); - const baseSettings: Record = { - "chat/kind": AGENT_DM_KIND, - "chat/pinned": preset.pinned, - "chat/participants": [], - [AGENT_DM_DEFINITION_ID_KEY]: input.definitionId, - }; - const settings: Record = - chatTitle !== undefined - ? { ...baseSettings, "chat/name": chatTitle } - : baseSettings; - - let row; - try { - row = await deps.store.createWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings, - updatedBy: input.callerPrincipalId, - }); - } catch (err) { - await compensateMint(err, "settings write"); - throw err; - } - - let joined: LaunchAndJoinAgentResult; - try { - joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: input.tenantId, - principalId: input.callerPrincipalId, - workbenchId, - definitionId: input.definitionId, - existingSettings: row.settings, - invitable, - }, - ); - } catch (err) { - await compensateMint(err, "agent mint"); - throw err; - } - - if (chatTitle === undefined) { - await deps.store.updateWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings: { ...joined.settings, "chat/name": joined.handle }, - updatedBy: input.callerPrincipalId, - }); - } - - deps.publish(input.callerWorkbenchId, { - type: "chat.workbenches-mutated", - data: { tenantId: input.tenantId }, - }); - - // Fire-and-forget pre-warm — never block mint+join+publish on deploy. - void deps.platform.ensureAwake(joined.address).catch((err: unknown) => { - mintAgentDmLog.error( - "Pre-warm deploy failed for minted agent DM {workbenchId}'s agent " + - "{address}; the next message to it retries the wake: {err}", - { workbenchId, address: joined.address, err }, - ); - }); - - return { - workbenchId, - address: joined.address, - definitionId: joined.definitionId, - handle: joined.handle, - displayName: joined.displayName, - }; -} - -async function reopenAgentDm( - deps: MintAgentDmDeps, - input: MintAgentDmInput, - existing: WorkbenchSettingsRow, -): Promise { - const agent = participantsOf(existing.settings).find((participant) => - isAgentAddress(participant.address), - ); - if (agent !== undefined) { - return { - workbenchId: existing.workbenchId, - address: agent.address, - definitionId: input.definitionId, - handle: agent.handle, - displayName: agent.handle, - }; - } - - const invitable = await deps.platform.listInvitableDefinitions( - input.tenantId, - ); - const joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: input.tenantId, - principalId: input.callerPrincipalId, - workbenchId: existing.workbenchId, - definitionId: input.definitionId, - existingSettings: existing.settings, - invitable, - }, - ); - return { - workbenchId: existing.workbenchId, - address: joined.address, - definitionId: joined.definitionId, - handle: joined.handle, - displayName: joined.displayName, - }; -} - export type LaunchAndJoinAgentDeps = { readonly store: Pick; readonly platform: WorkbenchLauncher; @@ -906,7 +465,6 @@ export type JoinHumanParticipantDeps = { readonly store: Pick; readonly roomMessages: RoomMessageStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; - readonly tenancy: Pick; }; export type JoinHumanParticipantInput = { @@ -919,13 +477,6 @@ export type JoinHumanParticipantInput = { * already validated by the caller (see `routes.ts`'s create handler) * to name a real, active, non-self principal in this tenant. */ readonly memberPrincipalId: string; - /** The invited member's own auth identity (`principal.refId`) — - * what `addWorkbenchMember` mints a member-role principal for in the - * workbench's own child tenant (CL-6332), by construction carrying - * that role's `room:*` read/write pair. Never `memberPrincipalId` - * itself: that id is scoped to the acting/bench tenant, not the - * workbench's own tenant a fresh principal is minted into. */ - readonly memberRefId: string; /** The participant record's `handle` — a human has no settings-held * name to derive one from the way an invited agent's definition * does, so the caller (the create route, which already has the @@ -957,18 +508,6 @@ export async function joinHumanParticipant( deps: JoinHumanParticipantDeps, input: JoinHumanParticipantInput, ): Promise { - // Mints (or, for a repeat invite, confirms) the member-role principal - // this workbench's own child tenant gates members-only access by — - // see `workbench-tenancy.ts`'s `addWorkbenchMember`. Ahead of the - // participant record: `chat/participants` is a mention handle only - // (CL-6332), never itself the membership signal, so a failure here - // must fail the whole invite rather than leave a participant record - // with no membership behind it. - await deps.tenancy.addWorkbenchMember({ - workbenchId: input.workbenchId, - refId: input.memberRefId, - }); - const row = await deps.store.mutateWorkbenchParticipants({ tenantId: input.tenantId, workbenchId: input.workbenchId, diff --git a/packages/chat/src/workbench-settings.ts b/packages/chat/src/workbench-settings.ts index a2342d5956..c9955da738 100644 --- a/packages/chat/src/workbench-settings.ts +++ b/packages/chat/src/workbench-settings.ts @@ -216,9 +216,9 @@ export type WorkbenchVisibility = "bench" | "members"; * A workbench's visibility, read off its settings the same way `kindOf` * reads kind: `"bench"` — every member of the owning bench opens it — * unless the creator has explicitly flipped it to `"members"` (CL-6332), - * where only principals the workbench's own child tenant has minted may. - * Defaults to `"bench"` for any other or absent value, never fails - * closed to `"members"` from a malformed setting. + * where only the creator (`chat/createdBy`) and invited human + * participants may. Defaults to `"bench"` for any other or absent + * value, never fails closed to `"members"` from a malformed setting. */ export function visibilityOf( settings: Record, diff --git a/packages/chat/src/workbench-share.ts b/packages/chat/src/workbench-share.ts index 2ba18f5510..79d0baa454 100644 --- a/packages/chat/src/workbench-share.ts +++ b/packages/chat/src/workbench-share.ts @@ -2,9 +2,9 @@ // (bench) projected into another, gated by bilateral federation trust // (see `./federation-trust.ts`) and, within the projected tenant, by an // explicit per-principal membership list its own admins maintain (see -// `workbench_share_member` in `./schema.ts`). Mirrors `workbench-tenancy.ts`'s -// shape: a store interface, a drizzle implementation, and an in-memory -// test double. +// `workbench_share_member` in `./schema.ts`). The module's shape is a +// store interface, a drizzle implementation, and an in-memory test +// double. // // Both write paths here are deliberately fail-closed: `createShare` never // inserts a row without bilateral trust (checked first, inside the same diff --git a/packages/chat/src/workbench-tenancy-routes.ts b/packages/chat/src/workbench-tenancy-routes.ts deleted file mode 100644 index 1482977105..0000000000 --- a/packages/chat/src/workbench-tenancy-routes.ts +++ /dev/null @@ -1,59 +0,0 @@ -// `POST /kinds`, mounted outside the hub's tenant-prefixed routes: -// answers "which of these tenant ids are workbench child tenancies", -// the one fact `workbench_tenancy` holds that no native tenant route -// exposes (see `./workbench-tenancy.ts`). A caller's own tenant ids come -// from `/api/me/principals`, which spans every tenant they belong to — -// asking about them here needs no per-tenant grant, only a signed-in -// user, the same bar `@workbench/onboarding`'s `/provision` route sets -// for its own cross-tenant read. -import type { AppEnv } from "@intx/hub-api"; -import { Hono } from "hono"; -import { type } from "arktype"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; - -const TenantIdsBody = type({ - tenantIds: "string[]", -}); - -export type CreateWorkbenchTenancyRoutesDeps = { - tenancy: Pick; -}; - -export function createWorkbenchTenancyRoutes( - deps: CreateWorkbenchTenancyRoutesDeps, -): Hono { - const app = new Hono(); - - app.post("/kinds", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Authentication required", - }), - 401, - ); - } - - const body = TenantIdsBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "invalid_body", - userMessage: body.summary, - }), - 400, - ); - } - - const workbenchTenantIds = await deps.tenancy.listWorkbenchTenantIds( - body.tenantIds, - ); - return c.json({ workbenchTenantIds: [...workbenchTenantIds] }); - }); - - return app; -} diff --git a/packages/chat/src/workbench-tenancy.ts b/packages/chat/src/workbench-tenancy.ts deleted file mode 100644 index 8e069d5e2d..0000000000 --- a/packages/chat/src/workbench-tenancy.ts +++ /dev/null @@ -1,1189 +0,0 @@ -// Workbenches as child tenants: minting the native tenant a workbench is -// anchored under, the chat-owned link table that records the -// parent↔child relationship (see `./schema.ts`'s `workbenchTenancy`), -// and the listing/move seams workbench owns because no native -// child-tenant listing or re-parenting route exists upstream (see -// `docs/workbench-tenancy.md`). -// -// Native tenants are minted through `POST /api/tenants` as the caller -// (the same path access-policy child-tenants and onboarding use). Extra -// member-role `room:*` grants are planted with `POST /api/tenants/:id/grants` -// using `origin: "creator"` — native POST records provenance-from-caller, -// not the `origin: "system"` SQL seed this module used to write. This -// module still reads native `tenant`/`principal`/`role`/`grant` rows for -// move and membership, and writes only `workbench_tenancy`. -import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { generateId } from "@intx/hub-common"; -import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; -import { parseGrantRow } from "@intx/db"; -import { evaluateGrants } from "@intx/authz"; -import type { ConditionRegistry, GrantRule } from "@intx/authz"; -import { getLogger } from "@intx/log"; -import { - paginatedSchema, - PrincipalResponse, - RoleResponse, - TenantResponse, -} from "@intx/types"; -import { reportError } from "@corbits/error-sink"; -import { parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { workbenchTenancy } from "./schema"; - -const log = getLogger(["chat", "workbench-tenancy"]); - -/** - * The resource/action a caller must hold a `manage`-level grant for, - * in the destination tenant, before a workbench can be moved there. - * Mirrors the shape the mint's own owner/admin grants are seeded - * with (see `createWorkbenchTenant` below): `resource: "*"` scoped by - * `action`, so any role carrying platform-wide manage authority in - * the destination tenant satisfies it. - */ -const MOVE_DESTINATION_RESOURCE = "workflow-run:*"; -const MOVE_DESTINATION_ACTION = "manage"; - -/** - * The member role's room grant pair (CL-6332): every workbench tenant's - * `member` role carries `room:*` read AND write from the moment it is - * minted, mirroring the pair `seed.ts` plants beside the run pair for - * seeded principals (PR #95) — but on the role itself, not one - * principal at a time, so a member-role principal (however it comes to - * hold that role — the mint's own creator-adjacent seeding today, an - * invite tomorrow) carries room write for its workbench by construction, - * with no separate per-principal grant to mint or fall out of sync. - */ -const MEMBER_ROOM_GRANTS = [ - { resource: "room:*", action: "read" }, - { resource: "room:*", action: "write" }, -] as const; - -/** - * A defensive ceiling on how many ancestors `tenantIsDescendantOf` - * will walk before giving up. The tenant hierarchy this rollout can - * observe is shallow (a bench and its workbenches), so a real chain this - * long can only mean a cycle already exists somewhere in the tree that - * predates this guard — surfacing that loudly (see the thrown error - * below) beats spinning forever on data this code did not create. - */ -const MAX_ANCESTOR_WALK = 1000; - -/** - * Derives a URL-safe slug from a workbench's name, plus a random tail - * for uniqueness. `tenant.slug` is unique across the whole platform, - * so a workbench tenant can never collide with another workbench's, or - * with a bench created through the native route — the tail is a - * collision-avoidance measure, not a display detail, and is never - * shown to a user. - */ -function slugForWorkbenchTenant(name: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - const tail = generateId("tenant").slice(-8); - return `${base !== "" ? base : "workbench"}-${tail}`; -} - -function throwHttp(label: string, status: number, data: unknown): never { - throw new Error( - `${label} failed with status ${status}: ${JSON.stringify(data)}`, - ); -} - -/** - * Mints a native child tenant through `POST /api/tenants` as the caller, - * then plants `MEMBER_ROOM_GRANTS` on the new tenant's `member` role. - * Returns the native tenant plus the owner's principal id in that tenant. - */ -async function mintNativeChildTenant( - api: ApiCall, - cookies: string[], - input: { - name: string; - slug: string; - parentTenantId: string; - creatorUserId: string; - }, -): Promise<{ - tenant: typeof TenantResponse.infer; - ownerPrincipalId: string; -}> { - const created = await api( - "POST", - "/api/tenants", - { - name: input.name, - slug: input.slug, - parentId: input.parentTenantId, - }, - cookies, - ); - if (created.status !== 201) { - throwHttp("POST /api/tenants", created.status, created.data); - } - const minted = parseAs(TenantResponse, created.data, "tenant response"); - - const principalsResponse = await api( - "GET", - `/api/tenants/${minted.id}/principals?limit=200`, - undefined, - cookies, - ); - if (principalsResponse.status !== 200) { - throwHttp( - `GET /api/tenants/${minted.id}/principals`, - principalsResponse.status, - principalsResponse.data, - ); - } - const principals = parseAs( - paginatedSchema(PrincipalResponse), - principalsResponse.data, - "principals response", - ); - const membership = principals.data.find( - (p) => p.refId === input.creatorUserId, - ); - if (membership === undefined) { - throw new Error( - `tenant ${minted.id} was created but has no principal for ${input.creatorUserId}`, - ); - } - - const rolesResponse = await api( - "GET", - `/api/tenants/${minted.id}/roles?limit=50`, - undefined, - cookies, - ); - if (rolesResponse.status !== 200) { - throwHttp( - `GET /api/tenants/${minted.id}/roles`, - rolesResponse.status, - rolesResponse.data, - ); - } - const roles = parseAs( - paginatedSchema(RoleResponse), - rolesResponse.data, - "roles response", - ); - const memberRole = roles.data.find((r) => r.name === "member"); - if (memberRole === undefined) { - throw new Error(`tenant ${minted.id} has no system "member" role`); - } - - // Native POST records provenance-from-caller (`origin: "creator"`), - // not the `origin: "system"` SQL this mint used to write. - for (const seed of MEMBER_ROOM_GRANTS) { - const posted = await api( - "POST", - `/api/tenants/${minted.id}/grants`, - { - roleId: memberRole.id, - resource: seed.resource, - action: seed.action, - effect: "allow", - origin: "creator", - }, - cookies, - ); - if (posted.status !== 201) { - throwHttp( - `POST /api/tenants/${minted.id}/grants ${seed.resource}/${seed.action}`, - posted.status, - posted.data, - ); - } - } - - return { tenant: minted, ownerPrincipalId: membership.id }; -} - -/** - * In-memory stand-in for `POST /api/tenants` + role list + grant plant, - * so unit tests exercise the same HTTP-shaped mint the drizzle store - * uses without pretending to SQL-insert native rows. - */ -export function createInMemoryNativeTenantApi(): ApiCall { - const minted: { - id: string; - name: string; - slug: string; - parentId?: string; - principalId: string; - memberRoleId: string; - }[] = []; - - return async (method, path, body, cookies = []) => { - if (method === "POST" && path === "/api/tenants") { - const parsed = body as { - name: string; - slug: string; - parentId?: string; - }; - const id = generateId("tenant"); - const principalId = generateId("principal"); - const memberRoleId = generateId("role"); - const row: (typeof minted)[number] = { - id, - name: parsed.name, - slug: parsed.slug, - principalId, - memberRoleId, - }; - if (parsed.parentId !== undefined) row.parentId = parsed.parentId; - minted.push(row); - const data: Record = { - id, - name: parsed.name, - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - if (parsed.parentId !== undefined) data["parentId"] = parsed.parentId; - return { status: 201, data, cookies }; - } - const principalsMatch = /^\/api\/tenants\/([^/]+)\/principals/.exec(path); - if (method === "GET" && principalsMatch !== null) { - const tenantId = principalsMatch[1] ?? ""; - const row = minted.find((t) => t.id === tenantId); - const principalId = row?.principalId ?? generateId("principal"); - return { - status: 200, - data: { - data: [ - { - id: principalId, - tenantId, - kind: "user", - refId: "pending", - displayName: row?.name ?? "owner", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies, - }; - } - const rolesMatch = /^\/api\/tenants\/([^/]+)\/roles/.exec(path); - if (method === "GET" && rolesMatch !== null) { - const tenantId = rolesMatch[1] ?? ""; - const row = minted.find((t) => t.id === tenantId); - const memberRoleId = row?.memberRoleId ?? generateId("role"); - return { - status: 200, - data: { - data: [ - { - id: memberRoleId, - tenantId, - name: "member", - description: "System member role", - isSystem: true, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies, - }; - } - if (method === "POST" && /\/api\/tenants\/[^/]+\/grants$/.test(path)) { - return { status: 201, data: {}, cookies }; - } - throw new Error(`unexpected ${method} ${path}`); - }; -} - -export type WorkbenchTenancyDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export interface WorkbenchTenancyRow { - readonly workbenchId: string; - readonly tenantId: string; - readonly parentTenantId: string; - readonly slug: string; - readonly createdAt: Date; -} - -export interface CreateWorkbenchTenantInput { - readonly parentTenantId: string; - readonly workbenchId: string; - readonly name: string; - /** - * The auth user id (a principal's `refId`, never a principal's own - * `id`) the child tenant's owner principal is minted for — the same - * identity the creator already holds in the parent bench, given a - * fresh principal and the child tenant's `owner` role, exactly as - * `POST /api/tenants` seeds its creator. - */ - readonly creatorUserId: string; - /** The caller's session cookies, forwarded into native tenant/grant HTTP. */ - readonly cookies: string[]; -} - -export interface CreateWorkbenchTenantResult { - readonly tenantId: string; - readonly parentTenantId: string; - readonly domain: string; - readonly slug: string; - readonly ownerPrincipalId: string; -} - -export interface MoveWorkbenchTenancyInput { - readonly workbenchId: string; - readonly newParentTenantId: string; - /** The caller's auth user id (`principal.refId`) — moving tenants - * looks up whatever principal that identity holds in the - * destination tenant, not the principal id the caller authenticated - * with in its own tenant, since those are different rows. Checked - * inside the same transaction that performs the move (see - * `moveWorkbenchTenancy` below), never as a separate round trip, so a - * grant revocation or destination-tenant deletion cannot land in a - * window between the check and the act. */ - readonly callerRefId: string; -} - -/** - * The outcome of a move attempt. `"destination_not_found"` and - * `"forbidden"` are the two ways the destination fails closed — the - * first when `newParentTenantId` names no real tenant, the second when - * it is real but the caller holds no manage-granted principal there. - * `"cycle"` is a third, distinct failure mode: the destination is the - * workbench's own tenant, or a descendant of it, so completing the move - * would make the workbench its own ancestor. This is a structural - * rejection, not an authorization one — a caller can hold every grant - * in the world and it is still refused, so it is reported separately - * rather than folded into `"forbidden"`, which would misstate why the - * move was refused. `"no_tenancy"` is the pre-existing "legacy - * workbench" case: nothing to move. All four, and the successful - * `"moved"` case, are reported by the same call so a route never has - * to reconcile results from two separate reads of a fact that can - * change between them. - */ -export type MoveWorkbenchTenancyOutcome = - | { readonly kind: "moved"; readonly row: WorkbenchTenancyRow } - | { readonly kind: "destination_not_found" } - | { readonly kind: "cycle" } - | { readonly kind: "forbidden" } - | { readonly kind: "no_tenancy" }; - -/** The subset of a native `principal` row a person-DM's counterpart - * check needs — never the full row, since nothing else here has a - * reason to touch identity fields (`refId`, timestamps). */ -export interface TenantPrincipal { - readonly id: string; - readonly kind: "user" | "agent" | "workflow"; - readonly status: "active" | "suspended" | "invited" | "deactivated"; - readonly refId: string; -} - -export interface WorkbenchTenancyStore { - /** - * Mints a native tenant as `input.workbenchId`'s own tenancy, parented - * under `input.parentTenantId`, by calling `POST /api/tenants` as the - * requesting user, planting the extra member-role room grants, and - * recording the link in `workbench_tenancy`. - */ - createWorkbenchTenant( - input: CreateWorkbenchTenantInput, - ): Promise; - - /** The workbench tenancy link row for one workbench, or `undefined` for - * a workbench that predates this rollout (a legacy flat workbench — see - * `docs/workbench-tenancy.md`) or was never anchored as a tenant. */ - getWorkbenchTenancy( - workbenchId: string, - ): Promise; - - /** Every workbench tenancy link parented under `parentTenantId` — the - * workbench-owned answer to "list this bench's child tenants", - * which no native route provides. */ - listChildWorkbenchTenancies( - parentTenantId: string, - ): Promise; - - /** - * Which of `tenantIds` is itself a workbench's own tenant — the - * workbench-owned answer to "is this a workbench or a workbench child - * tenancy", since native tenant rows carry no such marker. Callers - * (the bench switcher's memberships filter, chiefly) hold a list of - * tenant ids from `/api/me/principals` and need to know which ones - * to exclude, regardless of which parent each workbench belongs to. - */ - listWorkbenchTenantIds( - tenantIds: readonly string[], - ): Promise>; - - /** - * Re-parents a workbench's tenancy, but only after re-verifying — - * inside the very transaction that performs the move, under row - * locks — that `newParentTenantId` names a real tenant and that - * `callerRefId` holds an active, manage-granted principal there. - * The check and the act are not two round trips: a caller cannot - * pass authorization and then race a grant revocation or a - * destination-tenant deletion into the gap, because there is no gap - * — the destination tenant row, the caller's principal row, and - * every grant row that could resolve the decision are locked - * (`SELECT ... FOR UPDATE`) for the lifetime of the same transaction - * that writes both the chat-owned link row (what workbench's own - * listing reads) and the native `tenant.parentId` column — there is - * no native PATCH for a tenant's `parentId`, so this is the only way - * to keep the two in sync. Also rejects (`{ kind: "cycle" }`) a - * destination that is the workbench's own tenant or a descendant of - * it, walking the destination's ancestor chain under the same locks - * so the check can never observe a hierarchy that has since changed - * out from under it. Returns `{ kind: "no_tenancy" }` for a workbench - * with no tenancy link (nothing to move), checked before the - * destination is even considered. - */ - moveWorkbenchTenancy( - input: MoveWorkbenchTenancyInput, - ): Promise; - - /** - * Undoes a mint that a subsequent step (the workbench host launch) - * failed to complete: deletes the `workbench_tenancy` link only. - * Native tenant/role/grant/principal rows stay — compensation no - * longer deletes Interchange tenants. - */ - compensateWorkbenchTenant(tenantId: string): Promise; - - /** - * The native `principal` row for `principalId` within `tenantId`, - * or `undefined` when no such principal exists there — the - * validation a person-DM chat's `POST /workbenches` runs before - * minting the second participant, so a stale or cross-tenant - * `principalId` fails closed with an ordinary 400 rather than - * silently seeding a participant record nothing backs. Reads the - * native `principal` table directly, the same table this store's - * own mint seeds an owner row into (see `createWorkbenchTenant`) — - * not a workbench-tenancy concept on its own, but colocated here - * since this is the one store in the package already holding a - * `db` handle onto native tenancy tables. - */ - getTenantPrincipal( - tenantId: string, - principalId: string, - ): Promise; - - /** - * The native principal row for whoever `refId` (the caller's own auth - * user identity, `principal.refId`) resolves to within `tenantId`, or - * `undefined` when that identity holds no principal there — the - * membership check a members-only workbench's own child tenant gates - * room access by (CL-6332): "invited" means the workbench's own - * tenant minted a principal for this identity, with no separate - * membership table to fall out of sync with it. Unlike - * `getTenantPrincipal`, which looks up a principal already known to - * belong to `tenantId`, this crosses tenant boundaries by the one - * identity that stays stable across every tenant it holds a - * principal in. - */ - getTenantPrincipalByRefId( - tenantId: string, - refId: string, - ): Promise; - - /** - * Invites `refId` into `workbenchId`'s own child tenant as a `member` - * — the CL-6332 mint precedent (mirroring `createWorkbenchTenant`'s - * own owner mint, above) applied to a second, non-creator principal. - * The member role already carries the `room:*` read/write pair by - * construction (see `MEMBER_ROOM_GRANTS`), so this one insert is the - * whole invite: no separate grant to mint alongside it. Idempotent — - * a `refId` that already holds a principal in this tenant (the - * creator inviting themselves again, or a double-submit) is returned - * as-is, never duplicated. Returns `undefined` for a workbench with - * no tenancy link at all (a legacy workbench predating workbench - * tenancy — see `docs/workbench-tenancy.md` — has no child tenant to - * invite anyone into). - */ - addWorkbenchMember(input: { - readonly workbenchId: string; - readonly refId: string; - }): Promise< - { readonly tenantId: string; readonly principalId: string } | undefined - >; - - /** - * The auth user id (`principal.refId`) of an active owner-role principal - * in `tenantId`, or `undefined` when none is present — used by mint-dm - * to seed a new agent chat's child tenant with the human bench owner as - * creator, without the workflow child inventing a user id. - */ - getWorkbenchOwnerUserId(tenantId: string): Promise; -} - -export interface WorkbenchTenancyAuthzDeps< - TSchema extends Record = Record, -> { - /** - * Passed straight through to `evaluateGrants` for the destination - * check `moveWorkbenchTenancy` runs inside its own transaction — no - * `GrantStore` here, deliberately: a `GrantStore` (as `requireGrant` - * uses) always reads outside any caller-supplied transaction, which - * is exactly the two-round-trip shape that let authorization and the - * write it gated drift apart. The destination check instead collects - * and locks its own grant rows directly against the move's own `tx` - * (see `moveWorkbenchTenancy`). - */ - readonly conditionRegistry?: ConditionRegistry; - /** Self-HTTP caller used to mint native tenants and extra grants. */ - readonly api: ApiCall; - /** - * `@intx/db`'s single owner of principal creation, wired by the hub - * with its principal-key store: `addWorkbenchMember` creates the - * principal through it so every new member is minted a signing key in - * the same transaction that inserts the row. Typed structurally over - * this store's own transaction handle so the member write joins the - * surrounding transaction. - */ - readonly principalStore: { - create( - row: typeof principal.$inferInsert, - tx: Parameters< - Parameters["transaction"]>[0] - >[0], - ): Promise<{ id: string }>; - }; -} - -/** - * The production `WorkbenchTenancyStore`, operating on `@intx/db`'s - * native `tenant`/`principal`/`role`/`principalRole`/`grant` tables - * (read/move/membership) plus this package's own `workbench_tenancy` - * link table. Native tenant minting is HTTP, never a SQL insert. - */ -export function createDrizzleWorkbenchTenancyStore< - TSchema extends Record, ->( - db: WorkbenchTenancyDb, - authz: WorkbenchTenancyAuthzDeps, -): WorkbenchTenancyStore { - const { principalStore } = authz; - return { - async createWorkbenchTenant(input) { - const slug = slugForWorkbenchTenant(input.name); - let minted: Awaited>; - try { - minted = await mintNativeChildTenant(authz.api, input.cookies, { - name: input.name, - slug, - parentTenantId: input.parentTenantId, - creatorUserId: input.creatorUserId, - }); - } catch (cause) { - reportError(cause, { - operation: "createWorkbenchTenant", - tenantId: input.parentTenantId, - extra: { workbenchId: input.workbenchId }, - }); - throw cause; - } - const now = new Date(); - await db.insert(workbenchTenancy).values({ - workbenchId: input.workbenchId, - tenantId: minted.tenant.id, - parentTenantId: input.parentTenantId, - slug: minted.tenant.slug, - createdAt: now, - }); - - return { - tenantId: minted.tenant.id, - parentTenantId: input.parentTenantId, - domain: minted.tenant.domain, - slug: minted.tenant.slug, - ownerPrincipalId: minted.ownerPrincipalId, - }; - }, - - async getWorkbenchTenancy(workbenchId) { - const [row] = await db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .limit(1); - return row; - }, - - async listChildWorkbenchTenancies(parentTenantId) { - return db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.parentTenantId, parentTenantId)); - }, - - async listWorkbenchTenantIds(tenantIds) { - if (tenantIds.length === 0) return new Set(); - const rows = await db - .select({ tenantId: workbenchTenancy.tenantId }) - .from(workbenchTenancy) - .where(inArray(workbenchTenancy.tenantId, tenantIds)); - return new Set(rows.map((row) => row.tenantId)); - }, - - async moveWorkbenchTenancy({ - workbenchId, - newParentTenantId, - callerRefId, - }) { - return db.transaction(async (tx) => { - // Locks the link row for the lifetime of this transaction — - // a second, concurrent move of the same workbench blocks here - // rather than racing this one to a write. - const [linkRow] = await tx - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .for("update") - .limit(1); - if (linkRow === undefined) { - return { kind: "no_tenancy" as const }; - } - - // Everything below re-verifies the destination from inside - // this same transaction, under row locks, rather than trusting - // a decision made by an earlier, independent round trip: a - // grant revocation or a destination-tenant deletion committed - // by another transaction blocks on these locks until this one - // finishes, instead of landing in a gap between "checked" and - // "acted on". - const [destinationTenant] = await tx - .select() - .from(tenant) - .where(eq(tenant.id, newParentTenantId)) - .for("update") - .limit(1); - if (destinationTenant === undefined) { - return { kind: "destination_not_found" as const }; - } - - // Reject a move that would make the workbench its own ancestor: - // the destination is the workbench's own tenant (a self-parent), - // or the destination descends from it (a longer cycle). Walked - // under the same row locks as everything else here, so a - // concurrent re-parenting of an ancestor tenant cannot slip a - // cycle past this check between the read and the write. - let cursor: { id: string; parentId: string | null } = { - id: destinationTenant.id, - parentId: destinationTenant.parentId, - }; - const visited = new Set(); - for (let depth = 0; ; depth += 1) { - if (cursor.id === linkRow.tenantId) { - return { kind: "cycle" as const }; - } - if (cursor.parentId === null) break; - if (visited.has(cursor.id) || depth >= MAX_ANCESTOR_WALK) { - throw new Error( - `Tenant hierarchy cycle detected walking ancestors of ` + - `"${destinationTenant.id}", unrelated to this move`, - ); - } - visited.add(cursor.id); - const [ancestor] = await tx - .select({ id: tenant.id, parentId: tenant.parentId }) - .from(tenant) - .where(eq(tenant.id, cursor.parentId)) - .for("update") - .limit(1); - if (ancestor === undefined) break; - cursor = ancestor; - } - - const [destinationPrincipal] = await tx - .select() - .from(principal) - .where( - and( - eq(principal.tenantId, newParentTenantId), - eq(principal.refId, callerRefId), - eq(principal.status, "active"), - ), - ) - .for("update") - .limit(1); - if (destinationPrincipal === undefined) { - return { kind: "forbidden" as const }; - } - - const roleAssignments = await tx - .select() - .from(principalRole) - .where(eq(principalRole.principalId, destinationPrincipal.id)) - .for("update"); - const roleIds = roleAssignments.map((assignment) => assignment.roleId); - - const now = new Date(); - const ownership = [eq(grant.principalId, destinationPrincipal.id)]; - if (roleIds.length > 0) { - ownership.push(inArray(grant.roleId, roleIds)); - } - const grantRows = await tx - .select() - .from(grant) - .where( - and( - eq(grant.tenantId, newParentTenantId), - or(...ownership), - or(isNull(grant.expiresAt), gt(grant.expiresAt, now)), - ), - ) - .for("update"); - const grantRules: GrantRule[] = grantRows.map((row) => - parseGrantRow(row), - ); - - const evalOptionsBase = { - principalId: destinationPrincipal.id, - tenantId: newParentTenantId, - }; - const decision = await evaluateGrants( - grantRules, - MOVE_DESTINATION_RESOURCE, - MOVE_DESTINATION_ACTION, - authz.conditionRegistry - ? { ...evalOptionsBase, registry: authz.conditionRegistry } - : evalOptionsBase, - ); - if (decision.effect !== "allow") { - return { kind: "forbidden" as const }; - } - - const [movedLink] = await tx - .update(workbenchTenancy) - .set({ parentTenantId: newParentTenantId }) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .returning(); - if (movedLink === undefined) { - throw new Error( - `Workbench tenancy for "${workbenchId}" vanished mid-transaction`, - ); - } - await tx - .update(tenant) - .set({ parentId: newParentTenantId, updatedAt: now }) - .where(eq(tenant.id, movedLink.tenantId)); - - return { kind: "moved" as const, row: movedLink }; - }); - }, - - async compensateWorkbenchTenant(tenantId) { - log.error( - "Compensating workbench tenant {tenantId}: deleting the workbench_tenancy link after a downstream failure. Native tenant rows stay.", - { tenantId }, - ); - await db - .delete(workbenchTenancy) - .where(eq(workbenchTenancy.tenantId, tenantId)); - }, - - async getTenantPrincipal(tenantId, principalId) { - const [row] = await db - .select({ - id: principal.id, - kind: principal.kind, - status: principal.status, - refId: principal.refId, - }) - .from(principal) - .where( - and(eq(principal.tenantId, tenantId), eq(principal.id, principalId)), - ) - .limit(1); - return row as TenantPrincipal | undefined; - }, - - async getTenantPrincipalByRefId(tenantId, refId) { - const [row] = await db - .select({ - id: principal.id, - kind: principal.kind, - status: principal.status, - refId: principal.refId, - }) - .from(principal) - .where( - and(eq(principal.tenantId, tenantId), eq(principal.refId, refId)), - ) - .limit(1); - return row as TenantPrincipal | undefined; - }, - - async addWorkbenchMember({ workbenchId, refId }) { - const [link] = await db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .limit(1); - if (link === undefined) return undefined; - - return db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, link.tenantId), - eq(principal.refId, refId), - ), - ) - .limit(1); - if (existing !== undefined) { - return { tenantId: link.tenantId, principalId: existing.id }; - } - - const [memberRole] = await tx - .select({ id: role.id }) - .from(role) - .where(and(eq(role.tenantId, link.tenantId), eq(role.name, "member"))) - .limit(1); - if (memberRole === undefined) { - throw new Error( - `Workbench tenant "${link.tenantId}" has no "member" system role`, - ); - } - - const now = new Date(); - const created = await principalStore.create( - { - id: generateId("principal"), - tenantId: link.tenantId, - kind: "user", - refId, - status: "active", - createdAt: now, - updatedAt: now, - }, - tx, - ); - const principalId = created.id; - await tx.insert(principalRole).values({ - principalId, - roleId: memberRole.id, - createdAt: now, - }); - - return { tenantId: link.tenantId, principalId }; - }); - }, - - async getWorkbenchOwnerUserId(tenantId) { - const [row] = await db - .select({ refId: principal.refId }) - .from(principal) - .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) - .innerJoin(role, eq(role.id, principalRole.roleId)) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.status, "active"), - eq(role.tenantId, tenantId), - eq(role.name, "owner"), - ), - ) - .limit(1); - return row?.refId; - }, - }; -} - -/** - * An in-memory `WorkbenchTenancyStore`, for tests and any host wiring - * chat routes without a database. Mints native tenants through the - * injected `ApiCall` (defaulting to `createInMemoryNativeTenantApi`) - * and records only the chat-owned link — the same HTTP-shaped mint - * the drizzle store uses. - * - * The three extra methods below `WorkbenchTenancyStore` declares - * (`registerExistingTenant`, `grantManageInTenant`, `registerPrincipal`) - * are test-support only: `registerExistingTenant` takes an optional - * parent so a unit test can build an arbitrary tenant hierarchy without - * a database. Together they let a test stand up "a real tenant the - * caller has no standing in", "a real tenant the caller manages", and a - * cyclic hierarchy, exercising `moveWorkbenchTenancy`'s destination-check - * outcomes (`"destination_not_found"`, `"cycle"`, `"forbidden"`, - * `"moved"`). `registerPrincipal` does the same for `getTenantPrincipal` - * — standing up a fake native `principal` row without a database. - */ -export function createInMemoryWorkbenchTenancyStore( - deps: { - api?: ApiCall; - } = {}, -): WorkbenchTenancyStore & { - registerExistingTenant(tenantId: string, parentTenantId?: string): void; - grantManageInTenant(refId: string, tenantId: string): void; - registerPrincipal(tenantId: string, principal: TenantPrincipal): void; -} { - const inner = deps.api ?? createInMemoryNativeTenantApi(); - let pendingCreatorUserId = ""; - const api: ApiCall = async (method, path, body, cookies) => { - const result = await inner(method, path, body, cookies); - if ( - method === "GET" && - /\/api\/tenants\/[^/]+\/principals/.test(path) && - result.status === 200 && - result.data !== null && - typeof result.data === "object" && - "data" in result.data - ) { - const page = result.data as { data: Record[] }; - return { - ...result, - data: { - ...page, - data: page.data.map((row) => ({ - ...row, - refId: pendingCreatorUserId, - })), - }, - }; - } - return result; - }; - const byWorkbenchId = new Map(); - const existingTenants = new Set(); - const manageGrants = new Set(); - const principalsByKey = new Map(); - const principalsByRefKey = new Map(); - /** Owner `refId` per tenant — seeded by `createWorkbenchTenant`, and by - * `grantManageInTenant` when a test stands up a root bench owner without - * minting a child workbench. */ - const ownerRefByTenant = new Map(); - const principalKey = (tenantId: string, principalId: string) => - `${tenantId}::${principalId}`; - const refKey = (tenantId: string, refId: string) => `${tenantId}::${refId}`; - // Mirrors the drizzle store's `tenant.parentId`: every tenant this - // store knows of maps to its parent, or `undefined` for a root. Kept - // separately from `byWorkbenchId` because a destination tenant in a - // cycle test is often not itself a workbench's tenancy — plain - // `registerExistingTenant` callers need a parent too. - const parentOf = new Map(); - - const manageGrantKey = (refId: string, tenantId: string) => - `${refId}::${tenantId}`; - - // Mirrors the drizzle store's ancestor walk: true if `tenantId` is - // `ancestorCandidateId` itself, or descends from it, using the same - // parent links `moveWorkbenchTenancy` updates on every move — so a - // cycle this store already believes into existing (via a prior - // `moveWorkbenchTenancy` call) is caught exactly like the database - // would catch it. - function isSelfOrDescendantOf( - tenantId: string, - ancestorCandidateId: string, - ): boolean { - let cursor: string | undefined = ancestorCandidateId; - const visited = new Set(); - while (cursor !== undefined) { - if (cursor === tenantId) return true; - if (visited.has(cursor)) { - throw new Error( - `Tenant hierarchy cycle detected walking ancestors of ` + - `"${ancestorCandidateId}", unrelated to this move`, - ); - } - visited.add(cursor); - cursor = parentOf.get(cursor); - } - return false; - } - - return { - async createWorkbenchTenant(input) { - if (byWorkbenchId.has(input.workbenchId)) { - throw new Error( - `Workbench "${input.workbenchId}" already has a tenancy`, - ); - } - const slug = slugForWorkbenchTenant(input.name); - pendingCreatorUserId = input.creatorUserId; - let minted: Awaited>; - try { - minted = await mintNativeChildTenant(api, input.cookies, { - name: input.name, - slug, - parentTenantId: input.parentTenantId, - creatorUserId: input.creatorUserId, - }); - } catch (cause) { - reportError(cause, { - operation: "createWorkbenchTenant", - tenantId: input.parentTenantId, - extra: { workbenchId: input.workbenchId }, - }); - throw cause; - } - const tenantId = minted.tenant.id; - const row: WorkbenchTenancyRow = { - workbenchId: input.workbenchId, - tenantId, - parentTenantId: input.parentTenantId, - slug: minted.tenant.slug, - createdAt: new Date(), - }; - byWorkbenchId.set(input.workbenchId, row); - existingTenants.add(tenantId); - parentOf.set(tenantId, input.parentTenantId); - // The creator is minted as this tenant's owner, exactly as the - // native POST /api/tenants route does — the owner role's `*`/`*` - // grant covers the move-destination check too. - manageGrants.add(manageGrantKey(input.creatorUserId, tenantId)); - const ownerPrincipalId = minted.ownerPrincipalId; - const ownerPrincipal: TenantPrincipal = { - id: ownerPrincipalId, - kind: "user", - status: "active", - refId: input.creatorUserId, - }; - principalsByKey.set( - principalKey(tenantId, ownerPrincipalId), - ownerPrincipal, - ); - principalsByRefKey.set( - refKey(tenantId, input.creatorUserId), - ownerPrincipal, - ); - ownerRefByTenant.set(tenantId, input.creatorUserId); - return { - tenantId, - parentTenantId: input.parentTenantId, - domain: minted.tenant.domain, - slug: minted.tenant.slug, - ownerPrincipalId, - }; - }, - - async getWorkbenchTenancy(workbenchId) { - return byWorkbenchId.get(workbenchId); - }, - - async listChildWorkbenchTenancies(parentTenantId) { - return [...byWorkbenchId.values()].filter( - (row) => row.parentTenantId === parentTenantId, - ); - }, - - async listWorkbenchTenantIds(tenantIds) { - const requested = new Set(tenantIds); - const workbenchTenantIds = new Set( - [...byWorkbenchId.values()].map((row) => row.tenantId), - ); - return new Set( - [...requested].filter((tenantId) => workbenchTenantIds.has(tenantId)), - ); - }, - - async compensateWorkbenchTenant(tenantId) { - for (const [workbenchId, row] of byWorkbenchId) { - if (row.tenantId === tenantId) byWorkbenchId.delete(workbenchId); - } - }, - - registerExistingTenant(tenantId, parentTenantId) { - existingTenants.add(tenantId); - parentOf.set(tenantId, parentTenantId); - }, - - grantManageInTenant(refId, tenantId) { - manageGrants.add(manageGrantKey(refId, tenantId)); - // A test seeding a root bench owner (no createWorkbenchTenant) still - // needs getWorkbenchOwnerUserId to resolve — treat the manage grant - // as the owner marker when none is recorded yet. - if (!ownerRefByTenant.has(tenantId)) { - ownerRefByTenant.set(tenantId, refId); - } - }, - - registerPrincipal(tenantId, principalRow) { - principalsByKey.set( - principalKey(tenantId, principalRow.id), - principalRow, - ); - principalsByRefKey.set( - refKey(tenantId, principalRow.refId), - principalRow, - ); - }, - - async getTenantPrincipal(tenantId, principalId) { - return principalsByKey.get(principalKey(tenantId, principalId)); - }, - - async getTenantPrincipalByRefId(tenantId, refId) { - return principalsByRefKey.get(refKey(tenantId, refId)); - }, - - async getWorkbenchOwnerUserId(tenantId) { - return ownerRefByTenant.get(tenantId); - }, - - // Mirrors the drizzle store's fold of the destination check into - // the move itself: there is no separate pre-check call to race - // against, in-memory or not — every failure mode is an outcome of - // this one call. - async moveWorkbenchTenancy({ - workbenchId, - newParentTenantId, - callerRefId, - }) { - const existing = byWorkbenchId.get(workbenchId); - if (existing === undefined) { - return { kind: "no_tenancy" as const }; - } - if (!existingTenants.has(newParentTenantId)) { - return { kind: "destination_not_found" as const }; - } - if (isSelfOrDescendantOf(existing.tenantId, newParentTenantId)) { - return { kind: "cycle" as const }; - } - if (!manageGrants.has(manageGrantKey(callerRefId, newParentTenantId))) { - return { kind: "forbidden" as const }; - } - const moved: WorkbenchTenancyRow = { - ...existing, - parentTenantId: newParentTenantId, - }; - byWorkbenchId.set(workbenchId, moved); - parentOf.set(existing.tenantId, newParentTenantId); - return { kind: "moved" as const, row: moved }; - }, - - async addWorkbenchMember({ workbenchId, refId }) { - const link = byWorkbenchId.get(workbenchId); - if (link === undefined) return undefined; - - const existing = principalsByRefKey.get(refKey(link.tenantId, refId)); - if (existing !== undefined) { - return { tenantId: link.tenantId, principalId: existing.id }; - } - - const principalId = generateId("principal"); - const memberPrincipal: TenantPrincipal = { - id: principalId, - kind: "user", - status: "active", - refId, - }; - principalsByKey.set( - principalKey(link.tenantId, principalId), - memberPrincipal, - ); - principalsByRefKey.set(refKey(link.tenantId, refId), memberPrincipal); - return { tenantId: link.tenantId, principalId }; - }, - }; -} diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index a28e311ba9..279d5a2ac1 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -1,16 +1,13 @@ // Workflow-run-authenticated surfaces for a child process that is itself // messaging in a workbench: // -// - `POST /participants/mint-dm` — `create_agent`'s default path: mint or -// reopen the specialist's `kind: chat` 1:1 for a definition and launch -// the agent into THAT workbench (never into Myra's own DM). // - `POST /participants/invite` — invite an already-created definition // into a non-chat workbench the caller already participates in. // - `POST /participants/messages` — post a message (e.g. ask_user block) // into the caller's own workbench. // -// Invite reuses `./workbench-service.ts`'s `launchAndJoinAgent`; mint-dm -// reuses `mintAgentDm`. Mirrors `@corbits/agent-directory`'s +// Invite reuses `./workbench-service.ts`'s `launchAndJoinAgent`. Mirrors +// `@corbits/agent-directory`'s // `workflow-capability-routes.ts`/`workflow-create-routes.ts`: a // workflow child has no browser session, only its sidecar bearer token // and its own run address, so it authenticates through a @@ -51,7 +48,6 @@ import { DefinitionProjectionMissingError } from "@corbits/workflows"; import { KindIsChatError, launchAndJoinAgent, - mintAgentDm, sendWorkbenchMessage, type LaunchAndJoinAgentDeps, type SendWorkbenchMessageDeps, @@ -67,9 +63,7 @@ import { connectServiceConnectorIds, pendingConnectionsOf, } from "./connect-pending"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; -import { reportError } from "@corbits/error-sink"; import { makeErrorEnvelope } from "@corbits/error-sink"; /** @@ -106,7 +100,6 @@ export type WorkflowParticipantEnv = { }; const InviteParticipantInput = type({ definitionId: "string > 0" }); -const MintDmInput = type({ definitionId: "string > 0" }); const PostMessageInput = type({ parts: Part.array() }); @@ -170,21 +163,6 @@ export type CreateWorkflowParticipantRoutesDeps = { * message's dispatch records the same way a person's own send does. */ readonly turnMailCorrelation?: SendWorkbenchMessageDeps["turnMailCorrelation"]; readonly authenticator: WorkflowRunAuthenticator; - readonly tenancy: Pick< - WorkbenchTenancyStore, - | "createWorkbenchTenant" - | "compensateWorkbenchTenant" - | "getWorkbenchTenancy" - | "getWorkbenchOwnerUserId" - >; - /** - * Mints a session for the bench owner when a workflow child has no - * browser cookies. Production binds `createBenchSessionMinter`. - */ - readonly sessionFor: (args: { - userId: string; - tenantId: string; - }) => Promise; }; export function createWorkflowParticipantRoutes( @@ -303,136 +281,6 @@ export function createWorkflowParticipantRoutes( ); }); - // create_agent's default path: mint or reopen the specialist's own - // kind:chat 1:1 under the caller's bench. Never invites into the - // caller's DM. - app.post("/participants/mint-dm", async (c) => { - const scope = c.get("workflowParticipantScope"); - const body = MintDmInput(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid mint-dm body: ${body.summary}`, - }), - 400, - ); - } - - const workbench = await deps.store.findWorkbenchByParticipantAddress( - scope.tenantId, - scope.address, - ); - if (workbench === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, - }), - 404, - ); - } - - // Prefer the human owner of the parent bench: Myra's DM is itself a - // child workbench whose parentTenantId is the bench. - const link = await deps.tenancy.getWorkbenchTenancy(workbench.workbenchId); - const ownerTenantId = link?.parentTenantId ?? scope.tenantId; - const creatorUserId = - await deps.tenancy.getWorkbenchOwnerUserId(ownerTenantId); - if (creatorUserId === undefined) { - const userMessage = `No owner user id for tenant "${ownerTenantId}" — cannot mint an agent DM`; - const refId = reportError(new Error(userMessage), { - operation: "chat.mintDm.ownerUnresolved", - tenantId: ownerTenantId, - }); - return c.json( - makeErrorEnvelope({ - code: "owner_unresolved", - userMessage, - refId, - }), - 500, - ); - } - - const cookies = await deps.sessionFor({ - userId: creatorUserId, - tenantId: ownerTenantId, - }); - if (cookies === undefined) { - const userMessage = `Could not mint a session for owner "${creatorUserId}" to create an agent DM`; - const refId = reportError(new Error(userMessage), { - operation: "chat.mintDm.sessionUnmintable", - tenantId: ownerTenantId, - }); - return c.json( - makeErrorEnvelope({ - code: "session_unmintable", - userMessage, - refId, - }), - 500, - ); - } - - let minted: Awaited>; - try { - minted = await mintAgentDm( - { - tenancy: deps.tenancy, - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: scope.tenantId, - callerWorkbenchId: workbench.workbenchId, - callerPrincipalId: scope.principalId, - creatorUserId, - cookies, - definitionId: body.definitionId, - }, - ); - } catch (err) { - if (err instanceof DefinitionProjectionMissingError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: err.guidance, - }), - 409, - ); - } - if (err instanceof InferenceResolutionError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - }), - 409, - ); - } - if (err instanceof KindIsChatError) { - return c.json( - makeErrorEnvelope({ code: err.code, userMessage: err.message }), - 409, - ); - } - throw err; - } - - return c.json( - { - workbenchId: minted.workbenchId, - address: minted.address, - definitionId: minted.definitionId, - handle: minted.handle, - }, - 201, - ); - }); - // The posting half of an in-workbench gen-UI block: a workflow child // (`@corbits/interaction-tools`'s `ask_user`) posts a message carrying a // `block` part into its own workbench — resolved the same way diff --git a/packages/chat/test/agent-dm-warm-mailbox.test.ts b/packages/chat/test/agent-dm-warm-mailbox.test.ts deleted file mode 100644 index dceac06a1d..0000000000 --- a/packages/chat/test/agent-dm-warm-mailbox.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -// CL-7108: agent DMs run on the interactive warm-mailbox shape — one -// standing per-agent run provisioned from the conversation's definition -// asset, every inbound mail a header-threaded turn on that run, the turn -// rows a durable per-run INBOX. This test pins the shape at the chat -// layer: the DM settings pin (kind + definitionId, in exactly one place) -// and a two-turn DM conversation's durable trail (turn rows, RFC 5322 -// threading, turn-mail correlation, mailbox fan-out). -import { describe, expect, test } from "bun:test"; -import type { MailContent } from "../src/codec"; -import { - AGENT_DM_DEFINITION_ID_KEY, - AGENT_DM_KIND, - definitionIdOfSettings, - isAgentDmSettings, -} from "../src/agent-dm-mode"; -import type { WorkbenchLauncher } from "../src/platform-port"; -import type { - CreateWorkbenchTenantResult, - WorkbenchTenancyRow, -} from "../src/workbench-tenancy"; -import { findExistingAgentChat, mintAgentDm } from "../src/workbench-service"; -import { createInMemoryAgentTurnStore } from "../src/agent-turns"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnClaimStore } from "../src/turn-claims"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { createInMemoryTurnMailCorrelationStore } from "../src/turn-mail-correlation"; -import { createWorkbenchTurnQueue } from "../src/turn-queue"; -import { sendWorkbenchMessage } from "../src/workbench-service"; -import type { MailboxWriter } from "../src/mailbox-fanout"; - -const TENANT_ID = "tnt_1"; -const WORKBENCH_ID = "wb_dm_1"; -const DOMAIN = "acme.example"; -const SENDER = "prn_alice"; -const AGENT_ADDRESS = "ins_echo1@acme.example"; -const DEFINITION_ID = "def_echo"; - -function dmSettings() { - return { - // Built from the DM pin, never re-typed wire literals: the kind reads - // as the pin's kind, the definition id sits under the pin's computed - // key. ("chat/kind" / "chat/participants" keep their product-spelled - // keys — no pin constant names them, and the product writes them the - // same way.) The ONE test that names the pin's wire values as - // literals is "the pin names the exact settings keys on the wire" - // below; everything else here references the constants. - "chat/kind": AGENT_DM_KIND, - [AGENT_DM_DEFINITION_ID_KEY]: DEFINITION_ID, - "chat/participants": [ - { address: SENDER, handle: SENDER }, - { address: AGENT_ADDRESS, handle: "echo" }, - ], - }; -} - -describe("agent DM pin (CL-7108)", () => { - test("DM settings are a kind:chat conversation with a definition id", () => { - const settings = dmSettings(); - expect(isAgentDmSettings(settings)).toBe(true); - expect(definitionIdOfSettings(settings)).toBe(DEFINITION_ID); - }); - - test("the pin names the exact settings keys on the wire", () => { - expect(AGENT_DM_KIND).toBe("chat"); - expect(AGENT_DM_DEFINITION_ID_KEY).toBe("chat/definitionId"); - }); - - test("a group conversation with a definition id is not a DM", () => { - expect(isAgentDmSettings({ ...dmSettings(), "chat/kind": "group" })).toBe( - false, - ); - }); - - test("a chat without a definition id is not a DM", () => { - const { [AGENT_DM_DEFINITION_ID_KEY]: _dropped, ...rest } = dmSettings(); - expect(isAgentDmSettings(rest)).toBe(false); - expect(definitionIdOfSettings(rest)).toBeUndefined(); - }); - - test("a non-string definition id is not a DM", () => { - expect( - isAgentDmSettings({ ...dmSettings(), [AGENT_DM_DEFINITION_ID_KEY]: 42 }), - ).toBe(false); - expect( - definitionIdOfSettings({ [AGENT_DM_DEFINITION_ID_KEY]: 42 }), - ).toBeUndefined(); - }); - - test("a missing kind reads as chat, like the workbench view does", () => { - const { "chat/kind": _dropped, ...rest } = dmSettings(); - expect(isAgentDmSettings(rest)).toBe(true); - }); -}); - -// A group conversation is never an agent DM — even one that happens to -// carry a definition id must never take the DM find-or-reopen path: the -// pin (kind chat + definition id) is what opens that path, and the group -// kind keeps it closed. Red-proven by flipping the seeded kind to chat -// and watching the find-half fail (the row is found); the mint-fresh -// half is red-proven by inverting its inequality to toBe. -describe("non-DM settings never take the DM path (CL-7108)", () => { - const GROUP_WORKBENCH_ID = "wb_group_1"; - - function stubPlatform(): WorkbenchLauncher { - return { - async launchInvite() { - return { instanceId: "run_echo1", address: AGENT_ADDRESS }; - }, - async ensureAwake() {}, - async listInvitableDefinitions() { - return [{ id: DEFINITION_ID, name: "echo", description: "Echo" }]; - }, - async resolveDefinitionIdByAddress() { - return undefined; - }, - async resolveDefinitionAssetId() { - return "asset_echo"; - }, - async resolveDefinitionNameSource() { - return undefined; - }, - async refreshAgentInstanceFromDefinition() {}, - }; - } - - function stubTenancy() { - return { - async createWorkbenchTenant(input: { - readonly parentTenantId: string; - readonly workbenchId: string; - readonly name: string; - readonly creatorUserId: string; - readonly cookies: string[]; - }): Promise { - return { - tenantId: `tnt_child_${input.workbenchId}`, - parentTenantId: input.parentTenantId, - domain: DOMAIN, - slug: input.workbenchId, - ownerPrincipalId: SENDER, - }; - }, - async compensateWorkbenchTenant() {}, - async getWorkbenchTenancy(): Promise { - return undefined; - }, - }; - } - - test("a group chat carrying a definition id is invisible to findExistingAgentChat, and mintAgentDm mints fresh", async () => { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const groupSettings = { ...dmSettings(), "chat/kind": "group" }; - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: GROUP_WORKBENCH_ID, - updatedBy: SENDER, - settings: groupSettings, - }); - expect(isAgentDmSettings(groupSettings)).toBe(false); - - const deps = { - store, - roomMessages, - publish: () => undefined, - platform: stubPlatform(), - tenancy: stubTenancy(), - }; - - // The DM find-or-reopen path never sees the group row... - expect( - await findExistingAgentChat(deps, TENANT_ID, DEFINITION_ID), - ).toBeUndefined(); - - // ...so minting for that definition mints a fresh DM beside it. - const minted = await mintAgentDm(deps, { - tenantId: TENANT_ID, - callerWorkbenchId: "wb_myra_dm", - callerPrincipalId: SENDER, - creatorUserId: "usr_alice", - cookies: [], - definitionId: DEFINITION_ID, - }); - expect(minted.workbenchId).not.toBe(GROUP_WORKBENCH_ID); - expect(minted.definitionId).toBe(DEFINITION_ID); - const mintedRow = await store.getWorkbenchSettings( - TENANT_ID, - minted.workbenchId, - ); - expect(isAgentDmSettings(mintedRow?.settings ?? {})).toBe(true); - - // And the fresh DM — a real DM — is exactly what a later - // find-or-reopen finds. - const reopened = await findExistingAgentChat( - deps, - TENANT_ID, - DEFINITION_ID, - ); - expect(reopened?.workbenchId).toBe(minted.workbenchId); - }); -}); - -function noopMailbox() { - const writer: MailboxWriter = { - async writeBatch(items) { - return items.map((_item, index) => ({ - messageKey: String(index), - id: `mbx_${String(index)}`, - })); - }, - }; - return { - writer, - resolveKnownPrincipalIds: async () => new Set([SENDER]), - resolveTenantDomain: async () => DOMAIN, - }; -} - -async function makeDmDeps(sentMail: MailContent[]) { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); - const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const turnQueue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const turnCancellation = createTurnCancelRegistry(); - const agentTurns = createInMemoryAgentTurnStore(); - const turnMailCorrelation = createInMemoryTurnMailCorrelationStore(); - - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: dmSettings(), - }); - - const deps = { - store, - roomMessages, - threads, - publish: () => undefined, - platform: { - async sendMail(input: { content: MailContent }) { - sentMail.push(input.content); - return { id: "mail_agent_1", createdAt: new Date().toISOString() }; - }, - }, - turnQueue, - turnCancellation, - mailbox: noopMailbox(), - agentTurns, - turnMailCorrelation, - }; - return { deps, agentTurns, turnMailCorrelation }; -} - -async function sendDm( - deps: Awaited>["deps"], - text: string, -) { - const posted = await sendWorkbenchMessage(deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text }], - }); - await posted.fanoutDelivered; - return posted; -} - -describe("agent DM conversation trail (CL-7108)", () => { - test("two DM turns each open a turn row, dispatch a threaded frame, and correlate", async () => { - const sentMail: MailContent[] = []; - const { deps, agentTurns, turnMailCorrelation } = - await makeDmDeps(sentMail); - - const first = await sendDm(deps, "hello"); - const running = await agentTurns.findRunningTurn({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - agentAddress: AGENT_ADDRESS, - }); - expect(running?.requestMessageIds).toEqual([first.id]); - // Settle the first turn the way the reply path would, so the second - // inbound serializes behind it into its own turn instead of piling - // onto a still-running one. - await agentTurns.finishTurn({ - tenantId: TENANT_ID, - turnId: running?.id ?? "", - status: "completed", - }); - - const second = await sendDm(deps, "again"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - }); - expect(turns.map((turn) => turn.requestMessageIds)).toEqual([ - [second.id], - [first.id], - ]); - - // Every inbound mail is a threaded turn: each dispatched frame carries - // its own row-derived Message-ID (CL-7450), and each dispatch mail's - // correlation back to the row it answers is recorded for the reply - // path (CL-6314) — keyed by the answered row, whose derived - // Message-ID the sidecar's bracket reports back. - expect(sentMail).toHaveLength(2); - expect(sentMail[0]?.messageId).toBe(`<${first.id}@${DOMAIN}>`); - expect(sentMail[1]?.messageId).toBe(`<${second.id}@${DOMAIN}>`); - for (const posted of [first, second]) { - const source = await turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT_ID, - mailId: posted.id, - }); - expect(source?.workbenchId).toBe(WORKBENCH_ID); - expect(source?.sourceMessageId).toBe(posted.id); - } - }); -}); diff --git a/packages/chat/test/agent-turns-routes.test.ts b/packages/chat/test/agent-turns-routes.test.ts index 3d10fdf776..f254793c05 100644 --- a/packages/chat/test/agent-turns-routes.test.ts +++ b/packages/chat/test/agent-turns-routes.test.ts @@ -6,7 +6,13 @@ import { describe, expect, test } from "bun:test"; import { createInMemoryAgentTurnStore } from "../src/agent-turns"; import type { AgentTurn } from "../src/agent-turns"; import { createChatRoutes } from "../src/routes"; -import { buildDeps, createWorkbench, mountAs, TENANT } from "./test-support"; +import { + buildDeps, + createWorkbench, + mountAs, + OTHER_TENANT, + TENANT, +} from "./test-support"; describe("GET /workbenches/:id/turns", () => { test("404s when the host injected no turn store", async () => { @@ -209,22 +215,33 @@ describe("GET /workbenches/:id/turns/:turnId", () => { test("a turn belonging to another workbench is not found here", async () => { const agentTurns = createInMemoryAgentTurnStore(); const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); + const routes = createChatRoutes(deps); + // One workbench per conversation tenant — the tenant id IS the + // workbench id — so "another workbench" can only mean a workbench + // created under another tenant. + const appHere = mountAs(routes, "prn_alice", TENANT); + const appElsewhere = mountAs(routes, "prn_alice", OTHER_TENANT); const here = ( - await createWorkbench(app, { kind: "workbench", name: "here" }) + await createWorkbench(appHere, { kind: "workbench", name: "here" }) ).body.id; const elsewhere = ( - await createWorkbench(app, { kind: "workbench", name: "elsewhere" }) + await createWorkbench(appElsewhere, { + kind: "workbench", + name: "elsewhere", + }) ).body.id; + expect(elsewhere).not.toBe(here); const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, + tenantId: OTHER_TENANT.id, workbenchId: elsewhere, agentAddress: "ins_echo1@acme.example", requestMessageIds: ["msg_1"], }); - const res = await app.request(`/workbenches/${here}/turns/${opened.id}`); + const res = await appHere.request( + `/workbenches/${here}/turns/${opened.id}`, + ); expect(res.status).toBe(404); }); diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts index d15b217ca3..fa3eba45f0 100644 --- a/packages/chat/test/migrations.test.ts +++ b/packages/chat/test/migrations.test.ts @@ -52,6 +52,7 @@ const migrationNames = [ "0028_turn_mail_correlation", "0029_workbench_messages_mail_message_id", "0030_agent_turns_agent_occurrence_key", + "0031_drop_workbench_tenancy", ]; describeIfDb("applyChatMigrations", () => { @@ -100,17 +101,20 @@ describeIfDb("applyChatMigrations", () => { const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); try { + // Hard cutover: 0031 drops `workbench_tenancy` (and its index) + // outright — the client mints conversation tenants through stock + // Interchange now, so chat keeps no link table. It must be absent + // here, not merely unlisted. const tables = await sql.unsafe( `SELECT table_name FROM information_schema.tables ` + `WHERE table_schema = 'chat' AND table_name IN ` + - `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'workbench_tenancy', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'message_reactions', 'pinned_messages', 'finalized_turn_write_claim', 'workbench_messages', 'agent_turns')`, + `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'message_reactions', 'pinned_messages', 'finalized_turn_write_claim', 'workbench_messages', 'agent_turns')`, ); expect(tables.map((row) => String(row["table_name"])).sort()).toEqual( [ "workbench_launch", "workbench_read_state", "workbench_settings", - "workbench_tenancy", "workbench_thread_messages", "workbench_threads", "chat_bench_settings", @@ -121,6 +125,11 @@ describeIfDb("applyChatMigrations", () => { "agent_turns", ].sort(), ); + const droppedTenancy = await sql.unsafe( + `SELECT table_name FROM information_schema.tables ` + + `WHERE table_schema = 'chat' AND table_name = 'workbench_tenancy'`, + ); + expect(droppedTenancy).toHaveLength(0); // Renamed away (CL-6260): the old "channel"-named tables must not // linger alongside their renamed replacements. @@ -136,19 +145,16 @@ describeIfDb("applyChatMigrations", () => { const publicTables = await sql.unsafe( `SELECT table_name FROM information_schema.tables ` + `WHERE table_schema = 'public' AND table_name IN ` + - `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'workbench_tenancy', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'block_responses', 'message_reactions', 'pinned_messages')`, + `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'block_responses', 'message_reactions', 'pinned_messages')`, ); expect(publicTables).toHaveLength(0); - // `listChildWorkbenchTenancies` filters on `parent_tenant_id` on - // every `GET /workbenches` call — without an index that is a - // sequential scan on every request. - const indexes = await sql.unsafe( + // 0031 dropped `workbench_tenancy` with its parent index — no + // index on the dropped table may linger either. + const droppedIndexes = await sql.unsafe( `SELECT indexname FROM pg_indexes WHERE schemaname = 'chat' AND tablename = 'workbench_tenancy'`, ); - expect(indexes.map((row) => String(row["indexname"]))).toContain( - "workbench_tenancy_parent_tenant_id_idx", - ); + expect(droppedIndexes).toHaveLength(0); // The batched per-message reaction/pin reads on `GET /messages` // filter on (tenant, workbench[, message]) — without these, both diff --git a/packages/chat/test/provision-space-workbench.test.ts b/packages/chat/test/provision-space-workbench.test.ts deleted file mode 100644 index 9e32c2fe64..0000000000 --- a/packages/chat/test/provision-space-workbench.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// `provisionSpaceWorkbench`'s own contract: mints a new `kind: "workbench"` -// space exactly the way `POST /workbenches` does (mint tenant, write base -// settings — a workbench is data, nothing launches), used by a caller — a -// routine's create route, chiefly — that needs a fresh destination handed -// back rather than collected from a picker first. -import { describe, expect, test } from "bun:test"; - -import { provisionSpaceWorkbench } from "../src/workbench-service"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; -import { createInMemoryChatStore } from "../src/store"; -import { TENANT } from "./test-support"; - -describe("provisionSpaceWorkbench", () => { - test("mints a workbench tenant and writes base settings", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - - const result = await provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Morning digest", - cookies: ["session=test"], - }, - ); - - expect(typeof result.workbenchId).toBe("string"); - const link = await tenancy.getWorkbenchTenancy(result.workbenchId); - expect(link).toBeDefined(); - - const settings = await store.getWorkbenchSettings( - TENANT.id, - result.workbenchId, - ); - expect(settings?.settings["chat/kind"]).toBe("workbench"); - expect(settings?.settings["chat/name"]).toBe("Morning digest"); - }); - - test("compensates (deletes) the minted tenant when the settings write fails", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - store.createWorkbenchSettings = async () => { - throw new Error("settings write failed"); - }; - - await expect( - provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Doomed space", - cookies: ["session=test"], - }, - ), - ).rejects.toThrow("settings write failed"); - - // Nothing settled: the minted tenant was compensated away. - const links = await tenancy.listChildWorkbenchTenancies(TENANT.id); - expect(links).toHaveLength(0); - }); - - test("the returned compensate() undoes the mint", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - - const result = await provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Undo me", - cookies: ["session=test"], - }, - ); - - await result.compensate(); - const link = await tenancy.getWorkbenchTenancy(result.workbenchId); - expect(link).toBeUndefined(); - }); -}); diff --git a/packages/chat/test/room-access.test.ts b/packages/chat/test/room-access.test.ts index 2a6a991284..962258bb99 100644 --- a/packages/chat/test/room-access.test.ts +++ b/packages/chat/test/room-access.test.ts @@ -3,13 +3,12 @@ // membership/visibility check, never by a `workflow-run` grant — the // room stopped being a run when CL-6327 made it workbench data. Visits // two bench members through the same `deps` (so both see the same -// workbench), one who minted the workbench (and so holds a principal -// in its own child tenant) and one who is only ever a member of the -// owning bench. +// workbench), one who created the workbench (and so is stamped as its +// `chat/createdBy`) and one who is only ever a member of the owning +// bench. import { describe, expect, test } from "bun:test"; import { createChatRoutes } from "../src/routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { buildDeps, createWorkbench, @@ -50,12 +49,12 @@ describe("room access", () => { }); expect(flip.status).toBe(200); - // Alice minted the workbench, so `createWorkbenchTenant` already - // holds her as its own tenant's owner principal — she keeps access. + // Alice created the workbench, so creation stamped her as its + // `chat/createdBy` — she keeps access. const aliceRead = await asAlice.request(`/workbenches/${body.id}/messages`); expect(aliceRead.status).toBe(200); - // Bob holds no principal in the workbench's own tenant, and it no + // Bob holds no participant row on the workbench, and it no longer // longer falls back to bench-visible — both read and write 404 // exactly as an unknown workbench would, never leaking its existence. const bobRead = await asBob.request(`/workbenches/${body.id}/messages`); @@ -97,11 +96,9 @@ describe("room access", () => { ).toBe(false); }); - test("inviting a person mints them a member-role principal in the workbench's own tenant, unblocking a members-only flip", async () => { + test("inviting a person records them as a human participant, unblocking a members-only flip", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -113,8 +110,8 @@ describe("room access", () => { const { body } = await createWorkbench(asAlice, { kind: "workbench" }); // Bob is invited through the mention popover's pre-invite path - // (CL-6332) before the workbench ever flips to members-only — - // the mint happens on invite, not on first access. + // (CL-6332) before the workbench ever flips to members-only — the + // participant record lands on invite, not on first access. const invited = await asAlice.request(`/workbenches/${body.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, @@ -133,8 +130,8 @@ describe("room access", () => { expect(flip.status).toBe(200); // Bob was invited before the flip; he still opens and writes it — - // the members-only check reads his membership in the workbench's - // own tenant, not whether he was invited before or after the flip. + // the members-only check reads his participant row on the + // workbench, not whether he was invited before or after the flip. const bobRead = await asBob.request(`/workbenches/${body.id}/messages`); expect(bobRead.status).toBe(200); @@ -156,17 +153,13 @@ describe("room access", () => { test("a bench member never invited into a members-only workbench is still denied after another person's invite", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", refId: "prn_bob", }); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_carol", kind: "user", status: "active", diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 1f08f6055b..9458321635 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -5,14 +5,11 @@ // `workbench-service.test.ts`, and the SSE registry in // `workbench-events.test.ts`. import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; import { InferenceResolutionError } from "../src/model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; import { postRoomMessage } from "../src/room-messages"; import type { Part } from "../src/parts"; import { createChatRoutes } from "../src/routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { createInMemoryChatStore } from "../src/store"; import { createInMemoryThreadStore } from "../src/threads"; import { @@ -20,7 +17,7 @@ import { createWorkbench, fakePlatform, mountAs, - principal, + OTHER_TENANT, sendText, TENANT, timelineEvents, @@ -148,7 +145,7 @@ describe("POST /workbenches", () => { expect(body.error.code).toBe("bad_request"); }); - test("creating an unnamed chat titles it by the agent's display name, tenant row included", async () => { + test("creating an unnamed chat titles it by the agent's display name", async () => { const deliveries: (() => Promise)[] = []; const deps = buildDeps({ platform: fakePlatform({ @@ -181,11 +178,6 @@ describe("POST /workbenches", () => { expect(settled?.settings["chat/participants"]).toEqual([ { address: "ins_invited1@acme.example", handle: "myra" }, ]); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - const [minted] = await tenancy.listChildWorkbenchTenancies(TENANT.id); - expect(minted?.slug.startsWith("myra")).toBe(true); }); test("creating a chat auto-invites its agent and titles it by handle", async () => { @@ -346,7 +338,7 @@ describe("POST /workbenches", () => { expect(body.title).toBe("My Assistant"); }); - test("an agent mint failure compensates the workbench before returning", async () => { + test("an agent mint failure leaves the client-minted settings row in place", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -379,21 +371,18 @@ describe("POST /workbenches", () => { ); expect(errorBody.error.userMessage).not.toMatch(/HTTP/); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); // A workbench create must never 500 on a definition row with no // frozen wire projection stored on it (a pre-cutover row, or one // whose approval never completed) — it answers a named 4xx with - // consumer-language guidance, and still compensates the orphaned - // tenant/settings exactly as every other agent-mint failure does. - test("a definition with no stored launch body answers 409 with recovery guidance, not 500, and still compensates", async () => { + // consumer-language guidance, and the client-minted settings row + // stands exactly as with every other agent-mint failure. + test("a definition with no stored launch body answers 409 with recovery guidance, not 500, and keeps the settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -417,16 +406,13 @@ describe("POST /workbenches", () => { expect(errorBody.error.code).toBe("not_launchable"); expect(errorBody.error.userMessage).toMatch(/save its instructions/); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); - test("a generic agent mint failure compensates the workbench so a retry starts clean", async () => { + test("a generic agent mint failure keeps the settings row so a retry reuses it", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -444,16 +430,13 @@ describe("POST /workbenches", () => { expect(response.status).toBe(500); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); - test("an agent mint failure compensates the workbench", async () => { + test("an agent mint failure never removes the client-minted settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -469,13 +452,10 @@ describe("POST /workbenches", () => { }); expect(response.status).toBe(500); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); test("a failed agent pre-warm leaves the minted chat ready for first-message retry", async () => { @@ -498,13 +478,7 @@ describe("POST /workbenches", () => { expect(response.status).toBe(201); await deliveries[0]?.(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 1, - ); const settled = await deps.store.getWorkbenchSettings(TENANT.id, body.id); expect(settled?.settings["chat/participants"]).toEqual([ { address: "ins_invited1@acme.example", handle: "echo" }, @@ -514,8 +488,12 @@ describe("POST /workbenches", () => { }); }); -describe("POST /workbenches — kind: chat + definitionId always find-or-reopens (CL-6981)", () => { - test("creating a chat with the same agent twice, reuseExisting: true both times, reuses the first chat instead of forking a duplicate", async () => { +describe("POST /workbenches — kind: chat always mints, never find-or-reopens", () => { + test("creating a chat with the same agent twice runs the mint both times — the server never dedups", async () => { + // The workbench id is the tenant id, so both creates land on the + // same settings row — but each runs the full agent mint rather + // than reusing ("reopening") the first chat's agent. Reuse is the + // client's call, by listing its conversation tenants. const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -524,59 +502,22 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens const first = await createWorkbench(app, { kind: "chat", definitionId: "wfd_echo", - reuseExisting: true, }); expect(first.response.status).toBe(201); const second = await createWorkbench(app, { kind: "chat", definitionId: "wfd_echo", - reuseExisting: true, }); - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - expect(second.body.kind).toBe("chat"); + expect(second.response.status).toBe(201); + expect(second.body.id).toBe(TENANT.id); const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(1); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); + expect(platform.launchInviteCalls).toHaveLength(2); }); - test("reuses the chat when the agent's definition was re-projected under a new id over the same asset", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo_v1", name: "Echo" }, - { id: "wfd_echo_v2", name: "Echo" }, - ], - resolveDefinitionAssetId: async (definitionId: string) => - definitionId.startsWith("wfd_echo") ? "ast_echo" : undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo_v1", - reuseExisting: true, - }); - expect(first.response.status).toBe(201); - - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo_v2", - reuseExisting: true, - }); - - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); - - test("a new agent chat records its definitionId for future dedup", async () => { + test("a new agent chat records its definitionId on the settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -590,230 +531,11 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens const stored = await deps.store.getWorkbenchSettings(TENANT.id, body.id); expect(stored?.settings["chat/definitionId"]).toBe("wfd_echo"); }); - - test("a chat minted before chat/definitionId existed is still found, by reverse-resolving its agent participant's address", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - resolveDefinitionIdByAddress: async (address) => - address === "ins_legacy@acme.example" ? "wfd_echo" : undefined, - }), - }); - const legacyWorkbenchId = "run_legacy1"; - const legacyTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: legacyWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: legacyWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/participants": [ - { address: "ins_legacy@acme.example", handle: "echo" }, - ], - }, - updatedBy: "prn_alice", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - - expect(response.status).toBe(200); - expect(body.id).toBe(legacyWorkbenchId); - expect(body.tenancy).toEqual({ - tenantId: legacyTenant.tenantId, - parentTenantId: TENANT.id, - slug: legacyTenant.slug, - }); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); - - test("two pre-existing duplicate chats for the same agent resolve to the oldest, not whichever the caller hits first", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const olderWorkbenchId = "run_older1"; - await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: olderWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: olderWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/definitionId": "wfd_echo", - "chat/participants": [], - }, - updatedBy: "prn_alice", - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - - const newerWorkbenchId = "run_newer1"; - await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: newerWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: newerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/definitionId": "wfd_echo", - "chat/participants": [], - }, - updatedBy: "prn_alice", - }); - - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - - expect(response.status).toBe(200); - expect(body.id).toBe(olderWorkbenchId); - }); - - test("a message sent to the found-or-created chat still auto-responds without a mention", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - expect(second.response.status).toBe(200); - - const platform = deps.platform as ReturnType; - const mailBefore = platform.sentMail.length; - await sendText(app, second.body.id, "hello"); - - expect(platform.sentMail.length).toBeGreaterThan(mailBefore); - const fanOut = platform.sentMail[platform.sentMail.length - 1]; - expect(fanOut?.workbenchId).toBe("ins_invited1"); - expect(fanOut?.fromWorkbenchId).toBe(first.body.id); - }); -}); - -describe("POST /workbenches — reuseExisting no longer opts out of find-or-reopen (CL-6981)", () => { - test("creating a chat with the same agent twice, reuseExisting omitted both times, reopens the first chat", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - expect(first.response.status).toBe(201); - - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - expect(second.body.kind).toBe("chat"); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(1); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - - const firstTenancy = await deps.tenancy.getWorkbenchTenancy(first.body.id); - const secondTenancy = await deps.tenancy.getWorkbenchTenancy( - second.body.id, - ); - expect(firstTenancy?.tenantId).toBeDefined(); - expect(secondTenancy?.tenantId).toBe(firstTenancy?.tenantId); - }); - - test("creating a chat with the same agent twice, reuseExisting: false explicitly, still reopens the first chat", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: false, - }); - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: false, - }); - - expect(first.response.status).toBe(201); - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - }); - - test("a pre-existing chat for the same agent is reopened even when reuseExisting is omitted", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const landHop = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - expect(landHop.response.status).toBe(201); - - const picked = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - expect(picked.response.status).toBe(200); - expect(picked.body.id).toBe(landHop.body.id); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); }); describe("POST /workbenches — chat with a person (DM)", () => { function registerBob(deps: ReturnType) { - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -921,10 +643,7 @@ describe("POST /workbenches — chat with a person (DM)", () => { test("rejects a principalId naming a suspended member — 400", async () => { const deps = buildDeps(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "suspended", @@ -1276,9 +995,7 @@ describe("DELETE /workbenches/:id/participants/:address", () => { test("refuses to remove a chat's person counterpart (409)", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -1855,9 +1572,7 @@ describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pu test("a person mention of a non-participant bench member invites them, then sends", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -2023,17 +1738,13 @@ describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pu // people invited in the same request both survive, not just the last. test("inviting multiple people in one request lands every one of them", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", refId: "prn_bob", }); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_carol", kind: "user", status: "active", @@ -2709,8 +2420,8 @@ describe("typing", () => { }); }); -describe("workbench tenancy", () => { - test("creating a workbench mints a child tenant parented under the bench", async () => { +describe("chat hard cutover — no server tenancy", () => { + test("creating a workbench returns the settings view only — no tenancy, no legacy flag", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); @@ -2719,271 +2430,81 @@ describe("workbench tenancy", () => { name: "General", }); - const view = body as unknown as { - tenancy: { tenantId: string; parentTenantId: string; slug: string }; - legacy: boolean; - }; - expect(view.legacy).toBe(false); - expect(view.tenancy.parentTenantId).toBe(TENANT.id); - expect(view.tenancy.tenantId).toMatch(/^tnt_/); - - const link = await deps.tenancy.getWorkbenchTenancy(body.id); - expect(link?.tenantId).toBe(view.tenancy.tenantId); - expect(link?.parentTenantId).toBe(TENANT.id); - }); - - test("a compensation failure after a launch failure still surfaces the original launch error, never the compensation's", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const uncompensatableTenancy = { - ...tenancy, - async compensateWorkbenchTenant(): Promise { - throw new Error("compensation storage unavailable"); - }, - }; - const platform = fakePlatform({ - launchInvite: async () => { - throw new Error("agent launch failed"); - }, - }); - const deps = buildDeps({ tenancy: uncompensatableTenancy, platform }); - const routes = createChatRoutes(deps); - // Hono's default error handling swallows a thrown error into a - // generic 500 body, which is useless for telling "the original - // error propagated" apart from "the compensation error masked it" - // — both look identical over HTTP. `onError` intercepts the actual - // thrown value before Hono discards it, so the assertion below - // can inspect the real error rather than its flattened response. - let caught: unknown; - routes.onError((err) => { - caught = err; - return new Response(null, { status: 500 }); - }); - const app = mountAs(routes, "prn_alice"); - - // Both the launch and its compensation fail. The double failure - // must never produce a silently swallowed error: the route - // re-throws the ORIGINAL launch error, not the compensation - // failure that masked it in the bug this test guards against. - const response = await app.request("/workbenches", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(500); - expect(caught).toBeInstanceOf(Error); - expect((caught as Error).message).toBe("agent launch failed"); - - // The tenant this mint created is now an orphan the compensation - // could not clean up — that is the accepted, loudly-logged - // consequence of a double failure, not something this test can - // observe through the in-memory store (which has no "orphaned - // tenants" ledger), but the workbench itself must never have been - // recorded as ready to use. + expect(body).not.toHaveProperty("tenancy"); + expect(body).not.toHaveProperty("legacy"); + expect(body.id).toBe(TENANT.id); }); - test("GET /workbenches annotates every created workbench with its tenancy and marks a linkless row legacy", async () => { + test("GET /workbenches rows carry no tenancy or legacy fields", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: created } = await createWorkbench(app, { + await createWorkbench(app, { kind: "workbench", - name: "Tenanted", - }); - - // Simulates a workbench that predates the tenancy rollout: a - // workbench_settings row with no workbench_tenancy link. - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "ins_legacy", - settings: { "chat/kind": "workbench", "chat/name": "Legacy" }, - updatedBy: "prn_alice", + name: "General", }); const response = await app.request("/workbenches"); const body = (await response.json()) as { - items: { - id: string; - legacy: boolean; - tenancy: { tenantId: string } | null; - }[]; - }; - - const tenantedRow = body.items.find((item) => item.id === created.id); - expect(tenantedRow?.legacy).toBe(false); - expect(tenantedRow?.tenancy).not.toBeNull(); - - const legacyRow = body.items.find((item) => item.id === "ins_legacy"); - expect(legacyRow?.legacy).toBe(true); - expect(legacyRow?.tenancy).toBeNull(); - }); - - test("POST /workbenches/:id/move re-parents the workbench's tenancy when the caller manages the destination", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_new_bench"); - tenancy.grantManageInTenant("prn_alice", "tnt_new_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), - }); - - expect(response.status).toBe(200); - const moved = (await response.json()) as { - tenancy: { parentTenantId: string }; + items: Record[]; }; - expect(moved.tenancy.parentTenantId).toBe("tnt_new_bench"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe("tnt_new_bench"); - }); - - test("GET /workbenches still reports a moved workbench's current tenancy from the bench it was created in", async () => { - // A workbench's workbench_settings row stays keyed to the bench it was - // created in forever — a move only ever changes the tenancy link's - // parent, never that row. The regression this guards against: GET - // /workbenches used to look up tenancy links by "children of this - // bench", which goes stale the moment a workbench moves elsewhere, - // so the creating bench reported the moved workbench as `legacy` - // with a null tenancy instead of its real, current parent. - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_new_bench"); - tenancy.grantManageInTenant("prn_alice", "tnt_new_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - const moveResponse = await app.request( - `/workbenches/${workbench.id}/move`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), - }, - ); - expect(moveResponse.status).toBe(200); - - const listResponse = await app.request("/workbenches"); - const body = (await listResponse.json()) as { - items: { - id: string; - legacy: boolean; - tenancy: { parentTenantId: string } | null; - }[]; - }; - const row = body.items.find((item) => item.id === workbench.id); - expect(row?.legacy).toBe(false); - expect(row?.tenancy?.parentTenantId).toBe("tnt_new_bench"); + expect(body.items).toHaveLength(1); + expect(body.items[0]).not.toHaveProperty("tenancy"); + expect(body.items[0]).not.toHaveProperty("legacy"); }); - test("POST /workbenches/:id/move is refused when the destination tenant does not exist", async () => { + test("POST /workbenches/:id/move is gone — re-parenting is the client's Interchange call, not a chat route", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { + await createWorkbench(app, { kind: "workbench", - name: "Movable", + name: "General", }); - const response = await app.request(`/workbenches/${workbench.id}/move`, { + const response = await app.request(`/workbenches/${TENANT.id}/move`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_does_not_exist" }), + body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), }); expect(response.status).toBe(404); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("not_found"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe(TENANT.id); }); - test("POST /workbenches/:id/move is refused when the caller has no standing in a real destination tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_someone_elses_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_someone_elses_bench" }), - }); - - expect(response.status).toBe(403); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("forbidden"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe(TENANT.id); - }); - - test("POST /workbenches/:id/move is refused when the destination would make the workbench its own ancestor", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - if (link === undefined) throw new Error("expected a tenancy link"); - // The caller manages its own workbench's tenant (seeded as owner at - // creation) — proving this rejection is structural, not - // authorization: full grants and it is still refused. - tenancy.grantManageInTenant("prn_alice", link.tenantId); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: link.tenantId }), + test("a launch failure still surfaces the original launch error, never a masked one", async () => { + const platform = fakePlatform({ + launchInvite: async () => { + throw new Error("agent launch failed"); + }, }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("conflict"); - - const unchanged = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(unchanged?.parentTenantId).toBe(TENANT.id); - }); - - test("POST /workbenches/:id/move on a legacy workbench is a loud 409, never a silent no-op", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "ins_legacy", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_alice", + const deps = buildDeps({ platform }); + const routes = createChatRoutes(deps); + // Hono's default error handling swallows a thrown error into a + // generic 500 body, which is useless for telling "the original + // error propagated" apart from "some other error masked it" + // — both look identical over HTTP. `onError` intercepts the actual + // thrown value before Hono discards it, so the assertion below + // can inspect the real error rather than its flattened response. + let caught: unknown; + routes.onError((err) => { + caught = err; + return new Response(null, { status: 500 }); }); + const app = mountAs(routes, "prn_alice"); - const response = await app.request(`/workbenches/ins_legacy/move`, { + const response = await app.request("/workbenches", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), + body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), }); - expect(response.status).toBe(409); + expect(response.status).toBe(500); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("agent launch failed"); }); - test("a bench never sees another bench's workbench tenancies", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; + test("a bench never sees another bench's workbenches", async () => { const store = createInMemoryChatStore(); - const tenancy = createInMemoryWorkbenchTenancyStore(); - const deps = buildDeps({ store, tenancy }); + const deps = buildDeps({ store }); const routes = createChatRoutes(deps); const appBenchA = mountAs(routes, "prn_alice"); @@ -2992,13 +2513,7 @@ describe("workbench tenancy", () => { name: "Bench A Only", }); - const appBenchB = new Hono(); - appBenchB.use("*", async (c, next) => { - c.set("tenant", OTHER_TENANT); - c.set("principal", principal("prn_bob")); - await next(); - }); - appBenchB.route("/", routes); + const appBenchB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: benchBWorkbench } = await createWorkbench(appBenchB, { kind: "workbench", name: "Bench B Only", @@ -3016,37 +2531,15 @@ describe("workbench tenancy", () => { items: { id: string; title: string }[]; }; expect(listB.items.map((item) => item.title)).toEqual(["Bench B Only"]); - - const tenancyA = await tenancy.listChildWorkbenchTenancies(TENANT.id); - const tenancyB = await tenancy.listChildWorkbenchTenancies(OTHER_TENANT.id); - expect(tenancyA).toHaveLength(1); - expect(tenancyB).toHaveLength(1); - expect(tenancyA[0]?.tenantId).not.toBe(tenancyB[0]?.tenantId); }); }); describe("cross-tenant workbench isolation", () => { - function mountTenant( - routes: ReturnType, - tenant: typeof TENANT, - principalId: string, - ) { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", tenant); - c.set("principal", principal(principalId)); - await next(); - }); - app.route("/", routes); - return app; - } - test("POST/GET messages reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3069,11 +2562,10 @@ describe("cross-tenant workbench isolation", () => { }); test("typing and stream reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3089,11 +2581,10 @@ describe("cross-tenant workbench isolation", () => { }); test("read-state and invitable reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3125,7 +2616,7 @@ describe("cross-tenant workbench isolation", () => { test("GET messages allows a launched agent instance in the same tenant", async () => { // Agent mailboxes are instance ids with a workbench_launch row, not a - // workbench_settings row. The tenancy gate must accept those so the + // workbench_settings row. The tenant gate must accept those so the // e2e "invite agent → list its messages" path keeps working. const baseStore = createInMemoryChatStore(); const launchedKeys = new Set(); @@ -3137,18 +2628,14 @@ describe("cross-tenant workbench isolation", () => { }; const deps = buildDeps({ store: gatedStore }); const routes = createChatRoutes(deps); - const app = mountTenant(routes, TENANT, "prn_alice"); + const app = mountAs(routes, "prn_alice"); launchedKeys.add(`${TENANT.id}:ins_agent_mailbox`); const res = await app.request(`/workbenches/ins_agent_mailbox/messages`); expect(res.status).toBe(200); // Foreign tenant still 404s even with the same instance id shape. - const other = mountTenant( - routes, - { ...TENANT, id: "tnt_2", domain: "other.example" }, - "prn_bob", - ); + const other = mountAs(routes, "prn_bob", OTHER_TENANT); const denied = await other.request( `/workbenches/ins_agent_mailbox/messages`, ); diff --git a/packages/chat/test/test-support.ts b/packages/chat/test/test-support.ts index 7c5dc8113a..f665e9239b 100644 --- a/packages/chat/test/test-support.ts +++ b/packages/chat/test/test-support.ts @@ -13,7 +13,7 @@ import { createInMemoryRoomMessageStore, type RoomMessage, } from "../src/room-messages"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; +import { createInMemoryNativePrincipalStore } from "../src/native-principal"; import type { MailContent } from "../src/codec"; import type { MailboxFanoutDeps } from "../src/mailbox-fanout"; @@ -28,10 +28,18 @@ export const TENANT = { updatedAt: new Date(), }; -export function principal(id: string) { +export const OTHER_TENANT = { + ...TENANT, + id: "tnt_2", + name: "Other", + slug: "other", + domain: "other.example", +}; + +export function principal(id: string, tenantId: string = TENANT.id) { return { id, - tenantId: TENANT.id, + tenantId, kind: "user" as const, refId: id, status: "active" as const, @@ -209,10 +217,11 @@ export function fakePlatform( export function mountAs( routes: Hono, principalId: string, + tenant: typeof TENANT = TENANT, ): Hono { const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", principal(principalId)); + c.set("tenant", tenant); + c.set("principal", principal(principalId, tenant.id)); await next(); }; const app = new Hono(); @@ -243,14 +252,18 @@ export function stubMailbox(domain: string = TENANT.domain): MailboxFanoutDeps { }; } +export type TestDeps = Omit & { + principals: ReturnType; +}; + export function buildDeps( overrides: Partial = {}, -): CreateChatRoutesDeps { +): TestDeps { const deps: CreateChatRoutesDeps = { store: createInMemoryChatStore(), roomMessages: createInMemoryRoomMessageStore(), platform: fakePlatform(), - tenancy: createInMemoryWorkbenchTenancyStore(), + principals: createInMemoryNativePrincipalStore(), requireGrant: () => async (_c, next) => { await next(); }, @@ -262,7 +275,10 @@ export function buildDeps( }, ...overrides, }; - return deps; + // Sound while no caller overrides `principals` with a store lacking + // the test-only `registerPrincipal` (none do — every test registers + // counterpart principals on the default in-memory store). + return deps as TestDeps; } /** @@ -327,13 +343,6 @@ export interface WorkbenchView { kind: string; pinned: boolean; participants: { address: string; handle: string }[]; - /** Present on create/reopen responses: the workbench's own tenancy - * link, or null (with `legacy: true`) for a pre-tenancy workbench. */ - tenancy?: { - tenantId: string; - parentTenantId: string; - slug: string; - } | null; } export async function createWorkbench( diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index b08f5ead47..163d3a3403 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -7,7 +7,6 @@ import { InferenceResolutionError } from "../src/model-unavailable"; import { createChatRoutes } from "../src/routes"; import { decodeParts } from "../src/codec"; import type { Part, TextPart } from "../src/parts"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; import { cannedGreeting, @@ -24,6 +23,7 @@ import { fakePlatform, mountAs, nextTimelineMoment, + OTHER_TENANT, settleFanout, TENANT, timelineEvents, @@ -458,10 +458,7 @@ describe("message fan-out", () => { test("a message to a person-DM chat fans out to no one — the other party reads the workbench's own timeline", async () => { const deps = buildDeps(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -697,19 +694,25 @@ describe("message fan-out", () => { test("a default-route turn in room B does not include room A's rows", async () => { const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: roomA } = await createWorkbench(app, { + const routes = createChatRoutes(deps); + // One workbench per conversation tenant — the tenant id IS the + // workbench id — so two rooms are two tenants sharing one store, + // exactly as two client-minted conversation tenants would. + const appA = mountAs(routes, "prn_alice", TENANT); + const appB = mountAs(routes, "prn_alice", OTHER_TENANT); + const { body: roomA } = await createWorkbench(appA, { kind: "workbench", name: "room A", participants: ["ins_echo1@acme.example"], }); - const { body: roomB } = await createWorkbench(app, { + const { body: roomB } = await createWorkbench(appB, { kind: "workbench", name: "room B", participants: ["ins_echo1@acme.example"], }); + expect(roomB.id).not.toBe(roomA.id); - await app.request(`/workbenches/${roomA.id}/messages`, { + await appA.request(`/workbenches/${roomA.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -719,7 +722,7 @@ describe("message fan-out", () => { await settleFanout(); await nextTimelineMoment(); - await app.request(`/workbenches/${roomB.id}/messages`, { + await appB.request(`/workbenches/${roomB.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -729,7 +732,7 @@ describe("message fan-out", () => { await settleFanout(); await nextTimelineMoment(); - const response = await app.request(`/workbenches/${roomB.id}/messages`, { + const response = await appB.request(`/workbenches/${roomB.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -1543,8 +1546,6 @@ describe("launchAndJoinAgent 1:1 chats", () => { }); describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { - const tenancy = { addWorkbenchMember: async () => undefined }; - test("joinHumanParticipant adds the member without a caller-supplied settings snapshot", async () => { const store = createInMemoryChatStore(); await store.createWorkbenchSettings({ @@ -1559,14 +1560,12 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { store, roomMessages: createInMemoryRoomMessageStore(), publish: () => undefined, - tenancy, }, { tenantId: TENANT.id, principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_bob", - memberRefId: "prn_bob", memberHandle: "bob", }, ); @@ -1596,7 +1595,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { store, roomMessages: createInMemoryRoomMessageStore(), publish: () => undefined, - tenancy, }; await Promise.all([ @@ -1605,7 +1603,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_bob", - memberRefId: "prn_bob", memberHandle: "bob", }), joinHumanParticipant(deps, { @@ -1613,7 +1610,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_carol", - memberRefId: "prn_carol", memberHandle: "carol", }), ]); diff --git a/packages/chat/test/workbench-tenancy-routes.test.ts b/packages/chat/test/workbench-tenancy-routes.test.ts deleted file mode 100644 index c5d00e6197..0000000000 --- a/packages/chat/test/workbench-tenancy-routes.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; - -import { createWorkbenchTenancyRoutes } from "../src/workbench-tenancy-routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; - -const asUser: MiddlewareHandler = async (c, next) => { - c.set("user", { id: "user_1", email: "alice@example.com" } as never); - await next(); -}; - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser); - app.route("/", routes); - return app; -} - -describe("POST /kinds", () => { - test("reports which of the given tenant ids are workbench tenancies", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const { tenantId: workbenchTenantId } = await tenancy.createWorkbenchTenant( - { - parentTenantId: "tnt_bench", - workbenchId: "ins_general", - name: "General", - creatorUserId: "user_1", - cookies: ["session=test"], - }, - ); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - tenantIds: ["tnt_bench", workbenchTenantId, "tnt_unrelated"], - }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { workbenchTenantIds: string[] }; - expect(body.workbenchTenantIds).toEqual([workbenchTenantId]); - }); - - test("an empty tenantIds list is answered, not rejected", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: [] }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { workbenchTenantIds: string[] }; - expect(body.workbenchTenantIds).toEqual([]); - }); - - test("rejects an unauthenticated request", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = new Hono(); - app.route("/", createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: [] }), - }); - - expect(response.status).toBe(401); - }); - - test("rejects a malformed body", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: "not-an-array" }), - }); - - expect(response.status).toBe(400); - }); -}); diff --git a/packages/chat/test/workbench-tenancy.test.ts b/packages/chat/test/workbench-tenancy.test.ts deleted file mode 100644 index d40438a5cb..0000000000 --- a/packages/chat/test/workbench-tenancy.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -// Contract tests for the in-memory `WorkbenchTenancyStore`, exercised -// the same way `store.test.ts` exercises the in-memory `ChatStore`: -// creation records the parent link, listing scopes strictly to the -// requested parent, and a move updates the link without touching any -// other workbench's tenancy. `moveWorkbenchTenancy` folds its destination -// authorization into the same call as the write (see -// `workbench-tenancy.ts`), so every failure mode of that check is -// exercised here as an outcome of `moveWorkbenchTenancy` itself, never -// as a separate pre-check call. -import { expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { - createInMemoryNativeTenantApi, - createInMemoryWorkbenchTenancyStore, -} from "../src/workbench-tenancy"; - -test("createWorkbenchTenant mints a tenant and records the parent link", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - - const result = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - expect(result.tenantId).toMatch(/^tnt_/); - expect(result.ownerPrincipalId).toMatch(/^prn_/); - expect(result.slug).toContain("general"); - expect(result.domain).toBe(`${result.slug}.localhost`); - - const link = await tenancy.getWorkbenchTenancy("ins_general"); - expect(link).toEqual({ - workbenchId: "ins_general", - tenantId: result.tenantId, - parentTenantId: "tnt_bench_a", - slug: result.slug, - createdAt: link?.createdAt as Date, - }); -}); - -test("getWorkbenchTenancy returns undefined for a workbench with no tenancy — the legacy case", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect( - await tenancy.getWorkbenchTenancy("ins_predates_rollout"), - ).toBeUndefined(); -}); - -test("listChildWorkbenchTenancies scopes strictly to the requested parent bench", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_a1", - name: "A One", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_a2", - name: "A Two", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_b", - workbenchId: "ins_b1", - name: "B One", - creatorUserId: "usr_bob", - cookies: ["session=test"], - }); - - const benchAWorkbenches = - await tenancy.listChildWorkbenchTenancies("tnt_bench_a"); - expect(benchAWorkbenches.map((row) => row.workbenchId).sort()).toEqual([ - "ins_a1", - "ins_a2", - ]); - - const benchBWorkbenches = - await tenancy.listChildWorkbenchTenancies("tnt_bench_b"); - expect(benchBWorkbenches.map((row) => row.workbenchId)).toEqual(["ins_b1"]); -}); - -test("listWorkbenchTenantIds answers which requested ids are workbench tenancies", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const { tenantId: workbenchTenantId } = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const result = await tenancy.listWorkbenchTenantIds([ - "tnt_bench_a", - workbenchTenantId, - "tnt_unrelated", - ]); - - expect(result).toEqual(new Set([workbenchTenantId])); -}); - -test("listWorkbenchTenantIds returns an empty set for an empty request", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect(await tenancy.listWorkbenchTenantIds([])).toEqual(new Set()); -}); - -test("moveWorkbenchTenancy re-parents one workbench without disturbing others when the caller manages the destination", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_moving", - name: "Moving", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_staying", - name: "Staying", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.registerExistingTenant("tnt_bench_c"); - tenancy.grantManageInTenant("usr_alice", "tnt_bench_c"); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_moving", - newParentTenantId: "tnt_bench_c", - callerRefId: "usr_alice", - }); - expect(outcome.kind).toBe("moved"); - expect(outcome.kind === "moved" && outcome.row.parentTenantId).toBe( - "tnt_bench_c", - ); - - expect(await tenancy.listChildWorkbenchTenancies("tnt_bench_a")).toEqual([ - expect.objectContaining({ workbenchId: "ins_staying" }), - ]); - expect(await tenancy.listChildWorkbenchTenancies("tnt_bench_c")).toEqual([ - expect.objectContaining({ workbenchId: "ins_moving" }), - ]); -}); - -test("moveWorkbenchTenancy reports a nonexistent destination tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_does_not_exist", - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "destination_not_found" }); -}); - -test("moveWorkbenchTenancy is forbidden for a real destination tenant the caller has no standing in", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.registerExistingTenant("tnt_bench_c"); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_bench_c", - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "forbidden" }); -}); - -test("moveWorkbenchTenancy treats the destination tenant a workbench was minted as manageable by its own creator", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const destination = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: destination.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome.kind).toBe("moved"); -}); - -test("compensateWorkbenchTenant removes the tenancy link and leaves the native tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_orphaned", - name: "Orphaned", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_b", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_bob", - cookies: ["session=test"], - }); - - await tenancy.compensateWorkbenchTenant(minted.tenantId); - - expect(await tenancy.getWorkbenchTenancy("ins_orphaned")).toBeUndefined(); - expect(await tenancy.listWorkbenchTenantIds([minted.tenantId])).toEqual( - new Set(), - ); - // Native tenant rows stay — compensation is not a DELETE of Interchange - // tenants — so the compensated tenant is still a real move destination - // for a caller who holds manage there (the mint's own creator). - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: minted.tenantId, - callerRefId: "usr_alice", - }); - expect(outcome.kind).toBe("moved"); -}); - -test("moveWorkbenchTenancy rejects moving a workbench into its own tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - // The creator holds a manage grant in its own tenant (seeded as - // owner) — proving the rejection is structural, not authorization, - // since a caller with every grant in the world is still refused. - tenancy.grantManageInTenant("usr_alice", minted.tenantId); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_general", - newParentTenantId: minted.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "cycle" }); -}); - -test("moveWorkbenchTenancy rejects a multi-node cycle: moving a workbench into its own descendant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const parentWorkbench = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_parent", - name: "Parent", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - const childWorkbench = await tenancy.createWorkbenchTenant({ - // The child workbench's tenant is parented under the parent - // workbench's tenant — a real, two-node chain — before the move - // under test ever runs. - parentTenantId: parentWorkbench.tenantId, - workbenchId: "ins_child", - name: "Child", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.grantManageInTenant("usr_alice", childWorkbench.tenantId); - - // Moving the parent workbench into the child's tenant would make the - // parent its own grandchild's child — a cycle two hops deep, not - // just a direct self-parent. - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_parent", - newParentTenantId: childWorkbench.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "cycle" }); - - // Neither tenancy link moved. - expect(await tenancy.getWorkbenchTenancy("ins_parent")).toEqual( - expect.objectContaining({ parentTenantId: "tnt_bench_a" }), - ); - expect(await tenancy.getWorkbenchTenancy("ins_child")).toEqual( - expect.objectContaining({ parentTenantId: parentWorkbench.tenantId }), - ); -}); - -test("moveWorkbenchTenancy allows moving a workbench into an unrelated tenant that happens to share a grandparent", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_root"); - tenancy.registerExistingTenant("tnt_sibling", "tnt_root"); - tenancy.grantManageInTenant("usr_alice", "tnt_sibling"); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_root", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - // "tnt_sibling" shares an ancestor ("tnt_root") with the workbench's - // current tenant but is not itself a descendant of it — a cycle - // check that only compared "is this the current parent" rather than - // walking the whole chain could wrongly flag this as related. - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_sibling", - callerRefId: "usr_alice", - }); - - expect(outcome.kind).toBe("moved"); -}); - -test("moveWorkbenchTenancy reports no tenancy for a workbench with no tenancy link, before the destination is even considered", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_predates_rollout", - newParentTenantId: "tnt_does_not_exist", - callerRefId: "usr_alice", - }); - expect(outcome).toEqual({ kind: "no_tenancy" }); -}); - -test("getTenantPrincipal returns a registered principal scoped to its tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerPrincipal("tnt_bench_a", { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - - expect(await tenancy.getTenantPrincipal("tnt_bench_a", "prn_bob")).toEqual({ - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - // Same principal id, wrong tenant — not found. - expect( - await tenancy.getTenantPrincipal("tnt_bench_b", "prn_bob"), - ).toBeUndefined(); - // Unregistered principal id — not found. - expect( - await tenancy.getTenantPrincipal("tnt_bench_a", "prn_ghost"), - ).toBeUndefined(); -}); - -test("addWorkbenchMember mints a member-role principal in the workbench's own tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const result = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - - expect(result).toEqual({ - tenantId: minted.tenantId, - principalId: expect.stringMatching(/^prn_/) as unknown as string, - }); - const member = await tenancy.getTenantPrincipalByRefId( - minted.tenantId, - "usr_bob", - ); - expect(member?.status).toBe("active"); - expect(member?.kind).toBe("user"); -}); - -test("addWorkbenchMember is idempotent for a refId already holding a principal in that tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const first = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - const second = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - - expect(second).toEqual(first); -}); - -test("addWorkbenchMember returns undefined for a legacy workbench with no tenancy", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect( - await tenancy.addWorkbenchMember({ - workbenchId: "ins_legacy", - refId: "usr_bob", - }), - ).toBeUndefined(); -}); - -test("createWorkbenchTenant mints via POST /api/tenants and POST grants, not SQL", async () => { - const calls: { - method: string; - path: string; - body: unknown; - cookies: string[] | undefined; - }[] = []; - const inner = createInMemoryNativeTenantApi(); - const api: ApiCall = async (method, path, body, cookies) => { - calls.push({ method, path, body, cookies }); - return inner(method, path, body, cookies); - }; - const tenancy = createInMemoryWorkbenchTenancyStore({ api }); - - const result = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=alice"], - }); - - const tenantPosts = calls.filter( - (call) => call.method === "POST" && call.path === "/api/tenants", - ); - expect(tenantPosts).toHaveLength(1); - expect(tenantPosts[0]?.body).toEqual({ - name: "General", - slug: expect.stringContaining("general"), - parentId: "tnt_bench_a", - }); - expect(tenantPosts[0]?.cookies).toEqual(["session=alice"]); - - const grantPosts = calls.filter( - (call) => - call.method === "POST" && - call.path === `/api/tenants/${result.tenantId}/grants`, - ); - expect(grantPosts).toHaveLength(2); - expect(grantPosts.map((call) => call.body)).toEqual([ - { - roleId: expect.stringMatching(/^rol_/), - resource: "room:*", - action: "read", - effect: "allow", - origin: "creator", - }, - { - roleId: expect.stringMatching(/^rol_/), - resource: "room:*", - action: "write", - effect: "allow", - origin: "creator", - }, - ]); - expect( - calls.every( - (call) => - call.method !== "POST" || - call.path === "/api/tenants" || - /\/api\/tenants\/[^/]+\/grants$/.test(call.path), - ), - ).toBe(true); -}); - -test("createWorkbenchTenant fails closed when POST /api/tenants is rejected", async () => { - const api: ApiCall = async () => ({ - status: 403, - data: { error: { code: "forbidden", message: "nope" } }, - cookies: [], - }); - const tenancy = createInMemoryWorkbenchTenancyStore({ api }); - - await expect( - tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=alice"], - }), - ).rejects.toThrow("POST /api/tenants failed with status 403"); - - expect(await tenancy.getWorkbenchTenancy("ins_general")).toBeUndefined(); -}); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index 4b860a0afe..92fc530401 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -19,7 +19,6 @@ import { import { createInMemoryTurnClaimStore } from "../src/turn-claims"; import { createWorkbenchTurnQueue } from "../src/turn-queue"; import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { fakePlatform, TENANT } from "./test-support"; const RUN_ID = "run_1"; @@ -57,8 +56,6 @@ function buildApp( }), turnCancellation: overrides.turnCancellation ?? createTurnCancelRegistry(), authenticator: overrides.authenticator ?? authenticateAsRun, - tenancy: overrides.tenancy ?? createInMemoryWorkbenchTenancyStore(), - sessionFor: overrides.sessionFor ?? (async () => ["session=test"]), }) as unknown as Hono; } @@ -470,217 +467,3 @@ describe("POST /participants/messages", () => { expect(updated?.settings["connections/pending"]).toBeUndefined(); }); }); - -describe("POST /participants/mint-dm", () => { - test("is a 401 without a recognized run credential", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(response.status).toBe(401); - }); - - test("a run whose address is not a participant of any workbench is a 404", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(response.status).toBe(404); - }); - - test("an invalid body is a 400", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({}), - }); - expect(response.status).toBe(400); - }); - - test("is a 500 owner_unresolved when the parent tenant has no owner grant", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_dm", - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform, tenancy }); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("owner_unresolved"); - expect(platform.launchInviteCalls).toHaveLength(0); - }); - - test("mints a new kind:chat 1:1 and publishes chat.workbenches-mutated on the caller workbench", async () => { - const store = createInMemoryChatStore(); - const callerWorkbenchId = "chan_dm"; - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: callerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - tenancy.grantManageInTenant("usr_alice", TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - const published: { workbenchId: string; event: unknown }[] = []; - const publish = (workbenchId: string, event: unknown) => { - published.push({ workbenchId, event }); - }; - - const app = buildApp({ store, platform, tenancy, publish }); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { - workbenchId: string; - address: string; - definitionId: string; - handle: string; - }; - expect(typeof body.workbenchId).toBe("string"); - expect(body.workbenchId).not.toBe(callerWorkbenchId); - expect(body.address).toBe("ins_invited1@acme.example"); - expect(body.definitionId).toBe("wfd_echo"); - expect(body.handle).toBe("echo"); - expect(platform.launchInviteCalls).toEqual([ - { - tenantId: TENANT.id, - creatorPrincipalId: "prn_1", - definitionId: "wfd_echo", - }, - ]); - - const minted = await store.getWorkbenchSettings( - TENANT.id, - body.workbenchId, - ); - expect(minted?.settings["chat/kind"]).toBe("chat"); - expect(minted?.settings["chat/definitionId"]).toBe("wfd_echo"); - expect(minted?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - - // Caller DM is unchanged — mint never invites into Myra's chat. - const caller = await store.getWorkbenchSettings( - TENANT.id, - callerWorkbenchId, - ); - expect(caller?.settings["chat/participants"]).toEqual([ - { address: RUN_ADDRESS, handle: "myra" }, - ]); - - const mutated = published.filter( - (entry) => - (entry.event as { type?: string }).type === - "chat.workbenches-mutated" && entry.workbenchId === callerWorkbenchId, - ); - expect(mutated).toHaveLength(1); - expect( - published.some( - (entry) => - (entry.event as { type?: string }).type === - "chat.workbenches-mutated" && - entry.workbenchId === body.workbenchId, - ), - ).toBe(false); - }); - - test("second mint-dm with the same definitionId reopens the existing chat instead of cloning", async () => { - const store = createInMemoryChatStore(); - const callerWorkbenchId = "chan_dm"; - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: callerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - tenancy.grantManageInTenant("usr_alice", TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform, tenancy }); - const first = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(first.status).toBe(201); - const firstBody = (await first.json()) as { - workbenchId: string; - address: string; - handle: string; - }; - - const second = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(second.ok).toBe(true); - const secondBody = (await second.json()) as { - workbenchId: string; - address: string; - handle: string; - }; - - expect(secondBody.workbenchId).toBe(firstBody.workbenchId); - expect(secondBody.address).toBe(firstBody.address); - expect(secondBody.handle).toBe(firstBody.handle); - expect(platform.launchInviteCalls).toHaveLength(1); - - const chats = await store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(2); - expect( - chats.filter((row) => row.settings["chat/definitionId"] === "wfd_echo"), - ).toHaveLength(1); - - const caller = await store.getWorkbenchSettings( - TENANT.id, - callerWorkbenchId, - ); - expect(caller?.settings["chat/participants"]).toEqual([ - { address: RUN_ADDRESS, handle: "myra" }, - ]); - }); -}); diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 0722010273..b1605e8acc 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -41,7 +41,7 @@ const ALLOWLIST: readonly { }[] = [ { relPath: "packages/chat/src/schema.ts", - maxOccurrences: 17, + maxOccurrences: 16, tables: [ // Created as channel_settings et al.; renamed to workbench_* by // 0018_rename_channel_to_workbench (CL-6260) — see migrations.ts. @@ -49,7 +49,6 @@ const ALLOWLIST: readonly { "chat_bench_settings", "workbench_read_state", "workbench_launch", - "workbench_tenancy", "workbench_threads", "workbench_thread_messages", "workbench_share", From fb50cd99c87c05b30e3c914a44df719a89fb795a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:46:42 -0700 Subject: [PATCH 02/18] fix(chat): reuse validated person-DM counterpart instead of re-fetching Summary: Collapse the second person-DM principal lookup into the pre-mint gate's validated result, reusing it with the full kind/status predicate instead of re-fetching with an undefined-only check. Add pins for agent-kind and cross-bench principalId rejection plus a single-lookup assertion. Clear stale deleted-test-file and workbenchTenancy comment pointers. Verification: bun test (931 pass, 0 fail), bun run typecheck (exit 0), prettier --check and eslint on touched files (clean). --- packages/chat/src/routes.ts | 20 ++++-- packages/chat/src/schema.ts | 2 +- packages/chat/test/federation-trust.test.ts | 4 +- packages/chat/test/routes.test.ts | 75 +++++++++++++++++++++ packages/chat/test/workbench-share.test.ts | 3 +- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index c4ef3dbc89..1c6e2cf2e7 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -109,7 +109,7 @@ import { import type { CommandRegistry, CommandResult } from "@corbits/commands"; import { InferenceResolutionError } from "./model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; -import type { NativePrincipalStore } from "./native-principal"; +import type { NativePrincipal, NativePrincipalStore } from "./native-principal"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -989,6 +989,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // `principalId` must name a real, active member of this bench. // Both fail closed with an ordinary client error rather than // seeding a workbench with a participant record nothing backs. + // The validated counterpart is carried to the join below — one + // lookup, one predicate, no second read that could drift. + let dmCounterpart: NativePrincipal | undefined; if (isChatWithPrincipal(body)) { if (body.principalId === principal.id) { return c.json( @@ -1017,6 +1020,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 400, ); } + dmCounterpart = target; } // The client creates the conversation tenant through Interchange before @@ -1196,11 +1200,15 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // the local-part-of-the-principal-id fallback below only // fires for a bare API call that omits `name` entirely. const memberHandle = handleFromName(body.name ?? "", body.principalId); - const memberPrincipal = await deps.principals.getTenantPrincipal( - tenant.id, - body.principalId, - ); - if (memberPrincipal === undefined) { + // The pre-mint gate above already validated this counterpart — + // reused here with the full predicate rather than re-fetched, so + // the kind/status check cannot drift between the two reads. The + // fail-closed shape stays: an unset counterpart is still a 400. + if ( + dmCounterpart === undefined || + dmCounterpart.kind !== "user" || + dmCounterpart.status !== "active" + ) { return c.json( makeErrorEnvelope({ code: "bad_request", diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index ecf8aa9154..70b4cc5c3e 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -265,7 +265,7 @@ export const workbenchThreadMessages = chatSchema.table( * One workbench's projection into a sibling tenant (CL-5882's * Slack-Connect-style shared workbenches). The owning tenant is never * inferable from `workbenchId` alone (a workbench's own tenancy lives in - * `workbenchTenancy`/`workbench_settings`, not here), so it's carried + * `workbench_settings`, not here), so it's carried * explicitly — `getShare`/`listSharesForWorkbench` in `./workbench-share.ts` * always take it rather than re-deriving it. A row here is created only * after `FederationTrustStore.hasBilateralTrust` passes (see diff --git a/packages/chat/test/federation-trust.test.ts b/packages/chat/test/federation-trust.test.ts index da0d78df79..2371b0ac3a 100644 --- a/packages/chat/test/federation-trust.test.ts +++ b/packages/chat/test/federation-trust.test.ts @@ -1,9 +1,7 @@ // Contract tests for the in-memory `FederationTrustStore`: bilateral // trust requires both directions, revocation removes both, and // `resolveSharedViaParent` distinguishes true siblings from unrelated -// or parent-less tenants. Exercised the same way `workbench-tenancy.test.ts` -// exercises the in-memory `WorkbenchTenancyStore` — pure store contract, -// no HTTP. +// or parent-less tenants — pure store contract, no HTTP. import { expect, test } from "bun:test"; import { createInMemoryFederationTrustStore } from "../src/federation-trust"; diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 9458321635..f218bcb03d 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -659,6 +659,81 @@ describe("POST /workbenches — chat with a person (DM)", () => { expect(response.status).toBe(400); }); + + test("rejects a principalId naming a non-user (agent) member — 400", async () => { + const deps = buildDeps(); + deps.principals.registerPrincipal(TENANT.id, { + id: "prn_bot", + kind: "agent", + status: "active", + refId: "prn_bot", + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const response = await app.request("/workbenches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "chat", principalId: "prn_bot" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("bad_request"); + + const workbenches = await deps.store.listWorkbenchSettings(TENANT.id); + expect(workbenches).toHaveLength(0); + }); + + test("rejects a principalId naming a member of another bench — 400", async () => { + const deps = buildDeps(); + // prn_bob exists, but on OTHER_TENANT — never on the caller's bench. + deps.principals.registerPrincipal(OTHER_TENANT.id, { + id: "prn_bob", + kind: "user", + status: "active", + refId: "prn_bob", + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const response = await app.request("/workbenches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "chat", principalId: "prn_bob" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("bad_request"); + + const workbenches = await deps.store.listWorkbenchSettings(TENANT.id); + expect(workbenches).toHaveLength(0); + }); + + test("validates the person-DM counterpart with a single principal lookup", async () => { + const deps = buildDeps(); + const inner = deps.principals; + let lookups = 0; + deps.principals = { + registerPrincipal: (...args) => inner.registerPrincipal(...args), + getTenantPrincipal: async (...args) => { + lookups += 1; + return inner.getTenantPrincipal(...args); + }, + }; + registerBob(deps); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const { response } = await createWorkbench(app, { + kind: "chat", + principalId: "prn_bob", + name: "Bob", + }); + + expect(response.status).toBe(201); + // The pre-mint gate's validated principal is reused at join time — + // a second fetch could only re-check a weaker predicate. + expect(lookups).toBe(1); + }); }); describe("POST /workbenches/:id/invite", () => { diff --git a/packages/chat/test/workbench-share.test.ts b/packages/chat/test/workbench-share.test.ts index 4d7d5837fa..c4b1a523af 100644 --- a/packages/chat/test/workbench-share.test.ts +++ b/packages/chat/test/workbench-share.test.ts @@ -1,8 +1,7 @@ // Contract tests for the in-memory `WorkbenchShareStore`: fail-closed // creation (no share without bilateral trust), fail-closed membership // (no member row for a share that doesn't exist), and per-tenant -// membership isolation. Exercised the same way `workbench-tenancy.test.ts` -// exercises `WorkbenchTenancyStore` — pure store contract, no HTTP. +// membership isolation — pure store contract, no HTTP. import { expect, test } from "bun:test"; import { createInMemoryWorkbenchShareStore } from "../src/workbench-share"; import { createInMemoryFederationTrustStore } from "../src/federation-trust"; From fd3a43e2550b8b95c81fdee1a6bb654ac52c7440 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:59:46 -0700 Subject: [PATCH 03/18] feat(hub)!: cut over to stock Interchange composition Summary: - Delete Workbench product-policy mounts, guards, and onboarding from apps/hub (artifacts/memory/skills/slack-tag mounts, tenant-create guard/onboard, dispatch tenant guard, signup tenancy, connection-live reconcile) and repair composition wiring/imports. - Rename WORKBENCH_CHAT_IDLE_REAP_MS to HUB_CHAT_IDLE_REAP_MS; drop product-only signup/genesis knobs (WORKBENCH_SIGNUP, WORKBENCH_ALLOWED_EMAIL_DOMAINS, WORKBENCH_DEFAULT_TENANT/ORG_SLUG). - Update wiring-pin tests for cut routes; drop the dead draft-routes pin; shrink the report-error baseline to the remaining matches. Verification: - bun run --cwd apps/hub test: 216 pass, 0 fail (plus 1 file-level isolation-harness error: the shared test database carries an unknown chat migration 0031_workbench_tenancy_drop, so the live-sidecar probe cannot create workbenches; unrelated to this change). - bun run --cwd apps/hub typecheck: clean. - bun run lint: 0 errors (10 pre-existing warnings in unrelated packages). - bun run check:structural: exit 0, 18 checks ok. --- apps/hub/src/artifacts-mount.test.ts | 80 -- apps/hub/src/artifacts-mount.ts | 76 -- apps/hub/src/bench-session.ts | 2 +- apps/hub/src/config.ts | 78 +- .../hub/src/connection-live-reconcile.test.ts | 46 - apps/hub/src/connection-live-reconcile.ts | 30 - apps/hub/src/credential-expiry-sweep.ts | 2 +- apps/hub/src/credential-material-refresh.ts | 2 +- .../src/crypto-provider-cache-wiring.test.ts | 4 +- apps/hub/src/dispatch-tenant-guard.test.ts | 220 ---- apps/hub/src/dispatch-tenant-guard.ts | 247 ---- apps/hub/src/draft-route-envelope.test.ts | 11 - apps/hub/src/index.ts | 1051 ++--------------- apps/hub/src/launch-caches.ts | 2 +- apps/hub/src/mailbox-persist.ts | 20 +- apps/hub/src/memory-mount.test.ts | 290 ----- apps/hub/src/memory-mount.ts | 231 ---- apps/hub/src/memory-workflow-routes.test.ts | 181 --- .../native-workflow-routine-launch.test.ts | 8 +- apps/hub/src/routability-wiring.test.ts | 16 +- apps/hub/src/signup-tenancy.ts | 95 -- apps/hub/src/skills-mount.test.ts | 120 -- apps/hub/src/skills-mount.ts | 136 --- .../src/slack-channel-mint-session.test.ts | 177 --- apps/hub/src/slack-channel-mint-session.ts | 99 -- apps/hub/src/slack-tag-mount.ts | 244 ---- .../src/templates/template-block-routes.ts | 2 +- apps/hub/src/tenant-create-guard.ts | 256 ---- apps/hub/src/tenant-create-onboard.ts | 212 ---- apps/hub/src/workflow-scheduler.ts | 38 +- apps/hub/test/boot-does-not-seed.test.ts | 42 +- apps/hub/test/chat-mount.test.ts | 4 - apps/hub/test/composition.test.ts | 41 +- apps/hub/test/config.test.ts | 96 +- apps/hub/test/credential-cipher.test.ts | 4 - apps/hub/test/eval-runs-mount.test.ts | 4 - apps/hub/test/presence-mount.test.ts | 4 - apps/hub/test/relaunch-sweep-close.test.ts | 4 - apps/hub/test/signup-genesis.test.ts | 426 ------- apps/hub/test/slack-tag-mount.test.ts | 157 --- apps/hub/test/tenant-create-guard.test.ts | 336 ------ apps/hub/test/tenant-create-onboard.test.ts | 367 ------ apps/hub/test/tenant-create-shutdown.test.ts | 187 --- apps/hub/test/workflow-scheduler.test.ts | 48 +- scripts/checks/report-error-baseline.txt | 2 - 45 files changed, 181 insertions(+), 5517 deletions(-) delete mode 100644 apps/hub/src/artifacts-mount.test.ts delete mode 100644 apps/hub/src/artifacts-mount.ts delete mode 100644 apps/hub/src/connection-live-reconcile.test.ts delete mode 100644 apps/hub/src/connection-live-reconcile.ts delete mode 100644 apps/hub/src/dispatch-tenant-guard.test.ts delete mode 100644 apps/hub/src/dispatch-tenant-guard.ts delete mode 100644 apps/hub/src/draft-route-envelope.test.ts delete mode 100644 apps/hub/src/memory-mount.test.ts delete mode 100644 apps/hub/src/memory-mount.ts delete mode 100644 apps/hub/src/memory-workflow-routes.test.ts delete mode 100644 apps/hub/src/signup-tenancy.ts delete mode 100644 apps/hub/src/skills-mount.test.ts delete mode 100644 apps/hub/src/skills-mount.ts delete mode 100644 apps/hub/src/slack-channel-mint-session.test.ts delete mode 100644 apps/hub/src/slack-channel-mint-session.ts delete mode 100644 apps/hub/src/slack-tag-mount.ts delete mode 100644 apps/hub/src/tenant-create-guard.ts delete mode 100644 apps/hub/src/tenant-create-onboard.ts delete mode 100644 apps/hub/test/signup-genesis.test.ts delete mode 100644 apps/hub/test/slack-tag-mount.test.ts delete mode 100644 apps/hub/test/tenant-create-guard.test.ts delete mode 100644 apps/hub/test/tenant-create-onboard.test.ts delete mode 100644 apps/hub/test/tenant-create-shutdown.test.ts diff --git a/apps/hub/src/artifacts-mount.test.ts b/apps/hub/src/artifacts-mount.test.ts deleted file mode 100644 index 27c93abfc7..0000000000 --- a/apps/hub/src/artifacts-mount.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { InlineContentStore, type ArtifactDb } from "@corbits/artifacts"; - -import { mountArtifacts } from "./artifacts-mount"; - -// URL-resolution tests observe the mount through the injected engine seam -// instead of bun's `mock.module`, whose process-wide registry swap cannot -// be undone for later files in the same `bun test` process (it starved -// test/artifact-doc-persistence.test.ts of the real `createArtifactDb`). -const createArtifactDbCalls: string[] = []; - -const stubEngine = { - createArtifactDb: (databaseUrl: string) => { - createArtifactDbCalls.push(databaseUrl); - return { db: {} as ArtifactDb, close: async () => undefined }; - }, - runArtifactMigrations: async () => undefined, - contentStore: InlineContentStore, -}; - -const KEYS = ["DATABASE_URL"] as const; -type EnvKey = (typeof KEYS)[number]; -const saved: Partial> = {}; - -function clearEnvKey(key: EnvKey): void { - // Assigning `undefined` would store the string "undefined"; the key has to - // go. `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function stashEnv(): void { - for (const key of KEYS) { - saved[key] = process.env[key]; - clearEnvKey(key); - } - createArtifactDbCalls.length = 0; -} - -afterEach(() => { - for (const key of KEYS) { - const value = saved[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - saved[key] = undefined; - } - createArtifactDbCalls.length = 0; -}); - -describe("mountArtifacts URL resolution", () => { - test("returns undefined when no URL is available", async () => { - stashEnv(); - const handle = await mountArtifacts({ engine: stubEngine }); - expect(handle).toBeUndefined(); - expect(createArtifactDbCalls).toEqual([]); - }); - - test("mounts against DATABASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench_control"; - const handle = await mountArtifacts({ engine: stubEngine }); - expect(handle).toBeDefined(); - expect(createArtifactDbCalls).toEqual([ - "postgres://localhost:5432/workbench_control", - ]); - }); - - test("explicit options.databaseUrl wins over DATABASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/control"; - const handle = await mountArtifacts({ - databaseUrl: "postgres://localhost:5432/explicit", - engine: stubEngine, - }); - expect(handle).toBeDefined(); - expect(createArtifactDbCalls).toEqual([ - "postgres://localhost:5432/explicit", - ]); - }); -}); diff --git a/apps/hub/src/artifacts-mount.ts b/apps/hub/src/artifacts-mount.ts deleted file mode 100644 index 2a2969939a..0000000000 --- a/apps/hub/src/artifacts-mount.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Hub-side artifacts engine mount — the host's own analog of - * `@corbits/dock`'s `mountArtifacts`. - * - * Same Postgres URL as the hub control plane, different schema: the package - * owns `artifacts.*` (tables, migrations, ledger) and never writes the host - * search_path. Hard FKs from `artifacts.artifact` into `public.tenant` / - * `public.principal` require that shared URL — a separate database would - * break those FKs. - * - * URL resolution: explicit `databaseUrl` option (tests/DI) → `DATABASE_URL`. - * There is exactly one Postgres URL for this hub. When neither is set, the - * mount is skipped (logged once, never thrown) — same optional contract as - * the dock mount. - * - * This module lands the mount + factory only. Tenant-scoped HTTP - * list/get/upload routes live in `@corbits/artifacts-hub` and are - * registered from the hub composition root when the mount succeeds. - */ -import { getLogger } from "@intx/log"; -import { - createArtifactDb, - InlineContentStore, - runArtifactMigrations, - type ArtifactDb, - type ContentStore, -} from "@corbits/artifacts"; - -const log = getLogger(["hub", "artifacts-mount"]); - -export type MountArtifactsOptions = { - /** Explicit database URL. Defaults to `process.env.DATABASE_URL`. */ - databaseUrl?: string; - /** - * Engine seam for tests: lets URL-resolution tests observe the mount - * without a live Postgres or process-wide module mocking (bun's - * `mock.module` swap is irreversible for later files in the same - * test process). - */ - engine?: { - createArtifactDb: typeof createArtifactDb; - runArtifactMigrations: typeof runArtifactMigrations; - contentStore: ContentStore; - }; -}; - -/** - * Handle returned by a successful mount. The `db` is the engine's own - * drizzle handle; `contentStore` is the byte sink used by upload routes. - */ -export type ArtifactsMountHandle = { - db: ArtifactDb; - contentStore: ContentStore; -}; - -export async function mountArtifacts( - options: MountArtifactsOptions = {}, -): Promise { - const databaseUrl = options.databaseUrl ?? process.env["DATABASE_URL"]; - if (!databaseUrl) { - log.info("No DATABASE_URL — artifacts will not be persisted"); - return undefined; - } - - const engine = options.engine ?? { - createArtifactDb, - runArtifactMigrations, - contentStore: InlineContentStore, - }; - const { db } = engine.createArtifactDb(databaseUrl); - await engine.runArtifactMigrations(db); - log.info( - "Artifacts engine mounted — artifacts persist as versioned rows by kind", - ); - return { db, contentStore: engine.contentStore }; -} diff --git a/apps/hub/src/bench-session.ts b/apps/hub/src/bench-session.ts index ec824c43c8..c631c35683 100644 --- a/apps/hub/src/bench-session.ts +++ b/apps/hub/src/bench-session.ts @@ -27,7 +27,7 @@ import type { SessionForUser } from "@workbench/onboarding"; * provisioning pass can never have its session expire mid-flight. */ const REMINT_LEAD_MS = 60 * 60 * 1000; -const PROVISIONER_USER_AGENT = "workbench-bench-provisioner"; +const PROVISIONER_USER_AGENT = "hub-bench-provisioner"; type MintedSession = { readonly cookies: string[]; diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index aaac6a14e7..ef770ae638 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -41,7 +41,7 @@ function isLoopbackBaseUrl(baseUrl: string): boolean { const HubEnv = type({ DATABASE_URL: type(/^postgres(ql)?:\/\/.+$/).describe( - "a Postgres connection URL, e.g. postgres://workbench:workbench@localhost:5432/workbench", + "a Postgres connection URL, e.g. postgres://hub:hub@localhost:5432/hub", ), BASE_URL: type(HTTP_URL).describe( "an http(s) origin, e.g. http://localhost:3000", @@ -58,11 +58,6 @@ const HubEnv = type({ HUB_STATIC_DIR: type("string > 0").describe( "a directory of built user-interface files the hub serves, e.g. apps/hub/public", ), - "WORKBENCH_DEFAULT_TENANT?": type( - /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, - ).describe( - 'slug of the root tenant the hub ensures at boot; every self-served personal bench parents under it, and setup/seed resolve the same slug — ORG_SLUG is an alias when this is unset; default "workbench"', - ), "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( "the per-IP sign-up rate-limit window, in seconds, e.g. 60", ), @@ -75,18 +70,9 @@ const HubEnv = type({ "SIGNIN_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe( "the maximum failed sign-in attempts a single account may accrue per window before further failures are rejected, e.g. 10 — keyed on the target email, not client IP (see sign-in-rate-limit.ts); a correct password always succeeds regardless of this budget", ), - "WORKBENCH_SIGNUP?": type("'open' | 'closed'").describe( - "open = self-serve email signup allowed; closed (default) = owner adds users or copy-link invite only", - ), - "WORKBENCH_ALLOWED_EMAIL_DOMAINS?": type("string").describe( - "comma-separated email domains allowed when WORKBENCH_SIGNUP=open, e.g. acme.example", - ), "ROUTINE_SCHEDULER_POLL_INTERVAL_MS?": type(/^[1-9]\d*$/).describe( "dev/test-only override for the routine scheduler's poll interval, in milliseconds — unset (default) runs the real 30s production cadence; the e2e harness sets this to a fast interval so a scheduled-routine test doesn't wait out the real cadence", ), - "ORG_SLUG?": type(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).describe( - 'alias for WORKBENCH_DEFAULT_TENANT when that is unset — the same root/operator slug setup and seed resolve; default "workbench"', - ), "GOOGLE_CLIENT_ID?": type("string > 0").describe( "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", ), @@ -126,14 +112,11 @@ const HubEnv = type({ "ALLOW_PLAINTEXT_SECRETS?": type("'1' | 'true'").describe( "dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY or PRINCIPAL_KEY_ENCRYPTION_KEY, storing secrets and signing keys at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident", ), - "ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe( - "dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks normally require emailVerified; never set this for a real deployment", - ), "HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe( "opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch", ), "SIDECAR_PROVISIONERS?": type("string").describe( - "comma-separated sidecar-allocation backend ids to register for workbenches placed on their own exclusive sidecar: 'process' (a child process on this host), 'docker', or 'e2b'; unset or empty (default) registers 'process' alone, so a single-server install provisions exclusive sidecars with no operator configuration", + "comma-separated sidecar-allocation backend ids to register for chats placed on their own exclusive sidecar: 'process' (a child process on this host), 'docker', or 'e2b'; unset or empty (default) registers 'process' alone, so a single-server install provisions exclusive sidecars with no operator configuration", ), "SIDECAR_DEFAULT_PROVISIONER?": type("string > 0").describe( "which id listed in SIDECAR_PROVISIONERS is the default backend exclusive placements provision on; required when SIDECAR_PROVISIONERS lists more than one id, optional (defaults to that one id) when it lists exactly one", @@ -159,8 +142,8 @@ const HubEnv = type({ "HUB_SIDECAR_WEBSOCKET_URL?": type(/^wss?:\/\/.+$/).describe( "the ws(s):// URL a provisioned sidecar container dials back to reach this hub; unset (default) derives it from BASE_URL, which is wrong for a docker sidecar provisioner — that container's own localhost is itself, not the hub host — so set this whenever SIDECAR_PROVISIONER=docker", ), - "WORKBENCH_CHAT_IDLE_REAP_MS?": type("string").describe( - "how long a chat resident (workbench host or invited agent) may sit idle before the hub reaps it via a state-preserving undeploy, in milliseconds; unset defaults to 30 minutes", + "HUB_CHAT_IDLE_REAP_MS?": type("string").describe( + "how long a chat resident (host or invited agent) may sit idle before the hub reaps it via a state-preserving undeploy, in milliseconds; unset defaults to 30 minutes", ), }); @@ -182,7 +165,7 @@ const DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS = 60; const DEFAULT_SIGNIN_RATE_LIMIT_MAX = 10; /** - * Production default for `WORKBENCH_CHAT_IDLE_REAP_MS`: 30 minutes, + * Production default for `HUB_CHAT_IDLE_REAP_MS`: 30 minutes, * matching the old sidecar-side `WORKBENCH_CHILD_IDLE_REAP_MS` default * this replaces for chat. */ @@ -209,21 +192,16 @@ function parsePositiveMsEnv( return n; } -const DEFAULT_TENANT_SLUG = "workbench"; - // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own // package, add a member here with its settings, add its id to // `SIDECAR_PROVISIONER_IDS`, and register it in // `sidecarProvisionerFrom`'s per-id parsing below. // -// CL-7324: this is the CLOSED dispatchable-backend set — dispatchable -// work may only ever land on one of these hub-provisioned backends, and -// only ever tenant-bound (the anchor run's tenant; see -// `./dispatch-tenant-guard.ts`). No schema change carries that rule: it is -// enforced at the dispatch seams, not declared in config, so a new backend -// id added here is provisionable but never implicitly dispatchable -// cross-tenant. +// The ids here are the provisionable set exclusive placements may land +// on. Tenant binding is the caller's composition concern, not declared +// in config, so a new backend id added here is provisionable but never +// implicitly dispatchable cross-tenant. export type DockerSidecarProvisionerConfig = { readonly id: "docker"; readonly image: string; @@ -276,7 +254,6 @@ export type HubConfig = { readonly sessionSecret: string; readonly hubDataDir: string; readonly hubStaticDir: string; - readonly defaultTenantSlug: string; readonly signupRateLimit: { readonly windowSeconds: number; readonly max: number; @@ -285,10 +262,6 @@ export type HubConfig = { readonly windowSeconds: number; readonly max: number; }; - /** Self-serve signup. Default closed — see docs/TENANCY.md. */ - readonly signupMode: "open" | "closed"; - /** Domains allowed when signupMode is open. Empty = any domain. */ - readonly allowedEmailDomains: readonly string[]; readonly socialProviders: Readonly< Partial> >; @@ -307,9 +280,6 @@ export type HubConfig = { readonly principalKeyEncryptionKeyHex?: string; /** Dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY. */ readonly allowPlaintextSecrets: boolean; - /** Dev/test-only opt-in to skip @workbench/access-policy's email- - * verification requirement. */ - readonly allowUnverifiedEmails: boolean; /** Opt-in to initialize git-on-disk state inside an existing git work tree. */ readonly allowGitInsideWorkTree?: boolean; /** Dev/test-only override for the routine scheduler's poll interval — @@ -534,14 +504,6 @@ function sidecarProvisionerConfigFor( export function readHubConfig( env: Record, ): HubConfig { - if (env.OPERATOR_TENANT_ID !== undefined) { - throw new Error( - "OPERATOR_TENANT_ID is no longer read: first signup mints the root tenant by slug. " + - "Set WORKBENCH_DEFAULT_TENANT to your existing root tenant's slug " + - '(or remove OPERATOR_TENANT_ID to keep the default slug "workbench"), then restart.', - ); - } - const parsed = HubEnv(env); if (parsed instanceof type.errors) { throw new Error( @@ -555,32 +517,14 @@ export function readHubConfig( const socialProviders = socialProvidersFrom(parsed); const sidecarProvisioners = sidecarProvisionersFrom(parsed); - const allowedEmailDomains = - parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS === undefined || - parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS.trim() === "" - ? [] - : parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS.split(",") - .map((d) => d.trim()) - .filter((d) => d.length > 0); - - // One deployment fact shared by first-signup genesis, setup, and seed. - // WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the alias when that is - // unset. - const defaultTenantSlug = - parsed.WORKBENCH_DEFAULT_TENANT ?? parsed.ORG_SLUG ?? DEFAULT_TENANT_SLUG; - const hubConfig: { -readonly [K in keyof HubConfig]: HubConfig[K] } = { databaseUrl: parsed.DATABASE_URL, baseUrl: parsed.BASE_URL, sessionSecret: parsed.SESSION_SECRET, hubDataDir: parsed.HUB_DATA_DIR, hubStaticDir: parsed.HUB_STATIC_DIR, - defaultTenantSlug, socialProviders, - signupMode: parsed.WORKBENCH_SIGNUP ?? "closed", - allowedEmailDomains, allowPlaintextSecrets: parsed.ALLOW_PLAINTEXT_SECRETS !== undefined, - allowUnverifiedEmails: parsed.ALLOW_UNVERIFIED_EMAILS !== undefined, sidecarProvisioners: sidecarProvisioners.provisioners, signupRateLimit: { windowSeconds: parsed.SIGNUP_RATE_LIMIT_WINDOW_SECONDS @@ -599,8 +543,8 @@ export function readHubConfig( : DEFAULT_SIGNIN_RATE_LIMIT_MAX, }, chatIdleReapMs: parsePositiveMsEnv( - parsed.WORKBENCH_CHAT_IDLE_REAP_MS, - "WORKBENCH_CHAT_IDLE_REAP_MS", + parsed.HUB_CHAT_IDLE_REAP_MS, + "HUB_CHAT_IDLE_REAP_MS", DEFAULT_CHAT_IDLE_REAP_MS, ), }; diff --git a/apps/hub/src/connection-live-reconcile.test.ts b/apps/hub/src/connection-live-reconcile.test.ts deleted file mode 100644 index 61a9387f98..0000000000 --- a/apps/hub/src/connection-live-reconcile.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// The hub connect hook must relaunch a live assistant the moment a -// `feedsTools` connector (Manus) is stored — not wait for a new invite, -// and not persist-only. `createHub` is not booted here; this is the -// hook body `apps/hub/src/index.ts` fires from `settleServiceConnection`. -import { describe, expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; -import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; - -describe("reconcilePinnedToolPackagesAfterConnect", () => { - test("manus connect relaunches live assistants that pin @corbits/manus-tools", async () => { - const calls: { tenantId: string; packages: readonly string[] }[] = []; - const result = await reconcilePinnedToolPackagesAfterConnect( - { - reconcilePinnedToolPackages: async (tenantId, packageNames) => { - calls.push({ tenantId, packages: [...packageNames] }); - return { scanned: 1, relaunched: 1 }; - }, - }, - { tenantId: "ten_1", connectorId: "manus" }, - ); - - expect(CONNECTOR_REGISTRY["manus"]?.feedsTools).toEqual([ - "@corbits/manus-tools", - ]); - expect(result).toEqual({ scanned: 1, relaunched: 1 }); - expect(calls).toEqual([ - { tenantId: "ten_1", packages: ["@corbits/manus-tools"] }, - ]); - }); - - test("an inference-only connector does not relaunch for tool-package pins", async () => { - const calls: unknown[] = []; - const result = await reconcilePinnedToolPackagesAfterConnect( - { - reconcilePinnedToolPackages: async (...args) => { - calls.push(args); - return { scanned: 0, relaunched: 0 }; - }, - }, - { tenantId: "ten_1", connectorId: "anthropic" }, - ); - - expect(result).toBeUndefined(); - expect(calls).toEqual([]); - }); -}); diff --git a/apps/hub/src/connection-live-reconcile.ts b/apps/hub/src/connection-live-reconcile.ts deleted file mode 100644 index d4cb839fba..0000000000 --- a/apps/hub/src/connection-live-reconcile.ts +++ /dev/null @@ -1,30 +0,0 @@ -// After a connector's credential lands, live assistants that already -// pin that connector's `feedsTools` packages still run the snapshot -// deployed at launch. Bindings for those packages -// (`pinnedPackageCredentialBindingsFor`) fold only at deploy, so a Myra -// launched at signup before Manus was pasted cannot `resolve("manus")` -// until this pass relaunches it. Persist-only (stamp the pin onto the -// launch row and leave the run) is the bug: the sidecar keeps the old -// snapshot. -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; - -export type PinnedToolPackageReconcile = { - reconcilePinnedToolPackages( - tenantId: string, - packageNames: readonly string[], - ): Promise<{ scanned: number; relaunched: number }>; -}; - -/** - * Relaunches live participants in `tenantId` whose pins include this - * connector's `feedsTools`. Returns `undefined` when the connector - * feeds no tool packages (inference-only, webhook, …). - */ -export async function reconcilePinnedToolPackagesAfterConnect( - platform: PinnedToolPackageReconcile, - info: { readonly tenantId: string; readonly connectorId: string }, -): Promise<{ scanned: number; relaunched: number } | undefined> { - const feedsTools = CONNECTOR_REGISTRY[info.connectorId]?.feedsTools ?? []; - if (feedsTools.length === 0) return undefined; - return platform.reconcilePinnedToolPackages(info.tenantId, feedsTools); -} diff --git a/apps/hub/src/credential-expiry-sweep.ts b/apps/hub/src/credential-expiry-sweep.ts index 8ca491bdcd..8cc0f8c8bd 100644 --- a/apps/hub/src/credential-expiry-sweep.ts +++ b/apps/hub/src/credential-expiry-sweep.ts @@ -53,7 +53,7 @@ const POLL_INTERVAL_MS = 5 * 60 * 1000; // is refreshed — a token expiring before the *next* tick is refreshed // this tick, so a live session never sees the gap. const MCP_REFRESH_LEAD_MS = POLL_INTERVAL_MS; -const MCP_OAUTH_CLIENT_NAME = "Corbits Workbench"; +const MCP_OAUTH_CLIENT_NAME = "Corbits Hub"; const log = getLogger(["hub", "credential-expiry-sweep"]); // Mail-first-claim-after (see `mailThenClaimExpiry`) leaves a still-due diff --git a/apps/hub/src/credential-material-refresh.ts b/apps/hub/src/credential-material-refresh.ts index f311626b6f..de86a1e2fd 100644 --- a/apps/hub/src/credential-material-refresh.ts +++ b/apps/hub/src/credential-material-refresh.ts @@ -162,7 +162,7 @@ export function createServingRefresh(deps: ServingRefreshDeps): ServingRefresh { `/api/tenants/${args.tenantId}/mcp-servers/oauth/${mcpSlugOf(args.providerName)}/callback`, deps.hubUrl, ).toString(), - clientName: "Corbits Workbench", + clientName: "Corbits Hub", }); if (!result.ok) throw new Error(result.message); return { diff --git a/apps/hub/src/crypto-provider-cache-wiring.test.ts b/apps/hub/src/crypto-provider-cache-wiring.test.ts index 79800b8b34..4b65746ff0 100644 --- a/apps/hub/src/crypto-provider-cache-wiring.test.ts +++ b/apps/hub/src/crypto-provider-cache-wiring.test.ts @@ -74,6 +74,8 @@ describe("hub crypto-provider cache wiring", () => { expect(firstCall(source, "launchWebhookTrigger")).toContain( `cryptoProviderCache: ${assigned}`, ); - expect(firstCall(source, "runOneShotPrompt")).toContain(assigned); + // The planner draft route's `runOneShotPrompt` mail sender was cut + // with the stock-hub cutover (its inventory read through the deleted + // memory/skills mounts), so only the two senders above remain pinned. }); }); diff --git a/apps/hub/src/dispatch-tenant-guard.test.ts b/apps/hub/src/dispatch-tenant-guard.test.ts deleted file mode 100644 index 333258248f..0000000000 --- a/apps/hub/src/dispatch-tenant-guard.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -// CL-7324: hermetic tests for the Workbench-side dispatch containment -// (`dispatch-tenant-guard.ts`). No database: the service wrapper gets a -// stub anchor-tenant resolver and a stub delegate, and the route guard -// gets a stub tenancy loader in front of a counting inner app — proving -// the decision logic itself, not Postgres. -import { expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import type { SidecarAllocation } from "@intx/db"; -import type { WorkflowAllocationService } from "@intx/hub-sessions"; - -import { - ALLOCATION_TENANT_MISMATCH_CODE, - AllocationTenantMismatchError, - TENANT_MISMATCH_FAILURE_CODE, - withDispatchTenantGuard, - withTenantBoundAllocationService, -} from "./dispatch-tenant-guard"; - -function allocationFor(args: { - id?: string; - anchorRunId?: string; - tenantId?: string; -}): SidecarAllocation { - return { - id: args.id ?? "sal_test", - anchorRunId: args.anchorRunId ?? "wfr_anchor", - tenantId: args.tenantId ?? "ten_a", - provisionerId: "process", - provisionerApiVersion: 1, - provisionerBindingFingerprint: "fp", - status: "allocated", - generation: 1, - ensureAttempts: 0, - destroyAttempts: 0, - createdAt: new Date(0), - updatedAt: new Date(0), - }; -} - -function stubAllocationService(): WorkflowAllocationService & { - deployCalls: SidecarAllocation[]; -} { - const deployCalls: SidecarAllocation[] = []; - const service = { - deployCalls, - async prepareProvisionedDeployment() { - throw new Error("not implemented in stub"); - }, - async deployReadyAllocation(allocation: SidecarAllocation) { - deployCalls.push(allocation); - return null; - }, - } as unknown as WorkflowAllocationService & { - deployCalls: SidecarAllocation[]; - }; - return service; -} - -test("deployReadyAllocation delegates when allocation and anchor tenants match", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => "ten_a", - }); - const allocation = allocationFor({ tenantId: "ten_a" }); - const result = await guarded.deployReadyAllocation(allocation); - expect(result).toBeNull(); - expect(inner.deployCalls).toEqual([allocation]); -}); - -test("deployReadyAllocation throws tenant_mismatch without delegating on a foreign allocation", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => "ten_a", - }); - const thrown = await guarded - .deployReadyAllocation(allocationFor({ tenantId: "ten_b" })) - .then( - () => null, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AllocationTenantMismatchError); - expect((thrown as AllocationTenantMismatchError).code).toBe( - TENANT_MISMATCH_FAILURE_CODE, - ); - // Never falls through to another tenant: the delegate never ran, so the - // allocation can never deploy — the reconciler retries instead. - expect(inner.deployCalls).toEqual([]); -}); - -test("deployReadyAllocation fails closed when the anchor row is gone", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => undefined, - }); - const thrown = await guarded - .deployReadyAllocation(allocationFor({ tenantId: "ten_a" })) - .then( - () => null, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AllocationTenantMismatchError); - expect(inner.deployCalls).toEqual([]); -}); - -function guardedTestApp( - loadAnchorDispatch: ( - anchorRunId: string, - ) => Promise< - | { anchorTenantId: string; allocationTenantIds: readonly string[] } - | undefined - >, -): { app: Hono; state: { innerHits: number } } { - const state = { innerHits: 0 }; - const inner = new Hono(); - inner.all("*", (c) => { - state.innerHits += 1; - return c.json({ reached: true }, 200); - }); - const app = withDispatchTenantGuard(inner, { loadAnchorDispatch }); - return { app, state }; -} - -async function postApp( - app: Hono, - path: string, -): Promise<{ status: number; body: unknown; app: Hono }> { - const res = await app.request(path, { method: "POST" }); - return { status: res.status, body: await res.json(), app }; -} - -test("mail trigger with a foreign allocation answers 403 allocation_tenant_mismatch without reaching the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_b"], - })); - const { status, body } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(403); - expect(body).toMatchObject({ - error: expect.objectContaining({ code: ALLOCATION_TENANT_MISMATCH_CODE }), - }); - expect(state.innerHits).toBe(0); -}); - -test("signals route with a foreign allocation answers 403 without reaching the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_a", "ten_b"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/signals", - ); - expect(status).toBe(403); - expect(state.innerHits).toBe(0); -}); - -test("matching anchor and allocation tenants fall through to the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_a"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("a pre-provisioning anchor with no allocation rows falls through", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: [], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/signals", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("an absent anchor falls through so the native lookup owns the 404", async () => { - const { app, state } = guardedTestApp(async () => undefined); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_missing/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("an anchor owned by another tenant falls through so the native lookup owns the 404", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_other", - allocationTenantIds: ["ten_other"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("non-dispatch paths are never intercepted", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_b"], - })); - const res = await app.request("/api/tenants/ten_a/workflows", { - method: "GET", - }); - expect(res.status).toBe(200); - expect(state.innerHits).toBe(1); -}); diff --git a/apps/hub/src/dispatch-tenant-guard.ts b/apps/hub/src/dispatch-tenant-guard.ts deleted file mode 100644 index 6ab5690a95..0000000000 --- a/apps/hub/src/dispatch-tenant-guard.ts +++ /dev/null @@ -1,247 +0,0 @@ -// [Intx gap] CL-7324: the native dispatch path joins anchor + allocation -// on `anchorRunId` alone — neither -// `vendor/intx/hub-api/src/run-grant-materialization.ts`'s -// `lockDispatchableAllocation` nor the signal route's deployment query -// (`vendor/intx/hub-api/src/routes/workflows.ts`) filters the allocation -// leg by tenant. A sidecar allocation row bound to an anchor but carrying -// another tenant's id would therefore dispatch — and materialize grants — -// cross-tenant. Filed upstream; vendor is read-only, so this hub contains -// at its own seams instead of patching the join: -// -// 1. `withTenantBoundAllocationService` wraps the allocation service this -// composition root hands to `createApp` (and every Workbench-owned call -// site): `deployReadyAllocation` throws `AllocationTenantMismatchError` -// before delegating whenever the allocation's tenant is not the anchor -// run's tenant. The reconciler's `onReady` catch-all turns any throw -// into `scheduleRetry` (`sidecar_initialization_failed`), so a mismatch -// is retryable, never terminal, and never falls through to another -// tenant — the allocation simply never deploys. -// 2. `withDispatchTenantGuard` outer-wraps the fully-built app (the same -// composition as `tenant-create-guard.ts`'s `guardedHubApp`) and denies -// the two dispatch routes — `POST -// /api/tenants/:tenantId/workflows/:runId/mail` and `POST -// /api/tenants/:tenantId/workflows/:runId/signals` — with 403 -// `allocation_tenant_mismatch` when an allocation bound to the anchor -// carries a foreign tenant, before the native handler can enqueue or -// materialize grants. An absent anchor (or an anchor owned by another -// tenant) falls through: the native tenant-scoped lookup owns that 404. -// -// Bench-owner, hub-provisioned, tenant-bound dispatch only (operator -// ruling 2026-09-15, Option A). No enrollment or allowlist surfaces live -// here — those were deferred (B/C), so there is nothing to consult: any -// cross-tenant join is denied, not admitted. -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; -import type { DB } from "@intx/db"; -import { sidecarAllocation, workflowRun } from "@intx/db/schema"; -import type { AppEnv } from "@intx/hub-api"; -import type { SidecarAllocation } from "@intx/db"; -import type { WorkflowAllocationService } from "@intx/hub-sessions"; -import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; - -/** Error-code surfaced at the route seam on a foreign-tenant allocation. */ -export const ALLOCATION_TENANT_MISMATCH_CODE = "allocation_tenant_mismatch"; -/** Reconciler failure code a mismatch surfaces as: scheduled for retry, - * never terminal. */ -export const TENANT_MISMATCH_FAILURE_CODE = "tenant_mismatch"; - -/** - * Thrown (never returned) when an allocation's tenant is not its anchor - * run's tenant. Retryable by construction: nothing downstream treats this - * as terminal, and the throw precedes any delegate call, so a mismatched - * allocation can never deploy or dispatch into another tenant. - */ -export class AllocationTenantMismatchError extends Error { - readonly code = TENANT_MISMATCH_FAILURE_CODE; - readonly anchorRunId: string; - readonly anchorTenantId: string | undefined; - readonly allocationId: string | undefined; - readonly allocationTenantId: string; - - constructor(args: { - anchorRunId: string; - anchorTenantId: string | undefined; - allocationId: string | undefined; - allocationTenantId: string; - }) { - super( - `allocation ${args.allocationId ?? ""} carries tenant ` + - `${args.allocationTenantId} but anchors run ${args.anchorRunId} of ` + - `tenant ${args.anchorTenantId ?? ""}`, - ); - this.name = "AllocationTenantMismatchError"; - this.anchorRunId = args.anchorRunId; - this.anchorTenantId = args.anchorTenantId; - this.allocationId = args.allocationId; - this.allocationTenantId = args.allocationTenantId; - } -} - -function mismatchContext(error: AllocationTenantMismatchError): { - operation: string; - tenantId: string; - extra: Record; -} { - const extra: Record = { - anchorRunId: error.anchorRunId, - allocationTenantId: error.allocationTenantId, - }; - if (error.allocationId !== undefined) { - extra["allocationId"] = error.allocationId; - } - if (error.anchorTenantId !== undefined) { - extra["anchorTenantId"] = error.anchorTenantId; - } - return { - operation: "workflow-dispatch-tenant-guard", - tenantId: error.allocationTenantId, - extra, - }; -} - -/** Anchor run's owning tenant — `undefined` when the anchor row is gone. */ -export async function resolveAnchorTenantId( - db: DB["db"], - anchorRunId: string, -): Promise { - const [row] = await db - .select({ tenantId: workflowRun.tenantId }) - .from(workflowRun) - .where(eq(workflowRun.id, anchorRunId)) - .limit(1); - return row?.tenantId; -} - -export type TenantBoundAllocationServiceDeps = { - /** Anchor run's owning tenant (`undefined` when the row is gone), kept - * injectable so the wrapper's decision is testable without a database. */ - resolveAnchorTenantId: (anchorRunId: string) => Promise; -}; - -/** - * Wraps the allocation service so `deployReadyAllocation` enforces the - * anchor/allocation tenant join: on a mismatch (or a missing anchor row) - * it reports through the error sink and throws - * `AllocationTenantMismatchError` WITHOUT delegating — the reconciler - * retries, the allocation never deploys, and dispatch never crosses - * tenants. Every other method passes straight through. - */ -export function withTenantBoundAllocationService( - service: WorkflowAllocationService, - deps: TenantBoundAllocationServiceDeps, -): WorkflowAllocationService { - return { - ...service, - async deployReadyAllocation(allocation: SidecarAllocation) { - const anchorTenantId = await deps.resolveAnchorTenantId( - allocation.anchorRunId, - ); - if ( - anchorTenantId === undefined || - anchorTenantId !== allocation.tenantId - ) { - const mismatch = new AllocationTenantMismatchError({ - anchorRunId: allocation.anchorRunId, - anchorTenantId, - allocationId: allocation.id, - allocationTenantId: allocation.tenantId, - }); - reportError(mismatch, mismatchContext(mismatch)); - throw mismatch; - } - return service.deployReadyAllocation(allocation); - }, - }; -} - -export type AnchorDispatchTenancy = { - /** Owning tenant of the anchor run named in the URL. */ - readonly anchorTenantId: string; - /** Tenant of every allocation row bound to that anchor (empty when the - * deployment has no allocation yet — pre-provisioning deploys). */ - readonly allocationTenantIds: readonly string[]; -}; - -export type DispatchTenantGuardDeps = { - /** Anchor + bound-allocation tenancy (`undefined` when the anchor row is - * gone), injected so the guard's decision stays DB-free in tests. */ - loadAnchorDispatch: ( - anchorRunId: string, - ) => Promise; -}; - -/** Production read: anchor row plus every allocation row bound to it. */ -export async function loadAnchorDispatch( - db: DB["db"], - anchorRunId: string, -): Promise { - const [anchor] = await db - .select({ tenantId: workflowRun.tenantId }) - .from(workflowRun) - .where(eq(workflowRun.id, anchorRunId)) - .limit(1); - if (anchor === undefined) return undefined; - const rows = await db - .select({ tenantId: sidecarAllocation.tenantId }) - .from(sidecarAllocation) - .where(eq(sidecarAllocation.anchorRunId, anchorRunId)); - return { - anchorTenantId: anchor.tenantId, - allocationTenantIds: rows.map((row) => row.tenantId), - }; -} - -const DISPATCH_GUARD_PATHS = [ - "/api/tenants/:tenantId/workflows/:runId/mail", - "/api/tenants/:tenantId/workflows/:runId/signals", -] as const; - -/** - * Wraps the fully-built hub app in an outer app that denies the two - * dispatch routes with 403 `allocation_tenant_mismatch` when any - * allocation bound to the anchor carries a tenant other than the URL - * tenant — before the native handler joins anchor+allocation and - * materializes grants or enqueues. Every other path, an anchor with no - * foreign allocation, and an absent/foreign anchor (the native - * tenant-scoped lookup owns that 404) fall straight through. - */ -export function withDispatchTenantGuard( - nativeApp: Hono, - deps: DispatchTenantGuardDeps, -): Hono { - const guarded = new Hono(); - - for (const path of DISPATCH_GUARD_PATHS) { - guarded.use(path, async (c, next) => { - if (c.req.method !== "POST") return next(); - const tenantId = c.req.param("tenantId"); - const anchorRunId = c.req.param("runId"); - const anchor = await deps.loadAnchorDispatch(anchorRunId); - if (anchor === undefined || anchor.anchorTenantId !== tenantId) { - return next(); - } - const foreign = anchor.allocationTenantIds.find( - (allocationTenantId) => allocationTenantId !== tenantId, - ); - if (foreign === undefined) return next(); - const mismatch = new AllocationTenantMismatchError({ - anchorRunId, - anchorTenantId: anchor.anchorTenantId, - allocationId: undefined, - allocationTenantId: foreign, - }); - reportError(mismatch, mismatchContext(mismatch)); - return c.json( - makeErrorEnvelope({ - code: ALLOCATION_TENANT_MISMATCH_CODE, - userMessage: - "This deployment's allocation belongs to another workbench and cannot be dispatched here.", - }), - 403, - ); - }); - } - - guarded.route("/", nativeApp); - return guarded; -} diff --git a/apps/hub/src/draft-route-envelope.test.ts b/apps/hub/src/draft-route-envelope.test.ts deleted file mode 100644 index 1f30610808..0000000000 --- a/apps/hub/src/draft-route-envelope.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -// The hub composition root must serve the package draft routes (CL-6749) -// rather than a local `{ code, message }` 422 the client cannot parse. - -import { readFileSync } from "node:fs"; -import { expect, test } from "bun:test"; - -test("hub mounts package draft routes rather than a local {code, message} 422", () => { - const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); - expect(source).toContain("createAgentDefinitionDraftRoutes"); - expect(source).not.toMatch(/code:\s*"drafting_failed"/); -}); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 3dd2ca7892..b9da41c5d6 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -23,7 +23,6 @@ import { } from "@intx/db"; import { asset as assetTable, - model, modelPricing, tenant as tenantTable, user as userTable, @@ -51,23 +50,7 @@ import { WorkflowDefinitionInvalidError } from "@intx/workflow-deploy"; // projection `installAndApproveWorkflowSource` returns on `grants_not_approved` // — the gate itself only stamps this hash on the `ok:true` arm. -import { - createAgentDefinitionDraftRoutes, - createAgentDefinitionRoutes, - createDefinitionAssetHistory, - createWorkflowAgentCreateRoutes, - createWorkflowCapabilityRoutes, - createWorkflowSkillPinRoutes, - type CapabilityInventoryProvider, - createMyraAgentDefinitionDrafting, - isPlannerCreatedDefinitionName, - resolveMyraDefinitionIdFromDb, - type InventoryAgent, - type InventoryModel, - type InventorySources, - type InventoryToolPackage, - runOneShotPrompt, -} from "@corbits/agent-directory"; +import { isPlannerCreatedDefinitionName } from "@corbits/agent-directory"; import { DEFAULT_TURN_CLAIM_TTL_MS, @@ -162,7 +145,6 @@ import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes" import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; -import { createHubSignupTenancy } from "./signup-tenancy"; import { createInMemoryMailboxEventBus, createMailboxDb, @@ -190,8 +172,6 @@ import { import { deliveryWorkbenchRequiredForWorkflowName, isConversationalWorkflowName, - workflowDisplayName, - workbenchTemplateLibraryEntries, } from "@workbench/templates"; import { createConnectGithubRoutes } from "@corbits/connections/connect-github-routes"; import { webhookTriggerName } from "@corbits/connections/connect-github-setup"; @@ -258,41 +238,12 @@ import { readProcessProvisionerConfig, type ProcessProvisionerRole, } from "@corbits/process-provisioner"; -import { getArtifact, writeArtifactVersion } from "@corbits/artifacts"; -import { - createArtifactDbStore, - createArtifactRoutes, - createTemplateLibraryDbStore, - createTemplateLibraryRoutes, - createTemplateLibrarySeeder, - createUnavailableArtifactRoutes, - createUnavailableTemplateLibraryRoutes, - createUnavailableWorkflowArtifactRoutes, - createWorkflowArtifactDbStore, - createWorkflowArtifactRoutes, - createWorkflowRunAuthenticator, -} from "@corbits/artifacts-hub"; +import { createWorkflowRunAuthenticator } from "@corbits/artifacts-hub"; import { - createArtifactDocPersistence, createPresenceRoomRegistry, createPresenceRoutes, - type PresenceRoomKey, } from "@corbits/presence"; -import { supportedCredentialProviders } from "@corbits/connections/credential-test"; -import { createGitWorkflowPusher } from "@corbits/connections/workflow-push"; import { createHubAPI } from "@corbits/hub-api-client"; -import { - createDrizzlePendingSeedStore, - createBenchProvisioner, - createOnboardingRoutes, -} from "@workbench/onboarding"; -// The deployable-through-the-catalog-instantiate-route set, by asset -// name (CL-7585): `@workbench/onboarding`'s `tenant-seed` -// `CATALOG_WORKFLOWS`, strings only — the Routines picker admits a -// tenant's installed asset only when the same installed-asset registry -// says so (see `installedAssetNames` on the scheduled routes' catalog -// below). -import { CATALOG_WORKFLOW_ASSET_NAMES } from "@workbench/onboarding/tenant-seed"; import { createConnectionRoutes, isInferenceProvider, @@ -314,39 +265,16 @@ import { createProviderHealthPort, createProviderHealthStore, } from "@corbits/connections/provider-health"; -import { - applyAccessPolicyMigrations, - createAccessPolicyRoutes, - createDrizzleAccessPolicyStore, -} from "@workbench/access-policy"; -import { guardedHubApp, resolveCallerRoleNames } from "./tenant-create-guard"; -import { - loadAnchorDispatch, - resolveAnchorTenantId, - withDispatchTenantGuard, - withTenantBoundAllocationService, -} from "./dispatch-tenant-guard"; -import { createTenantCreateObserver } from "./tenant-create-onboard"; import { createInMemoryNotifyDispatchStore, createSinkRegistry, } from "@corbits/notify"; -import { mountMemory } from "./memory-mount"; -import { mountSkills } from "./skills-mount"; -import { - createUnavailableWorkflowMemoryRoutes, - createWorkflowMemoryRoutes, - createWorkflowMemoryStore, -} from "@corbits/memory-hub"; -import { createSkillRoutes, createWorkflowSkillRoutes } from "@corbits/skills"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes, WorkflowAuthorError, type WorkflowDeployer, } from "@corbits/workflows"; -import { mountArtifacts } from "./artifacts-mount"; -import { mountWorkbenchSlackTag } from "./slack-tag-mount"; import { createCredentialExpirySweep, createDrizzleCredentialExpirySweepStore, @@ -390,7 +318,6 @@ import { type ScheduledDeliveryJoinDeps, } from "./workflow-scheduler"; import { createToolGrantsForPins } from "./tool-grants"; -import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; import { drainHubServer, shutdownHub } from "./shutdown"; import { createInFlightRequestTracker, @@ -401,8 +328,8 @@ import { const MAX_TARBALL_BYTES = 10 * 1024 * 1024; // In-repo tool packages (`packages/granola-tools`, `packages/linear-tools`, // `packages/skills-tools`) are unpublished to npm and stay that way: -// they are workbench-specific integration bundles, not general-purpose -// npm packages, so publishing them to a public registry would be the +// they are integration bundles for this product's own routes, not +// general-purpose npm packages, so publishing them to a public registry would be the // wrong distribution surface for what they are. `@intx/hub-sessions` // already resolves any `package-registry`-kind asset visible to a // tenant as a named tool-package registry (see `session-service.ts`'s @@ -435,11 +362,9 @@ const SignInEmailBody = type({ email: "string" }); // later `wakeByAddress` relaunch resumes the same run rather than // starting a fresh one. -// Signup mode is operator-controlled (WORKBENCH_SIGNUP). Default closed: -// self-serve email signup is rejected; owners add users or share a -// copy-link invite (docs/TENANCY.md). Open mode keeps email+password -// signup and the existing rate limit. Email delivery of invites is out -// of scope. +// Email+password signup stays available through better-auth; the hub +// applies only coarse throttling around it, never an operator gate. +// Email delivery of invites is out of scope. // Email+password sign-in is always wired up. Google/GitHub OAuth are // wired up too, but only the providers `readHubConfig` found a full // credential pair for — better-auth's own `socialProviders` map is @@ -676,11 +601,6 @@ export async function createHub(config: HubConfig) { // Per-principal signing keys are sealed under their own operator key — // see `principalKeyStoreFrom`. const principalKeyStore = principalKeyStoreFrom(config, db, log); - // The genesis-or-join first-signup decision's tenancy reads/writes — - // also read by the sign-up gate (empty-hub exception) and the - // tenant-create guard below. See ./signup-tenancy.ts. - const signupTenancy = createHubSignupTenancy(db, principalKeyStore); - const auth = betterAuth({ baseURL: config.baseUrl, secret: config.sessionSecret, @@ -697,8 +617,8 @@ export async function createHub(config: HubConfig) { // `trustedProxies` configured (Railway publishes no stable edge CIDR // list to populate one with) a single-value header is trusted verbatim // regardless of who set it. That's an acceptable, low-stakes gap for - // sign-up's coarse throttling — a closed-by-default, operator-gated - // path — but not for brute-force resistance on sign-in, which is why + // sign-up's coarse throttling — an ungated path that needs only + // brute-force resistance on sign-in, which is why // sign-in has its own account-keyed limiter instead (see // `sign-in-rate-limit.ts`). advanced: { @@ -726,25 +646,6 @@ export async function createHub(config: HubConfig) { [SIGN_IN_EMAIL_PATH]: false, }, }, - // No mailer is wired up anywhere in this stack, so better-auth can - // never actually verify an address -- `emailVerified` would stay - // false forever and every self-serve signup would dead-end at - // @workbench/access-policy's gate. `allowUnverifiedEmails` - // (ALLOW_UNVERIFIED_EMAILS, dev/test only) auto-verifies at the - // source instead of leaving each downstream consumer of - // `emailVerified` to separately special-case it. - databaseHooks: config.allowUnverifiedEmails - ? { - user: { - create: { - before: async (user: { email: string }) => { - log.info`ALLOW_UNVERIFIED_EMAILS is set: auto-verifying ${user.email} at account creation (dev/test only)`; - return { data: { ...user, emailVerified: true } }; - }, - }, - }, - } - : undefined, }); // Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts` // for why this replaces better-auth's own IP-keyed sign-in enforcement @@ -766,7 +667,7 @@ export async function createHub(config: HubConfig) { // for the process, read here ahead of `workflow_run` and written to // there off every `agent.deploy.ack`. const runKeyHistoryStore = createDrizzleRunKeyHistoryStore(db); - // A workbench agent is a native provisioned deployment. Reconnect + // A chat agent is a native provisioned deployment. Reconnect // ownership is Interchange's live run + `@corbits/run-key-history`. // Completed means dead; wake is a fresh provision, not a folded-run // idle settle. @@ -1114,7 +1015,7 @@ export async function createHub(config: HubConfig) { // boundary, mirroring @intx/hub-sessions's own reference wiring. An // install that configures nothing registers the `process` backend // (`@corbits/process-provisioner`) as the sole default, so a - // workbench's "run this workbench on its own sidecar" setting works on + // chat's "run this chat on its own sidecar" setting works on // one server with no operator setup; `SIDECAR_PROVISIONERS` is the one // variable that changes where sidecars run. A deployment whose // definition declares sidecar capabilities no registered provisioner @@ -1142,7 +1043,7 @@ export async function createHub(config: HubConfig) { : {}), }); const sidecarPlugins = buildSidecarPlugins("deployment"); - const nativeWorkflowAllocationService = createWorkflowAllocationService({ + const workflowAllocationService = createWorkflowAllocationService({ db, deploymentPlugins: sidecarPlugins, probePlugins: buildSidecarPlugins("probe"), @@ -1151,19 +1052,6 @@ export async function createHub(config: HubConfig) { allocationRouter: sidecarRouter, hubWebSocketUrl, }); - // [Intx gap] CL-7324: the native dispatch path joins anchor + allocation - // on `anchorRunId` alone, so a foreign-tenant allocation bound to an - // anchor would dispatch cross-tenant. Vendor is read-only — wrap the - // service here, once, so `createApp`, the reconciler `onReady`, and - // every Workbench-owned call site below all share the tenant-bound - // instance (see ./dispatch-tenant-guard.ts). - const workflowAllocationService = withTenantBoundAllocationService( - nativeWorkflowAllocationService, - { - resolveAnchorTenantId: (anchorRunId) => - resolveAnchorTenantId(db, anchorRunId), - }, - ); const sidecarAllocationStore = createSidecarAllocationStore(db); const workflowDispatchService = createWorkflowDispatchService({ dispatchStore: createWorkflowRunDispatchStore(db), @@ -1287,63 +1175,6 @@ export async function createHub(config: HubConfig) { return result ? { user: result.user, session: result.session } : null; }, authHandler: async (c) => { - // Gate self-serve email signup. Sign-in stays open; only the - // sign-up/email path is product-controlled (docs/TENANCY.md). - if (c.req.method === "POST" && c.req.path.endsWith(SIGN_UP_EMAIL_PATH)) { - if (config.signupMode === "closed") { - // Empty-hub exception (CL-7578): with zero users and zero - // tenants, someone has to be first — the signup that opens a - // brand-new hub is allowed even when signup is closed, since - // the genesis path makes that caller the root tenant's owner. - // Everywhere else on the hub "empty" means zero tenants only; - // here a user row also counts, because it means the 0→1 - // signup already happened. - const [users, tenants] = await Promise.all([ - signupTenancy.countUsers(), - signupTenancy.countTenants(), - ]); - if (users > 0 || tenants > 0) { - return c.json( - { - error: "signup_closed", - message: - "Self-serve signup is disabled. Ask an owner for an invite.", - }, - 403, - ); - } - } - if (config.allowedEmailDomains.length > 0) { - let email = ""; - try { - const body: unknown = await c.req.raw.clone().json(); - if ( - body !== null && - typeof body === "object" && - "email" in body && - typeof (body as { email: unknown }).email === "string" - ) { - email = (body as { email: string }).email.toLowerCase(); - } - } catch { - email = ""; - } - const at = email.lastIndexOf("@"); - const domain = at >= 0 ? email.slice(at + 1) : ""; - const allow = new Set( - config.allowedEmailDomains.map((d) => d.toLowerCase()), - ); - if (!allow.has(domain)) { - return c.json( - { - error: "email_domain_not_allowed", - message: "That email domain is not allowed to sign up.", - }, - 403, - ); - } - } - } // Account-keyed sign-in brute-force protection (CL-6494, hardened // CL-6521) — see `sign-in-rate-limit.ts` for why this fully replaces // better-auth's own IP-keyed enforcement for this path instead of @@ -1415,26 +1246,8 @@ export async function createHub(config: HubConfig) { // nothing reported. See `hubErrorHandler`'s own doc comment. app.onError(hubErrorHandler()); - // One in-process presence room registry for this process, constructed - // here in the composition root — the same pattern `workbenchSubscribers` - // above uses. Presence rooms are ephemeral and process-local by design - // (see `@corbits/presence`'s docs/presence.md); the registry is built - // here rather than inside `createPresenceRoutes` itself so the - // co-editing doc-persistence wiring below (which needs the artifacts - // engine, mounted further down once its own DB handle resolves) can - // share the exact same registry the routes below serve traffic - // through — the same way `startWorkflowCommand` shares - // `workbenchSubscribers`. + // Presence rooms are ephemeral and process-local by design. const presenceRoomRegistry = createPresenceRoomRegistry(); - // Indirection so the join route can call into artifact-doc seeding - // before the artifacts engine (mounted later, once its DB handle is - // known) exists. `createPresenceRoutes` is constructed once, here, so - // its `onJoin` hook has to be a stable function that reads whatever - // `artifactSeedOnJoin` currently points to — `undefined` (a no-op) - // until the artifacts mount below assigns it, or forever if the - // artifacts plane never mounts. - let artifactSeedOnJoin: - ((key: PresenceRoomKey, principalId: string) => Promise) | undefined; // Chat's own grant store/condition registry, built the same way // `createApp` builds its default when none is supplied (see @@ -1507,36 +1320,16 @@ export async function createHub(config: HubConfig) { }, ); } - // Mounted here (not up with the registry construction above) because - // its `/update` route's grant gate needs `chatGrantStore`/ - // `chatConditionRegistry`, which don't exist yet up there — the same - // reason `artifactSeedOnJoin`'s indirection exists, just for a - // dependency that's ready sooner. app.route( `${TENANT_PREFIX}/presence`, createPresenceRoutes({ registry: presenceRoomRegistry, - onJoin: (key, principalId) => artifactSeedOnJoin?.(key, principalId), requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), }), ); - // Memory plane (optional): firm-memory HTTP under - // `/api/tenants/:tenantId/memory/*`, same `DATABASE_URL` as the control - // plane, isolated in its own `memory` schema. Degrades when EMBED_* is - // unset — see memory-mount.ts. Captured (not discarded) here, before - // `chatOrchestrator`/`createArtifactDeliveryHandler` below, so the - // in-process `Memory` handle can be threaded into both: a finalized - // turn's persisted artifact and the bounded daily transcript digest - // (CL-5852) both write through this same handle, never a second - // connection or the plane's own tenant-session-gated HTTP routes. - const memoryHandle = await mountMemory({ - app, - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }); const threadStore = createDrizzleThreadStore(db); const blockResponseStore = createDrizzleBlockResponseStore(db); const reactionStore = createDrizzleReactionStore(db); @@ -1577,7 +1370,7 @@ export async function createHub(config: HubConfig) { ); // The chat platform's invite-launch fallback: a definition with no // model requirements of its own resolves the tenant-catalog default. - const workbenchHostInferencePreferencesResolver = + const chatHostInferencePreferencesResolver = createWorkbenchHostInferencePreferencesResolver((tenantId) => listDefaultInferencePreferences(db, tenantId), ); @@ -1588,10 +1381,10 @@ export async function createHub(config: HubConfig) { // most of them hangs off it. const relaunchNoticeRef: RelaunchNoticePort = {}; // One CryptoProviderCache for the whole hub process (CL-7284). Chat - // sendMail keys by workbench id; webhook, routine, and agent-definition + // sendMail keys by chat id; webhook, routine, and agent-definition // drafting first-turn mail key by the launched run's instance id. New - // workbenches and run ids are `run_` (`generateId("workflowRun")`); - // older workbenches are `ins_` (`generateId("instance")`). They share a + // chats and run ids are `run_` (`generateId("workflowRun")`); + // older chats are `ins_` (`generateId("instance")`). They share a // string shape — a second cache for the same id would mint a different // signing key. generateId uniqueness keeps distinct entities from // colliding; sharing the cache keeps the same entity from rotating keys @@ -1630,7 +1423,7 @@ export async function createHub(config: HubConfig) { }), // Chat residents are undeployed on idle again (see the comment above // this function): `chatIdleReapMs` (env-overridable via - // `WORKBENCH_CHAT_IDLE_REAP_MS`, default 30 minutes) is + // `HUB_CHAT_IDLE_REAP_MS`, default 30 minutes) is // state-preserving (`IDLE_HIBERNATE_UNDEPLOY_REASON`), unlike a // destructive undeploy. lifecycle: { idleSleepMs: config.chatIdleReapMs }, @@ -1639,43 +1432,42 @@ export async function createHub(config: HubConfig) { // (see `@corbits/agent-directory`'s `createAgentDefinitionCore` // doc) still launches on invite by falling back to this same // tenant-catalog default, instead of 409ing `not_launchable`. - workbenchHostInferencePreferences: - workbenchHostInferencePreferencesResolver, + workbenchHostInferencePreferences: chatHostInferencePreferencesResolver, relaunchNotice: relaunchNoticeRef, }); wireMailRedelivery({ sidecarRouter, chatPlatform }); - // The one SSE subscriber registry for this process's workbench events + // The one SSE subscriber registry for this process's chat events // (see `@corbits/chat`'s `workbench-events.ts`), constructed here in // the composition root and shared by every consumer below: the chat // router bridges it onto `/workbenches/:id/stream`, the // workflow-command path publishes through the same instance so a // command-started workflow's join event reaches an open stream // immediately (exactly like `POST .../invite`'s does), and the - // orchestrator publishes every message it posts onto a workbench's + // orchestrator publishes every message it posts onto a chat's // timeline. - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - // One in-flight turn per workbench (CL-6331), shared by every send - // surface below the same way `workbenchSubscribers` is: the chat - // router, the workflow-participant router (a workflow child's own - // sends), and the Slack tag mount all route through this one queue, - // so a burst arriving through any of them for the same workbench + const chatSubscribers = createWorkbenchSubscriberRegistry(); + // One in-flight turn per chat (CL-6331), shared by every send + // surface below the same way `chatSubscribers` is: the chat + // router and the workflow-participant router (a workflow child's own + // sends) all route through this one queue, + // so a burst arriving through any of them for the same chat // still serializes against the others rather than each queue only // seeing its own slice of the traffic. const turnQueue = createWorkbenchTurnQueue({ claims: createInMemoryTurnClaimStore({ ttlMs: DEFAULT_TURN_CLAIM_TTL_MS }), - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }); // The live abort seam a running turn is reachable through (CL-7201) — // shared the same way `turnQueue` above is, so a cancel request lands - // wherever a workbench's turn was actually dispatched from. + // wherever a chat's turn was actually dispatched from. const turnCancellation = createTurnCancelRegistry(); relaunchNoticeRef.current = createRelaunchNoticePoster({ store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }); // Built once, beside the platform, for the process's lifetime: turns - // an invited agent's `connector.reply` events into workbench messages, + // an invited agent's `connector.reply` events into chat messages, // and a gate-blocked run's approval park into an in-chat approve // block, by subscribing to the sidecar's own event stream, replacing // the old per-agent reply-bridge machinery armed (and re-armed) from @@ -1692,7 +1484,7 @@ export async function createHub(config: HubConfig) { agentTurns, store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, platform: chatPlatform, events: sidecarRouter.events, approvals: createApprovalStore(db), @@ -1702,9 +1494,6 @@ export async function createHub(config: HubConfig) { turnMailCorrelation, connectorRegistry: CONNECTOR_REGISTRY, }; - if (memoryHandle !== undefined) { - chatOrchestratorDeps.memory = memoryHandle.memory; - } const chatOrchestrator = createChatOrchestrator(chatOrchestratorDeps); // CL-6644: a loud, unconditional boot confirmation that message intake // is actually wired — a composition-root mistake here (an import @@ -1774,15 +1563,13 @@ export async function createHub(config: HubConfig) { }); // Now that `chatStore`/`chatPlatform` exist, arm the finalized-turn // artifact-delivery ref declared beside `eventCollectors` above. - // `memory` (absent when the plane isn't mounted) lets this handler - // also record a memory entry for each persisted artifact (CL-5852). const artifactDeliveryHandlerDeps: Parameters< typeof createArtifactDeliveryHandler >[0] = { db, store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, platform: chatPlatform, events: sidecarRouter.events, approvals: createApprovalStore(db), @@ -1793,9 +1580,6 @@ export async function createHub(config: HubConfig) { providerHealth: createProviderHealthPort(providerHealthStore), listConnectedProviders: (tenantId) => listConnectedProviders(db, tenantId), }; - if (memoryHandle !== undefined) { - artifactDeliveryHandlerDeps.memory = memoryHandle.memory; - } artifactDeliveryHandlerRef.current = createArtifactDeliveryHandler( artifactDeliveryHandlerDeps, ); @@ -1806,7 +1590,7 @@ export async function createHub(config: HubConfig) { // no re-registration step. `startWorkflow` is `@corbits/chat`'s own // `startWorkflowCommand`, sharing the exact invite-then-send core // `POST .../invite` uses, including its live `publish` — bound to - // `workbenchSubscribers` above, the same registry `createChatRoutes` + // `chatSubscribers` above, the same registry `createChatRoutes` // is given below. const commandRegistry = createCommandRegistry(); commandRegistry.registerCommandPlugin( @@ -1819,7 +1603,7 @@ export async function createHub(config: HubConfig) { store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }, input, ), @@ -1830,7 +1614,7 @@ export async function createHub(config: HubConfig) { // picker that offers agents to a person and by a routine's `"agent"`-kind // trigger-field validation below: a catalog workflow whose entry says // `conversational: false` (routine/automation material — Echo, "Last 30 - // days research report", …) and workbench-host anchor definitions + // days research report", …) and chat-host anchor definitions // (chat's own plumbing, never a person-facing agent) belong in neither. // `isConversationalWorkflowName`, not `isAutomatableWorkflowName`: a // non-automatable utility workflow (Echo, the research report a routine @@ -1868,7 +1652,7 @@ export async function createHub(config: HubConfig) { reactions: reactionStore, pins: pinStore, clientIds: createDrizzleClientIdStore(db), - workbenchSubscribers, + workbenchSubscribers: chatSubscribers, turnQueue, turnCancellation, requireGrant: createRequireGrant({ @@ -1895,7 +1679,7 @@ export async function createHub(config: HubConfig) { // The same native undeploy call the idle-sleep lifecycle uses to // tear an invited agent's instance down (see `chatPlatform`'s own // `lifecycle.undeploy` above) — wired here too so removing an agent - // from a workbench's participants releases its running instance the + // from a chat's participants releases its running instance the // same way, rather than leaving it deployed with nothing routing // messages to it. releaseAgentInstance: (address, reason) => @@ -1941,15 +1725,15 @@ export async function createHub(config: HubConfig) { // workflow-run-authenticated counterpart to browser chat routes, // self-WORKBENCH scoped — see `@corbits/chat`'s // `workflow-participant-routes.ts` for the [Intx/repo gap] this resolves - // around (no direct run-address -> workbench index; resolved by scanning - // the tenant's workbench participant lists). + // around (no direct run-address -> chat index; resolved by scanning + // the tenant's chat participant lists). app.route( "/api/workflow-chat", createWorkflowParticipantRoutes({ store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, turnQueue, turnCancellation, turnMailCorrelation, @@ -1958,35 +1742,6 @@ export async function createHub(config: HubConfig) { sessionFor, }), ); - // Slack tag ingress (CL-5288 Phase 1): mounted OUTSIDE the tenant - // prefix and outside session auth, like the webhook ingress below — - // Slack is not a principal, and this route resolves its own - // Interchange identity per message (see `./slack-tag-mount.ts` and - // `@corbits/slack-tag`'s signature-verification-gated dispatch). A - // missing SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET pair is a valid - // configuration — the mount is silently skipped. - const slackTagMount = await mountWorkbenchSlackTag({ - app, - db, - databaseUrl: config.databaseUrl, - chatStore, - chatPlatform, - roomMessages, - chatTenancy, - sessionFor, - workbenchSubscribers, - turnQueue, - turnCancellation, - turnMailCorrelation, - }); - // Tells the routine trigger popover whether a Slack-bound webhook - // trigger is honestly offerable in this deployment — no session or - // tenant required to ask, the same reasoning as `/api/auth-config` - // above. Only a boolean crosses this route, never the credential pair - // itself. - app.get("/api/deployment-capabilities", (c) => - c.json({ slackConfigured: slackTagMount.mounted }), - ); // Product inbox over `@corbits/mailbox` — three groups, mark-all-read // (mentions + deliveries only), clear-done. The raw package surface // (including SSE events) mounts under `/mailbox` for hosts and tools @@ -2017,7 +1772,7 @@ export async function createHub(config: HubConfig) { latencyStore: insightsLatency.store, // Same `db` handle every other platform-table reader in this file // uses — lets /usage, /activity, /tools, and /scope roll up a - // workspace parent's child workbenches (see resolveScope in + // workspace parent's child chats (see resolveScope in // @corbits/insights' routes.ts). db, }), @@ -2027,7 +1782,7 @@ export async function createHub(config: HubConfig) { // Mounted alongside — not inside — the vendored // `createWorkflowDefinitionRoutes` (`vendor/intx/hub-api/src/app.ts` // already mounts that one at this same `/workflows/definitions` - // prefix): this GET is a Workbench-owned read composed over native + // prefix): this GET is a hub-composed read over native // rows, so it lives in `@corbits/workflows`'s `./detail`, not the // vendored tree. app.route( @@ -2041,15 +1796,15 @@ export async function createHub(config: HubConfig) { }), ); const scheduledDeliveryJoinDeps: ScheduledDeliveryJoinDeps = { - deliveryWorkbenchRequired: deliveryWorkbenchRequiredForWorkflowName, - resolveDeliveryWorkbench: async (tenantId) => { + deliveryChatRequired: deliveryWorkbenchRequiredForWorkflowName, + resolveDeliveryChat: async (tenantId) => { const rows = await chatStore.listWorkbenchSettings(tenantId); const first = [...rows].sort((a, b) => a.workbenchId.localeCompare(b.workbenchId), )[0]; return first?.workbenchId; }, - joinDeliveryWorkbench: (input) => + joinDeliveryChat: (input) => joinRunParticipant({ store: chatStore }, input), }; app.route( @@ -2060,7 +1815,6 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - catalogAssetNames: [...CATALOG_WORKFLOW_ASSET_NAMES], runNow: async (args) => runNowScheduledDefinition( { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, @@ -2190,41 +1944,6 @@ export async function createHub(config: HubConfig) { app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); } - // Agent definitions a person authors by hand from the Agents page's - - // create form, materialized the same way the platform's own starter - // agents are (see `@corbits/agent-directory`'s doc comment). Shares - // `chatGrantStore`/`chatConditionRegistry` with every other extension - // mounted here — there is nothing chat-specific about that pair, it - // is just this composition root's one db-backed grant store. - // The skill registry over native `kind:"skill"` assets, plus the two - // surfaces it serves: the tenant-session one the Skills settings - // section calls, and the run-authenticated one a workflow child's - // `@corbits/tools-skills` bundle calls (mounted outside the tenant - // prefix below, beside `/api/workflow-memory`). - const skills = mountSkills({ - db, - assetService, - repoStore: agentRepoStore.repoStore, - }); - app.route( - `${TENANT_PREFIX}/skills`, - createSkillRoutes({ - registry: skills.registry, - pinnedBy: skills.pinnedBy, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - app.route( - "/api/workflow-skills", - createWorkflowSkillRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - registry: skills.registry, - }), - ); // CL-7361: the `deploy` half of the run-authenticated deployer this // route's registry calls — the SAME `prepareProvisionedDeployment` the // native `POST /workflows/deployments` route drives. Inference sources @@ -2284,7 +2003,7 @@ export async function createHub(config: HubConfig) { // deployment mints no opening message of its own, but the // trigger that eventually reconciles a principal onto it runs // entirely through Interchange's own native route, with no - // Workbench-owned hook downstream to record the session + // hub-owned hook downstream to record the session // afterward — so this is the only chance to record it at all. await recordAgentSessionAtProvision({ db, @@ -2345,107 +2064,6 @@ export async function createHub(config: HubConfig) { }), }), ); - // The guided-capability-add fail-closed check reuses the exact same - // listers `plannerInventorySources` (below) wires — both this provider - // and the drafting inventory read `InventorySources`/`PlannerInventory` - // from `@corbits/agent-directory` directly, and the tenant's live - // inventory of usable tool packages, skills, and models is never - // assembled twice: both share the same - // `listMyraUsableToolPackages`/`listMyraModels`/`skills.registry.list` - // functions (declared further down this file, hoisted). - const capabilityInventory: CapabilityInventoryProvider = { - async resolve({ tenantId, principalId }) { - const [toolPackages, tenantSkills, models] = await Promise.all([ - listMyraUsableToolPackages(tenantId), - skills.registry.list({ tenantId, principalId }), - listMyraModels(tenantId), - ]); - return { - toolPackages: toolPackages.map((entry) => ({ name: entry.name })), - skills: tenantSkills.map((entry) => ({ name: entry.name })), - models: models.map((entry) => ({ - canonicalName: entry.canonicalName, - })), - }; - }, - }; - - app.route( - `${TENANT_PREFIX}/agent-definitions`, - createAgentDefinitionRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - history: createDefinitionAssetHistory({ - repoStore: agentRepoStore.repoStore, - }), - capabilityInventory, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - // A definition created with no `model` still declares one — the - // same tenant-catalog default a fresh workbench host resolves — - // rather than staying empty and 409ing `not_launchable` at - // invite time. - tenantDefaultModel: async (tenantId) => - (await workbenchHostInferencePreferencesResolver(tenantId))[0]?.model, - }), - ); - // Myra's own agent-creation surface (`@corbits/agent-directory-tools`' - // `create_agent`/`list_agents`): the workflow-run-authenticated - // counterpart to the tenant-session mount just above, self-TENANT - // scoped (Myra may create an agent anywhere in her own tenant). See - // `@corbits/agent-directory`'s `workflow-create-routes.ts` for the - // authorization reasoning. - app.route( - "/api/workflow-agent-directory", - createWorkflowAgentCreateRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - capabilityInventory, - authenticator: createWorkflowRunAuthenticator({ db }), - tenantDefaultModel: async (tenantId) => - (await workbenchHostInferencePreferencesResolver(tenantId))[0]?.model, - }), - ); - // The workflow-run-authenticated variant of the capabilities route - // just above (CL-6086): a workflow child has no browser session, only - // its sidecar bearer token and its own run address, so it reaches - // `POST /:definitionId/capabilities` through this surface instead, - // mirroring `/api/workflow-skills`/`/api/workflow-memory`. See - // `@corbits/agent-directory`'s `workflow-capability-routes.ts` for the - // deliberate, documented authorization decision this route enforces - // in place of a `requireGrant` check (CL-6085 tracks the durable fix). - app.route( - "/api/workflow-capabilities", - createWorkflowCapabilityRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - capabilityInventory, - authenticator: createWorkflowRunAuthenticator({ db }), - }), - ); - // Myra's own skill-pin surface (`@corbits/skills-tools`' `pin_skill`): - // self-TENANT scoped (unlike `/api/workflow-capabilities` above, which - // is self-definition scoped) — Myra may pin a skill onto any - // definition in her own tenant. See `@corbits/agent-directory`'s - // `workflow-skill-pin-routes.ts` for the authorization reasoning. - app.route( - "/api/workflow-skill-pins", - createWorkflowSkillPinRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - authenticator: createWorkflowRunAuthenticator({ db }), - }), - ); app.route( `${TENANT_PREFIX}/chat`, createCommandRoutes({ @@ -2454,10 +2072,9 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - workbenchBelongsToTenant: async (tenantId, workbenchId) => - (await chatStore.getWorkbenchSettings(tenantId, workbenchId)) !== - undefined || - (await chatStore.hasLaunchedInstance(tenantId, workbenchId)), + workbenchBelongsToTenant: async (tenantId, chatId) => + (await chatStore.getWorkbenchSettings(tenantId, chatId)) !== + undefined || (await chatStore.hasLaunchedInstance(tenantId, chatId)), }), ); @@ -2552,7 +2169,7 @@ export async function createHub(config: HubConfig) { store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, agentTurns, }, { @@ -2575,17 +2192,6 @@ export async function createHub(config: HubConfig) { }); }); } - void reconcilePinnedToolPackagesAfterConnect(chatPlatform, info) - .then((result) => { - if (result === undefined) return; - log.info`tool-package connector ${info.connectorId} changed on tenant ${info.tenantId}: re-checked ${String(result.scanned)} live agents, relaunched ${String(result.relaunched)}`; - }) - .catch((cause: unknown) => { - reportError(cause, { - operation: "connections.reconcile-pinned-tool-packages", - tenantId: info.tenantId, - }); - }); }; // Connections: the settings surface's tenant-scoped credential // test-and-store, mounted under the same tenant prefix and reusing @@ -2624,35 +2230,6 @@ export async function createHub(config: HubConfig) { ? { github: config.githubApiBaseUrl } : {}, onConnected: settleServiceConnection, - // CL-6568's other half: a tenant whose only provider is one it - // connected itself through Settings — never an operator-configured - // hub key — must converge on Myra and the default workflow set the - // same way an onboarding-connected one does. `pendingSeedStore` and - // `benchProvisioner` are declared further down this function, but - // this closure only runs on a future request, well after both are - // constructed below — the same forward-reference this file already - // relies on for `onboardingDeps`. - onInferenceCredentialUsable: async (info) => { - const provider = supportedCredentialProviders().find( - (candidate) => candidate.id === info.provider, - )?.id; - if (provider === undefined) { - log.error`onInferenceCredentialUsable fired for an unsupported provider ${info.provider} on tenant ${info.tenantId}; skipping the pending-seed row`; - return; - } - await pendingSeedStore.put({ - userId: info.userId, - tenantId: info.tenantId, - principalId: info.principalId, - tenantDomain: info.tenantDomain, - provider, - apiKey: info.apiKey, - ...(info.baseURLOverride !== undefined - ? { baseURLOverride: info.baseURLOverride } - : {}), - }); - benchProvisioner.wake(); - }, }), ); // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` @@ -2687,7 +2264,7 @@ export async function createHub(config: HubConfig) { }), onConnected: settleServiceConnection, defaultReturnPath: "/settings/connections", - // `/w/` is the workbench room prefix: the in-room connect card + // `/w/` is the chat room prefix: the in-room connect card // (CL-6393) starts OAuth from a room and must land back in it. returnPathAllowlist: [ ...DEFAULT_RETURN_PATH_ALLOWLIST, @@ -2815,8 +2392,8 @@ export async function createHub(config: HubConfig) { trigger.name === triggerName, ); }, - getTemplateSettings: async (tenantId, workbenchId) => { - const row = await chatStore.getWorkbenchSettings(tenantId, workbenchId); + getTemplateSettings: async (tenantId, chatId) => { + const row = await chatStore.getWorkbenchSettings(tenantId, chatId); const settings = row?.settings ?? {}; const pendingConnections = settings["template/pendingConnections"]; const selectedRepos = settings["template/selectedRepos"]; @@ -2829,34 +2406,26 @@ export async function createHub(config: HubConfig) { : [], }; }, - persistSelectedRepos: async ( - tenantId, - workbenchId, - principalId, - patch, - ) => { - const existing = await chatStore.getWorkbenchSettings( - tenantId, - workbenchId, - ); + persistSelectedRepos: async (tenantId, chatId, principalId, patch) => { + const existing = await chatStore.getWorkbenchSettings(tenantId, chatId); const row = await chatStore.updateWorkbenchSettings({ tenantId, - workbenchId, + workbenchId: chatId, settings: { ...(existing?.settings ?? {}), ...patch }, updatedBy: principalId, }); - workbenchSubscribers.publish(workbenchId, { + chatSubscribers.publish(chatId, { type: "chat.settings", data: { updatedBy: principalId, settings: row.settings }, }); }, onReviewingStarted: async ( tenantId, - workbenchId, + chatId, _principalId, introductions, ) => { - const row = await chatStore.getWorkbenchSettings(tenantId, workbenchId); + const row = await chatStore.getWorkbenchSettings(tenantId, chatId); const participants = parseParticipants( row?.settings["chat/participants"], ); @@ -2866,10 +2435,10 @@ export async function createHub(config: HubConfig) { ); if (participant === undefined) continue; await postRoomMessage( - { roomMessages, publish: workbenchSubscribers.publish }, + { roomMessages, publish: chatSubscribers.publish }, { tenantId, - workbenchId, + workbenchId: chatId, sender: { name: null, address: participant.address }, runId: localPartOf(participant.address), parts: [{ kind: "text", text: introduction.text }], @@ -2895,7 +2464,7 @@ export async function createHub(config: HubConfig) { }), log: (line) => log.info`${line}`, inferencePreferences: (tenantId) => - workbenchHostInferencePreferencesResolver(tenantId), + chatHostInferencePreferencesResolver(tenantId), deployWorkflowSource: async ({ tenantId, principalId, @@ -3128,12 +2697,12 @@ export async function createHub(config: HubConfig) { // down immediately — never a resident that outlives the request, so no // idle-sleep lifecycle is needed for it. - // Every genuine top-level deployment run, workbench-hosted and invited-agent + // Every genuine top-level deployment run, chat-hosted and invited-agent // runs excluded — the scoped listing CL-6061 adds so the Agent Directory // and the shell's "Running" bands stop deriving that exclusion - // client-side from a tenant's workbenches alone (see + // client-side from a tenant's chats alone (see // `@corbits/run-scope`'s `scope-routes.ts`, which a non-top-level run - // with no workbench involved silently slipped past). + // with no chat involved silently slipped past). app.route( `${TENANT_PREFIX}/top-level-runs`, createTopLevelRunRoutes({ @@ -3164,433 +2733,6 @@ export async function createHub(config: HubConfig) { : {}), }); - // The inventory Myra is offered when drafting a new agent definition - // (`plannerInventorySources` below, `@corbits/agent-directory`'s own - // `InventorySources` seam). Every inventory lister below generalizes a - // pattern that already lives elsewhere in this composition root - // (`isConversationalAgentDefinition`, `workbenchHostInferencePreferencesResolver`'s - // per-tenant connected-provider derivation) — this package owns the - // inventory's shape, never the listing logic. - const memoryToolPackageName = "@corbits/memory-tools"; - // `@corbits/capability-tools` (CL-6084/CL-6086)'s `request_capability` - // tool needs no per-tenant credential either, like memory-tools: the - // sidecar now threads its own `definitionId` into a step's tool env - // (`apps/sidecar/src/workflow-substrate-factory/step-env.ts`), and - // `/api/workflow-capabilities` (mounted below) gives it a - // workflow-run-authenticated path to the capabilities route the same - // way `/api/workflow-skills` and `/api/workflow-memory` do. Both gaps - // that used to keep it out of this lister are closed. - const capabilityToolPackageName = "@corbits/capability-tools"; - - async function listMyraConversationalAgents( - tenantId: string, - ): Promise { - const rows = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.status, "deployed"), - ), - }); - return rows - .filter((row) => isPickerListableDefinition(row)) - .map((row) => { - const agent = { - id: row.id, - name: row.name, - displayName: workflowDisplayName(row.name, row.description), - }; - if (row.description !== null) { - return { ...agent, description: row.description }; - } - return agent; - }); - } - - async function listMyraUsableToolPackages( - tenantId: string, - ): Promise { - const connectedConnectorIds = await listConnectedProviders(db, tenantId); - const entries: InventoryToolPackage[] = []; - for (const connectorId of connectedConnectorIds) { - const descriptor = CONNECTOR_REGISTRY[connectorId]; - if (descriptor === undefined) continue; - for (const toolPackageName of descriptor.feedsTools) { - // This listing is already scoped to connections registry ∩ - // tenant credentials that exist (`listConnectedProviders`), so - // every entry it returns necessarily has a live credential — - // the binding mirrors `workflows/granola-call`'s - // `GRANOLA_CALL_CREDENTIAL_BINDINGS` exactly: `handle`/`provider` - // both equal the connector id. - entries.push({ - name: toolPackageName, - connectorId: descriptor.id, - credentialBinding: { - package: toolPackageName, - handle: descriptor.id, - provider: descriptor.id, - locator: "tenant", - }, - }); - } - } - if (memoryHandle !== undefined) { - entries.push({ - name: memoryToolPackageName, - connectorId: "memory", - credentialBinding: null, - }); - } - entries.push({ - name: capabilityToolPackageName, - connectorId: "capability", - credentialBinding: null, - }); - // MCP tools are fed by MCP server connections, not by the classic - // connector registry above — without this the inventory rejects - // `@corbits/mcp-tools` on a bench with live MCP servers, so created - // specialists cannot search (CL-6206's live 400). Credential - // bindings for this package come from `mcpCredentialBindingsFor` - // at launch, never from an inventory row. - const mcpServers = await listMcpServerConnections(db, tenantId); - if (mcpServers.length > 0) { - entries.push({ - name: "@corbits/mcp-tools", - connectorId: "mcp", - credentialBinding: null, - }); - } - // The ask_user interaction card needs no credential at all — it is - // always offerable, exactly like capability-tools. - entries.push({ - name: "@corbits/interaction-tools", - connectorId: "interaction", - credentialBinding: null, - }); - // Workflow-source authoring needs no credential either: every write is - // authorized against the run's own asset grants by - // `/api/workflow-workflow-authoring` (mounted above). - entries.push({ - name: "@corbits/workflow-authoring-tools", - connectorId: "workflow-authoring", - credentialBinding: null, - }); - return entries; - } - - async function listMyraModels( - tenantId: string, - ): Promise { - const rows = await db.query.model.findMany({ - where: and(eq(model.tenantId, tenantId), eq(model.disabled, false)), - }); - return rows.map((row) => { - const entry = { canonicalName: row.canonicalName }; - if (row.displayName !== null) { - return { ...entry, displayName: row.displayName }; - } - return entry; - }); - } - - const plannerInventorySources: InventorySources = { - listConversationalAgents: listMyraConversationalAgents, - listUsableToolPackages: listMyraUsableToolPackages, - listSkills: (caller) => skills.registry.list(caller), - memoryAvailable: memoryHandle !== undefined, - listModels: listMyraModels, - }; - - // The create-agent panel's "Describe" step (CL-6074): a real one-shot - // Myra call that proposes a starting system prompt/tool pins/skills - // from a name + plain-language purpose, offering her the same - // inventory `capabilityInventory` above reads through. Never deploys - // on its own — the panel submits the validated draft through the - // ordinary create-agent-definition path once the person confirms. - // Failure copy is the package route's `makeErrorEnvelope` + - // `reportError` (CL-6749), not a local `{ code, message }` body. - const plannerRoutes = createAgentDefinitionDraftRoutes({ - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - draftAgentDefinition: (input) => - createMyraAgentDefinitionDrafting({ - resolveMyraDefinitionId: (tenantId) => - resolveMyraDefinitionIdFromDb(db, tenantId), - runner: { - run: (runnerInput) => - runOneShotPrompt( - { - db, - events: sidecarRouter.events, - cryptoProviders, - undeploy: (address, reason) => - sidecarRouter.sendAgentUndeploy(address, reason), - repoStore: agentRepoStore.repoStore, - workflowAllocationService, - sessionService, - eventCollectors, - isRoutable: isSidecarRoutable, - }, - runnerInput, - ), - }, - inventorySources: plannerInventorySources, - }).propose(input), - }); - app.route(`${TENANT_PREFIX}/planner`, plannerRoutes); - - // The sanctioned path for a workflow run to reach the memory plane - // (CL-5852), mirroring `/api/workflow-artifacts` immediately above: - // mounted OUTSIDE `TENANT_PREFIX` since a workflow-process child has - // no browser session, every request authenticates via the same - // `WorkflowRunAuthenticator` (sidecar bearer token + run address) - // against this hub's own control-plane `db`. Serves through - // `memoryHandle.memory` — the SAME in-process plane instance - // `mountMemory` mounted above, never a second connection. - if (memoryHandle !== undefined) { - app.route( - "/api/workflow-memory", - createWorkflowMemoryRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - store: createWorkflowMemoryStore(memoryHandle.memory), - }), - ); - } else { - app.route("/api/workflow-memory", createUnavailableWorkflowMemoryRoutes()); - } - - // Closed-by-default access policy: a per-tenant policy row layered - // over native tenancy/RBAC (see `@workbench/access-policy`). Migrated - // at hub start like insights/preferences/bench-settings; mounted - // tenant-scoped for the settings panel, and threaded into the - // onboarding hook below so first-login provisioning honors it without - // patching any vendor route. - await applyAccessPolicyMigrations(config.databaseUrl); - const accessPolicyStore = createDrizzleAccessPolicyStore(db); - app.route( - `${TENANT_PREFIX}/access-policy`, - createAccessPolicyRoutes({ - store: accessPolicyStore, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - api: selfApi, - }), - ); - - // The first-login hook mounts outside the tenant prefix, since the - // session it serves belongs to no tenant yet. The route is - // `@workbench/onboarding`'s; what it decides is documented in that - // package's provision.ts. - // Connecting a provider deploys nothing (CL-6457): the onboarding - // routes persist the credential and hand the workflow deploys to this - // drain, which converges every bench with a pending row — including - // one a previous process died halfway through, since the row itself is - // the durable work item. - const pendingSeedStore = createDrizzlePendingSeedStore(db, credentialCipher); - const benchProvisioner = createBenchProvisioner({ - api: selfApi, - hubUrl: config.baseUrl, - store: pendingSeedStore, - pushWorkflow: createGitWorkflowPusher(), - sessionFor, - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - }); - benchProvisioner.start(); - - // CL-7584: the tenant-create trigger. A 201 from the native - // `POST /api/tenants` route kicks a fire-and-forget desired-state - // reconcile for the new tenant under the creator's minted session — - // the revisit kick below and the drain above share this one - // reconciler. No durable row: the pending_seed row stays the only - // durable work item, and a kick lost to a restart is re-covered by - // the revisit kick on the tenant's next visit. The observer itself is - // composed just before the guard wrap, after every route mount: Hono - // copies routes at `.route()` time, so wrapping earlier would strand - // everything mounted after it. - const observerRef: { - current?: ReturnType; - } = {}; - - const onboardingDeps: Parameters[0] = { - hubUrl: config.baseUrl, - defaultTenantSlug: config.defaultTenantSlug, - tenancy: signupTenancy, - pushWorkflow: createGitWorkflowPusher(), - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - credentialCipher, - pendingSeedStore, - benchProvisioner, - desiredStateKick: (args) => { - // Fire-and-forget; the route already decided pins are pending. - void observerRef.current - ?.kick({ tenantId: args.tenantId, cookies: args.cookies }) - .catch(() => undefined); - }, - accessPolicy: { - store: accessPolicyStore, - envSignupMode: config.signupMode, - envAllowedDomains: config.allowedEmailDomains, - allowUnverifiedEmails: config.allowUnverifiedEmails, - }, - // Same provider-health store `@corbits/connections`' own routes - // report to and clear (CL-6092) — a successful `/complete` here must - // clear the same record the shell banner's zero-provider "Fix it" - // routed someone to onboarding to fix. - providerHealth: providerHealthStore, - }; - if (config.huggingfaceOAuthClientId !== undefined) - onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId; - - app.route("/api/onboarding", createOnboardingRoutes(onboardingDeps)); - - // Artifacts engine: mounts `@corbits/artifacts` against the same - // Postgres cluster as this hub's control plane (its - // `artifact`/`artifact_version` tables FK into `public.tenant` / - // `public.principal`). Uses DATABASE_URL — the same URL as everything - // else — so local `bun run dev` mounts Library with no extra env var. - // When it's unset (or mount fails), degrades to 503 routes. When - // mounted, tenant-scoped list + get + upload routes serve Library - // under `/artifacts`. - // - // The mount runs migrations against the configured DB; if the URL is - // present but points at an unreachable/invalid cluster the migration - // would otherwise throw and take the whole hub down at boot. We catch - // that here so the hub comes up in a degraded (no-artifacts) mode and - // surfaces the failure as a warning rather than a crash. - let artifactsHandle: Awaited>; - try { - artifactsHandle = await mountArtifacts(); - } catch (error) { - log.warn( - `Artifacts mount failed — continuing without artifacts persistence: ${error}`, - ); - artifactsHandle = undefined; - } - if (artifactsHandle !== undefined) { - app.route( - `${TENANT_PREFIX}/artifacts`, - createArtifactRoutes({ - store: createArtifactDbStore( - artifactsHandle.db, - artifactsHandle.contentStore, - ), - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - - // The bench library's template shelf (CL-6344): what the - // new-workbench picker instantiates from — seeded rows, never a - // hardcoded import. Reading the shelf is what seeds it (CL-6458), so - // a bench created at any point after boot carries the shipped - // manifests the first time its picker opens. - app.route( - `${TENANT_PREFIX}/library/templates`, - createTemplateLibraryRoutes({ - store: createTemplateLibraryDbStore(artifactsHandle.db), - seeder: createTemplateLibrarySeeder({ - db: artifactsHandle.db, - entries: workbenchTemplateLibraryEntries(), - log: (line) => log.info`${line}`, - }), - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - log: (line) => log.error`${line}`, - }), - ); - - // Co-editing persistence (CL-5958 phase 2): debounced snapshots of a - // presence room's Y.Text into a real artifact version, layered on top - // of the presence registry mounted above without changing its own - // "ephemeral, no storage" default. `writeArtifactVersion`/`getArtifact` - // are the engine's own versioned-row seam — the same one a workflow's - // artifact revision goes through — so a co-edited text artifact's - // history reads identically to any other revision. `anonymousIdentity` - // is not used here: `writeArtifactVersion` only needs a `{tenantId, - // principalId}` scope, not a resolved `Identity`. - const artifactDb = artifactsHandle.db; - const artifactPersistence = createArtifactDocPersistence({ - registry: presenceRoomRegistry, - loadArtifactContent: async (tenantId, artifactId) => { - const row = await getArtifact(artifactDb, artifactId); - if (row === null || row.tenantId !== tenantId) return null; - return row.content; - }, - writeArtifactSnapshot: async ( - tenantId, - artifactId, - authorPrincipalId, - content, - ) => { - const written = await writeArtifactVersion(artifactDb, { - scope: { tenantId, principalId: authorPrincipalId }, - artifactId, - content, - }); - return { version: written.version }; - }, - onSnapshotError: (key, error) => { - log.warn( - `Co-editing snapshot failed for ${key.tenantId}/${key.surface}: ${error}`, - ); - }, - }); - artifactSeedOnJoin = artifactPersistence.seedOnJoin; - } else { - log.info("Artifacts handle unavailable (degraded mode)"); - app.route( - `${TENANT_PREFIX}/artifacts`, - createUnavailableArtifactRoutes( - createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ), - ); - app.route( - `${TENANT_PREFIX}/library/templates`, - createUnavailableTemplateLibraryRoutes( - createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ), - ); - } - - // The sanctioned path for a workflow run to persist and read Library - // artifacts (CL-6000): mounted OUTSIDE `TENANT_PREFIX` since a - // workflow-process child has no browser session — every request here - // authenticates via `createWorkflowRunAuthenticator` (the sidecar's own - // bearer token plus the run's own address) against this hub's own - // control-plane `db`, never the artifacts engine's db. - if (artifactsHandle !== undefined) { - app.route( - "/api/workflow-artifacts", - createWorkflowArtifactRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - store: createWorkflowArtifactDbStore( - artifactsHandle.db, - artifactsHandle.contentStore, - ), - }), - ); - } else { - app.route( - "/api/workflow-artifacts", - createUnavailableWorkflowArtifactRoutes(), - ); - } - // Tells the signed-out screen which OAuth buttons to draw, without // exposing the credentials themselves — just which providers a full // pair was configured for. No session or tenant is required to ask, @@ -3599,60 +2741,14 @@ export async function createHub(config: HubConfig) { app.get("/api/auth-config", (c) => c.json({ socialProviders: enabledSocialProviders, - signupMode: config.signupMode, - allowedEmailDomains: config.allowedEmailDomains, }), ); app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); - // [Intx gap] CL-6041: the native POST /api/tenants route is ungated — - // wrap the fully-built app in a guard that enforces - // @workbench/access-policy in front of it. See - // ./tenant-create-guard.ts's module comment for why this has to be an - // outer wrap rather than an `app.use()` added here: the native route - // is already registered by the time `createApp()` returns above, and - // Hono composes handlers in registration order. - const guardDeps: Parameters[1] = { - store: accessPolicyStore, - resolveCallerRoleNames: (tenantId, userId) => - resolveCallerRoleNames(db, tenantId, userId), - countTenants: signupTenancy.countTenants, - envSignupMode: config.signupMode, - envAllowedDomains: config.allowedEmailDomains, - allowUnverifiedEmails: config.allowUnverifiedEmails, - getSessionUser: async (headers) => { - const result = await auth.api.getSession({ headers }); - return result - ? { - id: result.user.id, - email: result.user.email, - emailVerified: result.user.emailVerified, - } - : undefined; - }, - }; - const observer = createTenantCreateObserver( - { - api: selfApi, - hubUrl: config.baseUrl, - pushWorkflow: createGitWorkflowPusher(), - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - }, - app, - ); - observerRef.current = observer; - const guardedApp = guardedHubApp(observer.app, guardDeps); - // [Intx gap] CL-7324: deny foreign-tenant allocations at the two dispatch - // routes (403 `allocation_tenant_mismatch`) before the native handler can - // enqueue or materialize grants — same outer-wrap composition as the - // tenant-create guard above. See ./dispatch-tenant-guard.ts. - const dispatchGuardedApp = withDispatchTenantGuard(guardedApp, { - loadAnchorDispatch: (anchorRunId) => loadAnchorDispatch(db, anchorRunId), - }); + // Stock Interchange currently leaves tenant creation and dispatch ungated. const inFlight = createInFlightRequestTracker(); - const servingApp = withInFlightRequestTracking(dispatchGuardedApp, inFlight); + const servingApp = withInFlightRequestTracking(app, inFlight); return { app: servingApp, @@ -3660,16 +2756,6 @@ export async function createHub(config: HubConfig) { db, close: async () => { sidecarAllocationReconciliationStopped = true; - // Let any in-flight tenant-create reconcile reach its next HTTP - // call before the server stops — the call then fails and the kick - // logs it, so a fire-and-forget reconcile never races the DB - // teardown. Bounded: a kick stuck on an already-dying connection - // must not stall shutdown (CL-7584). - observerRef.current?.stop(); - await Promise.race([ - observerRef.current?.whenIdle(), - new Promise((resolve) => setTimeout(resolve, 250)), - ]); if (sidecarAllocationReconciliationTimer !== undefined) { clearTimeout(sidecarAllocationReconciliationTimer); } @@ -3693,7 +2779,6 @@ export async function createHub(config: HubConfig) { workflowScheduler.stop(); credentialExpirySweep.stop(); inboxUnsnoozeSweep.stop(); - benchProvisioner.stop(); await insightsUsage.close(); await insightsLatency.close(); await preferences.close(); diff --git a/apps/hub/src/launch-caches.ts b/apps/hub/src/launch-caches.ts index 0ca019d7cf..a030627ed0 100644 --- a/apps/hub/src/launch-caches.ts +++ b/apps/hub/src/launch-caches.ts @@ -1,4 +1,4 @@ -// CL-6225: workbench mint is slow because every agent launch re-reads every +// CL-6225: chat mint is slow because every agent launch re-reads every // tarball in a tenant's `corbits-tools` package-registry asset and rebuilds // a full git pack for every asset attachment — see // `AssetRegistrySource.#buildPackuments` in diff --git a/apps/hub/src/mailbox-persist.ts b/apps/hub/src/mailbox-persist.ts index 6d37382a6f..3232db07a9 100644 --- a/apps/hub/src/mailbox-persist.ts +++ b/apps/hub/src/mailbox-persist.ts @@ -143,28 +143,28 @@ export function createHubMailboxAuthorizeSender( /** * Build the `resolveRefs` seam: stamps - * `refs: [{ kind: "workbench", id: workbenchId }]` onto every recipient row + * `refs: [{ kind: "workbench", id: chatId }]` onto every recipient row * of one frame. * - * `workbenchId` is NOT `senderAuthorization.tenantId`. An agent run is + * The chat id is NOT `senderAuthorization.tenantId`. An agent run is * launched in its parent BENCH tenant -- that tenant is what * `authorizeSender` resolves, and it is also what the resulting * `principal_mail` rows themselves are scoped under (same tenant the - * addressed human principals belong to). The workbench the run is a + * addressed human principals belong to). The chat the run is a * participant of is a separate id `@corbits/chat` tracks inside that same * bench tenant, on `workbench_settings.workbenchId` -- one bench tenant - * hosts many workbenches. Stamping the bench's tenant id here instead would - * point every row at an id no workbench thread read can ever resolve. + * hosts many chats. Stamping the bench's tenant id here instead would + * point every row at an id no chat thread read can ever resolve. * * Header-first (CL-7449): an agent that participates in several - * workbenches at once has no single "the" workbench a bare participant + * chats at once has no single "the" chat a bare participant * scan can name honestly, so `@corbits/chat`'s * `resolveWorkbenchIdForAgentFrame` reads the frame's own `In-Reply-To` / * `References` and maps that Message-ID back to the timeline row it * answers -- that row's `workbenchId` is authoritative. Only when the * frame carries no such header does it fall back to the participant scan, * and only takes that scan's answer when it is unambiguous (exactly one - * workbench); this seam is a thin adapter handing that helper the two + * chat); this seam is a thin adapter handing that helper the two * stores it needs (`chatStore`, `roomMessages`) plus the frame's own * decoded headers. */ @@ -175,7 +175,7 @@ export function createHubMailboxResolveRefs( return async ({ senderAddress, senderAuthorization, decoded }) => { const inReplyTo = decoded?.headers.get("in-reply-to") ?? undefined; const references = decoded?.references; - const workbenchId = await resolveWorkbenchIdForAgentFrame( + const chatId = await resolveWorkbenchIdForAgentFrame( { chatStore, roomMessages }, senderAuthorization.tenantId, { @@ -184,8 +184,8 @@ export function createHubMailboxResolveRefs( ...(references !== undefined ? { references } : {}), }, ); - if (workbenchId === undefined) return undefined; - const ref: MailboxRef = { kind: "workbench", id: workbenchId }; + if (chatId === undefined) return undefined; + const ref: MailboxRef = { kind: "workbench", id: chatId }; return [ref]; }; } diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts deleted file mode 100644 index ca664ebcbc..0000000000 --- a/apps/hub/src/memory-mount.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { Hono } from "hono"; -import { createInMemoryGrantStore } from "@intx/authz"; - -import { - applyResolvedEmbedToProcessEnv, - mountMemory, - resolveMemoryEmbed, -} from "./memory-mount"; - -const KEYS = [ - "DATABASE_URL", - "EMBED_BASE_URL", - "EMBED_MODEL", - "EMBED_API_STYLE", - "EMBED_API_KEY", - "RERANK_BASE_URL", - "RERANK_MODEL", - "OLLAMA_BASE_URL", -] as const; - -type EnvKey = (typeof KEYS)[number]; - -const originals: Record = { - DATABASE_URL: process.env["DATABASE_URL"], - EMBED_BASE_URL: process.env["EMBED_BASE_URL"], - EMBED_MODEL: process.env["EMBED_MODEL"], - EMBED_API_STYLE: process.env["EMBED_API_STYLE"], - EMBED_API_KEY: process.env["EMBED_API_KEY"], - RERANK_BASE_URL: process.env["RERANK_BASE_URL"], - RERANK_MODEL: process.env["RERANK_MODEL"], - OLLAMA_BASE_URL: process.env["OLLAMA_BASE_URL"], -}; - -function clearEnvKey(key: EnvKey): void { - // Must actually remove the key: `process.env[key] = undefined` stores the - // *string* "undefined" (Bun >= 1.4 matches Node here), which reads as a - // configured value and makes every DATABASE_URL/EMBED_BASE_URL gate in this - // app's suites fire against postgres.js's localhost:5432 default. - // `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function restoreOriginalEnv(): void { - for (const key of KEYS) { - const value = originals[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - } -} - -afterEach(restoreOriginalEnv); - -function stashEnv(): void { - for (const key of KEYS) { - clearEnvKey(key); - } -} - -describe("resolveMemoryEmbed", () => { - test("returns undefined when neither EMBED_BASE_URL nor OLLAMA_BASE_URL is set", () => { - expect(resolveMemoryEmbed({})).toBeUndefined(); - }); - - test("explicit EMBED_BASE_URL wins over OLLAMA_BASE_URL", () => { - expect( - resolveMemoryEmbed({ - EMBED_BASE_URL: "https://api.openai.com/v1", - EMBED_MODEL: "text-embedding-3-small", - OLLAMA_BASE_URL: "http://localhost:11434", - }), - ).toEqual({ - embedBaseUrl: "https://api.openai.com/v1", - embedModel: "text-embedding-3-small", - embedApiStyle: "openai", - source: "EMBED_BASE_URL", - }); - }); - - test("OLLAMA_BASE_URL is a local embed path with nomic-embed-text / ollama style", () => { - expect( - resolveMemoryEmbed({ - OLLAMA_BASE_URL: "http://localhost:11434", - }), - ).toEqual({ - embedBaseUrl: "http://localhost:11434", - embedModel: "nomic-embed-text", - embedApiStyle: "ollama", - source: "OLLAMA_BASE_URL", - }); - }); - - test("treats a blank OLLAMA_BASE_URL as unset", () => { - expect(resolveMemoryEmbed({ OLLAMA_BASE_URL: "" })).toBeUndefined(); - }); - - test("blank EMBED_MODEL / EMBED_API_STYLE on OLLAMA path resolve to nomic-embed-text / ollama", () => { - expect( - resolveMemoryEmbed({ - OLLAMA_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "", - EMBED_API_STYLE: " ", - }), - ).toEqual({ - embedBaseUrl: "http://localhost:11434", - embedModel: "nomic-embed-text", - embedApiStyle: "ollama", - source: "OLLAMA_BASE_URL", - }); - }); - - test("afterEach restores KEYS after a non-stash resolveMemoryEmbed test", () => { - for (const key of KEYS) { - expect(process.env[key]).toBe(originals[key]); - } - }); -}); - -describe("applyResolvedEmbedToProcessEnv", () => { - test("plants OLLAMA defaults when EMBED_MODEL and EMBED_API_STYLE are blank", () => { - stashEnv(); - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - process.env["EMBED_MODEL"] = ""; - process.env["EMBED_API_STYLE"] = " "; - const resolved = resolveMemoryEmbed(process.env); - expect(resolved).toBeDefined(); - if (resolved === undefined) return; - applyResolvedEmbedToProcessEnv(resolved); - expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); - expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); - - test("does not overwrite a non-blank EMBED_MODEL", () => { - stashEnv(); - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - process.env["EMBED_MODEL"] = "custom-embed"; - const resolved = resolveMemoryEmbed(process.env); - expect(resolved).toBeDefined(); - if (resolved === undefined) return; - applyResolvedEmbedToProcessEnv(resolved); - expect(process.env["EMBED_MODEL"]).toBe("custom-embed"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); -}); - -describe("mountMemory", () => { - test("returns undefined when EMBED_BASE_URL and OLLAMA_BASE_URL are unset (optional)", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - const app = new Hono(); - const handle = await mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeUndefined(); - }); - - test("throws when optional is false and EMBED_BASE_URL and OLLAMA_BASE_URL are missing", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - optional: false, - }), - ).rejects.toThrow(/EMBED_BASE_URL or OLLAMA_BASE_URL/); - }); - - test("fails loudly at config parse when EMBED_BASE_URL is set but blank, rather than silently treating it as unset", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = ""; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/EMBED_BASE_URL/); - }); - - test("throws when RERANK_BASE_URL is set without RERANK_MODEL, rather than reranking silently failing later", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - process.env["RERANK_BASE_URL"] = "http://localhost:8080"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); - }); - - test("throws when RERANK_MODEL is set without RERANK_BASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - process.env["RERANK_MODEL"] = "bge-reranker-v2-m3"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); - }); -}); - -// DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's -// existing convention for tests that talk to a real Postgres (see -// apps/hub/test/artifact-doc-persistence.test.ts). Proves the actual cutover: -// mounting with only DATABASE_URL (no second memory-plane URL) lands the -// memory engine's tables in its own `memory` schema, never `public`. -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("mountMemory: schema isolation against a real database", () => { - afterAll(async () => { - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - await sql.unsafe(`DROP SCHEMA IF EXISTS "memory" CASCADE`); - } finally { - await sql.end(); - } - }); - - test("mounts off DATABASE_URL alone and creates tables under `memory`, not `public`", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - - const app = new Hono(); - const handle = await mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - const memoryTables = await sql<{ table_name: string }[]>` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'memory' - `; - expect(memoryTables.length).toBeGreaterThan(0); - - const publicLeaks = await sql<{ table_name: string }[]>` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('document', 'raw_capture', '_migrations') - `; - expect(publicLeaks.length).toBe(0); - } finally { - await sql.end(); - } - }); - - test("mounts from OLLAMA_BASE_URL when EMBED_BASE_URL is unset", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - - const handle = await mountMemory({ - app: new Hono(), - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); - expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); -}); diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts deleted file mode 100644 index 46b0681e21..0000000000 --- a/apps/hub/src/memory-mount.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Hub-side memory plane mount — host analog of Scout dock's `mountKnowledge` - * (`packages/agent-dock/src/knowledge.ts`). `@corbits/memory` (git pin) owns - * the firm-memory plane + HTTP under `/api/tenants/:tenantId/memory/*`, - * isolated in its own `memory` Postgres schema (drizzle `pgSchema`) — the - * same idiom `@corbits/mailbox` uses for `mailbox` and `@corbits/artifacts` - * for `artifacts`. There is exactly one Postgres URL for this hub: - * `DATABASE_URL`, and `@corbits/memory`'s own `loadMemoryConfig()` reads it - * directly, so this module just calls it. - * - * Embed resolution: explicit `EMBED_BASE_URL` wins. Otherwise a configured - * `OLLAMA_BASE_URL` is a local embed path (same origin the hub already - * plants as an inference credential), so local/dev does not leave the plane - * dark when Ollama is already wired. Missing both means "no memory plane", - * logged once at boot, never thrown — same optional-engine contract as the - * artifacts mount. When an embed URL is present, boot fails loudly if - * migrate/create throws so a half-wired deploy is never silent. - * - * This module lands the mount + factory only. Capture/ingestion glue is a - * later ticket; agents that want firm memory ask the returned handle. - * - * Boundary casts (`as never`) match Scout: `@corbits/memory` ships against its - * own Hono/authz type copies, and Hono env + ConditionRegistry are invariant - * across package roots — cast only at the mount boundary. - */ -import type { Hono } from "hono"; -import { type } from "arktype"; -import type { ConditionRegistry, GrantStore } from "@intx/authz"; -import { getLogger } from "@intx/log"; -import { - createMemory, - loadMemoryConfig, - runMemoryMigrations, - type Memory, -} from "@corbits/memory"; - -const log = getLogger(["hub", "memory-mount"]); - -// The boundary this module parses: whether the memory plane is configured -// at all, and — when a reranker is in play — whether it's configured -// completely. `"string > 0"` rejects a blank value the same as an absent -// one, while catching a non-string env value at the arktype boundary -// rather than letting a falsy check quietly wave through something -// unexpected. -// -// RERANK_BASE_URL and RERANK_MODEL must be set together: `@corbits/memory` -// treats each independently optional and would otherwise let a -// half-configured reranker surface as a confusing runtime failure deep in -// its rerank client, rather than a boot-time error naming the missing -// half — the same "fail loud on a half-configured pair" contract this -// hub already applies to GOOGLE_CLIENT_ID/SECRET (see ../config.ts). -// Reranking itself stays a soft-fail enhancement once configured (a -// reranker outage degrades search quietly, never breaks it) — this check -// only guards against shipping a pair that can never work at all. -const MemoryMountEnv = type({ - "EMBED_BASE_URL?": "string > 0", - "RERANK_BASE_URL?": "string > 0", - "RERANK_MODEL?": "string > 0", -}); - -type ParsedMemoryMountEnv = typeof MemoryMountEnv.infer; - -function omitUndefined( - env: Record, - keys: readonly string[], -): Record { - // Build the input object with unset keys OMITTED rather than present - // with an `undefined` value: arktype's optional-key check is keyed off - // property presence, and `process.env` (and this suite's env stashing) - // both sometimes leave an unset variable as a present-but-`undefined` - // own property rather than an absent one. - const input: Record = {}; - for (const key of keys) { - const value = env[key]; - if (value !== undefined) input[key] = value; - } - return input; -} - -function parseMemoryMountEnv( - env: Record, -): ParsedMemoryMountEnv { - const input = omitUndefined(env, [ - "EMBED_BASE_URL", - "RERANK_BASE_URL", - "RERANK_MODEL", - ]); - const parsed = MemoryMountEnv(input); - if (parsed instanceof type.errors) { - throw new Error(`invalid memory-plane environment: ${parsed.summary}`); - } - if ( - (parsed.RERANK_BASE_URL === undefined) !== - (parsed.RERANK_MODEL === undefined) - ) { - throw new Error( - [ - "invalid memory-plane environment: RERANK_BASE_URL and RERANK_MODEL must be set together to enable reranking; only one is set", - "Set both in .env, or unset both to search without reranking; see .env.example.", - ].join("\n"), - ); - } - return parsed; -} - -const LOCAL_OLLAMA_EMBED_MODEL = "nomic-embed-text"; -const LOCAL_OLLAMA_EMBED_STYLE = "ollama"; - -export type ResolvedMemoryEmbed = { - readonly embedBaseUrl: string; - readonly embedModel: string; - readonly embedApiStyle: string; - readonly source: "EMBED_BASE_URL" | "OLLAMA_BASE_URL"; -}; - -function nonemptyEnv( - env: Record, - key: string, -): string | undefined { - const value = env[key]; - if (value === undefined || value.trim() === "") return undefined; - return value; -} - -/** - * Explicit `EMBED_BASE_URL` wins. Otherwise `OLLAMA_BASE_URL` is the local - * embed path — nomic-embed-text / ollama style, matching `setup:memory`. - */ -export function resolveMemoryEmbed( - env: Record, -): ResolvedMemoryEmbed | undefined { - const embedBaseUrl = nonemptyEnv(env, "EMBED_BASE_URL"); - if (embedBaseUrl !== undefined) { - return { - embedBaseUrl, - embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? "", - embedApiStyle: nonemptyEnv(env, "EMBED_API_STYLE") ?? "openai", - source: "EMBED_BASE_URL", - }; - } - const ollamaBaseUrl = nonemptyEnv(env, "OLLAMA_BASE_URL"); - if (ollamaBaseUrl === undefined) return undefined; - return { - embedBaseUrl: ollamaBaseUrl, - embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? LOCAL_OLLAMA_EMBED_MODEL, - embedApiStyle: - nonemptyEnv(env, "EMBED_API_STYLE") ?? LOCAL_OLLAMA_EMBED_STYLE, - source: "OLLAMA_BASE_URL", - }; -} - -export function applyResolvedEmbedToProcessEnv( - resolved: ResolvedMemoryEmbed, -): void { - if (process.env["EMBED_BASE_URL"] === undefined) { - process.env["EMBED_BASE_URL"] = resolved.embedBaseUrl; - } - // Blank `EMBED_MODEL=` / `EMBED_API_STYLE=` is unset — same as a missing - // key — so OLLAMA defaults actually land instead of being skipped as - // "already configured". - if ( - nonemptyEnv(process.env, "EMBED_MODEL") === undefined && - resolved.embedModel !== "" - ) { - process.env["EMBED_MODEL"] = resolved.embedModel; - } - if (nonemptyEnv(process.env, "EMBED_API_STYLE") === undefined) { - process.env["EMBED_API_STYLE"] = resolved.embedApiStyle; - } -} - -export type MountMemoryOptions = { - /** Hub Hono app (routes register under tenant memory paths). */ - app: Hono; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; - /** - * When true (default), skip mount if neither `EMBED_BASE_URL` nor - * `OLLAMA_BASE_URL` is set. Tests can force a mount attempt by setting - * env + `optional: false`. - */ - optional?: boolean; -}; - -export type MemoryMountHandle = { - memory: Memory; -}; - -/** - * Returns a memory handle when the plane is configured and mounted; - * `undefined` when optional and neither `EMBED_BASE_URL` nor - * `OLLAMA_BASE_URL` is present. - */ -export async function mountMemory( - options: MountMemoryOptions, -): Promise { - const optional = options.optional !== false; - parseMemoryMountEnv(process.env); - const resolved = resolveMemoryEmbed(process.env); - if (resolved === undefined) { - if (optional) { - log.info( - "EMBED_BASE_URL / OLLAMA_BASE_URL not set — memory plane will not be mounted", - ); - return undefined; - } - throw new Error( - "EMBED_BASE_URL or OLLAMA_BASE_URL is required to mount memory", - ); - } - applyResolvedEmbedToProcessEnv(resolved); - if (resolved.source === "OLLAMA_BASE_URL") { - log.info( - `OLLAMA_BASE_URL set — mounting memory plane with embed ${resolved.embedBaseUrl} (${resolved.embedModel})`, - ); - } - - const config = loadMemoryConfig(); - await runMemoryMigrations(config.memory.databaseUrl, { - ftsLanguage: config.memory.ftsLanguage, - }); - const memory = createMemory({ - app: options.app as never, - config, - grantStore: options.grantStore as never, - conditionRegistry: options.conditionRegistry as never, - }); - - log.info("Memory plane mounted at /api/tenants/:tenantId/memory/*"); - return { memory }; -} diff --git a/apps/hub/src/memory-workflow-routes.test.ts b/apps/hub/src/memory-workflow-routes.test.ts deleted file mode 100644 index d1c267e8b3..0000000000 --- a/apps/hub/src/memory-workflow-routes.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// DB-gated integration test for CL-5852 M2/M3's real seam: a workflow -// run reaching the memory plane through `createWorkflowMemoryRoutes` -// (`@corbits/memory-hub`), backed by the SAME in-process `Memory` -// instance `mountMemory` mounts for real (not a fake store) — proving -// tenant isolation and the degraded-embedding lexical fallback against -// an actual Postgres `memory` schema, the same convention -// `memory-mount.test.ts` uses (`describeIfDb`, skipped when -// `DATABASE_URL` is unreachable). -// -// "No EMBED_BASE_URL" per CL-5852's test brief means no WORKING -// embedding backend, not a literally-unset env var: `@corbits/memory`'s -// own `loadMemoryConfig()` requires the var to be set at all (a vendored -// contract this repo cannot change), and the hub's own optional-mount -// gate (`memory-mount.ts`) treats a genuinely unset var as "don't mount -// at all" — not a degraded-but-present plane. `EMBED_BASE_URL` here -// points at `localhost:9` (the "discard" port, always closed), the exact -// trick `memory-mount.test.ts` already uses: every embed call fails, -// forcing the plane's real runtime degrade -// (`services/search.ts`'s `degraded: ["dense_unavailable"]`) rather than -// a fake standing in for it — zero real keys, and the ONLY inference this -// test performs is the plane's own lexical (Postgres FTS) fallback. -import { afterAll, afterEach, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { createInMemoryGrantStore } from "@intx/authz"; -import { - createWorkflowMemoryRoutes, - createWorkflowMemoryStore, -} from "@corbits/memory-hub"; -import type { ResolvedWorkflowRunScope } from "@corbits/artifacts-hub"; - -import { mountMemory } from "./memory-mount"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const KEYS = [ - "DATABASE_URL", - "EMBED_BASE_URL", - "EMBED_MODEL", - "EMBED_API_STYLE", - "EMBED_API_KEY", - "OLLAMA_BASE_URL", -] as const; - -type EnvKey = (typeof KEYS)[number]; - -const saved: Partial> = {}; - -function clearEnvKey(key: EnvKey): void { - // Assigning `undefined` would store the string "undefined"; the key has to - // go. `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function stashEnv(): void { - for (const key of KEYS) { - saved[key] = process.env[key]; - clearEnvKey(key); - } -} - -afterEach(() => { - for (const key of KEYS) { - const value = saved[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - saved[key] = undefined; - } -}); - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT_A: ResolvedWorkflowRunScope = { - tenantId: "ten_memory_a", - principalId: "prn_a", - runId: "run_a", -}; -const TENANT_B: ResolvedWorkflowRunScope = { - tenantId: "ten_memory_b", - principalId: "prn_b", - runId: "run_b", -}; -const TOKEN_A = "token-a"; -const TOKEN_B = "token-b"; - -describeIfDb( - "createWorkflowMemoryRoutes against a real memory plane (degraded, no working embed)", - () => { - afterAll(async () => { - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - await sql.unsafe(`DROP SCHEMA IF EXISTS "memory" CASCADE`); - } finally { - await sql.end(); - } - }); - - test("writes tenant-isolated rows and finds them lexically with the embed backend unreachable", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - - const handle = await mountMemory({ - app: new Hono(), - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - if (handle === undefined) throw new Error("expected a mounted plane"); - - const app = createWorkflowMemoryRoutes({ - authenticator: { - async resolve(token) { - if (token === TOKEN_A) return TENANT_A; - if (token === TOKEN_B) return TENANT_B; - return null; - }, - }, - store: createWorkflowMemoryStore(handle.memory), - }); - - const addFor = (token: string, title: string, text: string) => - app.request("/add", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "x-workflow-run-address": "irrelevant-for-this-fake-authenticator", - "content-type": "application/json", - }, - body: JSON.stringify({ title, text }), - }); - - const searchFor = (token: string, query: string) => - app.request("/search", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "x-workflow-run-address": "irrelevant-for-this-fake-authenticator", - "content-type": "application/json", - }, - body: JSON.stringify({ query }), - }); - - const addA = await addFor( - TOKEN_A, - "Decision A", - "Tenant A decided to ship the memory tools this week.", - ); - expect(addA.status).toBe(201); - - const addB = await addFor( - TOKEN_B, - "Decision B", - "Tenant B decided to ship the memory tools this week.", - ); - expect(addB.status).toBe(201); - - // Tenant A's search must find its own entry, and never tenant B's, - // even though both entries share the same words — real Postgres - // row-level tenant scoping, not a test double. - const searchA = await searchFor(TOKEN_A, "ship the memory tools"); - expect(searchA.status).toBe(200); - const bodyA = (await searchA.json()) as { - data: { items: { title: string }[] }; - }; - // The embed backend at localhost:9 is unreachable (proven by the - // "embedding pass failed" warning the plane itself logs on add), - // yet the search below still finds tenant A's own entry — the - // plane's real lexical (Postgres full-text) fallback, exercised - // end to end rather than mocked. - expect(bodyA.data.items.map((i) => i.title)).toEqual(["Decision A"]); - - const searchB = await searchFor(TOKEN_B, "ship the memory tools"); - const bodyB = (await searchB.json()) as { - data: { items: { title: string }[] }; - }; - expect(bodyB.data.items.map((i) => i.title)).toEqual(["Decision B"]); - }, 20000); - }, -); diff --git a/apps/hub/src/native-workflow-routine-launch.test.ts b/apps/hub/src/native-workflow-routine-launch.test.ts index 3fed7ba762..185807ec4e 100644 --- a/apps/hub/src/native-workflow-routine-launch.test.ts +++ b/apps/hub/src/native-workflow-routine-launch.test.ts @@ -43,7 +43,7 @@ const LIVE_ANCHOR: FakeRow = { definitionId: "wfd_multistep", tenantId: "ten_1", anchorRunId: "wfr_anchor1", - address: "wfr_anchor1@acme.workbench.test", + address: "wfr_anchor1@acme.hub.test", status: "deployed", createdAt: new Date("2026-01-01T00:00:00Z"), }; @@ -53,7 +53,7 @@ function baseParams(overrides: Record = {}) { tenantId: "ten_1", definitionId: "wfd_multistep", principalId: "usr_1", - fromDomain: "acme.workbench.test", + fromDomain: "acme.hub.test", content: "Run this routine now.", ...overrides, }; @@ -77,13 +77,13 @@ describe("triggerNativeWorkflowRoutineRun", () => { expect(result).toEqual({ runId: "wfr_anchor1", - address: "wfr_anchor1@acme.workbench.test", + address: "wfr_anchor1@acme.hub.test", }); expect(routeMailCalls).toHaveLength(1); const [call] = routeMailCalls as [ { address: string; base64: string; messageId: string }, ]; - expect(call.address).toBe("wfr_anchor1@acme.workbench.test"); + expect(call.address).toBe("wfr_anchor1@acme.hub.test"); expect(typeof call.base64).toBe("string"); expect(call.base64.length).toBeGreaterThan(0); }); diff --git a/apps/hub/src/routability-wiring.test.ts b/apps/hub/src/routability-wiring.test.ts index d06dbbf02c..62439bdf60 100644 --- a/apps/hub/src/routability-wiring.test.ts +++ b/apps/hub/src/routability-wiring.test.ts @@ -1,9 +1,10 @@ -// CL-7543 follow-up: the planner's one-shot runner and the webhook -// launch path must both consult the hub's live sidecar routing table -// through the shared `isSidecarRoutable` local. A regression replacing -// either wiring with a constant (`() => true`) or dropping the hoisted -// predicate would still typecheck; this source scan is the pin, in the -// same style as `crypto-provider-cache-wiring.test.ts`. +// CL-7543 follow-up: the webhook launch path must consult the hub's live +// sidecar routing table through the shared `isSidecarRoutable` local. A +// regression replacing the wiring with a constant (`() => true`) or +// dropping the hoisted predicate would still typecheck; this source scan +// is the pin, in the same style as `crypto-provider-cache-wiring.test.ts`. +// (The planner's one-shot runner pinned alongside it was cut with the +// stock-hub cutover, leaving this single launch path.) import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import path from "node:path"; @@ -44,9 +45,6 @@ describe("hub sidecar routability wiring", () => { ); expect(definitionCount).toHaveLength(1); - expect(firstCall(source, "runOneShotPrompt")).toContain( - "isRoutable: isSidecarRoutable", - ); expect(firstCall(source, "launchWebhookTrigger")).toContain( "isRoutable: isSidecarRoutable", ); diff --git a/apps/hub/src/signup-tenancy.ts b/apps/hub/src/signup-tenancy.ts deleted file mode 100644 index 89eb90eb0e..0000000000 --- a/apps/hub/src/signup-tenancy.ts +++ /dev/null @@ -1,95 +0,0 @@ -// The hub's `HubSignupTenancy` adapter (see -// packages/onboarding/src/genesis.ts): the native reads and writes the -// genesis-or-join first-signup decision needs — user/tenant counts, -// the root-tenant lookup, and the member-role join. Mirrors -// packages/chat/src/workbench-tenancy.ts's `addWorkbenchMember` -// (principalStore.create + principalRole "member") so a joined signup -// holds exactly the membership shape a workbench member holds. Reads -// native tables only; declares none of its own (see -// scripts/checks/no-product-tenancy.ts). -import { and, count, eq, isNull } from "drizzle-orm"; -import { generateId } from "@intx/hub-common"; -import { - createPrincipalStore, - type DB, - type PrincipalKeyStore, -} from "@intx/db"; -import { - principal, - principalRole, - role, - tenant, - user as userTable, -} from "@intx/db/schema"; -import type { HubSignupTenancy } from "@workbench/onboarding"; - -export function createHubSignupTenancy( - db: DB["db"], - principalKeyStore: PrincipalKeyStore, -): HubSignupTenancy { - const principalStore = createPrincipalStore(db, principalKeyStore); - return { - countUsers: async () => { - const [row] = await db.select({ n: count() }).from(userTable); - return row?.n ?? 0; - }, - countTenants: async () => { - const [row] = await db.select({ n: count() }).from(tenant); - return row?.n ?? 0; - }, - findRootTenant: async () => { - const [row] = await db - .select({ id: tenant.id, slug: tenant.slug }) - .from(tenant) - .where(isNull(tenant.parentId)) - .limit(1); - return row ?? null; - }, - addActiveMember: async ({ tenantId, userId, roleName }) => - db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - ), - ) - .limit(1); - if (existing !== undefined) return { principalId: existing.id }; - - const [memberRole] = await tx - .select({ id: role.id }) - .from(role) - .where(and(eq(role.tenantId, tenantId), eq(role.name, roleName))) - .limit(1); - if (memberRole === undefined) { - throw new Error( - `signup join: tenant "${tenantId}" has no "${roleName}" system role`, - ); - } - - const now = new Date(); - const created = await principalStore.create( - { - id: generateId("principal"), - tenantId, - kind: "user", - refId: userId, - status: "active", - createdAt: now, - updatedAt: now, - }, - tx, - ); - await tx.insert(principalRole).values({ - principalId: created.id, - roleId: memberRole.id, - createdAt: now, - }); - return { principalId: created.id }; - }), - }; -} diff --git a/apps/hub/src/skills-mount.test.ts b/apps/hub/src/skills-mount.test.ts deleted file mode 100644 index d8f246a7f7..0000000000 --- a/apps/hub/src/skills-mount.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Covers the `pinnedBy` resolver `mountSkills` hands the skill registry: -// every definition's pins are read out of its own asset snapshot, so one -// unreadable asset (a pre-cutover retired envelope, a missing blob) skips -// its row — reported, never failing the whole resolve — and the fan-out -// stays bounded no matter how many definitions a tenant carries. -import { expect, test } from "bun:test"; - -import type { DB } from "@intx/db"; -import type { AssetService, RepoStore } from "@intx/hub-sessions"; -import { - agentDefinitionSourceTree, - AGENT_DEFINITION_ENTRY_PATH, - buildAgentDefinitionWorkflow, - reindexPinnedSkills, - RetiredWorkflowEnvelopeError, - serializeAgentDefinitionWorkflow, -} from "@corbits/agent-directory"; - -import { mountSkills } from "./skills-mount"; - -/** Entry-module bytes pinning `names` — the stanza `pinnedBy` reads. */ -function definitionBytesPinning(...names: string[]): Uint8Array { - const tree = agentDefinitionSourceTree({ - handle: "research-buddy", - workflowJson: reindexPinnedSkills( - serializeAgentDefinitionWorkflow( - buildAgentDefinitionWorkflow({ - handle: "research-buddy", - tenantDomain: "acme.example", - description: "", - systemPrompt: "You are a careful research assistant.", - }), - ), - names.map((name) => ({ name, description: `What ${name} does.` })), - ), - }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); -} - -function mountFor( - rows: readonly { - id: string; - tenantId: string; - assetId: string | null; - name: string; - }[], - readAssetBlob: AssetService["readAssetBlob"], -) { - const db = { - query: { - workflowDefinition: { findMany: async () => rows }, - }, - } as unknown as DB["db"]; - return mountSkills({ - db, - assetService: { readAssetBlob } as AssetService, - repoStore: {} as RepoStore, - }); -} - -test("a row on the retired envelope skips while healthy rows still resolve", async () => { - const mount = mountFor( - [ - { - id: "def_healthy", - tenantId: "tnt_1", - assetId: "ast_healthy", - name: "research-buddy", - }, - { - id: "def_retired", - tenantId: "tnt_1", - assetId: "ast_retired", - name: "old-scout", - }, - ], - (params) => - params.assetId === "ast_healthy" - ? Promise.resolve(definitionBytesPinning("research")) - : Promise.reject(new RetiredWorkflowEnvelopeError(params.assetId)), - ); - const pinning = await mount.pinnedBy.resolve("tnt_1", "research"); - expect(pinning).toEqual([ - { definitionId: "def_healthy", name: "research-buddy" }, - ]); -}); - -test("the blob fan-out stays bounded no matter how many definitions pin", async () => { - const COUNT = 20; - const rows = Array.from({ length: COUNT }, (_, index) => ({ - id: `def_${index}`, - tenantId: "tnt_1", - assetId: `ast_${index}`, - name: `buddy-${index}`, - })); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let active = 0; - let peak = 0; - const mount = mountFor(rows, async () => { - active += 1; - peak = Math.max(peak, active); - try { - await gate; - return definitionBytesPinning("research"); - } finally { - active -= 1; - } - }); - const pending = mount.pinnedBy.resolve("tnt_1", "research"); - // Every worker reaches the gate before any read can finish, so the - // peak observed here is the whole fan-out. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(peak).toBeLessThanOrEqual(8); - release(); - const pinning = await pending; - expect(pinning).toHaveLength(COUNT); -}); diff --git a/apps/hub/src/skills-mount.ts b/apps/hub/src/skills-mount.ts deleted file mode 100644 index 9095427d95..0000000000 --- a/apps/hub/src/skills-mount.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Composition for `@corbits/skills`: the registry itself plus the two -// adapters that only this composition root can supply — "which agent -// definitions pin this skill" (read from each definition's own asset -// snapshot, where its pinned-skills stanza lives) and "what index does -// a definition's pinned names resolve to" (read from the registry, on -// behalf of the pushing principal). -import { and, eq } from "drizzle-orm"; - -import type { DB } from "@intx/db"; -import { workflowDefinition } from "@intx/db/schema"; -import { type AssetService, type RepoStore } from "@intx/hub-sessions"; -import { - readAgentDefinitionWorkflowJson, - readPinnedSkillNames, - type PinnedSkillIndexResolver, -} from "@corbits/agent-directory"; -import { reportError } from "@corbits/error-sink"; -import { - createHubSkillAssetStore, - createSkillRegistry, - SkillRegistryError, - type PinnedByResolver, - type SkillRegistry, -} from "@corbits/skills"; - -export type SkillsMount = { - registry: SkillRegistry; - pinnedBy: PinnedByResolver; - skillIndex: PinnedSkillIndexResolver; -}; - -/** At most this many concurrent asset-blob reads while resolving who - * pins a skill — a tenant's definition count is unbounded, and one - * `readAssetBlob` per definition with no cap is a self-inflicted load - * spike against the asset store. */ -const PINNED_BY_READ_CONCURRENCY = 8; - -/** Runs `fn` over `items` with at most `limit` in flight, preserving - * order — the same bounded fan-out every per-row asset read in this - * composition root needs, so a many-definition tenant cannot open a - * blob read per definition at once. */ -async function mapWithConcurrencyLimit( - items: readonly T[], - limit: number, - fn: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - const workers = Array.from( - { length: Math.min(limit, items.length) }, - async () => { - while (next < items.length) { - const index = next; - next += 1; - results[index] = await fn(items[index] as T); - } - }, - ); - await Promise.all(workers); - return results; -} - -export function mountSkills(deps: { - db: DB["db"]; - assetService: AssetService; - repoStore: RepoStore; -}): SkillsMount { - const registry = createSkillRegistry({ - assets: createHubSkillAssetStore({ - db: deps.db, - assetService: deps.assetService, - repoStore: deps.repoStore, - }), - }); - - const pinnedBy: PinnedByResolver = { - async resolve(tenantId, skillName) { - const rows = await deps.db.query.workflowDefinition.findMany({ - where: and(eq(workflowDefinition.tenantId, tenantId)), - }); - const candidates = rows.filter( - (row): row is typeof row & { assetId: string } => row.assetId !== null, - ); - // Pins live in each definition's own asset snapshot — the same - // stanza every agent-directory read goes through. Bounded fan-out - // instead of the old sequential N+1, and one unreadable asset - // (a pre-cutover retired envelope, a missing blob) skips its - // row — reported, never failing the whole resolve. - const matches = await mapWithConcurrencyLimit( - candidates, - PINNED_BY_READ_CONCURRENCY, - async (row) => { - try { - const workflowJson = await readAgentDefinitionWorkflowJson( - deps.assetService, - row.assetId, - ); - return readPinnedSkillNames(workflowJson).includes(skillName) - ? { definitionId: row.id, name: row.name } - : null; - } catch (err) { - reportError(err, { - operation: "skills.pinnedBy.resolve", - tenantId, - extra: { definitionId: row.id, skillName }, - }); - return null; - } - }, - ); - return matches.filter((match) => match !== null); - }, - }; - - const skillIndex: PinnedSkillIndexResolver = { - async resolve(tenantId, principalId, names) { - const visible = await registry.list({ tenantId, principalId }); - const byName = new Map(visible.map((skill) => [skill.name, skill])); - return names.map((name) => { - const skill = byName.get(name); - if (skill === undefined) { - // Pinning a skill the pusher cannot see would advertise a - // skill `skills_load` will refuse to fetch at run time. Reject - // the push instead of shipping an index that lies. - throw new SkillRegistryError( - "not_found", - `cannot pin skill "${name}": it is not in this workbench's registry, or not visible to you`, - ); - } - return { name: skill.name, description: skill.description }; - }); - }, - }; - - return { registry, pinnedBy, skillIndex }; -} diff --git a/apps/hub/src/slack-channel-mint-session.test.ts b/apps/hub/src/slack-channel-mint-session.test.ts deleted file mode 100644 index c0e721eda8..0000000000 --- a/apps/hub/src/slack-channel-mint-session.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { configureSync, resetSync } from "@intx/log"; -import { createInMemoryWorkbenchTenancyStore } from "@corbits/chat"; - -import { - mintSlackChannelWorkbench, - slackChannelMintSession, -} from "./slack-channel-mint-session"; - -const TENANT_ID = "tnt_bench"; -const OWNER_USER_ID = "user_owner"; -const MEMBER_REF_ID = "user_slack_member"; -const OWNER_COOKIES = ["session=owner"]; - -let records: { properties: Record }[]; - -function installCapturingSink(): void { - records = []; - configureSync({ - reset: true, - sinks: { - capture: (record) => { - records.push(record as { properties: Record }); - }, - }, - loggers: [ - { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, - { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, - ], - }); -} - -beforeEach(() => installCapturingSink()); -afterEach(() => resetSync()); - -describe("slackChannelMintSession", () => { - test("mints as the bench owner, never as a Slack member", async () => { - const sessionArgs: { userId: string; tenantId: string }[] = []; - const result = await slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async (tenantId) => { - expect(tenantId).toBe(TENANT_ID); - return OWNER_USER_ID; - }, - sessionFor: async (args) => { - sessionArgs.push(args); - return OWNER_COOKIES; - }, - }); - - expect(result).toEqual({ - ownerUserId: OWNER_USER_ID, - cookies: OWNER_COOKIES, - }); - expect(sessionArgs).toEqual([ - { userId: OWNER_USER_ID, tenantId: TENANT_ID }, - ]); - }); - - test("fails closed when the bench has no owner user id", async () => { - await expect( - slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => undefined, - sessionFor: async () => OWNER_COOKIES, - }), - ).rejects.toThrow(`no owner user id for tenant "${TENANT_ID}"`); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - }); - - test("fails closed when the owner session cannot be minted", async () => { - await expect( - slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => OWNER_USER_ID, - sessionFor: async () => undefined, - }), - ).rejects.toThrow( - `could not mint a session for bench owner "${OWNER_USER_ID}"`, - ); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - expect(records[0]?.properties.extra).toEqual({ - ownerUserId: OWNER_USER_ID, - }); - }); -}); - -describe("mintSlackChannelWorkbench", () => { - test("a member-role Slack identity provisions via the owner session", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT_ID); - tenancy.grantManageInTenant(OWNER_USER_ID, TENANT_ID); - - const mintCalls: { - creatorUserId: string; - cookies: string[]; - parentTenantId: string; - }[] = []; - const sessionArgs: { userId: string; tenantId: string }[] = []; - - const result = await mintSlackChannelWorkbench( - { - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: (id) => tenancy.getWorkbenchOwnerUserId(id), - sessionFor: async (args) => { - sessionArgs.push(args); - if (args.userId === MEMBER_REF_ID) return ["session=member"]; - if (args.userId === OWNER_USER_ID) return OWNER_COOKIES; - return undefined; - }, - chatTenancy: { - createWorkbenchTenant: async (input) => { - mintCalls.push({ - creatorUserId: input.creatorUserId, - cookies: input.cookies, - parentTenantId: input.parentTenantId, - }); - return tenancy.createWorkbenchTenant(input); - }, - addWorkbenchMember: (input) => tenancy.addWorkbenchMember(input), - }, - }, - { name: "#eng", creatorRefId: MEMBER_REF_ID }, - ); - - expect(sessionArgs).toEqual([ - { userId: OWNER_USER_ID, tenantId: TENANT_ID }, - ]); - expect(mintCalls).toEqual([ - { - creatorUserId: OWNER_USER_ID, - cookies: OWNER_COOKIES, - parentTenantId: TENANT_ID, - }, - ]); - expect(result.ownerUserId).toBe(OWNER_USER_ID); - expect(result.channelId.length).toBeGreaterThan(0); - - const member = await tenancy.getTenantPrincipalByRefId( - result.workbenchTenantId, - MEMBER_REF_ID, - ); - expect(member?.refId).toBe(MEMBER_REF_ID); - }); - - test("fails closed without minting when the owner session cannot be minted", async () => { - const mintCalls: unknown[] = []; - const memberCalls: unknown[] = []; - - await expect( - mintSlackChannelWorkbench( - { - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => OWNER_USER_ID, - sessionFor: async () => undefined, - chatTenancy: { - createWorkbenchTenant: async (input) => { - mintCalls.push(input); - throw new Error("should not mint"); - }, - addWorkbenchMember: async (input) => { - memberCalls.push(input); - return undefined; - }, - }, - }, - { name: "#eng", creatorRefId: MEMBER_REF_ID }, - ), - ).rejects.toThrow( - `could not mint a session for bench owner "${OWNER_USER_ID}"`, - ); - - expect(mintCalls).toEqual([]); - expect(memberCalls).toEqual([]); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - }); -}); diff --git a/apps/hub/src/slack-channel-mint-session.ts b/apps/hub/src/slack-channel-mint-session.ts deleted file mode 100644 index 7b4df3f01d..0000000000 --- a/apps/hub/src/slack-channel-mint-session.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { generateId } from "@intx/hub-common"; -import { reportError } from "@corbits/error-sink"; -import type { WorkbenchTenancyStore } from "@corbits/chat"; -import type { SessionForUser } from "@workbench/onboarding"; - -/** - * Slack auto-provisioned identities are members. POST /api/tenants is - * guarded by tenancyCreation (default: owners), so channel mint must - * run as the bench owner — same pattern as agent-DM mint. - */ -export async function slackChannelMintSession(args: { - tenantId: string; - getWorkbenchOwnerUserId: (tenantId: string) => Promise; - sessionFor: SessionForUser; -}): Promise<{ ownerUserId: string; cookies: string[] }> { - const ownerUserId = await args.getWorkbenchOwnerUserId(args.tenantId); - if (ownerUserId === undefined) { - const cause = new Error( - `no owner user id for tenant "${args.tenantId}" — cannot mint a Slack channel workbench`, - ); - reportError(cause, { - operation: "slack.provisionChannel", - tenantId: args.tenantId, - }); - throw cause; - } - const cookies = await args.sessionFor({ - userId: ownerUserId, - tenantId: args.tenantId, - }); - if (cookies === undefined) { - const cause = new Error( - `could not mint a session for bench owner "${ownerUserId}" to provision a Slack channel`, - ); - reportError(cause, { - operation: "slack.provisionChannel", - tenantId: args.tenantId, - extra: { ownerUserId }, - }); - throw cause; - } - return { ownerUserId, cookies }; -} - -export type MintSlackChannelWorkbenchDeps = { - readonly tenantId: string; - readonly getWorkbenchOwnerUserId: ( - tenantId: string, - ) => Promise; - readonly sessionFor: SessionForUser; - readonly chatTenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "addWorkbenchMember" - >; -}; - -export type MintSlackChannelWorkbenchInput = { - readonly name: string; - readonly creatorRefId: string; -}; - -/** - * Mints the child workbench as the bench owner, then adds the Slack - * member via the existing membership path so they can use the channel. - */ -export async function mintSlackChannelWorkbench( - deps: MintSlackChannelWorkbenchDeps, - input: MintSlackChannelWorkbenchInput, -): Promise<{ - readonly channelId: string; - readonly ownerUserId: string; - readonly workbenchTenantId: string; -}> { - const { ownerUserId, cookies } = await slackChannelMintSession({ - tenantId: deps.tenantId, - getWorkbenchOwnerUserId: deps.getWorkbenchOwnerUserId, - sessionFor: deps.sessionFor, - }); - - const channelId = generateId("workflowRun"); - const channelTenant = await deps.chatTenancy.createWorkbenchTenant({ - parentTenantId: deps.tenantId, - workbenchId: channelId, - name: input.name, - creatorUserId: ownerUserId, - cookies, - }); - - await deps.chatTenancy.addWorkbenchMember({ - workbenchId: channelId, - refId: input.creatorRefId, - }); - - return { - channelId, - ownerUserId, - workbenchTenantId: channelTenant.tenantId, - }; -} diff --git a/apps/hub/src/slack-tag-mount.ts b/apps/hub/src/slack-tag-mount.ts deleted file mode 100644 index 87a81804a3..0000000000 --- a/apps/hub/src/slack-tag-mount.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Slack tag ingress mount (CL-5288 Phase 1): the thin, env-gated glue -// between `@corbits/slack-tag`'s composition and this hub's already- -// running `@corbits/chat` instances. The overwhelming majority of the -// behavior lives in the package — this module only supplies the -// host-resident functions the package's `MountWorkbenchSlackDeps` -// wants injected (per AGENTS.md: apps stay generic, packages own the -// domain), plus the env-var gate. -// -// Absent `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` is a valid -// configuration, not an error: the hub runs fine with no Slack app -// installed, and this mount is silently skipped — mirrors every other -// optional integration mount in `./index.ts` (e.g. `mountMemory`, -// `mountArtifacts`). -import { eq } from "drizzle-orm"; -import type { Hono } from "hono"; -import type { DB } from "@intx/db"; -import { principal, tenant } from "@intx/db/schema"; -import { getLogger } from "@intx/log"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { AppEnv } from "@intx/hub-api"; - -import { - launchAndJoinAgent, - presetForKind, - sendWorkbenchMessage, - type WorkbenchSubscriberRegistry, - type WorkbenchTenancyStore, - type WorkbenchTurnQueue, - type TurnCancelRegistry, - type TurnMailCorrelationStore, - type ChatPlatform, - type ChatStore, - type RoomMessageStore, -} from "@corbits/chat"; -import type { SessionForUser } from "@workbench/onboarding"; -import { - createAutoProvisionPrincipalResolver, - createDrizzleSlackChannelBindingStore, - mountWorkbenchSlack, - resolveThreadState, - applySlackTagMigrations, -} from "@corbits/slack-tag"; -import { mintSlackChannelWorkbench } from "./slack-channel-mint-session"; - -const log = getLogger(["hub", "slack-tag"]); - -/** The one role every auto-provisioned Slack principal is granted. - * Every bench mints this system role at creation (see - * `@corbits/chat`'s `workbench-tenancy.ts`), so it always resolves. */ -const SLACK_PRINCIPAL_ROLE_NAMES = ["member"] as const; - -export type MountWorkbenchSlackTagDeps = { - readonly app: Hono; - readonly db: DB["db"]; - readonly databaseUrl: string; - readonly chatStore: Pick< - ChatStore, - | "getWorkbenchSettings" - | "getBenchSettings" - | "createWorkbenchSettings" - | "updateWorkbenchSettings" - | "mutateWorkbenchParticipants" - >; - readonly chatPlatform: ChatPlatform; - readonly roomMessages: RoomMessageStore; - readonly chatTenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "getWorkbenchOwnerUserId" | "addWorkbenchMember" - >; - readonly sessionFor: SessionForUser; - readonly workbenchSubscribers: WorkbenchSubscriberRegistry; - /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` - * is given (CL-6331) — shared, never a second instance, so a Slack - * send and a person's own message for the same channel serialize - * against each other too. */ - readonly turnQueue: WorkbenchTurnQueue; - /** The same cancellation registry `createChatRoutes` is given - * (CL-7201) — shared, never a second instance. */ - readonly turnCancellation: TurnCancelRegistry; - /** The same dispatch-mail correlation `createChatRoutes` is given - * (CL-6314) — shared, never a second instance. */ - readonly turnMailCorrelation?: TurnMailCorrelationStore; -}; - -export type MountedWorkbenchSlackTag = { readonly mounted: boolean }; - -/** - * Reads `SLACK_WORKBENCH_TENANT_SLUG` and `SLACK_DEFAULT_AGENT_DEFINITION_ID` - * alongside the credential pair: which bench a Slack workspace's messages - * land in, and which deployed agent definition a freshly bound channel - * launches with, are both irreducible to this mount — there is no honest - * default. Set together with the credential pair, or the mount fails - * loudly rather than silently answering nobody. - */ -export async function mountWorkbenchSlackTag( - deps: MountWorkbenchSlackTagDeps, -): Promise { - const botToken = process.env["SLACK_BOT_TOKEN"]; - const signingSecret = process.env["SLACK_SIGNING_SECRET"]; - if (!botToken || !signingSecret) { - log.info( - "Slack tag ingress not mounted — SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET unset", - ); - return { mounted: false }; - } - - const tenantSlug = process.env["SLACK_WORKBENCH_TENANT_SLUG"]; - const definitionId = process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"]; - if (!tenantSlug || !definitionId) { - throw new Error( - "SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET are set, but SLACK_WORKBENCH_TENANT_SLUG " + - "and/or SLACK_DEFAULT_AGENT_DEFINITION_ID are not — both name the bench and " + - "agent a Slack message resolves to, and there is no honest default for either.", - ); - } - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenant.slug, tenantSlug), - }); - if (tenantRow === undefined) { - throw new Error( - `SLACK_WORKBENCH_TENANT_SLUG "${tenantSlug}" does not name a real tenant`, - ); - } - - await applySlackTagMigrations(deps.databaseUrl); - - const resolvePrincipal = createAutoProvisionPrincipalResolver( - deps.db, - tenantSlug, - SLACK_PRINCIPAL_ROLE_NAMES, - ); - const bindings = createDrizzleSlackChannelBindingStore(deps.db); - const state = await resolveThreadState(createMemoryState()); - - // `mountWorkbenchSlack` (like `corbits-tag/slack`'s own `mountSlackTag`) - // deliberately types its `app` param against the default Hono env: the - // route lives outside the hub's session auth and reads nothing from - // `AppEnv`'s context. The cast reflects that, not a real env mismatch. - const { path } = mountWorkbenchSlack(deps.app as unknown as Hono, { - tenantId: tenantRow.id, - slack: { botToken, signingSecret }, - state, - bindings, - resolvePrincipal, - subscribeToChannel: deps.chatPlatform.subscribeToWorkbench, - provisionChannel: async (input) => { - const creatorPrincipal = await deps.db.query.principal.findFirst({ - where: eq(principal.id, input.creatorPrincipalId), - }); - if (creatorPrincipal === undefined) { - throw new Error( - `Slack-provisioned principal "${input.creatorPrincipalId}" vanished before its channel could be provisioned`, - ); - } - - const minted = await mintSlackChannelWorkbench( - { - tenantId: tenantRow.id, - getWorkbenchOwnerUserId: (id) => - deps.chatTenancy.getWorkbenchOwnerUserId(id), - sessionFor: deps.sessionFor, - chatTenancy: deps.chatTenancy, - }, - { - name: input.name, - creatorRefId: creatorPrincipal.refId, - }, - ); - const channelId = minted.channelId; - - const preset = presetForKind("chat"); - const settingsRow = await deps.chatStore.createWorkbenchSettings({ - tenantId: tenantRow.id, - workbenchId: channelId, - settings: { - "chat/kind": "chat", - "chat/pinned": preset.pinned, - "chat/participants": [], - "chat/name": input.name, - }, - updatedBy: input.creatorPrincipalId, - }); - - await launchAndJoinAgent( - { - store: deps.chatStore, - platform: deps.chatPlatform, - roomMessages: deps.roomMessages, - publish: deps.workbenchSubscribers.publish, - }, - { - tenantId: tenantRow.id, - principalId: input.creatorPrincipalId, - workbenchId: channelId, - definitionId, - existingSettings: settingsRow.settings, - invitable: await deps.chatPlatform.listInvitableDefinitions( - tenantRow.id, - ), - }, - ); - - log.info( - "Provisioned channel {channelId} for tenant {tenantId} ({channelTenant})", - { - channelId, - tenantId: tenantRow.id, - channelTenant: minted.workbenchTenantId, - }, - ); - return { channelId }; - }, - sendMessage: async (input) => { - const sent = await sendWorkbenchMessage( - { - store: deps.chatStore, - platform: deps.chatPlatform, - roomMessages: deps.roomMessages, - publish: deps.workbenchSubscribers.publish, - turnQueue: deps.turnQueue, - turnCancellation: deps.turnCancellation, - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - }, - { - tenantId: input.tenantId, - principalId: input.principalId, - senderAddress: `${input.principalId}@${tenantRow.domain}`, - workbenchId: input.channelId, - messageParts: [{ kind: "text", text: input.text }], - }, - ); - return { id: sent.id }; - }, - }); - - log.info("Slack tag ingress mounted at {path} for tenant {tenantSlug}", { - path, - tenantSlug, - }); - return { mounted: true }; -} diff --git a/apps/hub/src/templates/template-block-routes.ts b/apps/hub/src/templates/template-block-routes.ts index d363386efd..e444f0d0ef 100644 --- a/apps/hub/src/templates/template-block-routes.ts +++ b/apps/hub/src/templates/template-block-routes.ts @@ -27,7 +27,7 @@ export type TemplateBlockRoutesDeps = { * `userMessage`, the raw detail lands here. */ log: (line: string) => void; /** The tenant's default inference preferences — the same resolution a - * fresh workbench host launches against (`@corbits/chat`'s + * fresh chat host launches against (`@corbits/chat`'s * `createWorkbenchHostInferencePreferencesResolver`), so a deployed * block runs on the model the bench actually connected. */ inferencePreferences( diff --git a/apps/hub/src/tenant-create-guard.ts b/apps/hub/src/tenant-create-guard.ts deleted file mode 100644 index 85047f74e4..0000000000 --- a/apps/hub/src/tenant-create-guard.ts +++ /dev/null @@ -1,256 +0,0 @@ -// [Intx gap] CL-6041: the native `POST /api/tenants` route -// (vendor/intx/hub-api/src/routes/tenants.ts) is ungated — any -// authenticated user can call it directly, pick an arbitrary -// `parentId`, and become owner of a freshly-minted child under ANY -// tenant, or mint a top-level tenant, entirely bypassing -// `@workbench/access-policy`. Filed upstream; vendor is read-only, so -// this hub composes a guard in front of the whole native app instead of -// patching the route. `@workbench/access-policy`'s own -// `POST .../child-tenants` wrapper (mounted by -// `createAccessPolicyRoutes`) stays as the polished UI surface — a -// clean pre-flight 403 with a helpful message — but the actual -// enforcement lives here, in front of the primitive itself, since a -// caller can always skip the wrapper and hit the native route directly. -// -// Registered by wrapping: a fresh outer Hono app gets this guard -// middleware for the exact `/api/tenants` path registered FIRST, then -// mounts the fully-built native+extension app underneath via -// `.route("/", nativeApp)`. Hono composes handlers in registration -// order, so a request the guard denies never reaches the mounted app at -// all; one it allows falls through via `next()`. -import { and, eq, inArray } from "drizzle-orm"; -import { Hono } from "hono"; -import { principal, principalRole, role } from "@intx/db/schema"; -import type { DB } from "@intx/db"; -import type { AppEnv } from "@intx/hub-api"; -import { - canCreateTenancy, - checkSignupGate, - type AccessPolicyStore, -} from "@workbench/access-policy"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const NATIVE_TENANT_CREATE_PATH = "/api/tenants"; - -export type TenantCreateGuardDeps = { - store: AccessPolicyStore; - /** Native role names the caller holds in `tenantId`, or `undefined` - * when they have no principal there at all (not a member). Production - * wiring is `(tenantId, userId) => resolveCallerRoleNames(db, tenantId, userId)`; - * kept as an injected function (rather than a raw `db` handle) so the - * guard's own decision logic is testable without a database. */ - resolveCallerRoleNames: ( - tenantId: string, - userId: string, - ) => Promise; - /** Total native tenant count — the hub's signup-tenancy adapter's own - * read, injected so the decision stays DB-free in tests. */ - countTenants: () => Promise; - operatorTenantId?: string; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; - allowUnverifiedEmails: boolean; - getSessionUser: ( - headers: Headers, - ) => Promise< - { id: string; email: string; emailVerified: boolean } | undefined - >; -}; - -export type TenantCreateGuardVerdict = - | { allowed: true } - | { allowed: false; status: 401 | 403; code: string; message: string }; - -/** Native role names this user's principal holds in `tenantId` — - * `undefined` when they have no principal there at all (not a member), - * matching exactly what `vendor/intx/hub-api/src/routes/principals.ts`'s - * `loadRolesForPrincipal` reads, just filtered down to names. Reads - * native tables directly; declares none of its own (see - * `scripts/checks/no-product-tenancy.ts`). */ -export async function resolveCallerRoleNames( - db: DB["db"], - tenantId: string, - userId: string, -): Promise { - const principalRow = await db.query.principal.findFirst({ - where: and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - ), - }); - if (principalRow === undefined) return undefined; - - const assignments = await db.query.principalRole.findMany({ - where: eq(principalRole.principalId, principalRow.id), - }); - if (assignments.length === 0) return []; - - const roleIds = assignments.map((a) => a.roleId); - const roles = await db.query.role.findMany({ - where: inArray(role.id, roleIds), - }); - return roles.map((r) => r.name); -} - -/** - * The decision itself, given already-resolved inputs. A `parentId` - * equal to the operator tenant (or no `parentId` at all) is the - * self-service landing zone — governed by the signup gate, the same - * decision `packages/onboarding`'s first-login hook makes, so a - * hand-crafted request answers identically to the real onboarding flow. - * Any other `parentId` is an explicit sub-workbench create and requires - * the caller to already be a member of that exact tenant with a role - * this tenant's own `tenancyCreation` policy accepts — an attacker who - * is not a member of the target tenant can never satisfy this, however - * they picked the id. - */ -export async function decideTenantCreate( - deps: TenantCreateGuardDeps, - request: { - parentId?: string; - userId: string; - userEmail: string; - userEmailVerified: boolean; - }, -): Promise { - if ( - request.parentId === undefined || - request.parentId === deps.operatorTenantId - ) { - // Genesis on an empty hub (CL-7578): with zero tenants there is no - // policy, no operator tenant, and nobody to invite anyone — the - // first signup's unparented create is the one path that bypasses - // the signup gate, because the sign-up route's own empty-hub - // exception already admitted this caller. Empty means zero tenants - // only (the one emptiness rule shared with genesis.ts; user rows - // alone never make a hub occupied). - if (request.parentId === undefined && (await deps.countTenants()) === 0) { - return { allowed: true }; - } - type MutableSignupGateArgs = { - -readonly [K in keyof Parameters[0]]: Parameters< - typeof checkSignupGate - >[0][K]; - }; - const gateArgs: MutableSignupGateArgs = { - store: deps.store, - envSignupMode: deps.envSignupMode, - envAllowedDomains: deps.envAllowedDomains, - email: request.userEmail, - emailVerified: request.userEmailVerified, - allowUnverifiedEmails: deps.allowUnverifiedEmails, - }; - if (deps.operatorTenantId !== undefined) { - gateArgs.operatorTenantId = deps.operatorTenantId; - } - const gate = await checkSignupGate(gateArgs); - if (!gate.allowed) { - return { - allowed: false, - status: 403, - code: "signup_not_allowed", - message: - gate.reason === "email_unverified" - ? "This account's email address isn't verified, and this hub " + - "requires verified emails before provisioning. For local " + - "development set ALLOW_UNVERIFIED_EMAILS=1 in .env and " + - "restart." - : "This workbench isn't open for self-serve sign-up right now. " + - "Ask an owner for an invite.", - }; - } - return { allowed: true }; - } - - const parentId = request.parentId; - const roleNames = await deps.resolveCallerRoleNames(parentId, request.userId); - if (roleNames === undefined) { - return { - allowed: false, - status: 403, - code: "not_a_member", - message: - "You need to already belong to that workbench to create a sub-workbench under it.", - }; - } - - const policy = await deps.store.getPolicy(parentId); - if (!canCreateTenancy(policy, roleNames)) { - return { - allowed: false, - status: 403, - code: "tenancy_creation_forbidden", - message: - "This workbench's policy doesn't allow you to create a sub-workbench here.", - }; - } - return { allowed: true }; -} - -function parseParentId(body: unknown): string | undefined { - if (typeof body !== "object" || body === null) return undefined; - const parentId = (body as { parentId?: unknown }).parentId; - return typeof parentId === "string" && parentId.length > 0 - ? parentId - : undefined; -} - -/** - * Wraps `nativeApp` (the fully-built hub app, native tenant route - * included) inside a fresh outer app that guards `POST /api/tenants` - * ahead of it. Every other path falls straight through unguarded. - */ -export function guardedHubApp( - nativeApp: Hono, - deps: TenantCreateGuardDeps, -): Hono { - const guarded = new Hono(); - - guarded.use(NATIVE_TENANT_CREATE_PATH, async (c, next) => { - if (c.req.method !== "POST") return next(); - - const user = await deps.getSessionUser(c.req.raw.headers); - if (user === undefined) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Authentication required", - }), - 401, - ); - } - - // Malformed JSON: let the native route produce its own 400 — there - // is no parentId to branch on either way, so this falls into the - // self-service signup-gate branch same as "no parentId". - const body: unknown = await c.req.raw - .clone() - .json() - .catch(() => ({})); - - const parentId = parseParentId(body); - const decideRequest: Parameters[1] = { - userId: user.id, - userEmail: user.email, - userEmailVerified: user.emailVerified, - }; - if (parentId !== undefined) { - decideRequest.parentId = parentId; - } - const verdict = await decideTenantCreate(deps, decideRequest); - if (!verdict.allowed) { - return c.json( - makeErrorEnvelope({ - code: verdict.code, - userMessage: verdict.message, - }), - verdict.status, - ); - } - return next(); - }); - - guarded.route("/", nativeApp); - return guarded; -} diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts deleted file mode 100644 index 934a82df52..0000000000 --- a/apps/hub/src/tenant-create-onboard.ts +++ /dev/null @@ -1,212 +0,0 @@ -// CL-7584: the tenant-create trigger. Wrapped beside the access-policy -// guard (./tenant-create-guard.ts), this outer layer watches the native -// `POST /api/tenants` route: a 201 means a real tenant now exists that -// should converge onto the tenant desired-state document. The reconcile -// runs fire-and-forget under the creator's own session — the cookies -// that made the create are replayed, so no extra session is minted and -// the kick never touches the DB directly — so a fresh tenant gets Myra -// and the core pins without hub boot seeding anything. -// -// There is no durable work item here on purpose: the pending_seed row -// stays the only durable queue (a connect's credential), and a -// tenant-create kick that dies with the process is re-covered by the -// revisit kick (`POST /api/onboarding/provision`) on the next visit. -// In-process dedupe by tenantId is the same class of optimization the -// provisioner's in-flight map is: never a fact the system needs correct. -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client"; -import { - reconcileTenantDesiredState, - resolveTenantModelSource, - resolveTenantDeployer, - TENANT_DESIRED_STATE, - type ReconcileReport, -} from "@workbench/onboarding/desired-state"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; - -export type TenantCreateOnboardDeps = { - api: ApiCall; - hubUrl: string; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; - logError?: (line: string) => void; - /** - * The convergence step. Production resolves the tenant's deploy model - * from its resolved catalog plus the creator's deploy identity (owner - * principal + domain, via `resolveTenantDeployer` — a fresh native - * tenant has no pending-seed row to read those from) and delegates to - * `reconcileTenantDesiredState`; with no offerings it reports the - * workflow pins blocked (logged, never thrown). Tests replace the - * whole thing. - */ - reconcileFn?: (args: { - tenantId: string; - cookies: string[]; - }) => Promise; - /** - * Test seam for the reconcile step itself. The production default - * resolves the tenant's deploy model plus the creator's deploy identity - * and delegates to `reconcileTenantDesiredState`; tests inject a spy - * here to prove `principalId`/`tenantDomain` reach the seam without - * standing up the reconcile's full status-read surface. The blocked - * branches (no catalog offerings, no deployable principal) return or - * throw before this is ever entered. - */ - reconcileStateFn?: typeof reconcileTenantDesiredState; -}; - -export type TenantCreateObserver = { - /** The composed app: observes `POST /api/tenants` 201s, then falls - * through to the wrapped app. */ - app: Hono; - /** Kick a reconcile for one tenant directly (the revisit-kick wiring - * shares this with the observer). Deduped per tenant in-process. */ - kick(args: { tenantId: string; cookies: string[] }): Promise; - /** Stops accepting new kicks. In-flight ones are not interrupted — - * they run to their next HTTP call, which fails once the server has - * stopped, and the failure is caught and logged. Hub shutdown calls - * this before closing the DB and bounds the wait with `whenIdle`. */ - stop(): void; - /** Resolves when every in-flight kick has finished or bailed. */ - whenIdle(): Promise; -}; - -export function createTenantCreateObserver( - deps: TenantCreateOnboardDeps, - wrapped: Hono, -): TenantCreateObserver { - const logError = deps.logError ?? deps.log; - // In-process tenantId dedupe, same pattern as the provisioner's - // in-flight map: an optimization against double kicks, never a fact. - const kicked = new Set(); - const inFlight = new Set>(); - let stopped = false; - - async function runReconcile(args: { - tenantId: string; - cookies: string[]; - }): Promise { - if (stopped) return; - const cookies = args.cookies; - const reconcile = - deps.reconcileFn ?? - (async (reconcileArgs: { tenantId: string; cookies: string[] }) => { - const model = await resolveTenantModelSource( - deps.api, - reconcileArgs.cookies, - reconcileArgs.tenantId, - ); - if (model === undefined) { - // No catalog offerings yet — nothing is launchable. Report the - // workflow pins blocked; the next trigger (a connect's drain - // pass, a revisit probe) sees the pins still pending and - // re-kicks. - deps.log( - `tenant-create onboarding for ${reconcileArgs.tenantId} is blocked: no catalog offerings to deploy against yet`, - ); - return { - tenantId: reconcileArgs.tenantId, - ready: false, - pins: TENANT_DESIRED_STATE.workflows.map((pin) => ({ - name: pin.assetName, - kind: "workflow" as const, - status: "blocked" as const, - })), - } satisfies ReconcileReport; - } - // A fresh native tenant has no pending-seed row to read deploy - // identity from — resolve the creator's owner principal (minted - // in the create transaction) plus the tenant domain before - // reconciling, so the deploy lands under a real identity. - const deployer = await resolveTenantDeployer( - deps.api, - reconcileArgs.cookies, - reconcileArgs.tenantId, - ); - if (deployer === undefined) { - throw new Error( - `tenant-create onboarding for ${reconcileArgs.tenantId} has no deployable principal: the session holds no active user principal on the new tenant`, - ); - } - const reconcileState = - deps.reconcileStateFn ?? reconcileTenantDesiredState; - return reconcileState({ - api: deps.api, - cookies: reconcileArgs.cookies, - hubUrl: deps.hubUrl, - tenant: { - tenantId: reconcileArgs.tenantId, - principalId: deployer.principalId, - domain: deployer.tenantDomain, - }, - model, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - }); - }); - const report = await reconcile({ tenantId: args.tenantId, cookies }); - deps.log( - `tenant-create onboarding for ${args.tenantId}: ${report.pins.length} pins, ready=${report.ready}`, - ); - } - - function kick(args: { tenantId: string; cookies: string[] }): Promise { - if (stopped || kicked.has(args.tenantId)) return Promise.resolve(); - kicked.add(args.tenantId); - const operation = runReconcile(args) - .catch((cause: unknown) => { - logError( - `tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`, - ); - }) - .finally(() => { - kicked.delete(args.tenantId); - inFlight.delete(operation); - }); - inFlight.add(operation); - return operation; - } - - function stop(): void { - stopped = true; - } - - function whenIdle(): Promise { - return Promise.allSettled([...inFlight]).then(() => undefined); - } - - const app = new Hono(); - app.use("*", async (c, next) => { - await next(); - if ( - c.req.method !== "POST" || - c.req.path !== "/api/tenants" || - c.res.status !== 201 - ) { - return; - } - // The creator's own session cookies are replayed for the kick, so - // it acts under the same session that made the create without - // minting (or ever touching) anything of its own. - const cookies = cookiesFromHeader(c.req.header("cookie")); - if (cookies.length === 0) return; - const body = (await c.res - .clone() - .json() - .catch(() => undefined)) as - { id?: unknown; tenantId?: unknown } | undefined; - const tenantId = - typeof body?.id === "string" - ? body.id - : typeof body?.tenantId === "string" - ? body.tenantId - : undefined; - if (tenantId === undefined) return; - // Fire-and-forget: a 201 must answer immediately. - void kick({ tenantId, cookies }); - }); - app.route("/", wrapped); - - return { app, kick, stop, whenIdle }; -} diff --git a/apps/hub/src/workflow-scheduler.ts b/apps/hub/src/workflow-scheduler.ts index 5c5f5e5f63..1951f56296 100644 --- a/apps/hub/src/workflow-scheduler.ts +++ b/apps/hub/src/workflow-scheduler.ts @@ -38,40 +38,40 @@ export type ScheduledDefinition = { }; export type ScheduledDeliveryJoinDeps = { - deliveryWorkbenchRequired?: (name: string) => boolean | Promise; - resolveDeliveryWorkbench?: (tenantId: string) => Promise; - joinDeliveryWorkbench?: (input: JoinRunParticipantInput) => Promise; + deliveryChatRequired?: (name: string) => boolean | Promise; + resolveDeliveryChat?: (tenantId: string) => Promise; + joinDeliveryChat?: (input: JoinRunParticipantInput) => Promise; }; /** - * After a scheduled tick launches, join the run to a workbench when the - * catalog says this workflow delivers there. No workbench → still launched + * After a scheduled tick launches, join the run to a chat when the + * catalog says this workflow delivers there. No chat → still launched * (join omitted). Inbox-mode workflows skip the join. A join throw is the * caller's to log; this helper rethrows. */ -export async function joinScheduledDefinitionToWorkbench( +export async function joinScheduledDefinitionToChat( deps: ScheduledDeliveryJoinDeps, def: ScheduledDefinition, address: string, ): Promise { if (def.creatorPrincipalId === null) return; if ( - deps.deliveryWorkbenchRequired !== undefined && - (await deps.deliveryWorkbenchRequired(def.name)) !== true + deps.deliveryChatRequired !== undefined && + (await deps.deliveryChatRequired(def.name)) !== true ) { return; } if ( - deps.resolveDeliveryWorkbench === undefined || - deps.joinDeliveryWorkbench === undefined + deps.resolveDeliveryChat === undefined || + deps.joinDeliveryChat === undefined ) { return; } - const workbenchId = await deps.resolveDeliveryWorkbench(def.tenantId); - if (workbenchId === undefined) return; - await deps.joinDeliveryWorkbench({ + const chatId = await deps.resolveDeliveryChat(def.tenantId); + if (chatId === undefined) return; + await deps.joinDeliveryChat({ tenantId: def.tenantId, - workbenchId, + workbenchId: chatId, principalId: def.creatorPrincipalId, address, handle: handleFromName(def.name, address), @@ -250,10 +250,10 @@ export function launchScheduledDefinitionFromDb( content: SCHEDULE_TICK_CONTENT, }); try { - await joinScheduledDefinitionToWorkbench(deps, def, triggered.address); + await joinScheduledDefinitionToChat(deps, def, triggered.address); } catch (error) { reportError(error, { - operation: "workflow-scheduler.join-workbench", + operation: "workflow-scheduler.join-chat", tenantId: def.tenantId, extra: { definitionId: def.definitionId, @@ -275,7 +275,7 @@ export type RunNowScheduledDefinitionArgs = { }; /** - * Fire a scheduled definition now, then join the run to a workbench the + * Fire a scheduled definition now, then join the run to a chat the * same way the poller does. Join failures are reported and do not fail * the launch — the caller still gets `{ runId }`. */ @@ -291,7 +291,7 @@ export async function runNowScheduledDefinition( content: args.content, }); try { - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( deps, { definitionId: args.definitionId, @@ -305,7 +305,7 @@ export async function runNowScheduledDefinition( ); } catch (error) { reportError(error, { - operation: "scheduled-workflow.run-now.join-workbench", + operation: "scheduled-workflow.run-now.join-chat", tenantId: args.tenantId, extra: { definitionId: args.definitionId, diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 0443642225..2a1e646247 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -1,7 +1,8 @@ // Process-boot and createHub proof that hub production boot does not // mint a root tenant or an admin account. An empty database is a valid // hub: /status, health, and auth mechanics serve with zero tenant rows. -// First signup (signup-genesis.test.ts) is the 0→1 path. +// First self-serve signup plus a stock POST /api/tenants call is the +// 0→1 path. // // DB-gated: boots against its own scratch database so a reachable // DATABASE_URL is required and the suite skips without one. @@ -169,14 +170,10 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; @@ -201,7 +198,7 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { // CL-7579: hub boot must never plant provider credentials from // environment variables. Operators connect providers through the -// onboarding/connect flow instead, so a hub booted with curated +// client-driven connect flow instead, so a hub booted with curated // provider env vars set (e.g. OLLAMA_BASE_URL) inserts zero credential // rows and zero catalog offerings — verified against a local fake // Ollama whose probe the old env-plant would have passed. @@ -269,23 +266,24 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { expect(signUp.status).toBe(200); const cookies = signUp.headers.getSetCookie(); expect(cookies.length).toBeGreaterThan(0); - const provision = await fetch(`${baseUrl}/api/onboarding/provision`, { + const createTenant = await fetch(`${baseUrl}/api/tenants`, { method: "POST", headers: { "content-type": "application/json", cookie: cookies.join("; "), }, - body: JSON.stringify({ name: "Workbench" }), + body: JSON.stringify({ slug: "workbench", name: "Workbench" }), }); - expect(provision.status).toBe(200); + expect(createTenant.status).toBe(201); } test("process boot with OLLAMA_BASE_URL set inserts no credential or offering rows", async () => { await withScratchDatabase("boot_env_key_no_plant", async (url) => { - // Phase 1: mint the operator identity (signup genesis) so the old - // plant's target — admin sign-in + root tenant by slug — resolves - // immediately on the second boot. Without it the plant would sit - // in its retry loop and the test would prove nothing. + // Phase 1: mint the operator identity (self-serve signup plus the + // stock first-tenant create) so the old plant's target — an + // existing operator bench — resolves immediately on the second + // boot. Without it the plant would sit in its retry loop and the + // test would prove nothing. const genesis = await hop("hub process boot", async () => startHub({ databaseUrl: url, @@ -324,7 +322,7 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { }); }, 90_000); - test("createHub with a resolved operator bench plants no credential rows", async () => { + test("createHub with an existing operator tenant plants no credential rows", async () => { await withScratchDatabase("create_hub_env_key_no_plant", async (url) => { const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-plant-")); const staticDir = path.join(root, "static"); @@ -332,8 +330,8 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { writeFileSync(path.join(staticDir, "index.html"), "shell"); mkdirSync(path.join(root, "data"), { recursive: true }); - // Onboarding routes reach the hub over HTTP, so the composed app - // must be served on a real port and `baseUrl` must name it. + // Phase 1 works in-process: the signup and tenant routes are + // exercised through the composed app directly. const server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }), @@ -345,19 +343,15 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; - // Phase 1: mint the operator bench the old plant targeted. + // Phase 1: mint the operator tenant the old plant targeted. const genesis = await createHub(baseConfig); server.reload({ fetch: genesis.app.fetch }); const signUp = await genesis.app.request("/api/auth/sign-up/email", { @@ -373,15 +367,15 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { }), }); expect(signUp.status).toBe(200); - const provision = await genesis.app.request("/api/onboarding/provision", { + const createTenant = await genesis.app.request("/api/tenants", { method: "POST", headers: { "content-type": "application/json", cookie: signUp.headers.getSetCookie().join("; "), }, - body: JSON.stringify({ name: "Workbench" }), + body: JSON.stringify({ slug: "workbench", name: "Workbench" }), }); - expect(provision.status).toBe(200); + expect(createTenant.status).toBe(201); await genesis.close(); const hub = await createHub({ diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index 734026eb5a..c58a8856bc 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -29,16 +29,12 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 95435477f5..2003d28fa8 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -31,16 +31,12 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; @@ -134,24 +130,6 @@ describeIfDb("extension mounting", () => { expect(await outside.text()).toBe("shell"); }); - test("the first-login onboarding hook is gated the same way", async () => { - const hub = await bootHub(); - - const gated = await hub.app.request("/api/onboarding/provision", { - method: "POST", - }); - expect(gated.status).toBe(401); - // Onboarding answers in CL-6360's envelope: a consumer-language - // `userMessage` and a `refId` that ties the response to the log - // line, never a raw internal `message`. - const body = (await gated.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("unauthorized"); - expect(body.error.userMessage).toBe("Sign in to continue."); - expect(body.error.refId).toMatch(/\S/); - }); - test("the workbench-tenancy kind lookup the bench switcher uses is mounted and gated", async () => { const hub = await bootHub(); @@ -273,7 +251,6 @@ describeIfDb( test("an attacker exhausting the account's budget with wrong guesses never blocks the owner's correct password, and a successful sign-in resets the budget", async () => { const hub = await createHub({ ...config, - signupMode: "open", signInRateLimit: { windowSeconds: 60, max: 2 }, }); closers.push(hub.close); @@ -341,12 +318,9 @@ describeIfDb( }, ); -describeIfDb("dev-mode email verification", () => { - test("ALLOW_UNVERIFIED_EMAILS auto-verifies a fresh self-serve signup, so it never 403s on the unverified-email gate", async () => { - const hub = await createHub({ - ...config, - signupMode: "open", - }); +describeIfDb("stock signup carries no email-verification gate", () => { + test("a fresh self-serve signup can create a tenant without verifying anything", async () => { + const hub = await createHub(config); closers.push(hub.close); const email = `first-run-${crypto.randomUUID()}@example.com`; @@ -360,12 +334,6 @@ describeIfDb("dev-mode email verification", () => { }), }); expect(signUp.status).toBe(200); - const body = (await signUp.json()) as { user: { emailVerified: boolean } }; - // No mailer exists anywhere in this stack, so better-auth itself - // would leave this false forever without the dev-mode auto-verify - // hook -- this is the exact condition that used to dead-end fresh - // signup at signup_not_allowed. - expect(body.user.emailVerified).toBe(true); const cookie = signUp.headers.get("set-cookie"); expect(cookie).not.toBeNull(); @@ -377,8 +345,9 @@ describeIfDb("dev-mode email verification", () => { }, body: JSON.stringify({ slug: `bench-${crypto.randomUUID().slice(0, 8)}`, + name: "First Bench", }), }); - expect(createTenant.status).not.toBe(403); + expect(createTenant.status).toBe(201); }); }); diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index bc524ea43d..4ebae17dd3 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -28,14 +28,10 @@ describe("readHubConfig", () => { sessionSecret: validEnv.SESSION_SECRET, hubDataDir: validEnv.HUB_DATA_DIR, hubStaticDir: validEnv.HUB_STATIC_DIR, - defaultTenantSlug: "workbench", socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, allowPlaintextSecrets: false, - allowUnverifiedEmails: false, sidecarProvisioners: [{ id: "process" }], defaultSidecarProvisionerId: "process", chatIdleReapMs: 30 * 60_000, @@ -54,18 +50,14 @@ describe("readHubConfig", () => { expect(Object.keys(config).sort()).toEqual( [ "allowPlaintextSecrets", - "allowUnverifiedEmails", - "allowedEmailDomains", "baseUrl", "chatIdleReapMs", "databaseUrl", "defaultSidecarProvisionerId", - "defaultTenantSlug", "hubDataDir", "hubStaticDir", "sessionSecret", "signInRateLimit", - "signupMode", "signupRateLimit", "socialProviders", "sidecarProvisioners", @@ -133,49 +125,6 @@ describe("readHubConfig", () => { }); }); - test("WORKBENCH_DEFAULT_TENANT defaults to workbench and accepts an explicit slug", () => { - expect(readHubConfig(validEnv).defaultTenantSlug).toBe("workbench"); - expect( - readHubConfig({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "acme" }) - .defaultTenantSlug, - ).toBe("acme"); - }); - - test("ORG_SLUG aliases WORKBENCH_DEFAULT_TENANT when the latter is unset", () => { - const config = readHubConfig({ ...validEnv, ORG_SLUG: "acme" }); - expect(config.defaultTenantSlug).toBe("acme"); - }); - - test("WORKBENCH_DEFAULT_TENANT wins over ORG_SLUG when both are set", () => { - const config = readHubConfig({ - ...validEnv, - WORKBENCH_DEFAULT_TENANT: "root", - ORG_SLUG: "acme", - }); - expect(config.defaultTenantSlug).toBe("root"); - }); - - test("OPERATOR_TENANT_ID fails loudly with an actionable message", () => { - const message = readExpectingError({ - ...validEnv, - OPERATOR_TENANT_ID: "tnt_stale", - }); - expect(message).toContain("OPERATOR_TENANT_ID"); - expect(message).toContain("WORKBENCH_DEFAULT_TENANT"); - }); - - test("WORKBENCH_DEFAULT_TENANT rejects a non-slug value", () => { - expect( - readExpectingError({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "" }), - ).toContain("WORKBENCH_DEFAULT_TENANT"); - expect( - readExpectingError({ - ...validEnv, - WORKBENCH_DEFAULT_TENANT: "Not A Slug", - }), - ).toContain("WORKBENCH_DEFAULT_TENANT"); - }); - test("the signup rate limit is configurable and defaults sanely", () => { const config = readHubConfig({ ...validEnv, @@ -210,22 +159,6 @@ describe("readHubConfig", () => { expect(config.signInRateLimit).toEqual({ windowSeconds: 30, max: 2 }); }); - test("WORKBENCH_SIGNUP defaults closed and accepts open", () => { - expect(readHubConfig(validEnv).signupMode).toBe("closed"); - expect( - readHubConfig({ ...validEnv, WORKBENCH_SIGNUP: "open" }).signupMode, - ).toBe("open"); - }); - - test("WORKBENCH_ALLOWED_EMAIL_DOMAINS parses a comma list", () => { - expect( - readHubConfig({ - ...validEnv, - WORKBENCH_ALLOWED_EMAIL_DOMAINS: "acme.example, corp.example", - }).allowedEmailDomains, - ).toEqual(["acme.example", "corp.example"]); - }); - test("huggingfaceOAuthClientId is absent by default", () => { expect(readHubConfig(validEnv).huggingfaceOAuthClientId).toBeUndefined(); }); @@ -316,10 +249,6 @@ describe("readHubConfig", () => { expect(readHubConfig(validEnv).allowPlaintextSecrets).toBe(false); }); - test("allowUnverifiedEmails is false by default", () => { - expect(readHubConfig(validEnv).allowUnverifiedEmails).toBe(false); - }); - test("allowGitInsideWorkTree is omitted by default", () => { expect(readHubConfig(validEnv).allowGitInsideWorkTree).toBeUndefined(); }); @@ -343,17 +272,6 @@ describe("readHubConfig", () => { expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE"); }); - test("ALLOW_UNVERIFIED_EMAILS='1' or 'true' opts in", () => { - expect( - readHubConfig({ ...validEnv, ALLOW_UNVERIFIED_EMAILS: "1" }) - .allowUnverifiedEmails, - ).toBe(true); - expect( - readHubConfig({ ...validEnv, ALLOW_UNVERIFIED_EMAILS: "true" }) - .allowUnverifiedEmails, - ).toBe(true); - }); - test("ALLOW_PLAINTEXT_SECRETS='1' or 'true' opts in", () => { expect( readHubConfig({ ...validEnv, ALLOW_PLAINTEXT_SECRETS: "1" }) @@ -554,14 +472,14 @@ describe("readHubConfig", () => { }); describe("chatIdleReapMs", () => { - test("defaults to 30 minutes when WORKBENCH_CHAT_IDLE_REAP_MS is unset", () => { + test("defaults to 30 minutes when HUB_CHAT_IDLE_REAP_MS is unset", () => { expect(readHubConfig(validEnv).chatIdleReapMs).toBe(30 * 60_000); }); - test("is read from WORKBENCH_CHAT_IDLE_REAP_MS when set", () => { + test("is read from HUB_CHAT_IDLE_REAP_MS when set", () => { const config = readHubConfig({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "5000", + HUB_CHAT_IDLE_REAP_MS: "5000", }); expect(config.chatIdleReapMs).toBe(5000); }); @@ -569,17 +487,17 @@ describe("readHubConfig", () => { test("rejects zero", () => { const message = readExpectingError({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "0", + HUB_CHAT_IDLE_REAP_MS: "0", }); - expect(message).toContain("WORKBENCH_CHAT_IDLE_REAP_MS"); + expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); }); test("rejects a non-integer value", () => { const message = readExpectingError({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "not-a-number", + HUB_CHAT_IDLE_REAP_MS: "not-a-number", }); - expect(message).toContain("WORKBENCH_CHAT_IDLE_REAP_MS"); + expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); }); }); diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 547fd0c312..1d79fb7cfc 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -18,14 +18,10 @@ const baseConfig: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: ".data/hub", hubStaticDir: "apps/hub/public", - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: false, - allowUnverifiedEmails: false, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/eval-runs-mount.test.ts b/apps/hub/test/eval-runs-mount.test.ts index 0ac28ff631..649b4077bf 100644 --- a/apps/hub/test/eval-runs-mount.test.ts +++ b/apps/hub/test/eval-runs-mount.test.ts @@ -30,14 +30,10 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts index f83adda75e..2c92436b39 100644 --- a/apps/hub/test/presence-mount.test.ts +++ b/apps/hub/test/presence-mount.test.ts @@ -29,14 +29,10 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index 8991601706..8cfe3256b6 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -34,15 +34,11 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, }; diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts deleted file mode 100644 index c202084c50..0000000000 --- a/apps/hub/test/signup-genesis.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -// CL-7578 end-to-end proof of the 0→1 contract: a hub booted on an -// empty database starts truly empty, and the first signup — not a CLI, -// not a boot-time seed — mints the root tenant and becomes its owner. -// The second signup joins that root as a plain member. Signup never -// seeds workflows, tools, or grants. -// -// DB-gated: each test boots a full hub against its own scratch -// database, so a reachable DATABASE_URL is required and the suite -// skips without one. -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { - principal, - principalRole, - role, - tenant, - user as userTable, -} from "@intx/db/schema"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { setupDatabase } from "../../../scripts/db-setup"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const closers: (() => Promise)[] = []; -afterAll(async () => { - let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); - // 60s, not bun's 5s default hook timeout: each closer shuts a whole - // booted hub (server close + DB pool drain) sequentially, and under CI - // load that chain exceeds 5s — the timeout is the teardown budget, not - // a symptom being hidden. -}, 60_000); - -function scratchUrlFor(label: string): string { - const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_signup_genesis_${label}`; - return url.toString(); -} - -async function withScratchDatabase( - scratchUrl: string, - run: () => Promise, -): Promise { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl); - try { - await run(); - } finally { - const cleanup = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await cleanup.unsafe( - `DROP DATABASE IF EXISTS "${scratchDatabase}" WITH (FORCE)`, - ); - } finally { - await cleanup.end(); - } - } -} - -async function bootEmptyHub(args: { - scratchUrl: string; - signupMode: "open" | "closed"; -}): Promise<{ - baseUrl: string; - db: Awaited>["db"]; - stop: () => Promise; -}> { - const root = mkdtempSync(path.join(tmpdir(), "hub-signup-genesis-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - - // The onboarding routes reach the hub over HTTP (`createHubAPI`), so - // the composed app must be served on a real port — bind first to - // learn the port, boot the hub against that base URL, then hand the - // server the app. - const server = Bun.serve({ - port: 0, - fetch: () => new Response("booting", { status: 503 }), - }); - const baseUrl = `http://localhost:${server.port}`; - const config: HubConfig = { - databaseUrl: args.scratchUrl, - baseUrl, - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - signupMode: args.signupMode, - allowedEmailDomains: [], - allowPlaintextSecrets: true, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - }; - const hub = await createHub(config); - server.reload({ fetch: hub.app.fetch }); - let stopped = false; - const stop = async () => { - if (stopped) return; - stopped = true; - server.stop(true); - await hub.close(); - rmSync(root, { recursive: true, force: true }); - }; - closers.push(stop); - return { baseUrl, db: hub.db, stop }; -} - -// Distinct client IP per sign-up: better-auth's rate-limit storage is -// shared across every hub instance in this test process and keyed on -// the resolved client IP, so without this the suite's sign-ups all -// land in one budget bucket and can starve sibling suites -// (composition.test.ts signs up against the same bucket). -let signUpIpCounter = 0; - -async function signUp( - baseUrl: string, - args: { name: string; email: string; password: string }, -): Promise { - signUpIpCounter += 1; - const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-real-ip": `198.51.100.${signUpIpCounter}`, - }, - body: JSON.stringify(args), - }); - expect(response.status).toBe(200); - const cookies = response.headers.getSetCookie(); - expect(cookies.length).toBeGreaterThan(0); - return cookies; -} - -async function provision( - baseUrl: string, - cookies: string[], - name?: string, -): Promise<{ - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; -}> { - const response = await fetch(`${baseUrl}/api/onboarding/provision`, { - method: "POST", - headers: { - "content-type": "application/json", - cookie: cookies.join("; "), - }, - ...(name !== undefined ? { body: JSON.stringify({ name }) } : {}), - }); - expect(response.status).toBe(200); - return (await response.json()) as { - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; - }; -} - -async function roleNamesFor( - db: Awaited>["db"], - args: { tenantId: string; email: string }, -): Promise { - const [userRow] = await db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, args.email)) - .limit(1); - expect(userRow).toBeDefined(); - const userId: string = userRow?.id ?? ""; - expect(userId).not.toBe(""); - const rows = await db - .select({ roleName: role.name }) - .from(principal) - .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) - .innerJoin(role, eq(role.id, principalRole.roleId)) - .where( - and( - eq(principal.tenantId, args.tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - eq(principal.status, "active"), - ), - ); - return rows.map((r) => r.roleName); -} - -describeIfDb("signup genesis (CL-7578)", () => { - test("a closed empty hub admits the first signup, which mints the root tenant as owner — no seed", async () => { - const scratchUrl = scratchUrlFor("closed"); - await withScratchDatabase(scratchUrl, async () => { - const { baseUrl, db } = await bootEmptyHub({ - scratchUrl, - signupMode: "closed", - }); - - // The empty-hub exception: signup is closed, but zero users and - // zero tenants means this caller is the genesis owner. - const cookies = await signUp(baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - - const probe = await provision(baseUrl, cookies); - expect(probe.kind).toBe("needs-onboarding"); - - const result = await provision(baseUrl, cookies, "Acme"); - expect(result.kind).toBe("provisioned"); - expect(result.tenantSlug).toBe("workbench"); - expect(result.seeded).toBe(false); - expect(typeof result.tenantId).toBe("string"); - const tenantId = result.tenantId; - - const [userCount] = await db.select().from(userTable); - expect(userCount).toBeDefined(); - const roots = await db - .select() - .from(tenant) - .where(isNull(tenant.parentId)); - expect(roots).toHaveLength(1); - expect(roots[0]?.slug).toBe("workbench"); - const rootId: string = roots[0]?.id ?? ""; - expect(rootId).not.toBe(""); - expect((await db.select().from(tenant)).length).toBe(1); - - expect( - await roleNamesFor(db, { - tenantId: rootId, - email: "alice@example.com", - }), - ).toEqual(["owner"]); - - // No seed side effects: the genesis tenant carries no workflow - // assets and no deployments. - const assets = await fetch( - `${baseUrl}/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, - { headers: { cookie: cookies.join("; ") } }, - ); - expect(await assets.json()).toEqual([]); - const deployments = await fetch( - `${baseUrl}/api/tenants/${tenantId}/workflows/deployments`, - { headers: { cookie: cookies.join("; ") } }, - ); - expect(await deployments.json()).toEqual([]); - }); - }, 30_000); - - test("the second signup on an open hub joins the existing root as member, minting nothing", async () => { - const scratchUrl = scratchUrlFor("open"); - await withScratchDatabase(scratchUrl, async () => { - const { baseUrl, db } = await bootEmptyHub({ - scratchUrl, - signupMode: "open", - }); - - const alice = await signUp(baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - const genesis = await provision(baseUrl, alice, "Acme"); - expect(genesis.kind).toBe("provisioned"); - expect(genesis.tenantSlug).toBe("workbench"); - - const bob = await signUp(baseUrl, { - name: "Bob", - email: "bob@example.com", - password: "password123", - }); - // The join path needs no display name: a plain membership probe - // is enough, because the root already exists. - const joined = await provision(baseUrl, bob); - expect(joined.kind).toBe("existing-member"); - expect(joined.tenantSlug).toBe("workbench"); - expect(joined.tenantId).toBe(genesis.tenantId); - - const tenants = await db.select().from(tenant); - expect(tenants).toHaveLength(1); - const tenantId: string = tenants[0]?.id ?? ""; - expect(tenantId).not.toBe(""); - expect((await db.select().from(userTable)).length).toBe(2); - - expect( - await roleNamesFor(db, { - tenantId, - email: "alice@example.com", - }), - ).toEqual(["owner"]); - expect( - await roleNamesFor(db, { - tenantId, - email: "bob@example.com", - }), - ).toEqual(["member"]); - }); - }, 30_000); - - test("an operator-removed member cannot self-rejoin on a closed hub", async () => { - const scratchUrl = scratchUrlFor("rejoin"); - await withScratchDatabase(scratchUrl, async () => { - const open = await bootEmptyHub({ - scratchUrl, - signupMode: "open", - }); - - const alice = await signUp(open.baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - await provision(open.baseUrl, alice, "Acme"); - const bob = await signUp(open.baseUrl, { - name: "Bob", - email: "bob@example.com", - password: "password123", - }); - const joined = await provision(open.baseUrl, bob); - expect(joined.kind).toBe("existing-member"); - - // Operator removal: native removal deletes the member's - // principal rows outright, leaving the account itself alive. - const [bobRow] = await open.db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, "bob@example.com")) - .limit(1); - expect(bobRow).toBeDefined(); - await open.db.delete(principalRole).where( - inArray( - principalRole.principalId, - open.db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ), - ), - ); - await open.db - .delete(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ); - - // The hub flips signup to closed and restarts on the same data. - await open.stop(); - const closed = await bootEmptyHub({ - scratchUrl, - signupMode: "closed", - }); - - const signIn = await fetch(`${closed.baseUrl}/api/auth/sign-in/email`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email: "bob@example.com", - password: "password123", - }), - }); - expect(signIn.status).toBe(200); - const sessionCookies = signIn.headers.getSetCookie(); - - const res = await fetch(`${closed.baseUrl}/api/onboarding/provision`, { - method: "POST", - headers: { - "content-type": "application/json", - cookie: sessionCookies.join("; "), - }, - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("signup_not_allowed"); - - // No principal was minted: the removal sticks. - const [bobAfter] = await closed.db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ) - .limit(1); - expect(bobAfter).toBeUndefined(); - }); - }, 30_000); -}); diff --git a/apps/hub/test/slack-tag-mount.test.ts b/apps/hub/test/slack-tag-mount.test.ts deleted file mode 100644 index a612be0c9f..0000000000 --- a/apps/hub/test/slack-tag-mount.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Proves the Slack tag ingress env-gate: absent SLACK_BOT_TOKEN/ -// SLACK_SIGNING_SECRET never registers the webhook route; the pair -// present without SLACK_WORKBENCH_TENANT_SLUG/SLACK_DEFAULT_AGENT_DEFINITION_ID -// fails boot loudly (there is no honest default for either); the full -// config registers the route and inherits real Slack signature -// verification from `@corbits/slack-tag`/`corbits-tag/slack` — request/ -// reply behavior itself is covered by `packages/slack-tag`'s own tests. -import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -// DB-gated: skipped when DATABASE_URL is unset, matching this repo's -// convention for tests that talk to a real Postgres. -const DATABASE_URL = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(DATABASE_URL, import.meta.path); -const SLACK_WEBHOOK_PATH = "/api/tag/slack/webhook"; -const TENANT_SLUG = `slack-tag-test-${crypto.randomUUID().slice(0, 8)}`; - -const root = mkdtempSync(path.join(tmpdir(), "hub-slack-tag-mount-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl: DATABASE_URL, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - signupMode: "closed", - allowedEmailDomains: [], - allowPlaintextSecrets: true, -}; - -const closers: (() => Promise)[] = []; -let tenantId: string | undefined; - -async function seedTenant(): Promise { - const sql = postgres(DATABASE_URL, { max: 1 }); - try { - const id = `tnt_${crypto.randomUUID().replaceAll("-", "")}`; - await sql` - INSERT INTO "tenant" (id, name, slug, domain, created_at, updated_at) - VALUES (${id}, ${"Slack Tag Test Bench"}, ${TENANT_SLUG}, ${`${TENANT_SLUG}.localhost`}, now(), now()) - `; - return id; - } finally { - await sql.end(); - } -} - -async function unseedTenant(id: string): Promise { - const sql = postgres(DATABASE_URL, { max: 1 }); - try { - await sql`DELETE FROM "tenant" WHERE id = ${id}`; - } finally { - await sql.end(); - } -} - -beforeAll(async () => { - if (DATABASE_URL === "") return; - tenantId = await seedTenant(); -}); - -afterAll(async () => { - for (const close of closers) await close(); - if (tenantId !== undefined) await unseedTenant(tenantId); - rmSync(root, { recursive: true, force: true }); -}); - -const SLACK_ENV_KEYS = [ - "SLACK_BOT_TOKEN", - "SLACK_SIGNING_SECRET", - "SLACK_WORKBENCH_TENANT_SLUG", - "SLACK_DEFAULT_AGENT_DEFINITION_ID", -] as const; -const savedEnv = new Map(SLACK_ENV_KEYS.map((key) => [key, process.env[key]])); - -afterEach(async () => { - for (const key of SLACK_ENV_KEYS) { - const saved = savedEnv.get(key); - if (saved === undefined) Reflect.deleteProperty(process.env, key); - else process.env[key] = saved; - } - // Close each test's hub as soon as its test ends rather than letting - // every hub booted in this file stay live (and its background loops - // — relaunch sweep among them — keep running) until `afterAll`. Three - // live hubs contending for the same Postgres connections is exactly - // the kind of self-inflicted race this file should not add on top of - // CL-7453's fix in `apps/hub/src/index.ts`. - let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); -}); - -async function bootHub(): Promise>> { - const hub = await createHub(config); - closers.push(hub.close); - return hub; -} - -describeIfDb("Slack tag ingress env-gate", () => { - test("absent SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET never registers the webhook route", async () => { - for (const key of SLACK_ENV_KEYS) Reflect.deleteProperty(process.env, key); - - const hub = await bootHub(); - const res = await hub.app.request(SLACK_WEBHOOK_PATH, { - method: "POST", - body: "{}", - }); - expect(res.status).toBe(404); - }); - - test("the credential pair without a tenant slug and definition id fails boot loudly", async () => { - process.env["SLACK_BOT_TOKEN"] = "xoxb-test"; - process.env["SLACK_SIGNING_SECRET"] = "shhh-test"; - delete process.env["SLACK_WORKBENCH_TENANT_SLUG"]; - delete process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"]; - - await expect(bootHub()).rejects.toThrow( - /SLACK_WORKBENCH_TENANT_SLUG|SLACK_DEFAULT_AGENT_DEFINITION_ID/, - ); - }); - - test("a fully configured mount registers the route and rejects a bad signature", async () => { - process.env["SLACK_BOT_TOKEN"] = "xoxb-test"; - process.env["SLACK_SIGNING_SECRET"] = "shhh-test"; - process.env["SLACK_WORKBENCH_TENANT_SLUG"] = TENANT_SLUG; - process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"] = - "wfd_does_not_matter_for_this_test"; - - const hub = await bootHub(); - const res = await hub.app.request(SLACK_WEBHOOK_PATH, { - method: "POST", - body: "{}", - headers: { - "x-slack-request-timestamp": Math.floor(Date.now() / 1000).toString(), - "x-slack-signature": - "v0=0000000000000000000000000000000000000000000000000000000000000000", - }, - }); - expect(res.status).toBe(401); - }); -}); diff --git a/apps/hub/test/tenant-create-guard.test.ts b/apps/hub/test/tenant-create-guard.test.ts deleted file mode 100644 index 3caf71114c..0000000000 --- a/apps/hub/test/tenant-create-guard.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Route-level proof that POST /api/tenants cannot be used to bypass -// @workbench/access-policy — see ./src/tenant-create-guard.ts's module -// comment for the [Intx gap] this guards against (CL-6041). No real DB -// or full hub boot required: `guardedHubApp` wraps a minimal stub -// "native" app standing in for the vendored tenant route, and role -// resolution is injected as a plain function (the same seam -// `apps/hub/src/index.ts` wires to a real `resolveCallerRoleNames(db, ...)` -// in production), so these tests exercise exactly the guard's own -// decision without touching Postgres. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import { createInMemoryAccessPolicyStore } from "@workbench/access-policy"; - -import { - guardedHubApp, - type TenantCreateGuardDeps, -} from "../src/tenant-create-guard"; - -type FakeUser = { id: string; email: string; emailVerified: boolean }; - -function stubNativeApp(): { app: Hono; created: unknown[] } { - const app = new Hono(); - const created: unknown[] = []; - app.post("/api/tenants", async (c) => { - const body: unknown = await c.req.json().catch(() => ({})); - created.push(body); - return c.json({ id: "tnt_new", name: "New", slug: "new" }, 201); - }); - return { app, created }; -} - -/** No membership anywhere — the fake `resolveCallerRoleNames` every - * "not a member" test uses. */ -async function noMembership(): Promise { - return undefined; -} - -function depsFor(args: { - user: FakeUser | undefined; - operatorTenantId?: string; - envSignupMode?: "open" | "closed"; - resolveCallerRoleNames?: TenantCreateGuardDeps["resolveCallerRoleNames"]; - policiesByTenant?: Record< - string, - { tenancyCreation: "owners" | "owners-admins" | "none" } - >; - tenants?: number; -}): TenantCreateGuardDeps { - const store = createInMemoryAccessPolicyStore(); - for (const [tenantId, policy] of Object.entries( - args.policiesByTenant ?? {}, - )) { - void store.upsertPolicy(tenantId, policy); - } - - const deps: TenantCreateGuardDeps = { - store, - resolveCallerRoleNames: args.resolveCallerRoleNames ?? noMembership, - countTenants: async () => args.tenants ?? 1, - envSignupMode: args.envSignupMode ?? "closed", - envAllowedDomains: [], - allowUnverifiedEmails: false, - getSessionUser: async () => args.user, - }; - if (args.operatorTenantId !== undefined) { - deps.operatorTenantId = args.operatorTenantId; - } - return deps; -} - -describe("guardedHubApp — bypass shape A: unauthenticated caller", () => { - test("POST /api/tenants without a session is rejected before the native route ever runs", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const wrapped = guardedHubApp(nativeApp, depsFor({ user: undefined })); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Evil", slug: "evil" }), - }); - - expect(response.status).toBe(401); - expect(created).toHaveLength(0); - }); -}); - -describe("guardedHubApp — bypass shape B: arbitrary parentId under a tenant the caller does not belong to", () => { - test("an authenticated user cannot become owner of a child under a tenant they aren't a member of", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_1", - email: "attacker@evil.example", - emailVerified: true, - }, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Hostile Takeover", - slug: "hostile", - parentId: "tnt_victim", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("not_a_member"); - expect(created).toHaveLength(0); - }); - - test("a genuine owner-member of the parent may create a sub-workbench under it", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_2", email: "owner@acme.example", emailVerified: true }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_2" ? ["owner"] : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("a member without a sufficient role is denied by the parent's own tenancyCreation policy", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_3", - email: "member@acme.example", - emailVerified: true, - }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_3" ? ["member"] : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("tenancy_creation_forbidden"); - expect(created).toHaveLength(0); - }); - - test("an admin is denied by the default owners tenancyCreation policy", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_admin", - email: "admin@acme.example", - emailVerified: true, - }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_admin" - ? ["admin"] - : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("tenancy_creation_forbidden"); - expect(created).toHaveLength(0); - }); - - test("owners-admins policy also accepts an admin", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_8", email: "admin@acme.example", emailVerified: true }, - resolveCallerRoleNames: async () => ["admin"], - policiesByTenant: { tnt_acme: { tenancyCreation: "owners-admins" } }, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); -}); - -describe("guardedHubApp — top-level and operator-tenant creation go through the signup gate", () => { - test("no parentId on an empty hub (genesis) is allowed even with signup closed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_first", - email: "first@example.com", - emailVerified: true, - }, - envSignupMode: "closed", - tenants: 0, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Acme", slug: "workbench" }), - }); - - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("no parentId, signup closed -> denied, fail closed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_4", email: "new@acme.example", emailVerified: true }, - envSignupMode: "closed", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("signup_not_allowed"); - expect(created).toHaveLength(0); - }); - - test("no parentId, signup open and email verified -> allowed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_5", email: "new@acme.example", emailVerified: true }, - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("exploit: an unverified email cannot self-provision even with signup open", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_6", - email: "unverified@acme.example", - emailVerified: false, - }, - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("signup_not_allowed"); - expect(created).toHaveLength(0); - }); - - test("parentId equal to the operator tenant follows the same signup gate as no parentId", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_7", email: "new@acme.example", emailVerified: true }, - operatorTenantId: "tnt_operator", - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Mine", - slug: "mine", - parentId: "tnt_operator", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("GET requests to /api/tenants/:id are never intercepted", async () => { - const app = new Hono(); - app.get("/api/tenants/tnt_1", (c) => c.json({ id: "tnt_1" })); - const wrapped = guardedHubApp(app, depsFor({ user: undefined })); - - const response = await wrapped.request("/api/tenants/tnt_1"); - expect(response.status).toBe(200); - }); -}); diff --git a/apps/hub/test/tenant-create-onboard.test.ts b/apps/hub/test/tenant-create-onboard.test.ts deleted file mode 100644 index 1d4ad351be..0000000000 --- a/apps/hub/test/tenant-create-onboard.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -// CL-7584: the tenant-create trigger. A 201 from `POST /api/tenants` -// fires exactly one fire-and-forget desired-state reconcile for the new -// tenant under the creator's minted session; a 403 fires none; -// concurrent kicks for the same tenant dedupe in-process; and a tenant -// with no catalog offerings logs blocked instead of throwing. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import type { - ReconcileReport, - reconcileTenantDesiredState, -} from "@workbench/onboarding/desired-state"; -import { - createTenantCreateObserver, - type TenantCreateOnboardDeps, -} from "../src/tenant-create-onboard"; - -function harness( - overrides: Omit, "reconcileFn"> & { - reconcileFn?: TenantCreateOnboardDeps["reconcileFn"] | undefined; - reconcileStateFn?: typeof reconcileTenantDesiredState | undefined; - nativeApp?: Hono; - } = {}, -) { - const logged: string[] = []; - const reconciled: string[] = []; - let cookiesSeenValue = ""; - const native = - overrides.nativeApp ?? - new Hono().post("/api/tenants", (c) => - c.json({ id: "ten_new", name: "New" }, 201), - ); - - const { nativeApp, ...depOverrides } = overrides; - const deps = { - api: (async () => { - throw new Error("no hub calls expected with a reconcileFn stub"); - }) as unknown as ApiCall, - hubUrl: "https://hub.example.com", - pushWorkflow: (async () => ({ - outcome: "pushed", - commitSha: "a".repeat(40), - })) as unknown as WorkflowPusher, - log: (line) => logged.push(line), - reconcileFn: async (args) => { - reconciled.push(args.tenantId); - cookiesSeenValue = args.cookies.join(";"); - return { - tenantId: args.tenantId, - ready: true, - pins: [{ name: "assistant", kind: "workflow", status: "installed" }], - } satisfies ReconcileReport; - }, - ...depOverrides, - } as TenantCreateOnboardDeps; - - const cookiesSeen = () => cookiesSeenValue; - const { app, kick } = createTenantCreateObserver(deps, native); - return { app, kick, logged, reconciled, cookiesSeen }; -} - -describe("createTenantCreateObserver", () => { - test("a 201 create fires one reconcile for the new tenant", async () => { - const { app, reconciled, cookiesSeen } = harness(); - - const response = await app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - expect(response.status).toBe(201); - // The kick is fire-and-forget but its collector is synchronous up - // to the first await in the observer's own async work; yield once. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(reconciled).toEqual(["ten_new"]); - expect(cookiesSeen()).toContain("better-auth.session_token=creator"); - }); - - test("a 403 create fires nothing", async () => { - const denied = new Hono().post("/api/tenants", (c) => - c.json({ error: { code: "signup_not_allowed" } }, 403), - ); - const { app, reconciled } = harness({ nativeApp: denied }); - - const response = await app.request("/api/tenants", { method: "POST" }); - expect(response.status).toBe(403); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(reconciled).toEqual([]); - }); - - test("concurrent creates of the same tenant dedupe to one reconcile", async () => { - let releaseFirst: (() => void) | undefined; - const firstKick = new Promise((resolve) => { - releaseFirst = resolve; - }); - let calls = 0; - const { app } = harness({ - reconcileFn: async (args) => { - calls += 1; - if (calls === 1) await firstKick; - return { - tenantId: args.tenantId, - ready: true, - pins: [], - }; - }, - nativeApp: new Hono().post("/api/tenants", (c) => - c.json({ id: "ten_same" }, 201), - ), - }); - - const request = () => - app.request("/api/tenants", { - method: "POST", - headers: { cookie: "better-auth.session_token=creator" }, - }); - const first = request(); - const second = request(); - await Promise.all([first, second]); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls).toBe(1); - releaseFirst?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - test("a tenant with no catalog offerings logs blocked instead of throwing", async () => { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/tenants/ten_new/models") { - return { status: 200, data: [], cookies: [] }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall; - const { app, logged } = harness({ - api, - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - }); - - await app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect( - logged.some( - (line) => line.includes("ten_new") && line.includes("blocked"), - ), - ).toBe(true); - }); -}); - -// CL-7584: the production deployer path, DB-free. Past the -// model===undefined gate the observer resolves the creator's deploy -// identity (`resolveTenantDeployer`: GET /api/me/principals paginated + -// GET /api/tenants/:id) and hands principalId/tenantDomain to the -// reconcile seam — never a void-identity deploy when the session holds -// no deployable principal there. -describe("createTenantCreateObserver production deployer path", () => { - const DEPLOYER_PRINCIPAL_ID = "prn_creator"; - const TENANT_DOMAIN = "new.bench.local"; - - function principalsPayload(entries: { - status: string; - principalId?: string; - }) { - return { - data: [ - { - principalId: entries.principalId ?? DEPLOYER_PRINCIPAL_ID, - tenantId: "ten_new", - tenantName: "New", - tenantSlug: "new", - kind: "user", - status: entries.status, - roles: [], - }, - ], - nextCursor: null, - }; - } - - function productionApi(principals: { data: unknown[]; nextCursor: null }) { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/tenants/ten_new/models") { - return { - status: 200, - data: [ - { - id: "mdl_0", - canonicalName: "llama3.2", - offerings: [ - { - offeringId: "off_0", - providerId: "cpv_1", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }, - ], - cookies: [], - }; - } - if (method === "GET" && path === "/api/me/principals") { - return { status: 200, data: principals, cookies: [] }; - } - if (method === "GET" && path === "/api/tenants/ten_new") { - return { - status: 200, - data: { - id: "ten_new", - name: "New", - slug: "new", - domain: TENANT_DOMAIN, - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall; - return api; - } - - function postCreate(app: Hono) { - return app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - } - - // The kick is fire-and-forget behind the 201: the singleton lookup plus - // paginated-principal fetch settles after the response is serialized. - // Poll — never a fixed sleep — so the suite stays green when the whole - // monorepo test leg runs packages in parallel. - async function awaitSettled(done: () => boolean) { - const deadline = Date.now() + 2000; - while (!done()) { - if (Date.now() > deadline) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - test("the creator's principalId and tenant domain reach the reconcile seam", async () => { - const seen: { - tenantId: string; - principalId: string; - domain: string; - modelDefined: boolean; - }[] = []; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - seen.push({ - tenantId: args.tenant.tenantId, - principalId: args.tenant.principalId, - domain: args.tenant.domain, - modelDefined: args.model !== undefined, - }); - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi(principalsPayload({ status: "active" })), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => seen.length === 1); - expect(seen).toEqual([ - { - tenantId: "ten_new", - principalId: DEPLOYER_PRINCIPAL_ID, - domain: TENANT_DOMAIN, - modelDefined: true, - }, - ]); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(false); - }); - - test("a suspended principal never reaches the seam — blocked, never void-identity", async () => { - let calls = 0; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - calls += 1; - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi(principalsPayload({ status: "suspended" })), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => - logged.some((line) => line.includes("no deployable principal")), - ); - expect(calls).toBe(0); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(true); - }); - - test("no principal on the tenant never reaches the seam — blocked, never void-identity", async () => { - let calls = 0; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - calls += 1; - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi({ data: [], nextCursor: null }), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => - logged.some((line) => line.includes("no deployable principal")), - ); - expect(calls).toBe(0); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(true); - }); -}); diff --git a/apps/hub/test/tenant-create-shutdown.test.ts b/apps/hub/test/tenant-create-shutdown.test.ts deleted file mode 100644 index f8ab593987..0000000000 --- a/apps/hub/test/tenant-create-shutdown.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// The tenant-create observer's shutdown contract: `stop()` refuses new -// kicks, `whenIdle()` waits out the in-flight ones, and the hub `close()` -// 250ms race bounds that wait so a stuck reconcile can never stall -// teardown. The observer half is DB-free (the reconcile step is injected); -// the `close()` half boots a real hub and so is DB-gated like every other -// suite that does. -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import { Hono } from "hono"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { - createTenantCreateObserver, - type TenantCreateOnboardDeps, -} from "../src/tenant-create-onboard.ts"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { setupDatabase } from "../../../scripts/db-setup"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const unreachableApi = (async () => { - throw new Error("observer tests inject reconcileFn; the api is never called"); -}) as unknown as ApiCall; -const unreachablePusher = (async () => { - throw new Error( - "observer tests inject reconcileFn; the pusher is never called", - ); -}) as unknown as WorkflowPusher; - -function mountObserver( - reconcileFn: NonNullable, - logError: (line: string) => void = () => undefined, -) { - const wrapped = new Hono(); - wrapped.get("/ping", (c) => c.text("pong")); - return createTenantCreateObserver( - { - api: unreachableApi, - hubUrl: "http://localhost:1", - pushWorkflow: unreachablePusher, - log: () => undefined, - logError, - reconcileFn, - }, - // The wrapped app's type carries the hub's AppEnv; the observer only - // forwards through it, so a bare Hono app suffices for these tests. - wrapped as never, - ); -} - -describe("tenant-create observer shutdown", () => { - test("stop() refuses new kicks while whenIdle() waits out the in-flight one", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let runs = 0; - const observer = mountObserver(async ({ tenantId }) => { - runs += 1; - await gate; - return { tenantId, ready: true, pins: [] }; - }); - - const first = observer.kick({ tenantId: "ten_a", cookies: [] }); - // Let the kick reach the stuck reconcile before stopping. - await Bun.sleep(20); - observer.stop(); - // A kick after stop resolves without running anything. - await observer.kick({ tenantId: "ten_b", cookies: [] }); - expect(runs).toBe(1); - - // whenIdle is still waiting on the stuck kick, not resolved early. - let idle = false; - const idleWait = observer.whenIdle().then(() => { - idle = true; - }); - await Bun.sleep(20); - expect(idle).toBe(false); - - release(); - await first; - await idleWait; - expect(idle).toBe(true); - }); - - test("a failed kick settles (never rejects) so whenIdle always resolves", async () => { - const lines: string[] = []; - const observer = mountObserver( - async () => { - throw new Error("sidecar exploded mid-reconcile"); - }, - (line) => lines.push(line), - ); - await observer.kick({ tenantId: "ten_a", cookies: [] }); - await observer.whenIdle(); - expect(lines.some((line) => line.includes("ten_a"))).toBe(true); - }); -}); - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("hub close() bound (CL-7584)", () => { - test("close() on an idle hub resolves well inside the teardown budget", async () => { - const scratchUrl = new URL( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const dbName = scratchUrl.pathname.replace(/^\//, ""); - scratchUrl.pathname = `/${dbName}_shutdown`; - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - const scratchName = new URL(scratchUrl).pathname.replace(/^\//, ""); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchName}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchName}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl.toString()); - - const root = mkdtempSync(path.join(tmpdir(), "hub-shutdown-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - const server = Bun.serve({ - port: 0, - fetch: () => new Response("booting", { status: 503 }), - }); - const baseUrl = `http://localhost:${server.port}`; - const config: HubConfig = { - databaseUrl: scratchUrl.toString(), - baseUrl, - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - signupMode: "open", - allowedEmailDomains: [], - allowPlaintextSecrets: true, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - }; - const hub = await createHub(config); - server.reload({ fetch: hub.app.fetch }); - try { - const start = Date.now(); - await hub.close(); - const elapsed = Date.now() - start; - // Measured, not guessed: idle close took ~0.8s locally - // (2026-09-14, scratch Postgres on loopback); the assertion budget - // is 10s (generous: CI teardown of server + pool). The 250ms - // observer race inside close() bounds - // the kick wait by construction, so a stuck kick cannot stall - // teardown — but a stuck-kick-under-load timing proof is still - // open: production wiring has no injection point for a hung - // reconcile, so this covers the idle path only. - expect(elapsed).toBeLessThan(10_000); - } finally { - server.stop(true); - rmSync(root, { recursive: true, force: true }); - const cleanup = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await cleanup.unsafe( - `DROP DATABASE IF EXISTS "${scratchName}" WITH (FORCE)`, - ); - } finally { - await cleanup.end(); - } - } - }, 60_000); -}); diff --git a/apps/hub/test/workflow-scheduler.test.ts b/apps/hub/test/workflow-scheduler.test.ts index 5553e660b5..2a48ade364 100644 --- a/apps/hub/test/workflow-scheduler.test.ts +++ b/apps/hub/test/workflow-scheduler.test.ts @@ -4,7 +4,7 @@ import * as errorSink from "@corbits/error-sink"; import { createWorkflowScheduler, DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, - joinScheduledDefinitionToWorkbench, + joinScheduledDefinitionToChat, runNowScheduledDefinition, tickWorkflowScheduler, type ScheduledDefinition, @@ -239,14 +239,14 @@ describe("createWorkflowScheduler's setInterval wiring", () => { }); }); -describe("joinScheduledDefinitionToWorkbench", () => { - test("joins the first workbench when delivery is required", async () => { +describe("joinScheduledDefinitionToChat", () => { + test("joins the first chat when delivery is required", async () => { const joined: unknown[] = []; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async (input) => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async (input) => { joined.push(input); }, }, @@ -264,13 +264,13 @@ describe("joinScheduledDefinitionToWorkbench", () => { ]); }); - test("omits join when the tenant has no workbench and still returns", async () => { + test("omits join when the tenant has no chat and still returns", async () => { let joined = 0; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => undefined, - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => undefined, + joinDeliveryChat: async () => { joined += 1; }, }, @@ -282,11 +282,11 @@ describe("joinScheduledDefinitionToWorkbench", () => { test("omits join when the catalog says inbox delivery", async () => { let joined = 0; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => false, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => false, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async () => { joined += 1; }, }, @@ -347,15 +347,15 @@ function runNowArgs() { } describe("runNowScheduledDefinition", () => { - test("joins the first workbench after a successful trigger", async () => { + test("joins the first chat after a successful trigger", async () => { const joined: unknown[] = []; const result = await runNowScheduledDefinition( { db: createFakeTriggerDb([LIVE_ANCHOR]) as never, sidecarRouter: { routeMail: () => true } as never, - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async (input) => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async (input) => { joined.push(input); }, }, @@ -381,9 +381,9 @@ describe("runNowScheduledDefinition", () => { { db: createFakeTriggerDb([LIVE_ANCHOR]) as never, sidecarRouter: { routeMail: () => true } as never, - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async () => { throw boom; }, }, @@ -393,7 +393,7 @@ describe("runNowScheduledDefinition", () => { expect(report).toHaveBeenCalledTimes(1); expect(report.mock.calls[0]?.[0]).toBe(boom); expect(report.mock.calls[0]?.[1]).toEqual({ - operation: "scheduled-workflow.run-now.join-workbench", + operation: "scheduled-workflow.run-now.join-chat", tenantId: "t1", extra: { definitionId: "wfd_digest", diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 36d27e77e0..76226a7997 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -22,9 +22,7 @@ apps/hub/src/bench-session.ts 1 const message = cause instanceof Error ? cause.message : String(cause); apps/hub/src/credential-expiry-sweep.ts 1 log.error`credential expiry sweep failed: ${ apps/hub/src/grant-allowance.ts 1 return null; -apps/hub/src/index.ts 1 email = ""; apps/hub/src/index.ts 1 email = undefined; -apps/hub/src/index.ts 1 log.warn( apps/hub/src/index.ts 1 sidecarAllocationLog.error`Sidecar allocation reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; apps/hub/src/mail-redelivery.ts 1 log.warn`could not wake ${recipient} ahead of redelivery: ${ apps/hub/src/shutdown.ts 1 return { kind: "failed", error }; From a648d53c8d3181c132c04a0be2a8653ed11963af Mon Sep 17 00:00:00 2001 From: Corbits Builder Date: Wed, 16 Sep 2026 11:05:03 -0700 Subject: [PATCH 04/18] docs(hub): fix stale cutover comments and README --- apps/hub/README.md | 20 +++++++++++--------- apps/hub/src/index.ts | 15 ++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/hub/README.md b/apps/hub/README.md index bc55337ad4..2d34dad658 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -18,15 +18,18 @@ native tenant middleware. explicit import plus one `app.route(...)` call, inside or outside the tenant prefix as the extension requires. `src/index.ts` is the single file where all of this comes together. -- `src/*-mount.ts` files (`artifacts-mount.ts`, `memory-mount.ts`, - `skills-mount.ts`, `slack-tag-mount.ts`) are wiring helpers that resolve - and mount one extension's routes with its own DB handle/config. +- There are no local `src/*-mount.ts` wiring helpers: each extension's + routes mount directly in `src/index.ts` with the shared DB + handle/config passed inline at the `app.route(...)` call site. - Serves the built `apps/web` SPA from its own origin (`HUB_STATIC_DIR` points at `apps/web/dist`), so every `/api` call the interface makes is same-origin. -- `credential-expiry-sweep.ts`, `cron-due.ts`, `workflow-scheduler.ts`, - `tenant-create-guard.ts` are host-level - background jobs and guards wired at boot, alongside the route mounts. +- `credential-expiry-sweep.ts`, `workflow-scheduler.ts`, and + `inbox-unsnooze-sweep.ts` are host-level periodic sweeps wired at boot, + alongside the route mounts; `cron-due.ts` is a re-export seam onto + `@corbits/workflows`' own cron matcher, not a second implementation. + Tenancy is the stock platform's own middleware — there is no local + tenant-create guard. - `in-flight-requests.ts` and `shutdown.ts` bound SIGINT/SIGTERM: the hub waits for Hono handlers that have not yet returned a Response (a request still in a Postgres transaction, a git write), then @@ -47,7 +50,6 @@ bun run start cd apps/hub && bun test ``` -Several suites (`artifacts-mount.test.ts`, `memory-mount.test.ts`, -`memory-workflow-routes.test.ts`) exercise mount wiring against a real -database: +Suites under `test/` exercise composition and wiring; the DB-backed +ones run against a real database: `DATABASE_URL=postgres://localhost:5432/workbench_e2e bun test`. diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b9da41c5d6..5bb07b7eba 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -708,8 +708,8 @@ export async function createHub(config: HubConfig) { principalKeyStore, grantStore: createGrantStore(db), }); - // Hoisted ahead of their other uses below (`mountMemory`'s neighbors, - // the room timeline store at CL-6327) so `createHubMailboxResolveRefs` + // Hoisted ahead of their other uses below (chat routes, the room + // timeline store at CL-6327) so `createHubMailboxResolveRefs` // can share these two instances rather than constructing its own just // for the mailbox wiring. const chatStore = createDrizzleChatStore(db); @@ -1630,10 +1630,8 @@ export async function createHub(config: HubConfig) { // see `@corbits/agent-directory`'s `stale-task-agent-naming.ts`) // existed for exactly one now-retired task; any that still linger // must stay out of a picker meant for agents a person deliberately - // keeps around. Wired into every picker surface: - // chat's invite/new-chat dialogs (`chatDeps.isInvitableDefinition` below) - // and the agent-definition drafting inventory - // (`listMyraConversationalAgents` below). + // keeps around. Wired into the picker surface: chat's invite/new-chat + // dialogs (`chatDeps.isInvitableDefinition` below). const isPickerListableDefinition = (definition: { name: string }) => isConversationalAgentDefinition(definition) && !isPlannerCreatedDefinitionName(definition.name); @@ -2692,11 +2690,6 @@ export async function createHub(config: HubConfig) { bus: mailboxBus, }); - // One-shot Myra prompt (agent-definition drafting): provisions a run - // through Interchange, awaits its single reply, and tears the run - // down immediately — never a resident that outlives the request, so no - // idle-sleep lifecycle is needed for it. - // Every genuine top-level deployment run, chat-hosted and invited-agent // runs excluded — the scoped listing CL-6061 adds so the Agent Directory // and the shell's "Running" bands stop deriving that exclusion From 1fadffa83b838a88fdad70af18d6cd31e747ba3e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:02:48 -0700 Subject: [PATCH 05/18] feat(chat)!: purge dead tenancy migrations for hard-refresh cutover HARD REFRESH - EXISTING DATABASES MUST RESET (bun run reset). There is no upgrade path for the removed entries: any dev/test database whose chat.chat_migrations ledger already contains 0005_channel_tenancy or 0006_channel_tenancy_parent_index will NOT replay them (they no longer exist in the chain), and must be rebuilt from scratch rather than migrated forward. Summary: - Delete the dead chain rendered moot by 0031_drop_workbench_tenancy: 0005_channel_tenancy CREATE, 0006_channel_tenancy_parent_index, and the three 0018 tenancy rename rows (table/column/index renames that only ever served the link table). Fresh databases now build clean with no backfill and no legacy rows; the tenancy table is never created. - Keep 0031 as the forward-drop guard (SQL untouched, table stays dropped): a no-op DROP IF EXISTS on fresh databases, still drops the leftover table/index on pre-purge databases. - No other entries touched: audit found no additional backfill or no_tenancy-era migrations whose sole purpose dies with the dropped table (the only tenancy references in the chain were 0005/0006/0018/ 0031). Ledger names are stable identifiers, so later entries keep their numbers - the chain now has gaps at 0005/0006 by design and must never be renumbered. - Update test/migrations.test.ts ledger assertions to the 29-entry purged chain. No allowlist change: no-product-tenancy still passes at 16 tables (workbench_tenancy was already removed there), and its 0018 comment stays accurate. Verification: - bun test test/migrations.test.ts (isolated scratch DBs, never the shared DATABASE_URL): 4 pass, 0 fail - fresh replay applies the purged chain idempotently, tenancy tables/indexes absent - bun test (packages/chat): 931 pass, 0 fail - bun run typecheck (packages/chat): exit 0 - bunx prettier --check on changed files: pass; bunx eslint packages/chat: exit 0 - bun run check:structural: 17 ok; check:report-error FAILs on 4 stale baseline entries in routes.ts/workbench-service.ts left by the earlier cutover commits (pre-existing, unrelated to this change) --- packages/chat/src/migrations.ts | 45 ++++++++++++--------------- packages/chat/test/migrations.test.ts | 6 ++-- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index 2ce3080112..65c4b9e0c4 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -65,27 +65,18 @@ export const chatMigrations: readonly ChatMigration[] = [ ADD COLUMN IF NOT EXISTS "noop_inference" boolean NOT NULL DEFAULT false; `, }, - { - name: "0005_channel_tenancy", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_tenancy" ( - "channel_id" text NOT NULL, - "tenant_id" text NOT NULL, - "parent_tenant_id" text NOT NULL, - "slug" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("channel_id"), - UNIQUE ("tenant_id") - ); - `, - }, - { - name: "0006_channel_tenancy_parent_index", - sql: ` - CREATE INDEX IF NOT EXISTS "channel_tenancy_parent_tenant_id_idx" - ON "chat"."channel_tenancy" ("parent_tenant_id"); - `, - }, + // Hard refresh (chat tenancy cutover): the channel/workbench tenancy + // link table is gone — 0031 drops `workbench_tenancy` outright and the + // client mints conversation tenants through stock Interchange now, so + // chat keeps no link table. The 0005 CREATE, the 0006 parent index, and + // the three 0018 tenancy rename rows that only ever served that table + // are deleted here rather than replayed: fresh databases build clean + // with no legacy rows, and existing dev/test databases reset + // (`bun run reset`) instead of migrating — there is no upgrade path for + // the removed entries. Later entries keep their stable ledger names, so + // the numbering has gaps and must never be renumbered (the ledger skips + // by name, and a rename would replay against databases that already ran + // the old name). { name: "0007_chat_bench_settings", sql: ` @@ -263,6 +254,8 @@ export const chatMigrations: readonly ChatMigration[] = [ // one place that carries them forward. Plain renames only: no data // moves, no column ever changes type, so this is a metadata-only // operation on Postgres and safe to run against a live table. + // (Hard refresh: the purged channel/workbench tenancy table has no rows + // here — see the note above 0007. 0031 still drops it outright.) { name: "0018_rename_channel_to_workbench", sql: ` @@ -274,10 +267,6 @@ export const chatMigrations: readonly ChatMigration[] = [ ALTER TABLE "chat"."channel_launch" RENAME TO "workbench_launch"; - ALTER TABLE "chat"."channel_tenancy" RENAME TO "workbench_tenancy"; - ALTER TABLE "chat"."workbench_tenancy" RENAME COLUMN "channel_id" TO "workbench_id"; - ALTER INDEX "chat"."channel_tenancy_parent_tenant_id_idx" RENAME TO "workbench_tenancy_parent_tenant_id_idx"; - ALTER TABLE "chat"."channel_threads" RENAME TO "workbench_threads"; ALTER TABLE "chat"."workbench_threads" RENAME COLUMN "channel_id" TO "workbench_id"; ALTER INDEX "chat"."channel_threads_channel_idx" RENAME TO "workbench_threads_workbench_idx"; @@ -589,6 +578,12 @@ export const chatMigrations: readonly ChatMigration[] = [ `, }, { + // Hard cutover: chat keeps no tenancy link table — the client mints + // conversation tenants through stock Interchange now. On a fresh + // database this is a no-op guard (0005/0006/0018-tenancy rows are + // purged above, so the table is never created); on a pre-purge + // database it drops the leftover table and its index outright. Either + // way the table stays dropped — see the hard-refresh note above 0007. name: "0031_drop_workbench_tenancy", sql: ` DROP INDEX IF EXISTS "chat"."workbench_tenancy_parent_tenant_id_idx"; diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts index fa3eba45f0..759cb1f9ec 100644 --- a/packages/chat/test/migrations.test.ts +++ b/packages/chat/test/migrations.test.ts @@ -26,8 +26,10 @@ const migrationNames = [ "0002_channel_read_state", "0003_channel_launch", "0004_channel_launch_noop_inference", - "0005_channel_tenancy", - "0006_channel_tenancy_parent_index", + // Hard refresh: 0005_channel_tenancy and 0006_channel_tenancy_parent_index + // are purged from chatMigrations (see the note above 0007 there) — the + // link table they served was dropped outright by 0031, so fresh databases + // never create it. Existing databases reset rather than migrate. "0007_chat_bench_settings", "0008_channel_context_window_explicit_inherit", "0009_channel_threads", From c6b1c80242e86f5087d3ce913620e3dcfb354e0b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:04:28 -0700 Subject: [PATCH 06/18] chore(chat): drop stale report-error baseline entries for deleted tenancy surface --- scripts/checks/report-error-baseline.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 36d27e77e0..4554688cc2 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -126,13 +126,9 @@ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`drift check for ${bindi packages/chat/src/platform-adapter.ts 1 wakeLogger.error`inference-source reconcile for ${binding.roomAddress} (run ${run.id}) failed, leaving it as-is: ${ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`pinned-tool-package reconcile for ${live.binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`relaunch sweep: could not relaunch ${live.binding.roomAddress} (run ${live.run.id} is ${live.run.status}): ${ -packages/chat/src/routes.ts 1 log.error( -packages/chat/src/routes.ts 2 log.error( packages/chat/src/turn-context.ts 1 contextLog.warn`failed to assemble turn context on workbench ${input.workbenchId}: ${ packages/chat/src/workbench-service.ts 1 fanoutLog.error( packages/chat/src/workbench-service.ts 1 greetingLog.error( -packages/chat/src/workbench-service.ts 1 mintAgentDmLog.error( -packages/chat/src/workbench-service.ts 1 provisionLog.error( packages/chat/src/workbench-service.ts 1 removeLog.error( packages/chat/src/workbench-service.ts 2 fanoutLog.error( packages/code-review/src/report.ts 1 return { ok: false, reason: "the reply was not JSON" }; From 6bb78ca965878d4db972be5b4fd2a8ddca537599 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:13:52 -0700 Subject: [PATCH 07/18] docs(hub): clear stale signup/tenancy env references post-cutover Summary: - test/isolation/setup.ts, scripts/e2e/harness.ts: drop WORKBENCH_SIGNUP/ALLOW_UNVERIFIED_EMAILS boot env (stock composition leaves signup ungated). - scripts/dev.ts: drop WORKBENCH_SIGNUP=open injection and the signup_closed seed branch. - scripts/e2e/local-rip.test.ts: closed-signup hop becomes signup-stays-ungated against the main hub. - .env.example: remove WORKBENCH_SIGNUP/WORKBENCH_ALLOWED_EMAIL_DOMAINS/ALLOW_UNVERIFIED_EMAILS/WORKBENCH_DEFAULT_TENANT/ORG_SLUG blocks. - docs/TENANCY.md, docs/local-rip.md: describe ungated stock signup, caller-chosen root slug, removed tenant-create guard. Verification: - Hub typecheck (apps/hub tsc --noEmit): exit 0. - prettier --check + eslint on all touched files: clean. - Worktree grep for deleted keys: only intentional historical mentions remain (listed in report). --- .env.example | 39 ++------ docs/TENANCY.md | 167 ++++++++++++---------------------- docs/local-rip.md | 17 ++-- scripts/dev.ts | 27 ++---- scripts/e2e/harness.ts | 9 +- scripts/e2e/local-rip.test.ts | 44 +++------ test/isolation/setup.ts | 7 +- 7 files changed, 97 insertions(+), 213 deletions(-) diff --git a/.env.example b/.env.example index 435b6cd2bf..a767904753 100644 --- a/.env.example +++ b/.env.example @@ -44,10 +44,9 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 HUB_STATIC_DIR=../web/dist # The administrator identity `bun run dev` seeds for local development: -# the dev script signs this account up and makes it the owner of the root -# tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh checkout can sign in -# immediately. Unset values fall back to the defaults shown — fine for -# local development, set both for real deployments. +# the dev script signs this account up on the fresh hub, so a fresh +# checkout can sign in immediately. Unset values fall back to the defaults +# shown — fine for local development, set both for real deployments. # HUB_ADMIN_EMAIL=alice@example.com # HUB_ADMIN_PASSWORD=password123 @@ -55,16 +54,6 @@ HUB_STATIC_DIR=../web/dist # feature it configures off; the hub never treats a partially-set group # as configured — it fails loudly at boot instead. -# Slug of the root tenant first signup mints. Every self-served -# personal bench parents under it, and `workbench setup`/`seed` resolve -# the same slug. Unset falls back -# to "workbench". ORG_SLUG is an alias when this is unset — set only one. -# Upgrading a deploy whose existing root was not "workbench": set this -# to that slug (do not leave OPERATOR_TENANT_ID; the hub refuses to boot -# while that stale key is set). -# WORKBENCH_DEFAULT_TENANT=workbench -# ORG_SLUG=workbench - # Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60 # seconds when unset. # SIGNUP_RATE_LIMIT_WINDOW_SECONDS=60 @@ -79,15 +68,10 @@ HUB_STATIC_DIR=../web/dist # SIGNIN_RATE_LIMIT_WINDOW_SECONDS=60 # SIGNIN_RATE_LIMIT_MAX=10 -# Self-serve signup mode. Hub default is closed (owner adds users or -# shares a copy-link invite). Local `bun run dev` opens signup when this -# is unset so the first admin can seed — set closed explicitly to test -# the closed gate. See docs/TENANCY.md. -# WORKBENCH_SIGNUP=open - -# When WORKBENCH_SIGNUP=open, optionally restrict which email domains -# may register (comma-separated). Empty/unset = any domain. -# WORKBENCH_ALLOWED_EMAIL_DOMAINS=acme.example +# Self-serve signup is ungated: stock Interchange composition carries no +# signup mode, domain allowlist, or email-verification gate, so anyone +# can register (rate-limited only — see SIGNUP_RATE_LIMIT_* above). Gate +# access in the composition that embeds the hub if a deployment needs it. # Provider API keys are no longer read from the environment: hub boot # plants nothing, and setting ANTHROPIC_API_KEY or any other provider's @@ -271,15 +255,6 @@ HUB_STATIC_DIR=../web/dist # even if this file is copied verbatim. ALLOW_PLAINTEXT_SECRETS=1 -# Opt-out of @workbench/access-policy's email-verification requirement. -# The hub has no transactional email wired yet, so NO account can ever -# become verified — leaving this unset makes `bun run setup` and every -# self-signup provisioning fail with signup_not_allowed. It therefore -# ships ON for local development. Turn it off (and wire verification) -# before exposing a deployment where invite redemption must prove -# address ownership. -ALLOW_UNVERIFIED_EMAILS=1 - # Artifacts plane (@corbits/artifacts) uses DATABASE_URL too — different # schema only (`artifacts.*` FKs into public.tenant/principal). Nothing to # configure here; `bun run dev` mounts Library automatically. diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 7a9f71ee2f..20de4711c9 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -3,7 +3,7 @@ Workbench tenancy is **not** a parallel product schema. Membership, principals, grants, roles, and the tenant hierarchy live in Interchange (`@intx/db`, `@intx/hub-api`). Workbench adds product contracts on top: -signup mode, workbench icons, DM workbench shape, invite links, and +workbench icons, DM workbench shape, invite links, and parent-validation at creation time. This document is the authoritative gap list for anything that still @@ -17,105 +17,59 @@ requires an upstream Interchange change. **Do not patch `vendor/intx`.** | Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time | | Descendant walk | `getDescendantTenants` in `@intx/db` | | Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table | -| First-signup genesis / join | `packages/onboarding`'s `genesisOrJoinHubSignup`: on an empty hub (zero tenants) the first signup mints the root tenant and becomes its `owner`; every later signup joins the root as a plain `member`. Signup never seeds workflows, tools, or grants | +| First-signup genesis / join | `packages/onboarding`'s `genesisOrJoinHubSignup` is retained but unwired: stock composition mounts no first-login provisioning hook, so signup mints no tenants — the first tenant still comes from an ordinary `POST /api/tenants` with a chosen slug | | Memberships | Native principal + membership routes | Inheritance is **live**. Creating a sub-workbench must **not** copy catalog rows, credentials, or providers from the parent — resolution walks the chain on every read. -### Root tenant slug (one deployment fact) +### Root tenant slug (caller-chosen) -The default tenant slug is a deployment fact, not a boot insert. An -empty database is a valid hub: boot mints no root. First signup -creates the root with this slug, and `workbench setup`/`seed` resolve -the same slug: +The root tenant slug is a caller choice, not hub env. Stock +composition mints no root at boot and reads no slug key: +`WORKBENCH_DEFAULT_TENANT`, its `ORG_SLUG` alias, and the retired +`OPERATOR_TENANT_ID` are all gone. The first tenant comes from an +ordinary `POST /api/tenants` with a caller-chosen slug (see the +stock-signup test in `apps/hub/test/composition.test.ts`), and its +creator is the native `owner`. -1. `WORKBENCH_DEFAULT_TENANT` if set -2. else `ORG_SLUG` (alias) -3. else `workbench` +There is no signup fallback to configure: stock composition carries +no signup gate, so a freshly minted root needs no `access_policy` row +for signup to work. This cutover does not migrate policy rows from a +previous operator tenant. -Set only one. Custom-slug upgrades whose existing root is not -`workbench` must set `WORKBENCH_DEFAULT_TENANT=` -— otherwise first signup mints a `workbench` root. Leftover -`OPERATOR_TENANT_ID` is no longer read: `readHubConfig` fails loudly -and tells the operator to set `WORKBENCH_DEFAULT_TENANT` (or remove -the stale key for the default slug). - -A freshly minted root has no `access_policy` row yet, so signup falls -back to `WORKBENCH_SIGNUP` until Settings → People → "Who can join" -writes one. This cutover does not migrate policy rows from a previous -operator tenant. - -### The 0→1 contract (first signup is genesis) +### The 0→1 contract (no genesis mint) On a hub that starts with **zero tenants and zero users** — the default after boot, which never mints a root — nobody has to pre-seed -an admin: - -- The sign-up/email route admits the very first signup even when - `WORKBENCH_SIGNUP=closed` (the empty-hub exception in - `apps/hub/src/index.ts`'s `authHandler`: allowed only while both - `countUsers()` and `countTenants()` are zero). -- The tenant-create guard allows that caller's unparented - `POST /api/tenants` while `countTenants() === 0`. -- `genesisOrJoinHubSignup` mints the root tenant with the default - slug; the creator is its native `owner`. -- Every later signup (tenants > 0, or more than one user) joins the - existing root as a native `member` — and never mints a tenant of its - own. Signup never seeds the default workflow set; seeding belongs to - the credential step. +an admin, and the first signup mints nothing either: + +- The sign-up/email route registers the very first account ungated — + stock composition carries no signup mode, so there is no closed + default and no empty-hub exception to grant. +- The caller's composition creates the first tenant through an + ordinary `POST /api/tenants` with a caller-chosen slug; the creator + is its native `owner`. +- Every later signup only registers an account — it never mints a + tenant of its own. Signup never seeds the default workflow set; + seeding belongs to the credential step. ## Workbench-side contracts (this repo) -### Signup mode - -`@workbench/access-policy` (CL-5886) is the actual enforcement point, -called from `packages/onboarding`'s first-login provisioning hook — it -is never patched into a vendor route. Two layers, in order: - -1. **Bootstrap (env, no policy row yet)**: `WORKBENCH_SIGNUP=open|closed` - (default **`closed`**) plus optional `WORKBENCH_ALLOWED_EMAIL_DOMAINS` - (comma-separated). Local `bun run dev` injects `WORKBENCH_SIGNUP=open` - when the variable is unset, so a zero-edit `.env` can still seed the - admin account; an explicit value in `.env` always wins; production - deploys that do not use the dev launcher keep the closed default. -2. **Policy row (root tenant)**: the boot-ensured root tenant can carry an - explicit `access_policy.policy` row (editable from Settings - → People → "Who can join"); that row decides outright and the env - flag is no longer consulted — `selfSignup` is `"off"`, `"allowed- -domains"` (with an `allowedDomains` list), or `"open"`. An absent row - is closed defaults, identical in effect to `selfSignup: "off"`. - -**closed** — self-serve email signup is rejected. New humans join only -when an operator creates them through native APIs, or an owner shares a -**copy-link invite** (token in the URL, out of scope for delivery). - -**Email must be verified.** better-auth is configured without -`requireEmailVerification`, so a freshly-registered address is not -proof of ownership on its own. Every email-trust decision -`@workbench/access-policy` makes — an allowed-domains match or an -open-policy pass — requires -`user.emailVerified === true`; an unverified email is denied, fail- -closed, regardless of what the policy or env otherwise allow. -`ALLOW_UNVERIFIED_EMAILS=1` opts out for local dev/test only, mirroring -`ALLOW_PLAINTEXT_SECRETS` — never set it for a real deployment. - -**A policy row and the env switch can disagree**, and that disagreement -is not resolved automatically: `WORKBENCH_SIGNUP` also gates the -underlying better-auth `/sign-up/email` route directly (see -`apps/hub/src/index.ts`'s `authHandler`), independent of anything -`@workbench/access-policy` decides. Setting a bench's own policy to -`selfSignup: "allowed-domains"` or `"open"` while the operator's env -still has `WORKBENCH_SIGNUP=closed` does not open the sign-up form — -people still cannot create a password account at all, even though the -policy would otherwise let them join once they had one. The "Who can -join" settings panel surfaces this with an inline notice whenever the -policy would allow signup but the env switch is still closed, rather -than leaving it silently broken. There is no plan to make the policy -row flip the env switch automatically — the env switch is an operator -deployment fact, the policy row is a per-bench product setting, and the -mismatch is meant to be visible, not auto-resolved. +### Signup (ungated in stock composition) + +Stock composition carries no signup gate: `WORKBENCH_SIGNUP`, +`WORKBENCH_ALLOWED_EMAIL_DOMAINS`, and `ALLOW_UNVERIFIED_EMAILS` are +all gone, and nothing gates better-auth's `/sign-up/email` route or +requires `user.emailVerified`. Anyone can register (rate-limited +only — see `SIGNUP_RATE_LIMIT_*` in `.env.example`). + +`@workbench/access-policy` and `packages/onboarding`'s first-login +provisioning hook are retained as packages but unwired: the hub never +calls them, so no policy row or env flag decides signup. A deployment +that needs closed signup gates it in the composition that embeds the +hub. ### Workbench icon @@ -128,27 +82,19 @@ and avatar badges. See `WorkbenchIcon` in `@corbits/bench-ui`. **[Intx gap] CL-6041**: `POST /api/tenants` itself is ungated at the platform level — any authenticated caller can hit it directly with an -arbitrary `parentId` and become owner of a child under any tenant, -bypassing the wrapper below entirely. Filed upstream; until it lands, -`apps/hub/src/tenant-create-guard.ts` wraps the whole hub app in a guard -registered in front of the native route (Hono composes handlers in -registration order, so this has to be an outer wrap, not a middleware -added after the route already exists) — see that file's module comment. -Every `POST /api/tenants` call, whoever originates it, is decided the -same way: - -- No `parentId`, or `parentId` equal to the operator tenant: the - signup gate (same decision as above) — this is the self-service - landing zone. -- Any other `parentId`: the caller must already be a member of that - exact tenant, with a role its own `tenancyCreation` policy accepts — - `"owners"` (default), `"owners-admins"`, or `"none"`. +arbitrary `parentId` and become owner of a child under any tenant. +Filed upstream; the workbench-side outer guard +(`apps/hub/src/tenant-create-guard.ts`) was removed in the stock- +composition cutover, so until the platform check lands this gap is +open: every `POST /api/tenants` call, whoever originates it, mints a +tenant with the caller as owner — no signup-gate landing zone, no +membership check on `parentId`. `@workbench/access-policy`'s `POST /api/tenants/:tenantId/access-policy/ child-tenants` is the polished UI-facing wrapper for creating a child tenant under a parent — it makes the same decision and gives a clean -pre-flight 403, but the guard above is what actually closes the gap; the -wrapper alone would not. +pre-flight 403, but with the guard above removed in the cutover the +wrapper alone does not close the gap. Interchange currently does **not** validate `parentId` on POST and has **no** cycle constraint — see gaps below. @@ -297,7 +243,8 @@ fork or shim inside `vendor/intx`. 7. **Signup is always open at the auth layer** — better-auth email+password enablement is hub-config, not a platform tenancy - primitive. Workbench gates via `WORKBENCH_SIGNUP`. + primitive, and stock composition adds no gate on top — signup stays + ungated. 8. **Invite is membership-side, not token-link** — native invite is "add principal"; copy-link invite tokens are a workbench product @@ -311,9 +258,10 @@ fork or shim inside `vendor/intx`. 10. **[Intx gap] CL-6041 — `POST /api/tenants` has no grant/policy check of its own** — any authenticated user may call it directly with an arbitrary `parentId` and become owner of a freshly-minted - child under any tenant. Workbench closes this with an outer guard - in `apps/hub/src/tenant-create-guard.ts` (see "Sub-workbench - creation" above) rather than waiting on an upstream fix; the + child under any tenant. Workbench's outer guard + (`apps/hub/src/tenant-create-guard.ts`) was removed in the stock- + composition cutover, so this gap is currently open rather than + closed workbench-side (see "Sub-workbench creation" above); the platform should reject the request unless the caller already holds a create-child grant on `parentId`. @@ -332,8 +280,9 @@ needs a weaker role, that is an Interchange conversation first. - `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts - `@workbench/onboarding` — genesis-or-join first-signup provisioning -- `@workbench/access-policy` — closed-by-default signup/sub-workbench- - creation policy -- `apps/hub` — `WORKBENCH_SIGNUP`, invite routes, icon routes; one of the + (retained, unwired in stock composition) +- `@workbench/access-policy` — signup/sub-workbench-creation policy + package (retained, but the hub's stock composition mounts no gate) +- `apps/hub` — invite routes, icon routes; one of the explicitly-listed apps/hub mounts pending extraction into a package (see [ARCHITECTURE.md](../ARCHITECTURE.md), CL-6127) diff --git a/docs/local-rip.md b/docs/local-rip.md index 5a6cd277bb..59d632cf48 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -56,13 +56,11 @@ Once `bun run dev` reports the hub and web server up, open The app root renders the sign-in screen when you're not authenticated (`apps/web/src/app.tsx`'s ``); toggle it to sign-up and create -an account with any email/password. `WORKBENCH_SIGNUP` defaults to -`closed` — `bun run dev` forces it `open` for the duration of the dev -process regardless of what `.env` says (unless `.env` sets it explicitly), -so self-serve sign-up works out of the box here. On a deployed hub with no -override, the same sign-up attempt is refused at `POST /api/auth/sign-up/email` -with `signup_closed` — `scripts/e2e/local-rip.test.ts`'s first hop proves -exactly that response. +an account with any email/password. Self-serve sign-up is ungated in +stock composition — there is no signup mode left to open, so sign-up +works out of the box here, against `bun run dev` and any deployed hub +alike. `scripts/e2e/local-rip.test.ts`'s "signup stays ungated once the +hub is occupied" hop proves exactly that response. ## 3. Name your bench @@ -118,9 +116,8 @@ bench, and **assistant** comes up live by default; `scripts/e2e/local-rip.test.t also deploys **echo** and **workbench-digest** from `CATALOG_WORKFLOWS` onto that same bench (the on-demand path, exercised directly rather than assumed) to prove they still come up live too. The default -self-serve story is the other way: the hub ensures a root tenant at boot -(`WORKBENCH_DEFAULT_TENANT`, default `workbench`), and first-login -personal benches parent under it. +self-serve story is the other way: the hub ensures no root tenant at +boot — the walkthrough's personal bench is itself the root. ## 5. Check the Connections surface diff --git a/scripts/dev.ts b/scripts/dev.ts index 762edbc9ce..fe99ca1087 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -140,16 +140,12 @@ const hubApp: App = { dir: join(repoRoot, "apps", "hub"), command: ["bun", "--watch", "--env-file=../../.env", "src/index.ts"], }; -// Zero-edit local bootstrap: when the operator has not set a signup -// mode, open it so seedDevAccount can create alice. Production hub -// still defaults closed when this script is not the launcher. -// Explicit WORKBENCH_SIGNUP in .env always wins (bun loads env-file). +// Local bootstrap needs no signup env: stock Interchange composition +// leaves self-serve signup ungated (rate-limited only), so seedDevAccount +// below can always register alice on a fresh checkout. function mergeHubEnv(extra: Record): void { hubApp.env = { ...hubApp.env, ...extra }; } -if (process.env["WORKBENCH_SIGNUP"] === undefined) { - mergeHubEnv({ WORKBENCH_SIGNUP: "open" }); -} const localMemoryEmbed = localDevMemoryEmbedEnv(process.env, { hasNativeOllama: Bun.which("ollama") !== null, }); @@ -354,12 +350,12 @@ async function requireDatabaseSetUp(config: HubConfig): Promise { * Create the administrator account once the hub answers, so a fresh * checkout can sign in immediately. Runs beside the apps; unset * identity variables fall back to the same defaults the CLI uses, so a - * zero-edit .env still yields a signable account when signup is open. + * zero-edit .env still yields a signable account: stock composition + * leaves self-serve signup ungated, so fresh registration always works. * "Already exists" is a skip, not an error — re-runs stay quiet. * - * Sign-in is tried first so a closed signup mode still works once the - * admin has been created. Fresh registration needs WORKBENCH_SIGNUP=open - * (local `bun run dev` opens it when the env var is unset — see apps). + * Sign-in is tried first so a re-run against an existing account skips + * registration entirely. */ async function seedDevAccount(config: HubConfig): Promise { const email = process.env["HUB_ADMIN_EMAIL"] ?? "alice@example.com"; @@ -423,15 +419,6 @@ async function seedDevAccount(config: HubConfig): Promise { console.log(`[dev] account ${email} already exists`); return; } - if (signUp.status === 403 && /signup_closed/i.test(body)) { - fail( - [ - `[dev] could not seed account ${email}: self-serve signup is closed`, - "and the account does not exist yet. Set WORKBENCH_SIGNUP=open in", - ".env, restart, then re-run `bun run dev` once.", - ].join(" "), - ); - } fail(`[dev] could not seed account ${email}: ${signUp.status} ${body}`); } catch (error) { fail( diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index 0c4412f8c2..46c9443d3f 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -274,13 +274,13 @@ export async function startHub(options: { const baseUrl = `http://localhost:${options.port}`; const app = spawnApp("hub", HUB_DIR, { ...osEnv(), - // e2e hubs always allow signup, serve apps/hub/public, and advertise - // BASE_URL as their own listen address by default; a caller's + // e2e hubs serve apps/hub/public and advertise BASE_URL as their + // own listen address by default (signup is ungated in stock + // composition, so no signup env is needed); a caller's // extraEnv can still override any of these (e.g. a real web build's // dist dir, or a public BASE_URL that differs from the actual listen // port — see PORT below — for a browser-driven suite fronted by a // dev-server proxy). - WORKBENCH_SIGNUP: "open", HUB_STATIC_DIR: "public", BASE_URL: baseUrl, // The routine scheduler's real production cadence is a 30s @@ -306,9 +306,6 @@ export async function startHub(options: { // The e2e suite never configures CREDENTIAL_ENCRYPTION_KEY; opt into // the hub's dev/test fallback so boot doesn't hard-fail here. ALLOW_PLAINTEXT_SECRETS: "1", - // e2e accounts sign up over the wire with no mail delivery, so their - // emails can never verify; opt into the dev/test escape hatch. - ALLOW_UNVERIFIED_EMAILS: "1", }); const deadline = Date.now() + 30_000; for (;;) { diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 31e8f90f80..f15d844aad 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -7,7 +7,7 @@ // → a tester joins the genesis root as a plain member (the genesis // path — first signup on a truly empty hub mints the root — is also // covered in-process by `apps/hub/test/signup-genesis.test.ts`) → -// occupied closed signup is refused → the root's owner (alice) +// occupied signup stays ungated → the root's owner (alice) // connects a real inference credential through the key path // (`POST /api/onboarding/complete`'s own machinery, called directly — // see the stubbing note below), which fully seeds every default @@ -208,37 +208,19 @@ describe.skipIf(databaseUrl === undefined)( return { cookies: res.cookies, userId }; }); - // Occupied closed signup: a hub with WORKBENCH_SIGNUP=closed - // refuses a brand-new person once the hub is no longer empty — - // the empty-hub exception already admitted alice. Proven against - // a short-lived hub of its own so the rest of this scenario's - // open hub never muddies the assertion. - await hop("closed-by-default signup is respected", async () => { - const closedHub = await startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from( - crypto.getRandomValues(new Uint8Array(32)), - ).toString("hex"), - dataDir: await tempDir("e2e-local-rip-closed-hub-data-"), - extraEnv: { WORKBENCH_SIGNUP: "closed" }, + // Stock composition leaves self-serve signup ungated: a brand-new + // person can still sign up once the hub is occupied (there is no + // signup mode left to close). Proven against the main hub itself, + // so no second boot is needed. + await hop("signup stays ungated once the hub is occupied", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Open Signup Tester", + email: `local-rip-open-${crypto.randomUUID()}@example.invalid`, + password: `pw-${crypto.randomUUID()}`, }); - try { - const res = await api( - closedHub.baseUrl, - "POST", - "/api/auth/sign-up/email", - { - name: "Closed Signup Tester", - email: `local-rip-closed-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }, - ); - expectStatus("closed-signup sign-up attempt", res, 403); - const body = res.data as { error: string }; - expect(body.error).toBe("signup_closed"); - } finally { - await closedHub.stop(); + expectStatus("open-signup sign-up attempt", res, 200); + if (res.cookies.length === 0) { + throw new Error("open-signup sign-up returned no session cookie"); } }); diff --git a/test/isolation/setup.ts b/test/isolation/setup.ts index 07271b0c16..ebe8dbe450 100644 --- a/test/isolation/setup.ts +++ b/test/isolation/setup.ts @@ -110,11 +110,8 @@ export async function bootIsolationHub( dataDir: hubDataDir, extraEnv: { // Isolation suite mints throwaway accounts over the public signup - // surface; production default is closed, so open it only for this boot. - WORKBENCH_SIGNUP: "open", - // Throwaway accounts have no mail delivery, so their emails can - // never verify; opt into the dev/test escape hatch. - ALLOW_UNVERIFIED_EMAILS: "1", + // surface; stock Interchange composition leaves signup ungated + // (rate-limited only), so this boot needs no signup env at all. SIGNUP_RATE_LIMIT_MAX: String(ISOLATION_SIGNUP_RATE_LIMIT_MAX), SIGNUP_RATE_LIMIT_WINDOW_SECONDS: String( ISOLATION_SIGNUP_RATE_LIMIT_WINDOW_SECONDS, From 1edc1b5899861dff9c34405535ad7863f77a0767 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:19:41 -0700 Subject: [PATCH 08/18] test(hub): rewrite local-rip provision hops against stock tenant/agent routes --- scripts/e2e/local-rip.test.ts | 201 +++++++++++++++++++--------------- 1 file changed, 115 insertions(+), 86 deletions(-) diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index f15d844aad..77472130d2 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -3,14 +3,17 @@ // provider. One sequential scenario against a real hub, a real // sidecar, and a real Postgres. // -// Phase A (onboard → connect): alice signs up first as genesis owner -// → a tester joins the genesis root as a plain member (the genesis -// path — first signup on a truly empty hub mints the root — is also -// covered in-process by `apps/hub/test/signup-genesis.test.ts`) → -// occupied signup stays ungated → the root's owner (alice) -// connects a real inference credential through the key path -// (`POST /api/onboarding/complete`'s own machinery, called directly — -// see the stubbing note below), which fully seeds every default +// Phase A (onboard → connect): alice signs up first as genesis owner, +// then mints the root bench herself through the stock tenant route +// (stock composition mounts no provisioning hook — signup mints +// nothing, so the first tenant comes from an ordinary +// `POST /api/tenants` with a caller-chosen slug, and its creator is +// the native owner) → a tester signs up as a plain account and the +// owner invites them onto the root as a member (the stock +// `members/invite` route plus activation) → occupied signup stays +// ungated → the root's owner (alice) connects a real inference +// credential through the key path (the connect flow's own machinery, +// called directly — see the stubbing note below), which fully seeds every default // workflow, including "assistant" → the Connections surface (the // tenant's own credentials list, the same route `connectorStatus` in // `@workbench/settings-ui` reads) honestly reflects the connected @@ -27,12 +30,12 @@ // `runPublishTools` hop onto the root stands in for // setup, then `ensureSeeded` deploys without packing. // -// Stubbing note: onboarding's own `POST /api/onboarding/complete` route +// Stubbing note: the key-path connect flow's own machinery // (`testAndPersistCredential`, from `@workbench/onboarding`'s // `complete-credential.ts`) stores a pasted key immediately, with no // live probe of the provider gating it (CL-6123) — so there is nothing // left to stub there. This suite still drives the same two halves the -// route itself calls (`testAndPersistCredential`/`ensureSeeded`) +// connect flow itself calls (`testAndPersistCredential`/`ensureSeeded`) // directly rather than through HTTP, the same way `chat.test.ts` drives // `seedCatalog` directly rather than going through an HTTP surface that // has no test seam. Every call these two halves make is real HTTP @@ -189,23 +192,46 @@ describe.skipIf(databaseUrl === undefined)( ); const probe = await api( hub.baseUrl, - "POST", - "/api/onboarding/provision", + "GET", + "/api/me/principals", undefined, res.cookies, ); expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + expect((probe.data as { data: unknown[] }).data).toEqual([]); + // Stock composition mounts no provisioning hook — signup mints + // nothing — so the first tenant comes from an ordinary + // `POST /api/tenants` with a caller-chosen slug, and its + // creator is the native owner. const minted = await api( hub.baseUrl, "POST", - "/api/onboarding/provision", - { name: "Workbench" }, + "/api/tenants", + { slug: "workbench", name: "Workbench" }, res.cookies, ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; + expectStatus("alice genesis tenant create", minted, 201); + const mintedBody = minted.data as { + id?: string; + tenantId?: string; + }; + const tenantId = + typeof mintedBody.id === "string" + ? mintedBody.id + : typeof mintedBody.tenantId === "string" + ? mintedBody.tenantId + : undefined; + if (tenantId === undefined) { + throw new Error( + `create tenant answered no id: ${JSON.stringify(minted.data)}`, + ); + } + return { + cookies: res.cookies, + userId, + tenantId, + tenantSlug: "workbench", + }; }); // Stock composition leaves self-serve signup ungated: a brand-new @@ -228,79 +254,93 @@ describe.skipIf(databaseUrl === undefined)( signUp(hub.baseUrl, "Local Rip Tester"), ); - const provisioned = await hop( - "a membership probe joins the genesis root as a member", + // Stock composition auto-joins nobody: a second signup only + // registers an account. The owner brings the tester onto the + // root through the native invite route (member role, so read-only + // by design) and activates the membership. + await hop( + "the owner invites the tester onto the root bench as a member", async () => { - const res = await api( + const rolesRes = await api( hub.baseUrl, - "POST", - "/api/onboarding/provision", + "GET", + `/api/tenants/${admin.tenantId}/roles`, undefined, - user.cookies, + admin.cookies, ); - expectStatus("provision probe", res, 200); - const data = res.data as { - kind: string; - tenantId: string; - tenantSlug: string; - seeded: boolean; - seedSkipReason?: string; - }; - expect(data.kind).toBe("existing-member"); - stringField(data, "tenantId", "provision result"); - stringField(data, "tenantSlug", "provision result"); - return data; + expectStatus("list root roles", rolesRes, 200); + const roles = ( + rolesRes.data as { data: { id: string; name: string }[] } + ).data; + const memberRole = roles.find((role) => role.name === "member"); + if (memberRole === undefined) { + throw new Error( + `no member role on the root tenant: ${JSON.stringify(roles)}`, + ); + } + const invited = await api( + hub.baseUrl, + "POST", + `/api/tenants/${admin.tenantId}/members/invite`, + { email: user.email, roleId: memberRole.id }, + admin.cookies, + ); + expectStatus("invite tester", invited, 201); + const principalId = stringField(invited.data, "id", "invite result"); + const activated = await api( + hub.baseUrl, + "PATCH", + `/api/tenants/${admin.tenantId}/principals/${principalId}`, + { status: "active" }, + admin.cookies, + ); + expectStatus("activate tester", activated, 200); + // Inviting the same account again mints nothing: stock answers + // 409, the surviving idempotency half of the removed + // re-provision hop. + const repeat = await api( + hub.baseUrl, + "POST", + `/api/tenants/${admin.tenantId}/members/invite`, + { email: user.email, roleId: memberRole.id }, + admin.cookies, + ); + expectStatus("re-invite", repeat, 409); }, ); - await hop("re-provisioning the same account is idempotent", async () => { + await hop("the invited bench is a real tenant membership", async () => { const res = await api( hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Local Rip Tester's Bench" }, + "GET", + "/api/me/principals", + undefined, user.cookies, ); - expectStatus("re-provision", res, 200); - const data = res.data as { kind: string }; - expect(data.kind).toBe("existing-member"); - }); - - await hop( - "the provisioned bench is a real tenant membership", - async () => { - const res = await api( - hub.baseUrl, - "GET", - "/api/me/principals", - undefined, - user.cookies, + expectStatus("list principals", res, 200); + const rows = (res.data as { data: { tenantId: string }[] }).data; + const own = rows.find((row) => row.tenantId === admin.tenantId); + if (own === undefined) { + throw new Error( + `invited tenant ${admin.tenantId} is missing from the caller's own principals: ${JSON.stringify(rows)}`, ); - expectStatus("list principals", res, 200); - const rows = (res.data as { data: { tenantId: string }[] }).data; - const own = rows.find((row) => row.tenantId === provisioned.tenantId); - if (own === undefined) { - throw new Error( - `provisioned tenant ${provisioned.tenantId} is missing from the caller's own principals: ${JSON.stringify(rows)}`, - ); - } - }, - ); + } + }); const tenant = await hop( - "the freshly provisioned bench resolves through findPersonalTenant", + "the freshly created bench resolves through findPersonalTenant", async () => { const found = await findPersonalTenant( hubApi, admin.cookies, - provisioned.tenantSlug, + admin.tenantSlug, ); if (found === undefined) { throw new Error( - `findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`, + `findPersonalTenant found nothing for slug ${admin.tenantSlug}`, ); } - expect(found.tenantId).toBe(provisioned.tenantId); + expect(found.tenantId).toBe(admin.tenantId); return found; }, ); @@ -409,13 +449,13 @@ describe.skipIf(databaseUrl === undefined)( // publisher with a privileged cookie jar directly. Then // ensureSeeded deploys assistant without packing. await hop( - "publish-tools installs corbits-tools onto the provisioned root bench (setup's job, not seed's)", + "publish-tools installs corbits-tools onto the created root bench (setup's job, not seed's)", async () => { const result = await runPublishTools({ hubUrl: hub.baseUrl, email: "alice@example.com", password: "password123", - tenant: provisioned.tenantSlug, + tenant: admin.tenantSlug, log: () => undefined, }); expect(result.success).toBe(true); @@ -657,28 +697,16 @@ describe.skipIf(databaseUrl === undefined)( ); } // The tenant-create observer reconciles fire-and-forget; poll - // for the new tenant's own live assistant. The status read is - // the same doc lookup the kick reconciles against, so a timeout - // reports per-pin pending/blocked/failed instead of a bare - // "never converged". + // the new tenant's own assets and deployments for its live + // assistant. const deadline = Date.now() + 90_000; - let lastSteps: unknown; + let lastSeen: unknown; for (;;) { if (hub.exited()) { throw new Error( `hub exited before the second tenant converged; output:\n${hub.output()}`, ); } - const statusRes = await api( - hub.baseUrl, - "GET", - `/api/onboarding/provisioning-status?tenantId=${secondTenantId}`, - undefined, - admin.cookies, - ); - if (statusRes.status === 200) lastSteps = statusRes.data; - else - lastSteps = `provisioning-status ${statusRes.status}: ${JSON.stringify(statusRes.data)}`; const assetsRes = await api( hub.baseUrl, "GET", @@ -688,6 +716,7 @@ describe.skipIf(databaseUrl === undefined)( ); expectStatus("list second-tenant assets", assetsRes, 200); const assets = assetsRes.data as { id: string; name: string }[]; + lastSeen = assets.map((a) => a.name); const assistant = assets.find((a) => a.name === "assistant"); if (assistant !== undefined) { const deploymentsRes = await api( @@ -718,7 +747,7 @@ describe.skipIf(databaseUrl === undefined)( } if (Date.now() > deadline) { throw new Error( - `the second tenant never converged onto its own assistant; last provisioning-status: ${JSON.stringify(lastSteps ?? "no provisioning-status read yet")}`, + `the second tenant never converged onto its own assistant; last asset names: ${JSON.stringify(lastSeen)}`, ); } await Bun.sleep(500); From 368cd0bcd6ff6c841176494a67e12d7d3a1aa325 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:25:39 -0700 Subject: [PATCH 09/18] refactor(hub): cut retained signup env-key code from four packages Summary: - access-policy: drop envSignupMode/envAllowedDomains bootstrap from evaluateSignupGate/checkSignupGate; rowless evaluation is closed defaults. allowUnverifiedEmails stays as an explicit caller option. - bench-ui: delete SignupMode, DEFAULT_SIGNUP_MODE, emailAllowedForSignup, parseAllowedEmailDomains, parseSignupMode and their exports/tests; README prose updated. - settings-ui: remove envSignupClosed notice/probe from AccessPolicyBlock/Editor; delete getAuthConfig/AuthConfig client for the removed /api/auth-config endpoint and its string/test. - hub-api-client: remove signup_closed env-fix branch from authenticate; failure prose points at hub logs, never an env key. - onboarding: thread the trimmed gate options through genesis/provision/routes; gate-specific tests wire the policy explicitly, other tests run without a gate. Verification: - bun test in packages/access-policy (36 pass), packages/onboarding (223 pass), packages/bench-ui (22 pass), packages/settings-ui (161 pass), packages/hub-api-client (13 pass) - bun run typecheck: exit 0 - bun run lint: exit 0 (0 errors, 10 pre-existing warnings) - bun run check:structural: exit 0 --- packages/access-policy/src/gate.test.ts | 26 +--- packages/access-policy/src/gate.ts | 8 +- packages/access-policy/src/policy.test.ts | 62 +--------- packages/access-policy/src/policy.ts | 52 +++----- packages/bench-ui/README.md | 4 +- packages/bench-ui/src/index.ts | 5 - .../bench-ui/src/tenancy-contracts.test.ts | 72 ----------- packages/bench-ui/src/tenancy-contracts.ts | 44 ------- packages/hub-api-client/src/hub.test.ts | 115 ++++++++++++++++++ packages/hub-api-client/src/hub.ts | 28 ++--- packages/onboarding/src/genesis.ts | 4 - packages/onboarding/src/provision.ts | 2 - packages/onboarding/src/routes.ts | 2 - packages/onboarding/test/genesis.test.ts | 86 ++----------- packages/settings-ui/src/access-policy.tsx | 29 ----- packages/settings-ui/src/api.ts | 13 +- packages/settings-ui/src/index.ts | 9 +- packages/settings-ui/src/strings.ts | 2 - packages/settings-ui/test/api.test.ts | 23 +--- 19 files changed, 169 insertions(+), 417 deletions(-) create mode 100644 packages/hub-api-client/src/hub.test.ts diff --git a/packages/access-policy/src/gate.test.ts b/packages/access-policy/src/gate.test.ts index e587577c92..db48a19353 100644 --- a/packages/access-policy/src/gate.test.ts +++ b/packages/access-policy/src/gate.test.ts @@ -6,53 +6,45 @@ import { createInMemoryAccessPolicyStore } from "./store"; const verified = { emailVerified: true, allowUnverifiedEmails: false }; describe("checkSignupGate", () => { - test("no operator tenant configured -> only the env flag can gate", async () => { + test("no operator tenant configured -> closed defaults", async () => { const store = createInMemoryAccessPolicyStore(); const result = await checkSignupGate({ store, - envSignupMode: "closed", - envAllowedDomains: [], email: "person@acme.example", ...verified, }); expect(result).toEqual({ allowed: false, reason: "signup_closed" }); }); - test("operator tenant with no policy row yet -> env flag bootstraps", async () => { + test("operator tenant with no policy row yet -> closed defaults", async () => { const store = createInMemoryAccessPolicyStore(); const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "open", - envAllowedDomains: [], email: "person@acme.example", ...verified, }); - expect(result).toEqual({ allowed: true, reason: "env_open" }); + expect(result).toEqual({ allowed: false, reason: "signup_closed" }); }); - test("operator tenant with an explicit closed row overrides an open env flag", async () => { + test("operator tenant with an explicit closed row stays closed", async () => { const store = createInMemoryAccessPolicyStore(); await store.upsertPolicy("tnt_operator", { selfSignup: "off" }); const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "open", - envAllowedDomains: [], email: "person@acme.example", ...verified, }); expect(result).toEqual({ allowed: false, reason: "signup_closed" }); }); - test("operator tenant with an explicit open row overrides a closed env flag", async () => { + test("operator tenant with an explicit open row allows", async () => { const store = createInMemoryAccessPolicyStore(); await store.upsertPolicy("tnt_operator", { selfSignup: "open" }); const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "closed", - envAllowedDomains: [], email: "person@acme.example", ...verified, }); @@ -65,8 +57,6 @@ describe("checkSignupGate", () => { const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "closed", - envAllowedDomains: [], email: "person@acme.example", ...verified, }); @@ -79,8 +69,6 @@ describe("checkSignupGate", () => { const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "closed", - envAllowedDomains: [], email: "attacker@acme.example", emailVerified: false, allowUnverifiedEmails: false, @@ -88,14 +76,12 @@ describe("checkSignupGate", () => { expect(result).toEqual({ allowed: false, reason: "email_unverified" }); }); - test("ALLOW_UNVERIFIED_EMAILS opt-out restores the ordinary decision", async () => { + test("allowUnverifiedEmails opt-out restores the ordinary decision", async () => { const store = createInMemoryAccessPolicyStore(); await store.upsertPolicy("tnt_operator", { selfSignup: "open" }); const result = await checkSignupGate({ store, operatorTenantId: "tnt_operator", - envSignupMode: "closed", - envAllowedDomains: [], email: "dev@acme.example", emailVerified: false, allowUnverifiedEmails: true, diff --git a/packages/access-policy/src/gate.ts b/packages/access-policy/src/gate.ts index 708b54f82c..dedadb6b5a 100644 --- a/packages/access-policy/src/gate.ts +++ b/packages/access-policy/src/gate.ts @@ -9,10 +9,8 @@ export type SignupGateCheckArgs = { /** The tenant a fresh personal bench would be parented under, if * any — the same `operatorTenantId` the onboarding hook already * threads into tenant creation. No operator tenant means there is no - * tenant to hold a policy row, so only the env flag can gate. */ + * tenant to hold a policy row, so the closed defaults apply. */ readonly operatorTenantId?: string; - readonly envSignupMode: "open" | "closed"; - readonly envAllowedDomains: readonly string[]; readonly email: string; readonly emailVerified: boolean; readonly allowUnverifiedEmails: boolean; @@ -22,7 +20,7 @@ export type SignupGateCheckArgs = { * The one signup-gate check the first-login hook calls before minting a * personal bench. Looks up whether the operator tenant carries an * explicit policy row; if so that row decides outright, otherwise the - * env flag bootstraps. + * closed defaults apply. */ export async function checkSignupGate( args: SignupGateCheckArgs, @@ -34,8 +32,6 @@ export async function checkSignupGate( } return evaluateSignupGate({ policy, - envSignupMode: args.envSignupMode, - envAllowedDomains: args.envAllowedDomains, email: args.email, emailVerified: args.emailVerified, allowUnverifiedEmails: args.allowUnverifiedEmails, diff --git a/packages/access-policy/src/policy.test.ts b/packages/access-policy/src/policy.test.ts index c1792300ec..6e671eca02 100644 --- a/packages/access-policy/src/policy.test.ts +++ b/packages/access-policy/src/policy.test.ts @@ -78,48 +78,22 @@ describe("evaluateSignupGate", () => { test("rejects an unparseable email regardless of policy", () => { const result = evaluateSignupGate({ policy: { ...DEFAULT_ACCESS_POLICY, selfSignup: "open" }, - envSignupMode: "open", - envAllowedDomains: [], email: "not-an-email", ...verified, }); expect(result).toEqual({ allowed: false, reason: "invalid_email" }); }); - test("no policy row, env closed (the platform default) -> closed", () => { + test("no policy row -> closed defaults", () => { const result = evaluateSignupGate({ policy: undefined, - envSignupMode: "closed", - envAllowedDomains: [], email, ...verified, }); expect(result).toEqual({ allowed: false, reason: "signup_closed" }); }); - test("no policy row, env open with no domain restriction -> allowed (bootstrap)", () => { - const result = evaluateSignupGate({ - policy: undefined, - envSignupMode: "open", - envAllowedDomains: [], - email, - ...verified, - }); - expect(result).toEqual({ allowed: true, reason: "env_open" }); - }); - - test("no policy row, env open with a non-matching allowlist -> rejected", () => { - const result = evaluateSignupGate({ - policy: undefined, - envSignupMode: "open", - envAllowedDomains: ["other.example"], - email, - ...verified, - }); - expect(result).toEqual({ allowed: false, reason: "domain_not_allowed" }); - }); - - test("policy row wins outright: selfSignup off ignores env open", () => { + test("an explicit closed row stays closed", () => { const policy: AccessPolicy = { selfSignup: "off", allowedDomains: [], @@ -127,15 +101,13 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "open", - envAllowedDomains: [], email, ...verified, }); expect(result).toEqual({ allowed: false, reason: "signup_closed" }); }); - test("policy row wins outright: selfSignup open ignores env closed", () => { + test("an explicit open row allows", () => { const policy: AccessPolicy = { selfSignup: "open", allowedDomains: [], @@ -143,8 +115,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "closed", - envAllowedDomains: [], email, ...verified, }); @@ -159,8 +129,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "closed", - envAllowedDomains: [], email, ...verified, }); @@ -175,8 +143,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "open", - envAllowedDomains: [], email, ...verified, }); @@ -191,8 +157,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "closed", - envAllowedDomains: [], email, emailVerified: false, allowUnverifiedEmails: false, @@ -208,20 +172,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "closed", - envAllowedDomains: [], - email, - emailVerified: false, - allowUnverifiedEmails: false, - }); - expect(result).toEqual({ allowed: false, reason: "email_unverified" }); - }); - - test("exploit: an unverified email cannot pass the env bootstrap", () => { - const result = evaluateSignupGate({ - policy: undefined, - envSignupMode: "open", - envAllowedDomains: [], email, emailVerified: false, allowUnverifiedEmails: false, @@ -229,7 +179,7 @@ describe("evaluateSignupGate", () => { expect(result).toEqual({ allowed: false, reason: "email_unverified" }); }); - test("the ALLOW_UNVERIFIED_EMAILS dev escape hatch restores the normal decision", () => { + test("the allowUnverifiedEmails dev escape hatch restores the normal decision", () => { const policy: AccessPolicy = { selfSignup: "open", allowedDomains: [], @@ -237,8 +187,6 @@ describe("evaluateSignupGate", () => { }; const result = evaluateSignupGate({ policy, - envSignupMode: "closed", - envAllowedDomains: [], email, emailVerified: false, allowUnverifiedEmails: true, @@ -249,8 +197,6 @@ describe("evaluateSignupGate", () => { test("email-verification is checked before policy, so it also fails closed for selfSignup off", () => { const result = evaluateSignupGate({ policy: { ...DEFAULT_ACCESS_POLICY, selfSignup: "off" }, - envSignupMode: "closed", - envAllowedDomains: [], email, emailVerified: false, allowUnverifiedEmails: false, diff --git a/packages/access-policy/src/policy.ts b/packages/access-policy/src/policy.ts index 981cd4f73c..00bee06dcc 100644 --- a/packages/access-policy/src/policy.ts +++ b/packages/access-policy/src/policy.ts @@ -53,10 +53,7 @@ export function domainOf(email: string): string | undefined { return domain.length > 0 ? domain : undefined; } -/** An empty allowlist means "any domain" — matching the existing - * `emailAllowedForSignup` semantics in `@corbits/bench-ui`'s - * `tenancy-contracts.ts`, which this package supersedes as the actual - * enforcement point. */ +/** An empty allowlist means "any domain". */ export function domainAllowed( domain: string, allowedDomains: readonly string[], @@ -72,8 +69,7 @@ export type SignupGateReason = | "signup_closed" | "domain_not_allowed" | "policy_open" - | "policy_domain_match" - | "env_open"; + | "policy_domain_match"; export type SignupGateResult = { readonly allowed: boolean; @@ -82,30 +78,24 @@ export type SignupGateResult = { export type SignupGateArgs = { /** The tenant's own policy row, resolved through `resolveAccessPolicy` - * — pass `undefined` only when no row exists at all (bootstrap - * phase); an explicit closed-default row is not the same as absence - * and always wins over the env flag. */ + * — pass `undefined` only when no row exists at all, in which case + * the closed defaults apply. */ readonly policy: AccessPolicy | undefined; - readonly envSignupMode: "open" | "closed"; - readonly envAllowedDomains: readonly string[]; readonly email: string; /** better-auth is configured without `requireEmailVerification`, so * an unverified address can claim any domain. Every email-trust * decision this gate makes requires `emailVerified` unless - * `allowUnverifiedEmails` opts out (dev/test only — see - * `ALLOW_UNVERIFIED_EMAILS`, mirroring `ALLOW_PLAINTEXT_SECRETS`). - * Checked before policy/env are ever consulted, so no combination - * of settings can allow an unverified email through. */ + * `allowUnverifiedEmails` opts out (dev/test only). Checked before + * the policy is ever consulted, so no policy can allow an + * unverified email through. */ readonly emailVerified: boolean; readonly allowUnverifiedEmails: boolean; }; /** - * The one signup-gate evaluation function. `WORKBENCH_SIGNUP` bootstraps - * a hub with no policy row yet; once an operator (or the default row an - * operator tenant gets on creation) sets a policy row, that row wins - * outright and the env flag is no longer consulted — no fallback path - * runs beside it. + * The one signup-gate evaluation function. The tenant's policy row + * decides outright; with no row yet the closed defaults apply, so a + * hub without an explicit open row never self-grants membership. */ export function evaluateSignupGate(args: SignupGateArgs): SignupGateResult { const domain = domainOf(args.email); @@ -115,23 +105,15 @@ export function evaluateSignupGate(args: SignupGateArgs): SignupGateResult { return { allowed: false, reason: "email_unverified" }; } - if (args.policy !== undefined) { - if (args.policy.selfSignup === "off") { - return { allowed: false, reason: "signup_closed" }; - } - if (args.policy.selfSignup === "open") { - return { allowed: true, reason: "policy_open" }; - } - return domainAllowed(domain, args.policy.allowedDomains) - ? { allowed: true, reason: "policy_domain_match" } - : { allowed: false, reason: "domain_not_allowed" }; - } - - if (args.envSignupMode !== "open") { + const policy = args.policy ?? DEFAULT_ACCESS_POLICY; + if (policy.selfSignup === "off") { return { allowed: false, reason: "signup_closed" }; } - return domainAllowed(domain, args.envAllowedDomains) - ? { allowed: true, reason: "env_open" } + if (policy.selfSignup === "open") { + return { allowed: true, reason: "policy_open" }; + } + return domainAllowed(domain, policy.allowedDomains) + ? { allowed: true, reason: "policy_domain_match" } : { allowed: false, reason: "domain_not_allowed" }; } diff --git a/packages/bench-ui/README.md b/packages/bench-ui/README.md index b0fa531a4e..bc999ce89d 100644 --- a/packages/bench-ui/README.md +++ b/packages/bench-ui/README.md @@ -2,7 +2,7 @@ Bench tenancy helpers over Interchange's native tenant model: classifying memberships by kind, asking which tenant ids are workbench child tenancies, -and the shared tenancy contracts (roles, signup mode, DM naming, parent +and the shared tenancy contracts (roles, DM naming, parent cycle checks). Creation lives at `/new`; people management lives in `@corbits/settings-ui`'s PeopleSection — this package no longer ships switcher / create / members UI. @@ -13,7 +13,7 @@ switcher / create / members UI. - `api.ts` — `listWorkbenchTenantIds` HTTP client - `membership.ts` — `isRawIdentifier` (raw platform ids must never render) - `tenancy-contracts.ts` — shared tenancy constants and validation (roles, - signup mode, DM workbench naming, parent-tenant cycle checks) + DM workbench naming, parent-tenant cycle checks) ## Running tests diff --git a/packages/bench-ui/src/index.ts b/packages/bench-ui/src/index.ts index bf2ab3b639..5366fdd3b6 100644 --- a/packages/bench-ui/src/index.ts +++ b/packages/bench-ui/src/index.ts @@ -11,17 +11,12 @@ export type { BenchMembership } from "./api"; export { INTERCHANGE_ROLES, - DEFAULT_SIGNUP_MODE, - SignupMode, WorkbenchIcon, DmWorkbenchFlag, dmWorkbenchName, createDmWorkbenchSpec, validateParentId, wouldCreateParentCycle, - emailAllowedForSignup, - parseAllowedEmailDomains, - parseSignupMode, isInterchangeRole, canShareWorkbenchWithinParent, } from "./tenancy-contracts"; diff --git a/packages/bench-ui/src/tenancy-contracts.test.ts b/packages/bench-ui/src/tenancy-contracts.test.ts index 270ee7d392..5e8a4f8834 100644 --- a/packages/bench-ui/src/tenancy-contracts.test.ts +++ b/packages/bench-ui/src/tenancy-contracts.test.ts @@ -4,10 +4,7 @@ import { canShareWorkbenchWithinParent, createDmWorkbenchSpec, dmWorkbenchName, - emailAllowedForSignup, isInterchangeRole, - parseAllowedEmailDomains, - parseSignupMode, validateParentId, wouldCreateParentCycle, type TenantParentLookup, @@ -27,75 +24,6 @@ function memoryLookup( }; } -describe("parseSignupMode", () => { - test("defaults to closed", () => { - expect(parseSignupMode(undefined)).toBe("closed"); - expect(parseSignupMode("")).toBe("closed"); - }); - - test("accepts open and closed", () => { - expect(parseSignupMode("open")).toBe("open"); - expect(parseSignupMode("CLOSED")).toBe("closed"); - }); - - test("rejects garbage", () => { - expect(() => parseSignupMode("maybe")).toThrow(/WORKBENCH_SIGNUP/); - }); -}); - -describe("emailAllowedForSignup", () => { - test("closed rejects everyone", () => { - expect( - emailAllowedForSignup({ - email: "alice@acme.example", - mode: "closed", - allowedDomains: [], - }), - ).toBe(false); - }); - - test("open with empty allowlist accepts any email", () => { - expect( - emailAllowedForSignup({ - email: "alice@acme.example", - mode: "open", - allowedDomains: [], - }), - ).toBe(true); - }); - - test("open with allowlist filters domains", () => { - expect( - emailAllowedForSignup({ - email: "alice@acme.example", - mode: "open", - allowedDomains: ["acme.example"], - }), - ).toBe(true); - expect( - emailAllowedForSignup({ - email: "bob@other.example", - mode: "open", - allowedDomains: ["acme.example"], - }), - ).toBe(false); - }); -}); - -describe("parseAllowedEmailDomains", () => { - test("splits and trims", () => { - expect(parseAllowedEmailDomains("acme.example, other.example")).toEqual([ - "acme.example", - "other.example", - ]); - }); - - test("empty is empty list", () => { - expect(parseAllowedEmailDomains(undefined)).toEqual([]); - expect(parseAllowedEmailDomains(" ")).toEqual([]); - }); -}); - describe("validateParentId", () => { test("null parent is ok (root)", async () => { const lookup = memoryLookup({}); diff --git a/packages/bench-ui/src/tenancy-contracts.ts b/packages/bench-ui/src/tenancy-contracts.ts index 69f1dc50dc..73b38dca41 100644 --- a/packages/bench-ui/src/tenancy-contracts.ts +++ b/packages/bench-ui/src/tenancy-contracts.ts @@ -8,12 +8,6 @@ import { type } from "arktype"; export const INTERCHANGE_ROLES = ["owner", "admin", "member"] as const; export type InterchangeRole = (typeof INTERCHANGE_ROLES)[number]; -export const SignupMode = type("'open' | 'closed'"); -export type SignupMode = typeof SignupMode.infer; - -/** Default is closed — opening signup is an explicit operator choice. */ -export const DEFAULT_SIGNUP_MODE: SignupMode = "closed"; - export const WorkbenchIcon = type({ /** 1–2 character monogram shown in the switcher and badges. */ monogram: type("string >= 1").pipe((s) => s.slice(0, 2)), @@ -116,44 +110,6 @@ export async function wouldCreateParentCycle( return false; } -/** - * Whether an email may sign up under the current mode and domain allowlist. - * Domain list is compared case-insensitively to the email's host part. - */ -export function emailAllowedForSignup(args: { - readonly email: string; - readonly mode: SignupMode; - readonly allowedDomains: readonly string[]; -}): boolean { - if (args.mode === "closed") return false; - if (args.allowedDomains.length === 0) return true; - const at = args.email.lastIndexOf("@"); - if (at < 0) return false; - const domain = args.email.slice(at + 1).toLowerCase(); - const allow = new Set(args.allowedDomains.map((d) => d.toLowerCase())); - return allow.has(domain); -} - -/** Parse comma-separated domain list; empty tokens dropped. */ -export function parseAllowedEmailDomains(raw: string | undefined): string[] { - if (raw === undefined || raw.trim() === "") return []; - return raw - .split(",") - .map((d) => d.trim()) - .filter((d) => d.length > 0); -} - -export function parseSignupMode(raw: string | undefined): SignupMode { - if (raw === undefined || raw.trim() === "") return DEFAULT_SIGNUP_MODE; - const parsed = SignupMode(raw.trim().toLowerCase()); - if (parsed instanceof type.errors) { - throw new Error( - `WORKBENCH_SIGNUP must be "open" or "closed", got ${JSON.stringify(raw)}`, - ); - } - return parsed; -} - /** * Roles the product may assign or display. Rejects invented roles so UI * never drifts from Interchange. diff --git a/packages/hub-api-client/src/hub.test.ts b/packages/hub-api-client/src/hub.test.ts new file mode 100644 index 0000000000..0c5d47cbf4 --- /dev/null +++ b/packages/hub-api-client/src/hub.test.ts @@ -0,0 +1,115 @@ +// `authenticate`/`signIn` against a stub hub: signup is ungated in stock +// composition, so no failure path may prescribe a signup env key. +import { describe, expect, test } from "bun:test"; + +import { HubApiError } from "./errors"; +import { authenticate, signIn, type ApiCall, type ApiResult } from "./hub"; + +function stubApi( + handler: (method: string, path: string) => ApiResult, +): ApiCall { + return (method, path) => Promise.resolve(handler(method, path)); +} + +const signInOk: ApiResult = { + status: 200, + data: { user: { id: "user_1" } }, + cookies: ["session=abc"], +}; + +describe("authenticate", () => { + test("an existing account signs in without a sign-up call", async () => { + let signUpCalls = 0; + const api = stubApi((_, path) => { + if (path === "/api/auth/sign-up/email") signUpCalls += 1; + return signInOk; + }); + const session = await authenticate(api, { + email: "admin@acme.example", + password: "secret", + }); + expect(session).toEqual({ + cookies: ["session=abc"], + userId: "user_1", + signedUp: false, + }); + expect(signUpCalls).toBe(0); + }); + + test("a missing account signs up after sign-in fails", async () => { + const api = stubApi((_, path) => { + if (path === "/api/auth/sign-in/email") + return { status: 401, data: null, cookies: [] }; + return { + status: 200, + data: { user: { id: "user_2" } }, + cookies: ["session=def"], + }; + }); + const session = await authenticate(api, { + email: "new@acme.example", + password: "secret", + }); + expect(session.signedUp).toBe(true); + expect(session.userId).toBe("user_2"); + }); + + test("a 422 sign-up means the address exists with a different password", async () => { + const api = stubApi((_, path) => { + if (path === "/api/auth/sign-in/email") + return { status: 401, data: null, cookies: [] }; + return { status: 422, data: null, cookies: [] }; + }); + const error = await authenticate(api, { + email: "admin@acme.example", + password: "wrong", + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(HubApiError); + expect((error as HubApiError).fix).toMatch(/HUB_ADMIN_PASSWORD/); + }); + + test("a rejected sign-up never prescribes a signup env key", async () => { + const api = stubApi((_, path) => { + if (path === "/api/auth/sign-in/email") + return { status: 401, data: null, cookies: [] }; + return { + status: 403, + data: { error: "signup_closed" }, + cookies: [], + }; + }); + const error = await authenticate(api, { + email: "new@acme.example", + password: "secret", + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(HubApiError); + expect((error as HubApiError).message).not.toMatch(/WORKBENCH_SIGNUP/); + expect((error as HubApiError).fix).not.toMatch(/WORKBENCH_SIGNUP/); + }); +}); + +describe("signIn", () => { + test("sign-in only, never signs up", async () => { + let signUpCalls = 0; + const api = stubApi((_, path) => { + if (path === "/api/auth/sign-up/email") signUpCalls += 1; + return signInOk; + }); + const session = await signIn(api, { + email: "admin@acme.example", + password: "secret", + }); + expect(session.signedUp).toBe(false); + expect(signUpCalls).toBe(0); + }); + + test("a failed sign-in names the admin credential to check", async () => { + const api = stubApi(() => ({ status: 401, data: null, cookies: [] })); + const error = await signIn(api, { + email: "admin@acme.example", + password: "wrong", + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(HubApiError); + expect((error as HubApiError).fix).toMatch(/HUB_ADMIN/); + }); +}); diff --git a/packages/hub-api-client/src/hub.ts b/packages/hub-api-client/src/hub.ts index 4229814952..de24255d7f 100644 --- a/packages/hub-api-client/src/hub.ts +++ b/packages/hub-api-client/src/hub.ts @@ -119,10 +119,9 @@ async function trySignIn( /** * Sign the administrator in — sign-in only, never sign-up. For callers - * that must never mint an account (e.g. the env-key auto-plant, which - * runs unattended at boot and must not self-provision the default - * admin credential on a virgin, open-signup database), this is the only - * safe entry point. + * that must never mint an account (e.g. automated setup, which runs + * unattended and must not self-provision the default admin credential + * on a fresh database), this is the only safe entry point. */ export async function signIn( api: ApiCall, @@ -138,10 +137,10 @@ export async function signIn( /** * Sign the administrator in, or sign up when the account does not exist - * yet. Sign-in is tried first so `WORKBENCH_SIGNUP=closed` still works - * for an existing admin (the product gate only blocks self-serve - * registration). Fresh sign-up is allowed only when the hub is open; - * a closed hub with no matching account is reported with the env fix. + * yet. Sign-in is tried first so an existing admin never mints a + * duplicate account. Signup is ungated in stock composition, so a + * rejected sign-up is reported with the hub logs as the fix — never + * with an env key. */ export async function authenticate( api: ApiCall, @@ -175,19 +174,6 @@ export async function authenticate( ); } - const closed = - signUp.status === 403 && - signUp.data !== null && - typeof signUp.data === "object" && - "error" in signUp.data && - (signUp.data as { error: unknown }).error === "signup_closed"; - if (closed) { - throw new HubApiError( - `self-serve signup is closed and ${args.email} does not exist yet`, - "set WORKBENCH_SIGNUP=open in .env, restart the hub, re-run this command once to create the admin, then set WORKBENCH_SIGNUP=closed again if you want a closed deploy", - ); - } - throw new HubApiError( `the hub rejected sign-up for ${args.email} with status ${signUp.status}: ${JSON.stringify(signUp.data)}`, "check the hub logs for the underlying failure, fix it, then re-run the command", diff --git a/packages/onboarding/src/genesis.ts b/packages/onboarding/src/genesis.ts index 4299544c32..6132657872 100644 --- a/packages/onboarding/src/genesis.ts +++ b/packages/onboarding/src/genesis.ts @@ -31,8 +31,6 @@ export type GenesisOrJoinArgs = { tenancy: HubSignupTenancy; accessPolicy?: { store: AccessPolicyStore; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; allowUnverifiedEmails: boolean; }; log: (line: string) => void; @@ -116,8 +114,6 @@ async function requireSignupAllowed( if (args.accessPolicy === undefined) return; const gate = await checkSignupGate({ store: args.accessPolicy.store, - envSignupMode: args.accessPolicy.envSignupMode, - envAllowedDomains: args.accessPolicy.envAllowedDomains, email: args.userEmail, emailVerified: args.userEmailVerified, allowUnverifiedEmails: args.accessPolicy.allowUnverifiedEmails, diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index a550619e0c..3dc5c5545a 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -59,8 +59,6 @@ export type ProvisionArgs = { * domain allowlist still bind the first user. */ accessPolicy?: { store: AccessPolicyStore; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; allowUnverifiedEmails: boolean; }; }; diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 7f150aa3b7..69e9892fe1 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -219,8 +219,6 @@ export type CreateOnboardingRoutesDeps = { * is wired in at all; never a valid production shape. */ accessPolicy?: { store: AccessPolicyStore; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; allowUnverifiedEmails: boolean; }; /** Seals the OAuth connect state (PKCE verifier included) parked diff --git a/packages/onboarding/test/genesis.test.ts b/packages/onboarding/test/genesis.test.ts index 4b83812c20..a88d563bcb 100644 --- a/packages/onboarding/test/genesis.test.ts +++ b/packages/onboarding/test/genesis.test.ts @@ -57,20 +57,19 @@ function accessPolicy( overrides?: Partial< Pick< NonNullable, - "envSignupMode" | "envAllowedDomains" | "allowUnverifiedEmails" + "allowUnverifiedEmails" > >, ): NonNullable { return { - // Env-only evaluation: no operator tenant is threaded here, so the - // store is never read — a throwing store proves that. + // No operator tenant is threaded here, so the store is never read — + // a throwing store proves that. With no policy row the closed + // defaults apply. store: { getPolicy: async () => { - throw new Error("signup gate must evaluate env-only here"); + throw new Error("signup gate must evaluate rowless here"); }, } as unknown as AccessPolicyStore, - envSignupMode: "open", - envAllowedDomains: [], allowUnverifiedEmails: false, ...overrides, }; @@ -136,9 +135,10 @@ function argsFor(partial: { userEmailVerified: partial.userEmailVerified ?? true, defaultTenantSlug: TENANT_SLUG, tenancy: partial.tenancy, - accessPolicy: partial.accessPolicy ?? accessPolicy(), log: partial.log ?? collector().log, }; + if (partial.accessPolicy !== undefined) + args.accessPolicy = partial.accessPolicy; if (partial.displayName !== undefined) args.displayName = partial.displayName; return args; } @@ -238,7 +238,7 @@ describe("genesisOrJoinHubSignup", () => { joins, }), displayName: "Bob", - accessPolicy: accessPolicy({ envSignupMode: "closed" }), + accessPolicy: accessPolicy(), }), ), ).rejects.toMatchObject({ @@ -249,31 +249,6 @@ describe("genesisOrJoinHubSignup", () => { expect(joins).toEqual([]); }); - test("an open hub still joins with a closed-store policy absent: env decides", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 2, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - }), - ); - - expect(result.kind).toBe("joined"); - expect(joins).toHaveLength(1); - }); - test("genesis rejects an unverified email unless allowUnverifiedEmails", async () => { const probeOnly: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { @@ -289,6 +264,7 @@ describe("genesisOrJoinHubSignup", () => { tenancy: tenancy({ users: 1, tenants: 0, root: null }), displayName: "Acme", userEmailVerified: false, + accessPolicy: accessPolicy(), }), ), ).rejects.toMatchObject({ @@ -309,44 +285,6 @@ describe("genesisOrJoinHubSignup", () => { expect(result.kind).toBe("genesis"); }); - test("genesis enforces the env domain allowlist for the first user", async () => { - const probeOnly: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await expect( - genesisOrJoinHubSignup( - argsFor({ - api: probeOnly, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - userEmail: "alice@example.com", - accessPolicy: accessPolicy({ envAllowedDomains: ["corp.com"] }), - }), - ), - ).rejects.toMatchObject({ - name: "ProvisionError", - code: "signup_not_allowed", - }); - - const result = await genesisOrJoinHubSignup( - argsFor({ - api: genesisApi(), - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - userEmail: "alice@corp.com", - accessPolicy: accessPolicy({ - envSignupMode: "closed", - envAllowedDomains: ["corp.com"], - }), - }), - ); - expect(result.kind).toBe("genesis"); - }); - test("countUsers > 1 with zero tenants still lets the caller genesis", async () => { let tenantCreates = 0; const joins: { tenantId: string; userId: string; roleName: string }[] = []; @@ -561,14 +499,14 @@ describe("genesisOrJoinHubSignup", () => { }); test("genesis on an empty hub waives signup_closed but still evaluates the gate", async () => { - // signupMode closed: the empty-hub exception waives signup_closed, - // so the first verified, domain-allowed user still mints the root. + // No policy row: the empty-hub exception waives signup_closed, so + // the first verified user still mints the root. const result = await genesisOrJoinHubSignup( argsFor({ api: genesisApi(), tenancy: tenancy({ users: 1, tenants: 0, root: null }), displayName: "Acme", - accessPolicy: accessPolicy({ envSignupMode: "closed" }), + accessPolicy: accessPolicy(), }), ); expect(result.kind).toBe("genesis"); diff --git a/packages/settings-ui/src/access-policy.tsx b/packages/settings-ui/src/access-policy.tsx index 942704e51b..3e0a9a98e3 100644 --- a/packages/settings-ui/src/access-policy.tsx +++ b/packages/settings-ui/src/access-policy.tsx @@ -14,7 +14,6 @@ import { UnauthenticatedError, describeQueryError, } from "@corbits/api-query"; -import { getAuthConfig } from "./api"; import { getAccessPolicy, updateAccessPolicy, @@ -26,14 +25,6 @@ export function AccessPolicyBlock({ tenantId }: { readonly tenantId: string }) { const [query, setQuery] = useState>({ kind: "loading", }); - // Whether the operator's own env-level signup switch is still closed — - // independent of this bench's policy row and never editable here. - // `undefined` while loading or on a failed probe: the notice only - // renders once this is known to be `true`, never as a false positive - // from a still-loading state. - const [envSignupClosed, setEnvSignupClosed] = useState( - undefined, - ); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -56,13 +47,6 @@ export function AccessPolicyBlock({ tenantId }: { readonly tenantId: string }) { retry: load, }); }); - getAuthConfig() - .then((config) => { - if (!cancelled) setEnvSignupClosed(config.signupMode === "closed"); - }) - .catch(() => { - // Best-effort: the notice simply stays off rather than guessing. - }); return () => { cancelled = true; }; @@ -86,7 +70,6 @@ export function AccessPolicyBlock({ tenantId }: { readonly tenantId: string }) { policy={policy} saving={saving} error={saveError} - envSignupClosed={envSignupClosed === true} onChange={save} /> )} @@ -98,17 +81,11 @@ export function AccessPolicyEditor({ policy, saving, error = null, - envSignupClosed = false, onChange, }: { readonly policy: AccessPolicy; readonly saving: boolean; readonly error?: string | null; - /** True once it's known the operator's WORKBENCH_SIGNUP env switch is - * closed — shows an inline notice when this policy would otherwise - * allow self-signup, since that env switch still gates the - * underlying sign-up form itself regardless of this policy. */ - readonly envSignupClosed?: boolean; readonly onChange: (patch: Partial) => void; }) { const [domainDraft, setDomainDraft] = useState(""); @@ -159,12 +136,6 @@ export function AccessPolicyEditor({ - {policy.selfSignup !== "off" && envSignupClosed && ( -

- {SETTINGS_STRINGS.accessPolicyEnvOverrideNotice} -

- )} - {policy.selfSignup === "allowed-domains" && (
{SETTINGS_STRINGS.accessPolicyDomainsLabel} diff --git a/packages/settings-ui/src/api.ts b/packages/settings-ui/src/api.ts index f97dd85f12..5f1182c76c 100644 --- a/packages/settings-ui/src/api.ts +++ b/packages/settings-ui/src/api.ts @@ -4,7 +4,7 @@ // account's profile (`GET /api/me`). Workbench settings and bench membership // listings are not reimplemented here — they come straight from // `@corbits/chat-ui` and `@corbits/bench-ui`, the packages that already own -// those seams. Signup policy is operator env exposed at `GET /api/auth-config`. +// those seams. import { type } from "arktype"; import type { ArkErrors } from "arktype"; @@ -14,13 +14,6 @@ import { UnauthenticatedError } from "@corbits/api-query"; export type Bench = typeof TenantResponse.infer; export type Account = typeof UserProfile.infer; -export const AuthConfig = type({ - socialProviders: "string[]", - signupMode: "'open' | 'closed'", - allowedEmailDomains: "string[]", -}); -export type AuthConfig = typeof AuthConfig.infer; - export class SettingsApiError extends Error { constructor( message: string, @@ -71,10 +64,6 @@ export function getAccount(): Promise { return request("/api/me", UserProfile); } -export function getAuthConfig(): Promise { - return request("/api/auth-config", AuthConfig); -} - export function renameBench(tenantId: string, name: string): Promise { return request(`/api/tenants/${tenantId}`, TenantResponse, { method: "PATCH", diff --git a/packages/settings-ui/src/index.ts b/packages/settings-ui/src/index.ts index 747ab01bc0..bd98541f65 100644 --- a/packages/settings-ui/src/index.ts +++ b/packages/settings-ui/src/index.ts @@ -127,10 +127,5 @@ export { CONNECTOR_PINNED_WORKFLOWS } from "./connections-pinned-by"; export { SETTINGS_STRINGS } from "./strings"; -export { - SettingsApiError, - getAccount, - getAuthConfig, - renameBench, -} from "./api"; -export type { Account, AuthConfig, Bench } from "./api"; +export { SettingsApiError, getAccount, renameBench } from "./api"; +export type { Account, Bench } from "./api"; diff --git a/packages/settings-ui/src/strings.ts b/packages/settings-ui/src/strings.ts index 592ae00399..2caed8abd9 100644 --- a/packages/settings-ui/src/strings.ts +++ b/packages/settings-ui/src/strings.ts @@ -95,8 +95,6 @@ export const SETTINGS_STRINGS = { accessPolicySaveError: "Couldn't save that change — try again.", accessPolicyNote: "Closed by default: no open signup, no self-serve sub-workbenches. These settings apply to this workbench.", - accessPolicyEnvOverrideNotice: - "Sign-up is switched off at a higher level, so people still can't create an account yet — even though this setting would otherwise allow it. Ask whoever controls this install's environment to enable self-signup.", rolesSectionTitle: "Roles", rolesSectionDescription: diff --git a/packages/settings-ui/test/api.test.ts b/packages/settings-ui/test/api.test.ts index 56586cacb6..eed87d2f67 100644 --- a/packages/settings-ui/test/api.test.ts +++ b/packages/settings-ui/test/api.test.ts @@ -5,12 +5,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { UnauthenticatedError } from "@corbits/api-query"; -import { - SettingsApiError, - getAccount, - getAuthConfig, - renameBench, -} from "../src/api"; +import { SettingsApiError, getAccount, renameBench } from "../src/api"; const realFetch = globalThis.fetch; @@ -60,22 +55,6 @@ describe("getAccount", () => { }); }); -describe("getAuthConfig", () => { - test("fetches /api/auth-config and returns signup policy", async () => { - const calls = stubFetch(() => - json({ - socialProviders: [], - signupMode: "closed", - allowedEmailDomains: ["example.com"], - }), - ); - const config = await getAuthConfig(); - expect(calls[0]?.path).toBe("/api/auth-config"); - expect(config.signupMode).toBe("closed"); - expect(config.allowedEmailDomains).toEqual(["example.com"]); - }); -}); - describe("renameBench", () => { test("PATCHes the tenant with just the new name", async () => { const calls = stubFetch(() => From c0a9070d08eab8d997478cfeb8a80392acaaaeb8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:28:37 -0700 Subject: [PATCH 10/18] refactor(auth): remove /api/auth-config, sign-in buttons from client config Stock Interchange exposes no sign-in discovery endpoint, so the hub stops serving GET /api/auth-config and the web sign-in screen draws its OAuth buttons from a static client list (SOCIAL_SIGN_IN_PROVIDERS) instead of probing the hub. better-auth still decides which providers actually work. The settings-ui oauth-configured probe is untouched. --- apps/hub/src/index.ts | 11 --- apps/web/src/auth-screen.tsx | 85 +++++---------------- apps/web/src/deployment-capabilities-api.ts | 5 +- apps/web/src/session.ts | 50 +++--------- apps/web/test/auth.test.tsx | 44 ++++------- 5 files changed, 46 insertions(+), 149 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5bb07b7eba..2cfeb4c21b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2726,17 +2726,6 @@ export async function createHub(config: HubConfig) { : {}), }); - // Tells the signed-out screen which OAuth buttons to draw, without - // exposing the credentials themselves — just which providers a full - // pair was configured for. No session or tenant is required to ask, - // since this decides what the sign-in screen even offers. - const enabledSocialProviders = Object.keys(config.socialProviders); - app.get("/api/auth-config", (c) => - c.json({ - socialProviders: enabledSocialProviders, - }), - ); - app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); // Stock Interchange currently leaves tenant creation and dispatch ungated. diff --git a/apps/web/src/auth-screen.tsx b/apps/web/src/auth-screen.tsx index 39a090f415..ea7fc67a5d 100644 --- a/apps/web/src/auth-screen.tsx +++ b/apps/web/src/auth-screen.tsx @@ -4,15 +4,17 @@ import { Button } from "@corbits/react-ui"; import { LoginForm } from "@corbits/react-ui/blocks/login/login-form"; -import { getLogger } from "@corbits/client-log"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { AuthLayout } from "./auth/auth-layout"; -import { fetchAuthConfig, signIn, signInSocial, signUp } from "./session"; +import { + SOCIAL_SIGN_IN_PROVIDERS, + signIn, + signInSocial, + signUp, +} from "./session"; import type { SessionUser, SocialProviderId } from "./session"; -const log = getLogger("web.auth-screen"); - type Mode = "sign-in" | "sign-up"; const COPY = { @@ -41,42 +43,6 @@ export function AuthScreen({ const [mode, setMode] = useState("sign-in"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [socialProviders, setSocialProviders] = useState< - readonly SocialProviderId[] - >([]); - const [authConfigUnavailable, setAuthConfigUnavailable] = useState(false); - - useEffect(() => { - let cancelled = false; - void fetchAuthConfig().then((result) => { - if (cancelled) return; - if (result.kind === "unavailable") { - log.error("Could not load sign-in options", { - message: result.message, - }); - setAuthConfigUnavailable(true); - return; - } - setSocialProviders(result.providers); - }); - return () => { - cancelled = true; - }; - }, []); - - const retryAuthConfig = () => { - setAuthConfigUnavailable(false); - void fetchAuthConfig().then((result) => { - if (result.kind === "unavailable") { - log.error("Could not load sign-in options", { - message: result.message, - }); - setAuthConfigUnavailable(true); - return; - } - setSocialProviders(result.providers); - }); - }; const submit = async (credentials: { readonly email: string; @@ -118,34 +84,19 @@ export function AuthScreen({ error={error} footer={ <> - {authConfigUnavailable && ( -

- Google and GitHub sign-in aren't loading. Email and password - still work.{" "} - -

- )} - {socialProviders.length > 0 && ( -
- {socialProviders.map((provider) => ( - - ))} -
- )} + {SOCIAL_PROVIDER_LABEL[provider]} + + ))} +
{copy.switchPrompt}{" "}