diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 32f0e6bec7..c3eb61a30c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -162,7 +162,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, @@ -278,7 +277,6 @@ import { createPresenceRoutes, type PresenceRoomKey, } from "@corbits/presence"; -import { supportedCredentialProviders } from "@corbits/connections/credential-test"; import { CATALOG_WORKFLOWS, createGitWorkflowPusher } from "@corbits/seeding"; import { createHubAPI } from "@corbits/hub-api-client"; import { createOnboardingRoutes } from "@workbench/onboarding"; @@ -307,15 +305,14 @@ import { applyAccessPolicyMigrations, createAccessPolicyRoutes, createDrizzleAccessPolicyStore, + createEmptyHubCheck, } 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, @@ -661,10 +658,11 @@ 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); + // The closed-signup empty-hub exception's native count read (CL-8085): + // with zero users and zero tenants, someone has to be first. Owned by + // `@workbench/access-policy`, not this hub — see + // packages/access-policy/src/empty-hub.ts. + const emptyHubCheck = createEmptyHubCheck(db); const auth = betterAuth({ baseURL: config.baseUrl, @@ -1284,8 +1282,8 @@ export async function createHub(config: HubConfig) { // 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(), + emptyHubCheck.countUsers(), + emptyHubCheck.countTenants(), ]); if (users > 0 || tenants > 0) { return c.json( @@ -2610,37 +2608,10 @@ 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. The drain is gone - // (CL-7586), so this kicks the same desired-state reconcile the - // tenant-create observer and the onboarding routes kick, under the - // connecting user's own minted session — `sessionFor` is declared - // further up this function, and `observerRef` further down, but - // this closure only runs on a future request, well after both are - // constructed below. Best-effort like every other kick: absent or - // unmintable means convergence waits for the revisit probe. - 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 desired-state kick`; - return; - } - const cookies = await sessionFor({ - userId: info.userId, - tenantId: info.tenantId, - }); - if (cookies === undefined) { - log.error`onInferenceCredentialUsable could not mint a session for user ${info.userId} on tenant ${info.tenantId}; skipping the desired-state kick`; - return; - } - void observerRef.current - ?.kick({ tenantId: info.tenantId, cookies }) - .catch(() => undefined); - }, + // CL-6568's other half — a tenant whose only provider is one it + // connected itself through Settings converging on Myra and the + // default workflow set — now runs from the client's own + // needs-list convergence (CL-8085), not a server-side kick. }), ); // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` @@ -3368,50 +3339,18 @@ export async function createHub(config: HubConfig) { }), ); - // 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 by itself (CL-6457): the - // onboarding routes persist the credential and kick the desired-state - // reconcile below, which converges the bench on the next kick (tenant - // create, connect, revisit probe) — including one a previous process - // died halfway through, since the reconcile re-reads - // the pins on every kick rather than consuming a durable work item. - // (CL-7586 removed the pending-seed drain that used to do this.) - - // 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. - // No durable row anywhere: 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; - } = {}; - + // The credential-connect hook mounts outside the tenant prefix, since a + // fresh signup belongs to no tenant yet. 0→1 tenant creation and + // desired-state convergence no longer live here (CL-8085): the + // client's own needs-list drives both directly over stock routes + // (`apps/web/src/needs-converge.ts`). This mount is credential-connect + // only — see `@workbench/onboarding/routes.ts`. 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, - 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" @@ -3581,49 +3520,12 @@ export async function createHub(config: HubConfig) { 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, { + // enqueue or materialize grants. See ./dispatch-tenant-guard.ts. The + // tenant-create guard that used to wrap here is gone (CL-7590/CL-8085): + // native grant-gating on `POST /api/tenants` is the only gate now. + const dispatchGuardedApp = withDispatchTenantGuard(app, { loadAnchorDispatch: (anchorRunId) => loadAnchorDispatch(db, anchorRunId), }); const inFlight = createInFlightRequestTracker(); @@ -3635,16 +3537,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); } 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/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 0f9b5f7d27..0000000000 --- a/apps/hub/src/tenant-create-onboard.ts +++ /dev/null @@ -1,210 +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: nothing is queued, 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 purely an optimization against double -// kicks, 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/seeding"; - -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 carries no credential material 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 against double kicks: an optimization, - // never a fact the system needs correct. - 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 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 carries no credential material to read - // deploy identity from — resolve the creator's owner principal - // 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 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/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 0443642225..ea83bf2a84 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -269,15 +269,18 @@ 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`, { + // Client convergence replaced the deleted genesis hook: the root + // tenant the old plant looked up by slug is created over the stock + // route, with the signer as owner. + const created = await fetch(`${baseUrl}/api/tenants`, { method: "POST", headers: { "content-type": "application/json", cookie: cookies.join("; "), }, - body: JSON.stringify({ name: "Workbench" }), + body: JSON.stringify({ name: "Workbench", slug: "workbench" }), }); - expect(provision.status).toBe(200); + expect(created.status).toBe(201); } test("process boot with OLLAMA_BASE_URL set inserts no credential or offering rows", async () => { @@ -373,15 +376,17 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { }), }); expect(signUp.status).toBe(200); - const provision = await genesis.app.request("/api/onboarding/provision", { + // Client convergence replaced the deleted genesis hook: mint the + // operator bench over the stock tenant route. + const created = 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({ name: "Workbench", slug: "workbench" }), }); - expect(provision.status).toBe(200); + expect(created.status).toBe(201); await genesis.close(); const hub = await createHub({ diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 95435477f5..965cf5ae28 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -134,22 +134,16 @@ describeIfDb("extension mounting", () => { expect(await outside.text()).toBe("shell"); }); - test("the first-login onboarding hook is gated the same way", async () => { + test("the deleted first-login onboarding hook stays deleted", async () => { const hub = await bootHub(); - const gated = await hub.app.request("/api/onboarding/provision", { + // CL-8085 removed server-side provisioning: tenant creation is a + // client-driven stock-route sequence now, so the old hook path must + // 404 rather than answer (gated or otherwise). + const gone = 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/); + expect(gone.status).toBe(404); }); test("the workbench-tenancy kind lookup the bench switcher uses is mounted and gated", async () => { diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index c202084c50..e0b55f2185 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -1,8 +1,10 @@ // 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. +// empty database starts truly empty. Signup mints no tenant — under the +// CL-8085 client-convergence contract the client creates the root tenant +// itself over the stock route (`POST /api/tenants`, whose signer becomes +// owner) and later members join by owner invite + activate. The first +// signup — not a CLI, not a boot-time seed — still owns the root it +// creates. 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 @@ -161,31 +163,77 @@ async function signUp( return cookies; } -async function provision( +// Client convergence over the stock tenant route: the signer becomes +// the tenant owner (see walking-skeleton's "tenant creation" hop). +async function createTenant( baseUrl: string, cookies: string[], - name?: string, -): Promise<{ - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; -}> { - const response = await fetch(`${baseUrl}/api/onboarding/provision`, { + args: { name: string; slug: string }, +): Promise<{ tenantId: string }> { + const response = await fetch(`${baseUrl}/api/tenants`, { method: "POST", headers: { "content-type": "application/json", cookie: cookies.join("; "), }, - ...(name !== undefined ? { body: JSON.stringify({ name }) } : {}), + body: JSON.stringify(args), }); - expect(response.status).toBe(200); - return (await response.json()) as { - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; - }; + expect(response.status).toBe(201); + const body = (await response.json()) as { id?: string }; + expect(typeof body.id).toBe("string"); + return { tenantId: body.id ?? "" }; +} + +// Owner invite + activate: the join path that replaced the deleted +// genesis-or-join hook. The invitee must already hold an account; +// the owner activates the invited principal into a role by id. +async function inviteAndActivate( + baseUrl: string, + ownerCookies: string[], + args: { tenantId: string; email: string; roleId?: string }, +): Promise { + const invited = await fetch( + `${baseUrl}/api/tenants/${args.tenantId}/members/invite`, + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: ownerCookies.join("; "), + }, + body: JSON.stringify({ + email: args.email, + ...(args.roleId !== undefined ? { roleId: args.roleId } : {}), + }), + }, + ); + expect(invited.status).toBe(201); + const inviteBody = (await invited.json()) as { id?: string }; + expect(typeof inviteBody.id).toBe("string"); + const activated = await fetch( + `${baseUrl}/api/tenants/${args.tenantId}/principals/${inviteBody.id ?? ""}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + cookie: ownerCookies.join("; "), + }, + body: JSON.stringify({ status: "active" }), + }, + ); + expect(activated.status).toBe(200); +} + +async function memberRoleIdFor( + db: Awaited>["db"], + tenantId: string, +): Promise { + const [memberRole] = await db + .select({ id: role.id }) + .from(role) + .where(and(eq(role.tenantId, tenantId), eq(role.name, "member"))) + .limit(1); + expect(memberRole).toBeDefined(); + return memberRole?.id ?? ""; } async function roleNamesFor( @@ -216,8 +264,8 @@ async function roleNamesFor( 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 () => { +describeIfDb("signup-driven tenant creation (CL-7578, CL-8085)", () => { + test("a closed empty hub admits the first signup, which creates the root tenant as owner — no seed", async () => { const scratchUrl = scratchUrlFor("closed"); await withScratchDatabase(scratchUrl, async () => { const { baseUrl, db } = await bootEmptyHub({ @@ -233,15 +281,15 @@ describeIfDb("signup genesis (CL-7578)", () => { 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; + // Client convergence: the first signup creates the root over the + // stock route and becomes its owner. + const created = await createTenant(baseUrl, cookies, { + name: "Acme", + slug: "workbench", + }); + expect(typeof created.tenantId).toBe("string"); + expect(created.tenantId).not.toBe(""); + const tenantId = created.tenantId; const [userCount] = await db.select().from(userTable); expect(userCount).toBeDefined(); @@ -262,7 +310,7 @@ describeIfDb("signup genesis (CL-7578)", () => { }), ).toEqual(["owner"]); - // No seed side effects: the genesis tenant carries no workflow + // No seed side effects: the created root carries no workflow // assets and no deployments. const assets = await fetch( `${baseUrl}/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, @@ -290,21 +338,25 @@ describeIfDb("signup genesis (CL-7578)", () => { email: "alice@example.com", password: "password123", }); - const genesis = await provision(baseUrl, alice, "Acme"); - expect(genesis.kind).toBe("provisioned"); - expect(genesis.tenantSlug).toBe("workbench"); + const created = await createTenant(baseUrl, alice, { + name: "Acme", + slug: "workbench", + }); - const bob = await signUp(baseUrl, { + // Bob must already hold an account for the owner to invite him — + // the invite path looks the invitee up by email. + 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); + // The join path is owner invite + activate into the member role: + // no second tenant is minted. + await inviteAndActivate(baseUrl, alice, { + tenantId: created.tenantId, + email: "bob@example.com", + roleId: await memberRoleIdFor(db, created.tenantId), + }); const tenants = await db.select().from(tenant); expect(tenants).toHaveLength(1); @@ -340,14 +392,21 @@ describeIfDb("signup genesis (CL-7578)", () => { email: "alice@example.com", password: "password123", }); - await provision(open.baseUrl, alice, "Acme"); - const bob = await signUp(open.baseUrl, { + const created = await createTenant(open.baseUrl, alice, { + name: "Acme", + slug: "workbench", + }); + // Bob must already hold an account for the owner to invite him. + 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"); + await inviteAndActivate(open.baseUrl, alice, { + tenantId: created.tenantId, + email: "bob@example.com", + roleId: await memberRoleIdFor(open.db, created.tenantId), + }); // Operator removal: native removal deletes the member's // principal rows outright, leaving the account itself alive. @@ -398,16 +457,23 @@ describeIfDb("signup genesis (CL-7578)", () => { 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("; "), + // Self-rejoin is invite-shaped now, and bob holds no membership: + // the tenant middleware answers 403 before any invite handler + // runs, so the removal sticks. + const res = await fetch( + `${closed.baseUrl}/api/tenants/${created.tenantId}/members/invite`, + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: sessionCookies.join("; "), + }, + body: JSON.stringify({ email: "bob@example.com" }), }, - }); + ); expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("signup_not_allowed"); + expect(body.error.code).toBe("forbidden"); // No principal was minted: the removal sticks. const [bobAfter] = await closed.db 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 d83434401f..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/seeding"; -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 93aec0e163..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/seeding"; -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/web/src/app.tsx b/apps/web/src/app.tsx index d301afb904..67ef737259 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -62,16 +62,10 @@ function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) { * account with no completed onboarding must never see "Select a workbench"; * the wizard is the only thing on screen until it hands off to `/`. */ -function OnboardingGate({ - navigate, - user, -}: { - readonly navigate: Navigate; - readonly user: SessionUser; -}) { +function OnboardingGate({ navigate }: { readonly navigate: Navigate }) { return ( - + ); } @@ -104,7 +98,7 @@ function Shell({ return ( - + @@ -197,7 +191,7 @@ export function App({ return ; } if (path === ONBOARDING_PATH) { - return ; + return ; } return ( 0 ? user.name.trim() : user.email.split("@")[0]; + return `${source || "Your"}'s team`; +} + function readStoredTenantId(): string | null { try { return window.localStorage.getItem(STORAGE_KEY); @@ -84,13 +121,56 @@ export function resolveSelection( ); } -export function BenchProvider({ children }: { readonly children: ReactNode }) { +export function BenchProvider({ + children, + user, +}: { + readonly children: ReactNode; + /** Absent only in tests that stand up `BenchProvider` without a full + * signed-in shell — genesis convergence below simply never fires + * without one. Every real mount (`Shell` in app.tsx) supplies it. */ + readonly user?: SessionUser; +}) { const queryClient = useQueryClient(); const memberships = useAPIQuery("/api/me/principals", PrincipalsSchema); const [stored, setStored] = useState(() => readStoredTenantId(), ); + // Genesis (CL-8085): a signed-in session with zero memberships anywhere + // converges its own primary tenant directly, over stock routes — the + // hub mints, gates, and observes nothing. Runs once per mount per + // empty-membership observation; a failure is reported and simply + // leaves membership empty for the next mount/retry to pick up. + const genesisRanRef = useRef(false); + useEffect(() => { + if (user === undefined) return; + if (memberships.kind !== "ready") return; + if (memberships.data.data.length > 0) return; + if (genesisRanRef.current) return; + genesisRanRef.current = true; + const manifest = buildNeedsList({ + user: { id: user.id, email: user.email }, + primaryTenant: { + slug: personalTenantSlug(user.email, user.id), + name: defaultPrimaryTenantName(user), + }, + myraDefinitionRefId: MYRA_DEFINITION_REF_ID, + workbenches: [], + }); + const hub = createFetchStockHub(fetch, { + resolveAgentDeploy: resolveMyraDeployBody, + }); + void convergeNeedsList(manifest, hub) + .then(() => { + void queryClient.invalidateQueries({ queryKey: meKeys.principals }); + }) + .catch((cause: unknown) => { + genesisRanRef.current = false; + reportError(cause, { operation: "needs_list_genesis_converge" }); + }); + }, [memberships, user, queryClient]); + const tenantIds = memberships.kind === "ready" ? memberships.data.data.map((membership) => membership.tenantId) @@ -140,6 +220,19 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) { writeStoredTenantId(tenantId); setStored(tenantId); void queryClient.invalidateQueries({ queryKey: meKeys.principals }); + // CL-8085: every bench create converges Myra onto it directly — + // a stock deploy call, not a server-side kick. + const hub = createFetchStockHub(fetch, { + resolveAgentDeploy: resolveMyraDeployBody, + }); + void hub + .deployAgent(tenantId, { definitionRefId: MYRA_DEFINITION_REF_ID }) + .catch((cause: unknown) => { + reportError(cause, { + operation: "needs_list_bench_create_converge", + tenantId, + }); + }); }, }), [memberships, resolved, stored, queryClient], diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bca0b99d6a..bca5ef4cba 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,8 +10,6 @@ import { getLogger } from "@corbits/client-log"; import { AppErrorBoundary } from "./app-error-boundary"; import { App } from "./app"; import { validatedNextPath } from "./login-next"; -import { triggerFirstLoginProvisioning } from "./onboarding"; -import { ONBOARDING_PATH } from "./routes"; import { fetchSession, signOut } from "./session"; import type { SessionState, SessionUser } from "./session"; @@ -48,37 +46,11 @@ function Root() { [navigate], ); - // The first-login hook: once per session that reaches signed-in, ask - // the hub whether this is a session with zero principals anywhere. - // Without a display name the hub does not mint a bench — it returns - // needs-onboarding so we route into the naming wizard. Existing members - // cost one read. A failure blocks the shell entirely. - const [provisioningError, setProvisioningError] = useState<{ - message: string; - refId?: string | undefined; - } | null>(null); - const provisionedUserId = - session.kind === "signed-in" ? session.user.id : null; - const runProvisioning = useCallback(() => { - if (provisionedUserId === null) return () => undefined; - let cancelled = false; - setProvisioningError(null); - void triggerFirstLoginProvisioning().then((result) => { - if (cancelled) return; - if (result.kind === "needs-onboarding" || result.kind === "provisioned") { - navigate(ONBOARDING_PATH); - } else if (result.kind === "error") { - setProvisioningError({ message: result.message, refId: result.refId }); - } - }); - return () => { - cancelled = true; - }; - }, [provisionedUserId, navigate]); - useEffect(runProvisioning, [runProvisioning]); - const handleRetryProvisioning = useCallback(() => { - runProvisioning(); - }, [runProvisioning]); + // 0→1 genesis moved to the client's needs-list (CL-8085): a signed-in + // session with zero memberships converges its own primary tenant + // directly over stock routes from inside `BenchProvider`, once the + // shell mounts — there is no server-side first-login hook left to call + // here. const handleSignOut = useCallback(() => { setSession({ kind: "signed-out" }); toast( @@ -110,9 +82,6 @@ function Root() { onSignedIn={handleSignedIn} onSignOut={handleSignOut} onRetry={probe} - provisioningError={provisioningError?.message ?? null} - provisioningErrorRefId={provisioningError?.refId} - onRetryProvisioning={handleRetryProvisioning} /> diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts new file mode 100644 index 0000000000..10f806da63 --- /dev/null +++ b/apps/web/src/myra-deploy.test.ts @@ -0,0 +1,300 @@ +// The post-credential Myra driver: after the credential step seeds the +// catalog, the client itself brings Myra up on the tenant over stock +// routes — no hub-side kick, no new routes. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { + driveMyraDeployAfterCredential, + ensureMyraDeployed, + resolveTenantIdForSlug, +} from "./myra-deploy"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +function stubFetch(respond: (path: string, init?: RequestInit) => Response) { + const calls: { path: string; init?: RequestInit }[] = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + calls.push(init === undefined ? { path } : { path, init }); + return Promise.resolve(respond(path, init)); + }) as typeof fetch; + return calls; +} + +const assistantAsset = [{ id: "ast_1", name: "assistant" }]; + +describe("ensureMyraDeployed", () => { + test("an empty catalog is pending, not an error and not a deploy", async () => { + const calls = stubFetch((path) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json([]); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const outcome = await ensureMyraDeployed("tnt_1"); + expect(outcome).toEqual({ + kind: "pending", + reason: "the tenant catalog has no assistant asset yet", + }); + expect( + calls.some((call) => call.path.endsWith("/workflows/deployments")), + ).toBe(false); + }); + + test("a seeded catalog with no deployment posts the resolved body", async () => { + const calls = stubFetch((path, init) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + (init?.method ?? "GET") === "GET" + ) { + return json([]); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + init?.method === "POST" + ) { + return json({ id: "run_1" }, 201); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const outcome = await ensureMyraDeployed("tnt_1"); + expect(outcome).toEqual({ kind: "deployed" }); + const post = calls.find( + (call) => + call.path === "/api/tenants/tnt_1/workflows/deployments" && + call.init?.method === "POST", + ); + expect(JSON.parse(String(post?.init?.body))).toEqual({ + definitionAssetId: "ast_1", + confirmDeployments: false, + }); + }); + + test("an existing live deployment is left alone", async () => { + const calls = stubFetch((path, init) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if (path === "/api/tenants/tnt_1/workflows/deployments") { + return json([ + { + id: "run_1", + tenantId: "tnt_1", + definitionAssetId: "ast_1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const outcome = await ensureMyraDeployed("tnt_1"); + expect(outcome).toEqual({ kind: "already-running" }); + expect( + calls.some( + (call) => + call.path === "/api/tenants/tnt_1/workflows/deployments" && + call.init?.method === "POST", + ), + ).toBe(false); + }); + + test("a released or failed deployment is replaced, not mistaken for live", async () => { + const calls = stubFetch((path, init) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + (init?.method ?? "GET") === "GET" + ) { + return json([ + { + id: "run_old", + tenantId: "tnt_1", + definitionAssetId: "ast_1", + status: "failed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + init?.method === "POST" + ) { + return json({ id: "run_new" }, 201); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const outcome = await ensureMyraDeployed("tnt_1"); + expect(outcome).toEqual({ kind: "deployed" }); + expect( + calls.some( + (call) => + call.path === "/api/tenants/tnt_1/workflows/deployments" && + call.init?.method === "POST", + ), + ).toBe(true); + }); + + test("a deployments-list failure surfaces instead of deploying blind", async () => { + stubFetch((path) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if (path === "/api/tenants/tnt_1/workflows/deployments") { + return json({ error: "boom" }, 500); + } + throw new Error(`unexpected fetch: ${path}`); + }); + await expect(ensureMyraDeployed("tnt_1")).rejects.toThrow( + /workflows.*500|500.*workflows/, + ); + }); +}); + +describe("resolveTenantIdForSlug", () => { + test("finds the tenant id across membership pages", async () => { + stubFetch((path) => { + if (path === "/api/me/principals") { + return json({ + data: [ + { + principalId: "prn_1", + tenantId: "tnt_1", + tenantName: "Ada", + tenantSlug: "ada", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: "cursor_2", + }); + } + if (path === "/api/me/principals?cursor=cursor_2") { + return json({ + data: [ + { + principalId: "prn_2", + tenantId: "tnt_2", + tenantName: "Atlas", + tenantSlug: "ada-atlas", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + await expect(resolveTenantIdForSlug("ada-atlas")).resolves.toBe("tnt_2"); + await expect(resolveTenantIdForSlug("missing")).resolves.toBe(null); + }); +}); + +describe("driveMyraDeployAfterCredential", () => { + test("uses the outcome tenant id directly when present", async () => { + const calls = stubFetch((path, init) => { + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + (init?.method ?? "GET") === "GET" + ) { + return json([]); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + init?.method === "POST" + ) { + return json({ id: "run_1" }, 201); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const outcome = await driveMyraDeployAfterCredential({ + tenantId: "tnt_1", + tenantSlug: "ada", + }); + expect(outcome).toEqual({ kind: "deployed" }); + expect(calls.some((call) => call.path === "/api/me/principals")).toBe( + false, + ); + }); + + test("an older response without a tenant id resolves it from the slug", async () => { + const calls = stubFetch((path, init) => { + if (path === "/api/me/principals") { + return json({ + data: [ + { + principalId: "prn_1", + tenantId: "tnt_1", + tenantName: "Ada", + tenantSlug: "ada", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }); + } + if (path === "/api/tenants/tnt_1/assets?kind=workflow&inherited=true") { + return json(assistantAsset); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + (init?.method ?? "GET") === "GET" + ) { + return json([]); + } + if ( + path === "/api/tenants/tnt_1/workflows/deployments" && + init?.method === "POST" + ) { + return json({ id: "run_1" }, 201); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const outcome = await driveMyraDeployAfterCredential({ + tenantSlug: "ada", + }); + expect(outcome).toEqual({ kind: "deployed" }); + expect(calls.some((call) => call.path === "/api/me/principals")).toBe(true); + }); + + test("an unresolvable slug is pending, not a crash", async () => { + stubFetch((path) => { + if (path === "/api/me/principals") { + return json({ data: [], nextCursor: null }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const outcome = await driveMyraDeployAfterCredential({ + tenantSlug: "gone", + }); + expect(outcome).toEqual({ + kind: "pending", + reason: "no membership on tenant slug gone", + }); + }); +}); diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts new file mode 100644 index 0000000000..a0c3f15c6e --- /dev/null +++ b/apps/web/src/myra-deploy.ts @@ -0,0 +1,212 @@ +// Resolves the stock deploy body the needs-list converge driver +// (needs-converge.ts) needs to bring Myra up on a tenant: the tenant's +// own resolved `assistant` workflow asset, read from its catalog +// (inherited included) rather than invented client-side. Browser-safe — +// stock `/api/tenants/:id/assets` reads only, never `@corbits/seeding` +// or any other server-only package. + +export const MYRA_DEFINITION_REF_ID = "assistant"; + +type WorkflowAsset = { readonly id: string; readonly name: string }; + +/** + * Finds the tenant's resolved `assistant` workflow asset and returns the + * stock `POST .../workflows/deployments` body for it. `undefined` when + * the tenant's catalog carries no such asset yet — nothing this client + * can deploy against; the caller (needs-converge) then refuses the op + * rather than guessing a body. The `needs-converge.ts` `resolveAgentDeploy` + * shape — accepts and ignores `tenantSlug`/`definitionRefId`, since this + * resolver only ever supplies the one Myra deploy. + */ +export async function resolveMyraDeployBody(op: { + readonly tenantId: string; +}): Promise { + const { tenantId } = op; + const response = await fetch( + `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=workflow&inherited=true`, + ); + if (!response.ok) return undefined; + const body: unknown = await response.json().catch(() => null); + if (!Array.isArray(body)) return undefined; + const asset = body.find( + (row): row is WorkflowAsset => + typeof row === "object" && + row !== null && + "id" in row && + "name" in row && + typeof (row as WorkflowAsset).id === "string" && + (row as WorkflowAsset).name === MYRA_DEFINITION_REF_ID, + ); + if (asset === undefined) return undefined; + return { definitionAssetId: asset.id, confirmDeployments: false }; +} + +export type MyraDeployOutcome = + | { readonly kind: "deployed" } + | { readonly kind: "already-running" } + | { readonly kind: "pending"; readonly reason: string }; + +type DeploymentRow = { + readonly definitionAssetId: string; + readonly status: string; +}; + +function isDeploymentRow(row: unknown): row is DeploymentRow { + return ( + typeof row === "object" && + row !== null && + "definitionAssetId" in row && + typeof (row as DeploymentRow).definitionAssetId === "string" && + "status" in row && + typeof (row as DeploymentRow).status === "string" + ); +} + +/** A deployment that will never become live — replacing it is the only way + * Myra gets up. Anything else on the asset (deployed, deploying, queued) + * counts as running and is left alone. */ +function isTerminalDeploymentStatus(status: string): boolean { + return status === "released" || status === "failed"; +} + +/** + * Brings Myra up on an already-seeded tenant over stock routes: resolve + * the `assistant` asset from the tenant's own catalog, skip the post when + * a live deployment of it already exists, replace it when only a terminal + * (released/failed) one does. A catalog with no `assistant` asset yet is + * an expected pending — the credential seed hasn't landed — never an + * error and never a blind deploy. A deployments list that cannot be read + * throws: posting without knowing what is live would duplicate runners. + */ +export async function ensureMyraDeployed( + tenantId: string, +): Promise { + const encoded = encodeURIComponent(tenantId); + const deploy = await resolveMyraDeployBody({ tenantId }); + if (deploy === undefined) { + return { + kind: "pending", + reason: "the tenant catalog has no assistant asset yet", + }; + } + if ( + typeof deploy !== "object" || + deploy === null || + !("definitionAssetId" in deploy) || + typeof (deploy as { definitionAssetId: unknown }).definitionAssetId !== + "string" + ) { + throw new Error("myra: the resolved body has no workflow id"); + } + const definitionAssetId = (deploy as { definitionAssetId: string }) + .definitionAssetId; + const listed = await fetch(`/api/tenants/${encoded}/workflows/deployments`); + if (!listed.ok) { + throw new Error(`myra: listing workflows failed: HTTP ${listed.status}`); + } + const existing: unknown = await listed.json().catch(() => null); + if (!Array.isArray(existing)) { + throw new Error("myra: the workflows answer was not a list"); + } + const live = existing + .filter(isDeploymentRow) + .some( + (row) => + row.definitionAssetId === definitionAssetId && + !isTerminalDeploymentStatus(row.status), + ); + if (live) return { kind: "already-running" }; + const posted = await fetch(`/api/tenants/${encoded}/workflows/deployments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(deploy), + }); + if (!posted.ok) { + throw new Error( + `myra: starting the assistant failed: HTTP ${posted.status}`, + ); + } + return { kind: "deployed" }; +} + +type MembershipRow = { + readonly tenantId: string; + readonly tenantSlug: string; +}; + +function isMembershipRow(row: unknown): row is MembershipRow { + return ( + typeof row === "object" && + row !== null && + "tenantId" in row && + typeof (row as MembershipRow).tenantId === "string" && + "tenantSlug" in row && + typeof (row as MembershipRow).tenantSlug === "string" + ); +} + +/** + * Finds the tenant id for a manifest slug by walking the account's own + * cursor-paged membership — the older `/complete` answers carry no tenant + * id, only the slug. `null` when the account holds no such membership. + */ +export async function resolveTenantIdForSlug( + tenantSlug: string, +): Promise { + let cursor: string | null = null; + for (;;) { + const response = await fetch( + cursor === null + ? "/api/me/principals" + : `/api/me/principals?cursor=${encodeURIComponent(cursor)}`, + ); + if (!response.ok) { + throw new Error( + `myra: listing memberships failed: HTTP ${response.status}`, + ); + } + const body: unknown = await response.json().catch(() => null); + if ( + typeof body !== "object" || + body === null || + !("data" in body) || + !Array.isArray((body as { data: unknown }).data) + ) { + throw new Error("myra: the memberships answer was not a page"); + } + const page = body as { + readonly data: unknown[]; + readonly nextCursor?: unknown; + }; + for (const row of page.data) { + if (isMembershipRow(row) && row.tenantSlug === tenantSlug) { + return row.tenantId; + } + } + const next = typeof page.nextCursor === "string" ? page.nextCursor : null; + if (next === null) return null; + cursor = next; + } +} + +/** + * The post-credential trigger: once the credential step has seeded the + * catalog, the client itself retries the deploy that genesis had to leave + * pending. Prefers the outcome's own tenant id; an older answer without + * one resolves it from the slug. An unresolvable slug is pending, not a + * crash — the tenant genuinely is not there yet. + */ +export async function driveMyraDeployAfterCredential(input: { + readonly tenantId?: string | undefined; + readonly tenantSlug: string; +}): Promise { + const tenantId = + input.tenantId ?? (await resolveTenantIdForSlug(input.tenantSlug)); + if (tenantId === null) { + return { + kind: "pending", + reason: `no membership on tenant slug ${input.tenantSlug}`, + }; + } + return ensureMyraDeployed(tenantId); +} diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts new file mode 100644 index 0000000000..95338d8e89 --- /dev/null +++ b/apps/web/src/needs-converge.test.ts @@ -0,0 +1,516 @@ +// The convergence driver diffs the client's needs-list against stock-hub +// reads and executes the minimal stock-API sequence to converge. Every +// test here runs against doubles — never a live hub. + +import { describe, expect, test } from "bun:test"; + +import { buildNeedsList, type NeedsList } from "./needs-list"; +import { + convergeNeedsList, + createFetchStockHub, + diffNeedsList, + readHubSnapshot, + type HubSnapshot, + type StockHub, +} from "./needs-converge"; + +const manifest: NeedsList = buildNeedsList({ + user: { id: "usr_1", email: "ada@example.com" }, + primaryTenant: { slug: "ada", name: "Ada" }, + myraDefinitionRefId: "assistant", + workbenches: [ + { + slug: "ada-atlas", + name: "Atlas", + members: [{ refId: "usr_2", email: "bea@example.com", role: "member" }], + }, + ], + shares: [{ workbenchSlug: "ada-atlas", email: "cyd@example.com" }], + createdWorkbenchTenantIds: [], +}); + +function emptySnapshot(): HubSnapshot { + return { myPrincipals: [], tenantsById: {}, principalsByTenant: {} }; +} + +function convergedSnapshot(): HubSnapshot { + return { + myPrincipals: [ + { + principalId: "prn_1", + tenantId: "tnt_primary", + tenantName: "Ada", + tenantSlug: "ada", + kind: "user", + status: "active", + roles: [{ id: "r1", name: "owner" }], + }, + { + principalId: "prn_2", + tenantId: "tnt_atlas", + tenantName: "Atlas", + tenantSlug: "ada-atlas", + kind: "user", + status: "active", + roles: [{ id: "r1", name: "owner" }], + }, + ], + tenantsById: { + tnt_primary: { + id: "tnt_primary", + name: "Ada", + slug: "ada", + parentId: null, + }, + tnt_atlas: { + id: "tnt_atlas", + name: "Atlas", + slug: "ada-atlas", + parentId: "tnt_primary", + }, + }, + principalsByTenant: { + tnt_primary: [ + { + id: "prn_1", + tenantId: "tnt_primary", + kind: "user", + refId: "usr_1", + status: "active", + roles: ["owner"], + }, + { + id: "prn_myra", + tenantId: "tnt_primary", + kind: "workflow", + refId: "assistant", + status: "active", + roles: [], + }, + ], + tnt_atlas: [ + { + id: "prn_3", + tenantId: "tnt_atlas", + kind: "user", + refId: "usr_1", + status: "active", + roles: ["owner"], + }, + { + id: "prn_4", + tenantId: "tnt_atlas", + kind: "user", + refId: "usr_2", + email: "bea@example.com", + status: "active", + roles: ["member"], + }, + { + id: "prn_5", + tenantId: "tnt_atlas", + kind: "user", + refId: "usr_3", + email: "cyd@example.com", + status: "active", + roles: ["member"], + }, + ], + }, + }; +} + +function recordingHub(snapshot: HubSnapshot) { + const calls: string[] = []; + const hub: StockHub = { + listMyPrincipals: () => { + calls.push("listMyPrincipals"); + return Promise.resolve(snapshot.myPrincipals); + }, + getTenant: (id) => { + calls.push(`getTenant:${id}`); + return Promise.resolve(snapshot.tenantsById[id] ?? null); + }, + listPrincipals: (tenantId) => { + calls.push(`listPrincipals:${tenantId}`); + return Promise.resolve(snapshot.principalsByTenant[tenantId] ?? []); + }, + createTenant: (input) => { + calls.push(`createTenant:${input.slug}`); + return Promise.resolve({ + id: `tnt_${input.slug}`, + name: input.name, + slug: input.slug, + parentId: input.parentId ?? null, + }); + }, + inviteMember: (tenantId, input) => { + calls.push(`inviteMember:${tenantId}:${input.email}`); + return Promise.resolve(); + }, + deployAgent: (tenantId, input) => { + calls.push(`deployAgent:${tenantId}:${input.definitionRefId}`); + return Promise.resolve(); + }, + }; + return { hub, calls }; +} + +describe("diffNeedsList", () => { + test("an empty hub needs primary, Myra, and the workbench", () => { + const ops = diffNeedsList(manifest, emptySnapshot()); + expect(ops.map((op) => op.kind)).toEqual([ + "create-tenant", + "deploy-agent", + "create-tenant", + "invite-member", + "invite-member", + ]); + }); + + test("a converged hub needs nothing", () => { + expect(diffNeedsList(manifest, convergedSnapshot())).toEqual([]); + }); + + test("a missing Myra principal yields one deploy-agent op", () => { + const snapshot = convergedSnapshot(); + const primary = snapshot.principalsByTenant["tnt_primary"] ?? []; + snapshot.principalsByTenant["tnt_primary"] = primary.filter( + (principal) => principal.kind !== "workflow", + ); + const ops = diffNeedsList(manifest, snapshot); + expect(ops).toEqual([ + { + kind: "deploy-agent", + tenantSlug: "ada", + definitionRefId: "assistant", + }, + ]); + }); + + test("a workbench member with the wrong status is re-invited", () => { + const snapshot = convergedSnapshot(); + const member = snapshot.principalsByTenant["tnt_atlas"]?.find( + (p) => p.refId === "usr_2", + ); + if (member !== undefined) member.status = "suspended"; + const ops = diffNeedsList(manifest, snapshot); + expect(ops.map((op) => op.kind)).toEqual(["invite-member"]); + }); +}); + +describe("convergeNeedsList", () => { + test("reads first, then converges an empty hub via stock calls", async () => { + const { hub, calls } = recordingHub(emptySnapshot()); + const report = await convergeNeedsList(manifest, hub); + expect(report.applied.map((op) => op.kind)).toEqual([ + "create-tenant", + "deploy-agent", + "create-tenant", + "invite-member", + "invite-member", + ]); + expect(calls[0]).toBe("listMyPrincipals"); + const firstWrite = calls.findIndex((call) => + call.startsWith("createTenant"), + ); + const lastRead = calls.reduce( + (latest, call, index) => + call.startsWith("list") || call.startsWith("getTenant") + ? index + : latest, + -1, + ); + expect(firstWrite).toBeGreaterThan(lastRead); + }); + + test("a converged hub issues no writes", async () => { + const { hub, calls } = recordingHub(convergedSnapshot()); + const report = await convergeNeedsList(manifest, hub); + expect(report.applied).toEqual([]); + expect( + calls.some( + (call) => + call.startsWith("createTenant") || + call.startsWith("inviteMember") || + call.startsWith("deployAgent"), + ), + ).toBe(false); + }); + + test("accepts a pre-read snapshot and skips the reads", async () => { + const { hub, calls } = recordingHub(convergedSnapshot()); + const report = await convergeNeedsList(manifest, hub, convergedSnapshot()); + expect(report.applied).toEqual([]); + expect(calls).toEqual([]); + }); +}); + +describe("readHubSnapshot", () => { + test("fans out over native tenants/principals reads only", async () => { + const { hub, calls } = recordingHub(convergedSnapshot()); + const snapshot = await readHubSnapshot(hub, manifest); + expect(Object.keys(snapshot.tenantsById).sort()).toEqual([ + "tnt_atlas", + "tnt_primary", + ]); + expect(calls).toEqual([ + "listMyPrincipals", + "getTenant:tnt_primary", + "listPrincipals:tnt_primary", + "getTenant:tnt_atlas", + "listPrincipals:tnt_atlas", + ]); + }); +}); + +describe("convergeNeedsList deploy pending", () => { + test("an unresolvable deploy body is reported pending, never thrown or silently skipped", async () => { + const { hub, calls } = recordingHub(emptySnapshot()); + const report = await convergeNeedsList(manifest, hub, emptySnapshot(), { + resolveAgentDeploy: () => undefined, + }); + // Everything else still converges — only the agent deploy waits for + // the catalog seed that the credential step brings. + expect(report.applied.map((op) => op.kind)).toEqual([ + "create-tenant", + "create-tenant", + "invite-member", + "invite-member", + ]); + expect(report.pending).toEqual([ + { + kind: "deploy-agent", + tenantSlug: "ada", + definitionRefId: "assistant", + }, + ]); + expect(calls.some((call) => call.startsWith("deployAgent"))).toBe(false); + }); + + test("a resolved deploy body still deploys and leaves nothing pending", async () => { + const { hub, calls } = recordingHub(emptySnapshot()); + const report = await convergeNeedsList(manifest, hub, emptySnapshot(), { + resolveAgentDeploy: () => ({ definitionAssetId: "ast_1" }), + }); + expect(report.applied.map((op) => op.kind)).toEqual([ + "create-tenant", + "deploy-agent", + "create-tenant", + "invite-member", + "invite-member", + ]); + expect(report.pending).toEqual([]); + expect(calls.some((call) => call.startsWith("deployAgent:tnt_ada"))).toBe( + true, + ); + }); + + test("a workbench member without an email is an explicit skip, not a silent drop", async () => { + const emailLess: NeedsList = buildNeedsList({ + user: { id: "usr_1", email: "ada@example.com" }, + primaryTenant: { slug: "ada", name: "Ada" }, + myraDefinitionRefId: "assistant", + workbenches: [ + { + slug: "ada-atlas", + name: "Atlas", + members: [{ refId: "usr_9", role: "member" }], + }, + ], + shares: [], + createdWorkbenchTenantIds: [], + }); + const { hub } = recordingHub(emptySnapshot()); + const report = await convergeNeedsList(emailLess, hub, emptySnapshot(), { + resolveAgentDeploy: () => ({ definitionAssetId: "ast_1" }), + }); + expect(report.skipped).toEqual([ + { + kind: "invite-member", + tenantSlug: "ada-atlas", + reason: "member without an email address cannot be invited", + }, + ]); + }); +}); + +describe("createFetchStockHub", () => { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + + function stubFetch(respond: (path: string, init?: RequestInit) => Response) { + const calls: { path: string; init?: RequestInit }[] = []; + const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + calls.push(init === undefined ? { path } : { path, init }); + return Promise.resolve(respond(path, init)); + }; + return { fetchImpl: fetchImpl as typeof fetch, calls }; + } + + test("reads hit the native tenants/principals routes", async () => { + const { fetchImpl, calls } = stubFetch((path) => { + if (path === "/api/me/principals") { + return json({ data: [], nextCursor: null }); + } + if (path === "/api/tenants/tnt_1") { + return json({ + id: "tnt_1", + name: "Ada", + slug: "ada", + domain: "ada.localhost", + parentId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + } + if (path.startsWith("/api/tenants/tnt_1/principals")) { + return json({ data: [], nextCursor: null }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const hub = createFetchStockHub(fetchImpl); + await hub.listMyPrincipals(); + await hub.getTenant("tnt_1"); + await hub.listPrincipals("tnt_1"); + expect(calls.map((call) => call.path)).toEqual([ + "/api/me/principals", + "/api/tenants/tnt_1", + "/api/tenants/tnt_1/principals?limit=100", + ]); + }); + + test("writes use stock tenant, invite, and workflow routes", async () => { + const { fetchImpl, calls } = stubFetch((path, init) => { + if (path === "/api/tenants" && init?.method === "POST") { + return json( + { + id: "tnt_new", + name: "Atlas", + slug: "ada-atlas", + domain: "ada-atlas.localhost", + parentId: "tnt_1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + 201, + ); + } + if (path === "/api/tenants/tnt_1/members/invite") { + return json({}, 201); + } + if (path === "/api/tenants/tnt_1/workflows/deployments") { + return json({}, 201); + } + throw new Error(`unexpected fetch: ${path} ${init?.method}`); + }); + const hub = createFetchStockHub(fetchImpl, { + resolveAgentDeploy: (op) => ({ + source: "test-double", + definitionRefId: op.definitionRefId, + }), + }); + await hub.createTenant({ + name: "Atlas", + slug: "ada-atlas", + parentId: "tnt_1", + }); + await hub.inviteMember("tnt_1", { email: "bea@example.com" }); + await hub.deployAgent("tnt_1", { definitionRefId: "assistant" }); + const posts = calls.filter((call) => call.init?.method === "POST"); + expect(posts.map((call) => call.path)).toEqual([ + "/api/tenants", + "/api/tenants/tnt_1/members/invite", + "/api/tenants/tnt_1/workflows/deployments", + ]); + for (const call of calls) { + expect(call.path.startsWith("/api/")).toBe(true); + expect(call.path).not.toContain("/api/onboarding"); + expect(call.path).not.toContain("/api/workbench-tenancies"); + expect(call.path).not.toContain("/chat/"); + } + }); + + test("membership reads follow cursor pages instead of stopping at the first", async () => { + const page = (rows: unknown[], nextCursor: string | null) => + json({ data: rows, nextCursor }); + const mine = (tenantId: string, tenantSlug: string) => ({ + principalId: `prn_${tenantId}`, + tenantId, + tenantName: tenantSlug, + tenantSlug, + kind: "user", + status: "active", + roles: [{ id: "r1", name: "owner" }], + }); + const { fetchImpl, calls } = stubFetch((path) => { + if (path === "/api/me/principals") { + return page([mine("tnt_1", "ada")], "cursor_2"); + } + if (path === "/api/me/principals?cursor=cursor_2") { + return page([mine("tnt_2", "ada-atlas")], null); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const hub = createFetchStockHub(fetchImpl); + const principals = await hub.listMyPrincipals(); + expect(principals.map((row) => row.tenantId)).toEqual(["tnt_1", "tnt_2"]); + expect(calls.map((call) => call.path)).toEqual([ + "/api/me/principals", + "/api/me/principals?cursor=cursor_2", + ]); + }); + + test("tenant principal reads follow cursor pages", async () => { + const row = (id: string) => ({ + id, + tenantId: "tnt_1", + kind: "user", + refId: "usr_1", + status: "active", + roles: ["owner"], + }); + const { fetchImpl, calls } = stubFetch((path) => { + if (path === "/api/tenants/tnt_1/principals?limit=100") { + return json({ data: [row("prn_1")], nextCursor: "cursor_2" }); + } + if (path === "/api/tenants/tnt_1/principals?limit=100&cursor=cursor_2") { + return json({ data: [row("prn_2")], nextCursor: null }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const hub = createFetchStockHub(fetchImpl); + const principals = await hub.listPrincipals("tnt_1"); + expect(principals.map((row) => row.id)).toEqual(["prn_1", "prn_2"]); + expect(calls.map((call) => call.path)).toEqual([ + "/api/tenants/tnt_1/principals?limit=100", + "/api/tenants/tnt_1/principals?limit=100&cursor=cursor_2", + ]); + }); + + test("deployAgent hands the resolver the manifest tenant slug, not the tenant id", async () => { + const seen: { tenantId: string; tenantSlug: string }[] = []; + const { fetchImpl } = stubFetch((path) => { + if (path === "/api/tenants/tnt_1/workflows/deployments") { + return json({}, 201); + } + throw new Error(`unexpected fetch: ${path}`); + }); + const hub = createFetchStockHub(fetchImpl, { + resolveAgentDeploy: (op) => { + seen.push({ tenantId: op.tenantId, tenantSlug: op.tenantSlug }); + return { definitionAssetId: "ast_1" }; + }, + }); + await hub.deployAgent("tnt_1", { + definitionRefId: "assistant", + tenantSlug: "ada", + }); + expect(seen).toEqual([{ tenantId: "tnt_1", tenantSlug: "ada" }]); + }); +}); diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts new file mode 100644 index 0000000000..f4ad1da527 --- /dev/null +++ b/apps/web/src/needs-converge.ts @@ -0,0 +1,588 @@ +// The convergence driver: diff the client's needs-list against a stock +// Interchange hub, then execute the minimal stock-API sequence that makes +// the hub match. Reads use the native tenants/principals routes only +// (`GET /api/me/principals`, `GET /api/tenants/:id`, +// `GET /api/tenants/:id/principals`); writes are stock hub routes only +// (`POST /api/tenants`, `POST /api/tenants/:id/members/invite`, +// `POST /api/tenants/:id/workflows/deployments`). No workbench, +// onboarding, or chat routes — the driver stays portable to nearly any +// hub by default. All hub access goes through the StockHub port so tests +// (and previews) run against doubles, never a live hub. + +import { type } from "arktype"; + +import type { NeedsList } from "./needs-list"; + +export type HubTenant = { + id: string; + name: string; + slug: string; + parentId: string | null; +}; + +export type HubPrincipal = { + id: string; + tenantId: string; + kind: string; + refId: string; + email?: string; + status: string; + roles: string[]; +}; + +export type MyMembership = { + principalId: string; + tenantId: string; + tenantName: string; + tenantSlug: string; + kind: string; + status: string; + roles: { id: string; name: string }[]; +}; + +export type HubSnapshot = { + myPrincipals: MyMembership[]; + tenantsById: Record; + principalsByTenant: Record; +}; + +export type ConvergeOp = + | { + kind: "create-tenant"; + slug: string; + name: string; + parentSlug: string | null; + } + | { kind: "invite-member"; tenantSlug: string; email: string } + | { kind: "deploy-agent"; tenantSlug: string; definitionRefId: string }; + +export type StockHub = { + listMyPrincipals(): Promise; + getTenant(id: string): Promise; + listPrincipals(tenantId: string): Promise; + createTenant(input: { + name: string; + slug: string; + parentId?: string; + }): Promise; + inviteMember(tenantId: string, input: { email: string }): Promise; + deployAgent( + tenantId: string, + input: { + definitionRefId: string; + /** The manifest slug being converged — the resolver reads the + * tenant's own catalog, and a slug is what the manifest knows. Falls + * back to the tenant id only for callers older than this field. */ + tenantSlug?: string; + deploy?: unknown; + }, + ): Promise; +}; + +/** + * Reads the hub state the diff needs, fanning out over the native + * tenants/principals routes: my memberships first, then each visible + * tenant plus its principals. + */ +export async function readHubSnapshot( + hub: StockHub, + _manifest: NeedsList, +): Promise { + const myPrincipals = await hub.listMyPrincipals(); + const tenantIds = [...new Set(myPrincipals.map((row) => row.tenantId))]; + const tenantsById: Record = {}; + const principalsByTenant: Record = {}; + for (const tenantId of tenantIds) { + const [tenant, principals] = await Promise.all([ + hub.getTenant(tenantId), + hub.listPrincipals(tenantId), + ]); + if (tenant !== null) tenantsById[tenant.id] = tenant; + principalsByTenant[tenantId] = principals; + } + return { myPrincipals, tenantsById, principalsByTenant }; +} + +function tenantIdForSlug(snapshot: HubSnapshot, slug: string): string | null { + for (const tenant of Object.values(snapshot.tenantsById)) { + if (tenant.slug === slug) return tenant.id; + } + return null; +} + +function activePrincipal( + principals: HubPrincipal[] | undefined, + match: (principal: HubPrincipal) => boolean, +): HubPrincipal | null { + const found = (principals ?? []).find( + (principal) => principal.status === "active" && match(principal), + ); + return found ?? null; +} + +/** + * Diffs desired (manifest) against observed (snapshot) and returns the + * minimal op sequence, ordered so parents exist before children: the + * primary tenant, the Myra agent at top level, then each workbench with + * its members and shares. Pure — no hub calls. + */ +export function diffNeedsList( + manifest: NeedsList, + snapshot: HubSnapshot, +): ConvergeOp[] { + const ops: ConvergeOp[] = []; + const primaryId = tenantIdForSlug(snapshot, manifest.primaryTenant.slug); + + if (primaryId === null) { + ops.push({ + kind: "create-tenant", + slug: manifest.primaryTenant.slug, + name: manifest.primaryTenant.name, + parentSlug: null, + }); + } + const primaryPrincipals = + primaryId === null ? [] : snapshot.principalsByTenant[primaryId]; + + const myraRunning = + activePrincipal( + primaryPrincipals, + (principal) => + principal.kind === "workflow" && + principal.refId === manifest.myra.definitionRefId, + ) !== null; + if (!myraRunning) { + ops.push({ + kind: "deploy-agent", + tenantSlug: manifest.primaryTenant.slug, + definitionRefId: manifest.myra.definitionRefId, + }); + } + + for (const workbench of manifest.workbenches) { + const workbenchId = + primaryId === null + ? null + : (Object.values(snapshot.tenantsById).find( + (tenant) => + tenant.slug === workbench.slug && tenant.parentId === primaryId, + )?.id ?? + manifest.createdWorkbenchTenantIds + .map((id) => snapshot.tenantsById[id]) + .find((tenant) => tenant?.slug === workbench.slug)?.id ?? + null); + if (workbenchId === null) { + ops.push({ + kind: "create-tenant", + slug: workbench.slug, + name: workbench.name, + parentSlug: manifest.primaryTenant.slug, + }); + } + const observed = + workbenchId === null ? [] : snapshot.principalsByTenant[workbenchId]; + for (const member of workbench.members) { + const present = + activePrincipal( + observed, + (principal) => + principal.kind === "user" && principal.refId === member.refId, + ) !== null; + if (!present && member.email !== undefined) { + ops.push({ + kind: "invite-member", + tenantSlug: workbench.slug, + email: member.email, + }); + } + } + const sharesByWorkbench = manifest.shares.filter( + (share) => share.workbenchSlug === workbench.slug, + ); + for (const share of sharesByWorkbench) { + const present = + activePrincipal( + observed, + (principal) => + principal.kind === "user" && + principal.email?.toLowerCase() === share.email.toLowerCase(), + ) !== null; + if (!present) { + ops.push({ + kind: "invite-member", + tenantSlug: workbench.slug, + email: share.email, + }); + } + } + } + return ops; +} + +export type ConvergeReport = { + ops: ConvergeOp[]; + applied: ConvergeOp[]; + /** Ops the driver deliberately did not attempt: an agent deploy whose + * body the resolver cannot build yet (fresh tenant, catalog still + * unseeded). Pending is a retry contract, not a skip — the + * post-credential driver re-resolves once the seed lands. */ + pending: ConvergeOp[]; + /** Desired invites the driver refused to attempt, with the reason kept + * alongside — an email-less member can never be invited, so it is + * reported, never silently dropped. */ + skipped: ConvergeSkip[]; +}; + +export type ConvergeSkip = { + readonly kind: "invite-member"; + readonly tenantSlug: string; + readonly reason: string; +}; + +export type ConvergeOptions = { + /** + * Resolves the stock deploy body (asset source, entry, offerings) for + * the Myra definition ref. The driver cannot invent hub-specific + * deploy values from a bare refId, so the app layer supplies them; + * without a resolver the fetch hub refuses the op rather than posting + * a guessed body. `tenantId` is included (alongside `tenantSlug`) + * because resolving a real deploy body means reading the tenant's own + * catalog, which only exists once the tenant does — synchronous or + * async, either is accepted. + */ + resolveAgentDeploy?: (op: { + tenantId: string; + tenantSlug: string; + definitionRefId: string; + }) => unknown | Promise; +}; + +/** + * Email-less workbench members can never be invited (the stock invite + * route takes an email), so the diff drops them — this names each one as + * an explicit skip instead of letting that drop read as silence. A member + * already active on the tenant needs no invite and is not reported. + */ +function skippedEmailLessInvites( + manifest: NeedsList, + snapshot: HubSnapshot, +): ConvergeSkip[] { + const primaryId = tenantIdForSlug(snapshot, manifest.primaryTenant.slug); + const skipped: ConvergeSkip[] = []; + for (const workbench of manifest.workbenches) { + const workbenchId = + primaryId === null + ? null + : (Object.values(snapshot.tenantsById).find( + (tenant) => + tenant.slug === workbench.slug && tenant.parentId === primaryId, + )?.id ?? + manifest.createdWorkbenchTenantIds + .map((id) => snapshot.tenantsById[id]) + .find((tenant) => tenant?.slug === workbench.slug)?.id ?? + null); + const observed = + workbenchId === null ? [] : snapshot.principalsByTenant[workbenchId]; + for (const member of workbench.members) { + if (member.email !== undefined) continue; + const present = + activePrincipal( + observed, + (principal) => + principal.kind === "user" && principal.refId === member.refId, + ) !== null; + if (!present) { + skipped.push({ + kind: "invite-member", + tenantSlug: workbench.slug, + reason: "member without an email address cannot be invited", + }); + } + } + } + return skipped; +} + +/** + * Converges the hub toward the manifest: read (unless a snapshot is + * supplied), diff, then execute each op against stock APIs in order. A + * supplied snapshot skips the reads entirely — the caller already looked. + * + * A configured `resolveAgentDeploy` that returns `undefined` (fresh tenant, + * catalog still unseeded) reports the deploy as pending rather than posting + * a guessed body or failing the run — everything else still converges, and + * the post-credential driver retries the pending deploy once the seed + * lands. Without a resolver at all the hub port's own contract applies + * (the fetch hub refuses the op). + */ +export async function convergeNeedsList( + manifest: NeedsList, + hub: StockHub, + snapshot?: HubSnapshot, + options?: ConvergeOptions, +): Promise { + const observed = snapshot ?? (await readHubSnapshot(hub, manifest)); + const ops = diffNeedsList(manifest, observed); + const skipped = skippedEmailLessInvites(manifest, observed); + const tenantIds = new Map( + Object.values(observed.tenantsById).map((tenant): [string, string] => [ + tenant.slug, + tenant.id, + ]), + ); + const applied: ConvergeOp[] = []; + const pending: ConvergeOp[] = []; + for (const op of ops) { + if (op.kind === "create-tenant") { + const parentId = + op.parentSlug === null ? undefined : tenantIds.get(op.parentSlug); + if (op.parentSlug !== null && parentId === undefined) { + throw new Error( + `needs-list converge cannot create ${op.slug}: parent ${op.parentSlug} is unknown`, + ); + } + const created = await hub.createTenant({ + name: op.name, + slug: op.slug, + ...(parentId === undefined ? {} : { parentId }), + }); + tenantIds.set(op.slug, created.id); + applied.push(op); + continue; + } + const tenantId = tenantIds.get(op.tenantSlug); + if (tenantId === undefined) { + throw new Error( + `needs-list converge cannot apply ${op.kind} on ${op.tenantSlug}: no id is known`, + ); + } + if (op.kind === "invite-member") { + await hub.inviteMember(tenantId, { email: op.email }); + applied.push(op); + continue; + } + if (op.kind === "deploy-agent") { + const deploy = await options?.resolveAgentDeploy?.({ + tenantId, + tenantSlug: op.tenantSlug, + definitionRefId: op.definitionRefId, + }); + if (options?.resolveAgentDeploy !== undefined && deploy === undefined) { + pending.push(op); + continue; + } + await hub.deployAgent(tenantId, { + definitionRefId: op.definitionRefId, + tenantSlug: op.tenantSlug, + deploy, + }); + applied.push(op); + continue; + } + } + return { ops, applied, pending, skipped }; +} + +const TenantShape = type({ + id: "string", + name: "string", + slug: "string", + "parentId?": "string | null", +}); + +const MembershipShape = type({ + principalId: "string", + tenantId: "string", + tenantName: "string", + tenantSlug: "string", + kind: "string", + status: "string", + roles: type({ id: "string", name: "string" }).array(), +}); + +const TenantPrincipalShape = type({ + id: "string", + tenantId: "string", + kind: "string", + refId: "string", + "email?": "string", + status: "string", + roles: "string[]", +}); + +const MembershipPageShape = type({ + data: MembershipShape.array(), + nextCursor: "string | null", +}); + +const TenantPrincipalPageShape = type({ + data: TenantPrincipalShape.array(), + nextCursor: "string | null", +}); + +async function readJson(response: Response, what: string): Promise { + if (!response.ok) { + throw new Error(`stock ${what} failed: HTTP ${response.status}`); + } + return (await response.json()) as unknown; +} + +function tenantOf(value: unknown, what: string): HubTenant { + const parsed = TenantShape(value); + if (parsed instanceof type.errors) { + throw new Error(`stock ${what} shape changed: ${parsed.summary}`); + } + return { + id: parsed.id, + name: parsed.name, + slug: parsed.slug, + parentId: parsed.parentId ?? null, + }; +} + +/** + * Builds the StockHub port on fetch over stock hub routes only. The + * optional fetch defaults to global fetch; pass a double in tests. The + * deploy op posts the caller-resolved stock body — without + * resolveAgentDeploy it throws rather than guessing hub-specific deploy + * values. + */ +export function createFetchStockHub( + fetchImpl: typeof fetch = fetch, + options?: ConvergeOptions, +): StockHub { + return { + async listMyPrincipals() { + // Membership is cursor-paged: a first page is never the whole + // answer for an account with several benches, so follow `nextCursor` + // until the hub says there is no more. + const rows: MyMembership[] = []; + let cursor: string | null = null; + for (;;) { + const body = await readJson( + await fetchImpl( + cursor === null + ? "/api/me/principals" + : `/api/me/principals?cursor=${encodeURIComponent(cursor)}`, + ), + "listMyPrincipals", + ); + const parsed = MembershipPageShape(body); + if (parsed instanceof type.errors) { + throw new Error(`stock membership shape changed: ${parsed.summary}`); + } + rows.push( + ...parsed.data.map((row) => ({ + principalId: row.principalId, + tenantId: row.tenantId, + tenantName: row.tenantName, + tenantSlug: row.tenantSlug, + kind: row.kind, + status: row.status, + roles: row.roles.map((role) => ({ id: role.id, name: role.name })), + })), + ); + if (parsed.nextCursor === null) return rows; + cursor = parsed.nextCursor; + } + }, + async getTenant(id) { + const response = await fetchImpl( + `/api/tenants/${encodeURIComponent(id)}`, + ); + if (response.status === 404) return null; + return tenantOf(await readJson(response, "getTenant"), "getTenant"); + }, + async listPrincipals(tenantId) { + // Same cursor contract as the membership list — a bench with more + // than one page of principals must not converge against a partial + // read and re-invite people who are already there. + const rows: HubPrincipal[] = []; + let cursor: string | null = null; + for (;;) { + const query = + cursor === null + ? "limit=100" + : `limit=100&cursor=${encodeURIComponent(cursor)}`; + const body = await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/principals?${query}`, + ), + "listPrincipals", + ); + const parsed = TenantPrincipalPageShape(body); + if (parsed instanceof type.errors) { + throw new Error(`stock membership shape changed: ${parsed.summary}`); + } + rows.push( + ...parsed.data.map((row) => ({ + id: row.id, + tenantId: row.tenantId, + kind: row.kind, + refId: row.refId, + ...(row.email === undefined ? {} : { email: row.email }), + status: row.status, + roles: [...row.roles], + })), + ); + if (parsed.nextCursor === null) return rows; + cursor = parsed.nextCursor; + } + }, + async createTenant(input) { + const body = await readJson( + await fetchImpl("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: input.name, + slug: input.slug, + ...(input.parentId === undefined + ? {} + : { parentId: input.parentId }), + }), + }), + "createTenant", + ); + return tenantOf(body, "createTenant"); + }, + async inviteMember(tenantId, input) { + await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/members/invite`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: input.email }), + }, + ), + "inviteMember", + ); + }, + async deployAgent(tenantId, input) { + const deploy = + input.deploy ?? + (await options?.resolveAgentDeploy?.({ + tenantId, + tenantSlug: input.tenantSlug ?? tenantId, + definitionRefId: input.definitionRefId, + })); + if (deploy === undefined) { + throw new Error( + `stock deployAgent needs a caller-resolved body for ${input.definitionRefId}`, + ); + } + await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/workflows/deployments`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(deploy), + }, + ), + "deployAgent", + ); + }, + }; +} diff --git a/apps/web/src/needs-list.test.ts b/apps/web/src/needs-list.test.ts new file mode 100644 index 0000000000..3ce03e2c71 --- /dev/null +++ b/apps/web/src/needs-list.test.ts @@ -0,0 +1,115 @@ +// The needs-list manifest is the client's portable desired state: one +// user's primary tenant, the Myra agent running at top level, and every +// workbench sub-tenant with its members — plus the client-kept list of +// workbench tenant ids it created. These tests pin the builder, the +// arktype boundary, and the created-ids store. + +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + buildNeedsList, + loadCreatedWorkbenchTenantIds, + NeedsListSchema, + parseNeedsList, + recordCreatedWorkbenchTenantId, + type NeedsListInput, +} from "./needs-list"; + +const input: NeedsListInput = { + user: { id: "usr_1", email: "ada@example.com" }, + primaryTenant: { slug: "ada", name: "Ada" }, + myraDefinitionRefId: "assistant", + workbenches: [ + { + slug: "ada-atlas", + name: "Atlas", + members: [{ refId: "usr_2", email: "bea@example.com", role: "member" }], + }, + ], + shares: [{ workbenchSlug: "ada-atlas", email: "cyd@example.com" }], + createdWorkbenchTenantIds: ["tnt_atlas"], +}; + +describe("buildNeedsList", () => { + test("builds the full desired state for one user", () => { + const manifest = buildNeedsList(input); + expect(manifest.version).toBe(1); + expect(manifest.primaryTenant).toEqual({ slug: "ada", name: "Ada" }); + expect(manifest.myra).toEqual({ + definitionRefId: "assistant", + scope: "top-level", + want: "running", + }); + expect(manifest.workbenches).toHaveLength(1); + expect(manifest.workbenches[0]?.parentSlug).toBe("ada"); + expect(manifest.workbenches[0]?.members[0]).toMatchObject({ + refId: "usr_2", + kind: "user", + status: "active", + roles: ["member"], + }); + expect(manifest.shares).toEqual([ + { workbenchSlug: "ada-atlas", email: "cyd@example.com" }, + ]); + expect(manifest.createdWorkbenchTenantIds).toEqual(["tnt_atlas"]); + }); + + test("defaults the created-ids list to empty when the client is new", () => { + const { createdWorkbenchTenantIds: _omitted, ...fresh } = input; + const manifest = buildNeedsList(fresh); + expect(manifest.createdWorkbenchTenantIds).toEqual([]); + }); + + test("the built manifest parses cleanly at the arktype boundary", () => { + const parsed = parseNeedsList(buildNeedsList(input)); + expect(parsed instanceof type.errors).toBe(false); + }); +}); + +describe("parseNeedsList", () => { + test("rejects a manifest missing the Myra refId", () => { + const bad = { ...buildNeedsList(input), myra: {} }; + expect(parseNeedsList(bad) instanceof type.errors).toBe(true); + }); + + test("rejects an unknown manifest version", () => { + const bad = { ...buildNeedsList(input), version: 99 }; + expect(parseNeedsList(bad) instanceof type.errors).toBe(true); + }); + + test("exposes the schema for hub-shaped reads to validate against", () => { + expect(NeedsListSchema).toBeDefined(); + }); +}); + +describe("created workbench tenant ids store", () => { + function memoryStorage(initial: Record = {}) { + const rows = new Map(Object.entries(initial)); + return { + getItem: (key: string) => rows.get(key) ?? null, + setItem: (key: string, value: string) => { + rows.set(key, value); + }, + }; + } + + test("loads empty when the client never created a workbench", () => { + expect(loadCreatedWorkbenchTenantIds(memoryStorage())).toEqual([]); + }); + + test("records ids without duplicating or dropping existing ones", () => { + const storage = memoryStorage(); + recordCreatedWorkbenchTenantId(storage, "tnt_a"); + recordCreatedWorkbenchTenantId(storage, "tnt_b"); + recordCreatedWorkbenchTenantId(storage, "tnt_a"); + expect(loadCreatedWorkbenchTenantIds(storage)).toEqual(["tnt_a", "tnt_b"]); + }); + + test("treats a corrupt row as empty rather than throwing", () => { + const storage = memoryStorage({ + "workbench.needs-list.created-tenant-ids": "not-json{{{", + }); + expect(loadCreatedWorkbenchTenantIds(storage)).toEqual([]); + }); +}); diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts new file mode 100644 index 0000000000..43494b0981 --- /dev/null +++ b/apps/web/src/needs-list.ts @@ -0,0 +1,159 @@ +// The client's needs-list: the portable desired state for one user. +// The client holds this list and drives the experience against a stock +// Interchange hub — the hub never learns workbench product concepts. One +// manifest covers the primary tenant (with this user's principal), the +// Myra workflow agent running at top level (by definition refId), every +// workbench sub-tenant with its principals (the same refIds projected in, +// each with per-tenant status and roles), and the memberships/shares that +// project workbenches sideways. The client also keeps its own list of the +// workbench tenant ids it created, so a reinstall can reclaim them by id +// instead of guessing by slug. + +import { type } from "arktype"; + +// The manifest version is a numeric tag pinned to 1: arktype object +// literals only take string/object values, so the literal rides a narrow. +const ManifestVersion = type("number").narrow( + (version, ctx) => version === 1 || ctx.mustBe("manifest version 1"), +); + +export const NeedsListSchema = type({ + version: ManifestVersion, + user: { + id: "string", + "email?": "string", + }, + primaryTenant: { + slug: "string", + name: "string", + }, + myra: { + definitionRefId: "string", + scope: "'top-level'", + want: "'running'", + }, + workbenches: type({ + slug: "string", + name: "string", + parentSlug: "string", + members: type({ + refId: "string", + kind: "'user'", + "email?": "string", + status: "'active'", + roles: "string[]", + }).array(), + }).array(), + shares: type({ + workbenchSlug: "string", + email: "string", + }).array(), + createdWorkbenchTenantIds: "string[]", +}); + +export type NeedsList = typeof NeedsListSchema.infer; + +export type NeedsListInput = { + readonly user: { readonly id: string; readonly email?: string }; + readonly primaryTenant: { readonly slug: string; readonly name: string }; + readonly myraDefinitionRefId: string; + readonly workbenches: readonly { + readonly slug: string; + readonly name: string; + readonly members?: readonly { + readonly refId: string; + readonly email?: string; + readonly role?: string; + }[]; + }[]; + readonly shares?: readonly { + readonly workbenchSlug: string; + readonly email: string; + }[]; + readonly createdWorkbenchTenantIds?: readonly string[]; +}; + +/** + * Builds the desired state for one user: the primary tenant exists with + * this user's principal, the Myra agent (by definition refId) runs at the + * top level, and each workbench hangs under the primary tenant carrying + * the same member refIds with per-tenant roles. Pure — no hub reads, no + * storage; the driver diffs this against the hub. + */ +export function buildNeedsList(input: NeedsListInput): NeedsList { + return { + version: 1 as const, + ...(input.user.email === undefined + ? { user: { id: input.user.id } } + : { user: { id: input.user.id, email: input.user.email } }), + primaryTenant: { + slug: input.primaryTenant.slug, + name: input.primaryTenant.name, + }, + myra: { + definitionRefId: input.myraDefinitionRefId, + scope: "top-level" as const, + want: "running" as const, + }, + workbenches: input.workbenches.map((workbench) => ({ + slug: workbench.slug, + name: workbench.name, + parentSlug: input.primaryTenant.slug, + members: (workbench.members ?? []).map((member) => ({ + refId: member.refId, + kind: "user" as const, + ...(member.email === undefined ? {} : { email: member.email }), + status: "active" as const, + roles: [member.role ?? "member"], + })), + })), + shares: (input.shares ?? []).map((share) => ({ + workbenchSlug: share.workbenchSlug, + email: share.email, + })), + createdWorkbenchTenantIds: [...(input.createdWorkbenchTenantIds ?? [])], + }; +} + +/** Validates untrusted input (storage, postMessage, tests) as a NeedsList. */ +export function parseNeedsList(data: unknown) { + return NeedsListSchema(data); +} + +const CREATED_TENANT_IDS_KEY = "workbench.needs-list.created-tenant-ids"; + +export type StringStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +}; + +/** + * The client-kept list of workbench tenant ids it created. Stored as a + * JSON string array; a corrupt row reads as empty rather than throwing, + * so a damaged localStorage can never block convergence. + */ +export function loadCreatedWorkbenchTenantIds( + storage: StringStorage, +): string[] { + const raw = storage.getItem(CREATED_TENANT_IDS_KEY); + if (raw === null) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is string => typeof entry === "string"); + } catch { + // report-error-ignore: a corrupt localStorage row reads as empty by + // design (see above) — client-local damage with no incident to report. + return []; + } +} + +/** Records one created workbench tenant id, keeping insertion order. */ +export function recordCreatedWorkbenchTenantId( + storage: StringStorage, + tenantId: string, +): void { + const ids = loadCreatedWorkbenchTenantIds(storage); + if (!ids.includes(tenantId)) ids.push(tenantId); + storage.setItem(CREATED_TENANT_IDS_KEY, JSON.stringify(ids)); +} diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index 5f1aa8e36b..8ef8dd906e 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -1,22 +1,12 @@ -// The browser side of the first-login hook: one call against the -// hub's native onboarding route, made once per session. A session with -// zero principals and no display name is reported as needs-onboarding so -// the UI can route into the naming wizard; only an explicit name creates -// the personal bench. Distinguishes real failures from "nothing to do", -// so a broken provisioning call never leaves the user silently benchless. +// The browser side of onboarding's credential-connect surface. 0→1 +// tenant creation moved to the client's needs-list (CL-8085, +// see needs-list.ts/needs-converge.ts) — this module no longer calls a +// first-login provisioning route. import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; import type { SupportedCredentialProvider } from "@corbits/connections/credential-test"; -const ProvisionResult = type({ - kind: "'existing-member' | 'provisioned' | 'needs-onboarding'", - "tenantId?": "string", - "tenantSlug?": "string", - "seeded?": "boolean", - "seedSkipReason?": "string", -}); - /** Any credential row this bench actually has stored — the cheap * pre-skip read `OnboardingPage` uses to independently confirm a * working inference credential exists before trusting a `seeded: true` @@ -78,119 +68,6 @@ const FALLBACK_ERROR_MESSAGE = export const CREDENTIAL_PROBE_FAILURE_MESSAGE = "Checking for a connected key hit a snag. Try again in a moment."; -export type ProvisionOutcome = - | { - readonly kind: "existing-member"; - /** - * Present only for the caller's own personal bench: `false` means - * it still has no working inference credential (`bench_unseeded`) - * and the credential step should stay open rather than read as - * finished. Absent when membership is on some other tenant, whose - * seed state this account has no say over. - */ - readonly seeded?: boolean; - /** - * Present under the same condition as `seeded`: the caller's own - * personal bench. Lets the onboarding page independently confirm - * (`hasActiveCredential`) a working credential exists before - * trusting `seeded: true` enough to skip the credential step. - * A probe `error` must not be treated as absent (CL-6868). - */ - readonly tenantId?: string; - /** Present when the caller just joined the root as a plain - * member: the tenant they joined, for the member-onboarding UX - * to surface later (CL-7584). */ - readonly tenantSlug?: string; - } - | { readonly kind: "needs-onboarding" } - | { - readonly kind: "provisioned"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly seeded: boolean; - readonly seedSkipReason?: string; - } - | { - readonly kind: "error"; - readonly message: string; - readonly refId?: string; - }; - -export async function triggerFirstLoginProvisioning( - displayName?: string, -): Promise { - try { - const response = await fetch("/api/onboarding/provision", { - method: "POST", - ...(displayName !== undefined - ? { - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: displayName }), - } - : {}), - }); - const body: unknown = await response.json().catch(() => null); - if (!response.ok) { - const envelope = ErrorEnvelope(body); - return envelope instanceof type.errors - ? { kind: "error", message: FALLBACK_ERROR_MESSAGE } - : { - kind: "error", - message: envelope.error.userMessage, - refId: envelope.error.refId, - }; - } - const parsed = ProvisionResult(body); - if (parsed instanceof type.errors) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - if (parsed.kind === "existing-member") { - if (parsed.seeded === undefined) { - return parsed.tenantId === undefined - ? { kind: "existing-member" } - : { - kind: "existing-member", - tenantId: parsed.tenantId, - ...(parsed.tenantSlug !== undefined - ? { tenantSlug: parsed.tenantSlug } - : {}), - }; - } - return parsed.tenantId === undefined - ? { kind: "existing-member", seeded: parsed.seeded } - : { - kind: "existing-member", - seeded: parsed.seeded, - tenantId: parsed.tenantId, - }; - } - if (parsed.kind === "needs-onboarding") return { kind: "needs-onboarding" }; - if ( - parsed.tenantId === undefined || - parsed.tenantSlug === undefined || - parsed.seeded === undefined - ) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - return parsed.seedSkipReason === undefined - ? { - kind: "provisioned", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - seeded: parsed.seeded, - } - : { - kind: "provisioned", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - seeded: parsed.seeded, - seedSkipReason: parsed.seedSkipReason, - }; - } catch { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } -} - export type CredentialProvider = SupportedCredentialProvider; // The one-click paths: a plain navigation to the hub's connect route, @@ -602,6 +479,69 @@ export type CompleteSetupOutcome = * nothing to finish setting up with yet, and the wizard falls back to * the ordinary credential step rather than treating it as broken. */ +export const FINISHING_SETUP_MAX_ATTEMPTS = 20; +export const FINISHING_SETUP_POLL_DELAY_MS = 2000; + +export type SetupCompletionSettled = + | CompleteSetupOutcome + | { + readonly kind: "timeout"; + readonly attempts: number; + readonly lastOutcome: CompleteSetupOutcome; + } + | { readonly kind: "cancelled" }; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Re-polls `/api/setup/complete` while the tenant is connected but its + * agents are still pending — the credential seed needs a moment, and the + * older unconditional loop never stopped asking on a stalled bench. The + * poll gives up after `maxAttempts` and hands the caller the last answer + * so it can still drive the deploy and navigate; both the coasting + * (still polling) and the giving-up (timed out) are explicit outcomes, + * never an unbounded wait and never a silent swallow. + */ +export async function waitForSetupCompletion( + poll: () => Promise, + options?: { + readonly maxAttempts?: number; + readonly delayMs?: number; + readonly isCancelled?: () => boolean; + readonly onPolling?: (outcome: CompleteSetupOutcome) => void; + }, +): Promise { + const maxAttempts = Math.max( + 1, + options?.maxAttempts ?? FINISHING_SETUP_MAX_ATTEMPTS, + ); + const delayMs = options?.delayMs ?? FINISHING_SETUP_POLL_DELAY_MS; + const isCancelled = options?.isCancelled ?? (() => false); + let attempts = 0; + let lastOutcome: CompleteSetupOutcome | undefined; + while (attempts < maxAttempts) { + if (isCancelled()) return { kind: "cancelled" }; + const outcome = await poll(); + attempts += 1; + if (isCancelled()) return { kind: "cancelled" }; + lastOutcome = outcome; + if (outcome.kind === "connected" && outcome.agentsPending) { + options?.onPolling?.(outcome); + if (attempts < maxAttempts) await sleep(delayMs); + continue; + } + return outcome; + } + // maxAttempts is clamped to >= 1, so the loop always polls at least + // once, and every answer that isn't "keep waiting" returns early — + // reaching here means the last poll asked to keep waiting. + const last = lastOutcome; + if (last === undefined) return { kind: "cancelled" }; + return { kind: "timeout", attempts, lastOutcome: last }; +} + export async function completeSetup(): Promise { try { const response = await fetch("/api/onboarding/complete-setup", { diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 5552cbe683..44736a2eab 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,12 +1,10 @@ -// First-run wizard: provision the account's one workbench under a -// default name derived from the account, then add an inference -// credential. There is no naming step — CL-6089 collapsed the +// First-run wizard: add an inference credential once the account's one +// workbench exists. There is no naming step — CL-6089 collapsed the // multi-bench model down to one workbench per account, so nothing is -// left to name. The heavy lifting — storing the key immediately (no -// probe gates this, CL-6123), seeding the bench, and deploying every -// default workflow — happens server-side in `@workbench/onboarding`; -// this page is the guided shell around it. The credential step is -// skipped entirely +// left to name; CL-8085 moved the workbench's own creation to +// `BenchProvider`'s client-side needs-list convergence, so this page +// only checks membership and credential state before deciding whether +// to show the credential step. The credential step is skipped entirely // (straight to `navigate("/")`) only once this page has independently // confirmed (`hasActiveCredential`, a cheap credentials read) that the // bench actually has a working credential — a hub-owned key (env-key @@ -33,6 +31,7 @@ import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; import type { FormEvent } from "react"; import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test"; +import { type } from "arktype"; import { useNavigate } from "../navigation"; import { @@ -47,33 +46,23 @@ import { readOpenRouterConnectReturn, SECONDARY_CREDENTIAL_PROVIDERS, submitCredential, - triggerFirstLoginProvisioning, + waitForSetupCompletion, } from "../onboarding"; +import { driveMyraDeployAfterCredential } from "../myra-deploy"; +import { reportError } from "@corbits/error-sink"; import type { CredentialProvider, CredentialProviderCard, OnboardingStep, } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; -import type { SessionUser } from "../session"; -/** No naming step means provisioning always needs a name to send — this - * derives one from the account so `/api/onboarding/provision` never gets - * called bare. Prefers the account's display name; an account with no - * usable name falls back to the email's local part. Editable later from - * Settings, same as any other display name. - * - * This names the account's one root tenant — the container real - * workbenches (each its own child tenant, CL-6089) live under, never a - * workbench itself (CL-6368). "…'s workbench" mislabeled it as one; - * every fresh account now mints under its own name instead ("team space" - * / "workspace" stay off the table too — check:ui-vocabulary bans both as - * synonyms the CL-6089 product collapse deliberately retired). */ -function defaultTeamName(user: SessionUser): string { - const source = - user.name.trim().length > 0 ? user.name.trim() : user.email.split("@")[0]; - return `${source || "Your"}'s team`; -} +const PrincipalsProbe = type({ + data: type({ + tenantId: "string", + status: "string", + }).array(), +}); type WizardState = | { readonly phase: "provisioning" } @@ -197,7 +186,7 @@ function initialWizardState(): WizardState { return { phase: "credential", error: returned.message }; } -export function OnboardingPage({ user }: { readonly user: SessionUser }) { +export function OnboardingPage() { const navigate = useNavigate(); const [state, setState] = useState(initialWizardState); const [provider, setProvider] = useState("anthropic"); @@ -213,90 +202,56 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // finishes setup, instead of the first-run pitch. const [resumingUnseeded, setResumingUnseeded] = useState(false); - const runProvisioning = useCallback( - (name: string) => { - setState({ phase: "provisioning" }); - void triggerFirstLoginProvisioning(name).then(async (result) => { - if (result.kind === "error") { - setState( - result.refId === undefined - ? { phase: "provisioning-error", message: result.message } - : { - phase: "provisioning-error", - message: result.message, - refId: result.refId, - }, - ); - } else if ( - result.kind === "existing-member" && - result.seeded === true - ) { - // `seeded: true` only means every default workflow has an - // active deployment — never that a credential was checked. - // Confirm one independently (a cheap credentials read) before - // handing off; no tenantId (should not happen alongside - // seeded: true) falls through to the credential step too. - // A probe that cannot complete stays on this setup step with - // Retry (CL-6868) — never opens paste-a-key as if none exists. - if (result.tenantId === undefined) { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - return; - } - const probe = await hasActiveCredential(result.tenantId); - if (probe.kind === "active") { - navigate("/"); - } else if (probe.kind === "error") { - setState({ - phase: "provisioning-error", - message: CREDENTIAL_PROBE_FAILURE_MESSAGE, - }); - } else { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } - } else if (result.kind === "existing-member") { - // `seeded === false` is the bench_unseeded condition: this - // account's own workbench exists but never got a working - // credential (no operator key configured, and none connected - // yet). `undefined` (membership on some other tenant) also lands - // here rather than handing off — there is nothing to skip ahead to. - const unseeded = result.seeded === false; - setResumingUnseeded(unseeded); - setState({ phase: "credential", error: null }); - } else if (result.kind === "provisioned" && result.seeded) { - // The seed run's validation trigger only proves a workflow run - // started, never that it succeeded against a real credential. - // Confirm one independently before handing off. Probe failure - // stays here with Retry (CL-6868) — never paste-a-key as none. - const probe = await hasActiveCredential(result.tenantId); - if (probe.kind === "active") { - setResumingUnseeded(false); - navigate("/"); - } else if (probe.kind === "error") { - setState({ - phase: "provisioning-error", - message: CREDENTIAL_PROBE_FAILURE_MESSAGE, - }); - } else { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } - } else if (result.kind === "provisioned") { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } else { - // needs-onboarding after an explicit name should not happen — a - // default name is always sent — so this reads as a soft error. - setState({ - phase: "provisioning-error", - message: "Setup couldn't create your workbench. Try again.", - }); - } - }); - }, - [navigate], - ); + // 0→1 tenant creation moved to the client's needs-list (CL-8085): by + // the time this page is reachable, `BenchProvider`'s own genesis + // convergence has already minted the account's primary tenant (or is + // racing to). This step only has to find that membership and decide + // whether a working credential is already there to skip ahead to. + const runProvisioning = useCallback(() => { + setState({ phase: "provisioning" }); + void (async () => { + let principals; + try { + const response = await fetch("/api/me/principals"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const parsed = PrincipalsProbe(await response.json()); + if (parsed instanceof type.errors) throw new Error(parsed.summary); + principals = parsed.data; + } catch (error) { + const refId = reportError(error, { + operation: "onboarding_provisioning", + }); + setState({ + phase: "provisioning-error", + message: + "Setting up your workbench hit a snag — we're on it. Try again in a moment.", + refId, + }); + return; + } + const own = principals.find((p) => p.status === "active"); + if (own === undefined) { + // Genesis convergence is still in flight (or hasn't started) — + // there is nothing to skip ahead to yet. + setResumingUnseeded(false); + setState({ phase: "credential", error: null }); + return; + } + const probe = await hasActiveCredential(own.tenantId); + if (probe.kind === "active") { + setResumingUnseeded(false); + navigate("/"); + } else if (probe.kind === "error") { + setState({ + phase: "provisioning-error", + message: CREDENTIAL_PROBE_FAILURE_MESSAGE, + }); + } else { + setResumingUnseeded(true); + setState({ phase: "credential", error: null }); + } + })(); + }, [navigate]); // A connect round-trip's outcome is consumed into the initial wizard // state above; dropping it from the URL keeps a reload or a shared @@ -315,54 +270,77 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // deferred: `completeSetup` runs the workflow deploy the callback // never ran inline. Everything else — the ordinary first-run landing, // or a stale connect error from a duplicate callback this page never - // saw resolved — provisions with a default name derived from the - // account: there is no naming step to gate this on, so it must always - // send a name (see `defaultTeamName`). A returning member's - // already-provisioned workbench is unaffected — the hub route only - // creates one the first time an account has none. + // saw resolved — checks membership and credential state. useEffect(() => { if (state.phase === "finishing-setup") { let cancelled = false; - // Poll until ready: each pass renders the hub's own desired-state - // step list (CL-7584); a ready answer collapses it and hands off. + // The OAuth callback only stored the key — the catalog seed it + // triggered is what genesis was missing, so the wait is bounded (a + // stalled bench settles as a timeout instead of spinning forever) + // and this page drives the Myra deploy itself over stock routes + // before handing off. A still-empty catalog is an expected pending, + // not a failure; a real transport error is reported, never silent. + // Either way the person still moves on, and the warm loading state + // on the other side covers whatever is still coming online. void (async () => { - for (;;) { - const outcome = await completeSetup(); - if (cancelled) return; - if (outcome.kind === "connected") { - if (!outcome.agentsPending) { - navigate("/"); - return; - } + const settled = await waitForSetupCompletion(completeSetup, { + isCancelled: () => cancelled, + onPolling: (outcome) => { + if (cancelled || outcome.kind !== "connected") return; setState({ phase: "finishing-setup", steps: outcome.steps ?? [], }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - continue; - } - if (outcome.kind === "unseeded") { - setResumingUnseeded(true); - setState({ phase: "credential", error: null }); - return; + }, + }); + if (cancelled || settled.kind === "cancelled") return; + if (settled.kind === "connected" || settled.kind === "timeout") { + const ready = + settled.kind === "connected" + ? settled + : settled.lastOutcome.kind === "connected" + ? settled.lastOutcome + : null; + if (ready !== null) { + try { + await driveMyraDeployAfterCredential({ + tenantId: ready.tenantId, + tenantSlug: ready.tenantSlug, + }); + } catch (error) { + reportError(error, { + operation: "myra_deploy_after_credential", + ...(ready.tenantId === undefined + ? {} + : { tenantId: ready.tenantId }), + extra: { tenantSlug: ready.tenantSlug }, + }); + } + if (cancelled) return; } - setState( - outcome.refId === undefined - ? { phase: "credential", error: outcome.message } - : { - phase: "credential", - error: outcome.message, - errorRefId: outcome.refId, - }, - ); + navigate("/"); return; } + if (settled.kind === "unseeded") { + setResumingUnseeded(true); + setState({ phase: "credential", error: null }); + return; + } + setState( + settled.refId === undefined + ? { phase: "credential", error: settled.message } + : { + phase: "credential", + error: settled.message, + errorRefId: settled.refId, + }, + ); })(); return () => { cancelled = true; }; } - runProvisioning(defaultTeamName(user)); + runProvisioning(); // Mount-only: this reads `state.phase` exactly once, at the value // `initialWizardState` produced, to decide which of the two checks // above applies to this landing. @@ -406,8 +384,24 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // Connected is the finish line for this screen (CL-6457): // whether or not the agents have finished deploying, the person // moves on now, and the warm loading state on the other side - // covers whatever is still coming online. + // covers whatever is still coming online. The deploy itself is + // still driven here — fire-and-forget, so the handoff never waits + // on it — because this direct-submit path never passes through + // the finishing-setup poll below, and a deploy nobody drives is + // a bench without Myra. if (outcome.kind === "connected") { + void driveMyraDeployAfterCredential({ + tenantId: outcome.tenantId, + tenantSlug: outcome.tenantSlug, + }).catch((cause: unknown) => { + reportError(cause, { + operation: "myra_deploy_after_credential", + ...(outcome.tenantId === undefined + ? {} + : { tenantId: outcome.tenantId }), + extra: { tenantSlug: outcome.tenantSlug }, + }); + }); navigate("/"); } else { setState( @@ -503,10 +497,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { ) } action={ - } diff --git a/apps/web/src/setup-completion-poll.test.ts b/apps/web/src/setup-completion-poll.test.ts new file mode 100644 index 0000000000..6b8584e51a --- /dev/null +++ b/apps/web/src/setup-completion-poll.test.ts @@ -0,0 +1,88 @@ +// The finishing-setup step used to poll `/api/setup/complete` in an +// unbounded loop: a stalled bench kept the person on a spinner forever. +// Every case here pins the bound — the poll coasts while agents are +// pending, stops after the attempt cap, and an unmount cancels cleanly. + +import { describe, expect, test } from "bun:test"; + +import { + type CompleteSetupOutcome, + waitForSetupCompletion, +} from "./onboarding"; + +const connectedPending: CompleteSetupOutcome = { + kind: "connected", + tenantSlug: "acme", + agentsPending: true, + steps: [], +}; + +const connectedReady: CompleteSetupOutcome = { + kind: "connected", + tenantSlug: "acme", + agentsPending: false, +}; + +describe("waitForSetupCompletion", () => { + test("a tenant that is already seeded returns its answer without polling again", async () => { + let calls = 0; + const settled = await waitForSetupCompletion( + async () => { + calls += 1; + return connectedReady; + }, + { delayMs: 0 }, + ); + expect(settled).toEqual(connectedReady); + expect(calls).toBe(1); + }); + + test("a tenant finishing its seed is re-polled until the agents land", async () => { + const seen: CompleteSetupOutcome[] = []; + let calls = 0; + const settled = await waitForSetupCompletion( + async () => { + calls += 1; + return calls < 3 ? connectedPending : connectedReady; + }, + { + delayMs: 0, + onPolling: (outcome) => { + seen.push(outcome); + }, + }, + ); + expect(settled).toEqual(connectedReady); + expect(calls).toBe(3); + expect(seen).toEqual([connectedPending, connectedPending]); + }); + + test("a stalled bench stops after the attempt cap and hands back the last answer", async () => { + let calls = 0; + const settled = await waitForSetupCompletion( + async () => { + calls += 1; + return connectedPending; + }, + { maxAttempts: 3, delayMs: 0 }, + ); + expect(calls).toBe(3); + expect(settled).toEqual({ + kind: "timeout", + attempts: 3, + lastOutcome: connectedPending, + }); + }); + + test("unmounting mid-poll settles as cancelled instead of navigating later", async () => { + let calls = 0; + const settled = await waitForSetupCompletion( + async () => { + calls += 1; + return connectedPending; + }, + { maxAttempts: 20, delayMs: 0, isCancelled: () => calls >= 2 }, + ); + expect(settled).toEqual({ kind: "cancelled" }); + }); +}); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 2377a33aa4..ad0c951221 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -1,12 +1,12 @@ -// The failure path for first-login provisioning: a broken hub call -// must not collapse to "nothing to do". `triggerFirstLoginProvisioning` -// should report it as a distinct error outcome, and the app shell must -// render a full blocking screen for it rather than silently continuing -// into a shell with zero benches. +// App-shell rendering around the onboarding wizard's credential-connect +// surface. 0→1 tenant creation moved to the client's needs-list +// (CL-8085, see bench-context.tsx) — this page now only checks +// membership (`/api/me/principals`) and credential state before +// deciding whether to show the credential step. import { ThemeProvider } from "@corbits/react-ui"; import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; +import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; @@ -16,7 +16,6 @@ import { } from "@corbits/connections/credential-test"; import { App } from "../src/app"; -import { NavigationProvider } from "../src/navigation"; import { CREDENTIAL_PROVIDERS, hasActiveCredential, @@ -25,12 +24,20 @@ import { readOpenRouterConnectReturn, SECONDARY_CREDENTIAL_PROVIDERS, submitCredential, - triggerFirstLoginProvisioning, } from "../src/onboarding"; -import { OnboardingPage } from "../src/pages/onboarding-page"; import { ONBOARDING_PATH } from "../src/routes"; import type { SessionState } from "../src/session"; +/** The `/api/me/principals` page an existing member with an active + * principal on `tenantId` reads as, so the onboarding wizard's own + * membership check resolves past genesis straight to the credential + * probe. */ +const existingMemberPrincipals = (tenantId: string) => + json({ + data: [{ tenantId, status: "active" }], + nextCursor: null, + }); + const realFetch = globalThis.fetch; afterEach(() => { @@ -127,140 +134,6 @@ describe("PRIMARY_CREDENTIAL_PROVIDERS and SECONDARY_CREDENTIAL_PROVIDERS", () = }); }); -describe("triggerFirstLoginProvisioning", () => { - test("a structured error envelope becomes an error outcome, not null", async () => { - globalThis.fetch = (async () => - json( - { - error: { - code: "provisioning_failed", - userMessage: - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", - refId: "abc123", - }, - }, - 500, - )) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(result).toEqual({ - kind: "error", - message: - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", - refId: "abc123", - }); - }); - - // CL-6360: a raw network failure (or any body that doesn't match the - // hub's error envelope) must never surface its own text to the user — - // only the fixed consumer sentence. - test("a network failure becomes a consumer-language error outcome, never the raw cause", async () => { - globalThis.fetch = (async () => { - throw new Error("connection refused"); - }) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(result.kind).toBe("error"); - if (result.kind !== "error") throw new Error("unreachable"); - expect(result.message).not.toContain("connection refused"); - expect(result.message).toBe( - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", - ); - }); - - test("an ordinary success still parses through", async () => { - globalThis.fetch = (async () => - json({ kind: "existing-member" })) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(result).toEqual({ kind: "existing-member" }); - }); - - test("sends the workbench name in the provision request body", async () => { - let requestBody: unknown = undefined; - let requestInit: RequestInit | undefined; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestInit = init; - requestBody = - init.body === undefined ? undefined : JSON.parse(init.body as string); - return json({ kind: "existing-member" }); - }) as unknown as typeof fetch; - - await triggerFirstLoginProvisioning("Research bench"); - expect(requestBody).toEqual({ name: "Research bench" }); - expect(requestInit?.method).toBe("POST"); - }); - - test("omits a body when no name is given (the shell routing probe)", async () => { - let sentBody: unknown = "__sentinel__"; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - sentBody = init.body; - return json({ kind: "existing-member" }); - }) as unknown as typeof fetch; - - await triggerFirstLoginProvisioning(); - expect(sentBody).toBeUndefined(); - }); - - test("a provisioned bench with a server seed stays a 'provisioned' outcome — seeded is reported faithfully", async () => { - // Regression guard: a server-side seed (operator-configured or - // env-key-auto-planted key) must not collapse the outcome into - // `existing-member` or otherwise hide that the bench was just - // provisioned. The wizard reads `seeded` off this exact shape to - // decide whether to skip the credential step (see - // apps/web/test/onboarding.test.tsx's "App landing fresh on a - // hub-seeded workbench" suite). - let requestBody: unknown = undefined; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestBody = - init.body === undefined ? undefined : JSON.parse(init.body as string); - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - seedSkipReason: "operator_seed_key", - }); - }) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(requestBody).toEqual({ name: "Ada's bench" }); - expect(result).toEqual({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - seedSkipReason: "operator_seed_key", - }); - // The credential step stays in the flow precisely because seeded is - // reported faithfully, not folded away. - if (result.kind === "provisioned") expect(result.seeded).toBe(true); - }); - - test("an existing member whose bench is not fully seeded reports seeded: false, not silently dropped", async () => { - // Regression guard for the bench_unseeded defect: a returning user - // with a real membership but no working credential yet must not read - // as an ordinary, fully-set-up existing member. Before this fix the - // client discarded `seeded` entirely for `existing-member`. - globalThis.fetch = (async () => - json({ - kind: "existing-member", - seeded: false, - })) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning(); - expect(result).toEqual({ kind: "existing-member", seeded: false }); - }); - - test("an existing member on someone else's tenant carries no seeded field", async () => { - globalThis.fetch = (async () => - json({ kind: "existing-member" })) as unknown as typeof fetch; - - const result = await triggerFirstLoginProvisioning(); - expect(result).toEqual({ kind: "existing-member" }); - }); -}); - describe("hasActiveCredential", () => { // CL-6868: a transient credentials read must never coerce to "no key" — // that path opens paste-a-key as if none exists when one may already be @@ -463,318 +336,6 @@ describe("App at the onboarding path", () => { }); }); -describe("App landing fresh on a hub-seeded workbench", () => { - test("a freshly provisioned, fully seeded bench skips the credential step entirely (CL-6101)", async () => { - // A hub-owned key (the env-key auto-plant, or the older - // ANTHROPIC_API_KEY path) already deployed and confirmed the - // default workflow set by the time `/api/onboarding/provision` - // answers — there is nothing left to prove or connect, so the - // wizard must land straight on the finished ending, exactly like a - // returning fully-seeded member does. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - // The page independently confirms `seeded: true` against a real - // stored credential (CL-6101 review) before hard-skipping — this - // scenario's premise (a hub-owned key already deployed) means that - // confirmation succeeds. - if (url === "/api/tenants/ten_1/credentials") { - return json({ - data: [{ id: "cred_1", status: "active" }], - nextCursor: null, - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(calls).toEqual(["/"]); - expect(container.textContent).not.toContain("Bring your own AI"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("provisioned, seeded: true does NOT hard-skip when no active credential is confirmed (CL-6101 review)", async () => { - // `seeded: true` here only means the seed run's validation trigger - // started a workflow run — never that it succeeded against a real - // credential. With the credentials read confirming nothing active, - // the page must fall through to the credential step, not the - // finished ending. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("existing-member, seeded: true does NOT hard-skip when no active credential is confirmed (CL-6101 review)", async () => { - // `seeded: true` for an existing member means every default workflow - // has an active deployment (the doc reader's workflow pins) — never - // that a credential was checked. With the credentials read - // confirming nothing active, the page must fall through to the - // credential step. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("provisioned, seeded: true stays on setup with Retry when the credential probe fails (CL-6868)", async () => { - // Transient credentials-read failure must not open paste-a-key as if - // none exists — the key may already be connected. Stay on the setup - // step with one consumer sentence and Retry. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - if (url === "/api/tenants/ten_1/credentials") { - throw new Error("connection refused"); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const text = container.textContent ?? ""; - expect(text).not.toContain("Bring your own AI"); - expect(text).not.toContain("or paste a provider API key"); - expect(text).toContain( - "Checking for a connected key hit a snag. Try again in a moment.", - ); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Try again", - ); - expect(retry).not.toBeUndefined(); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("existing-member, seeded: true stays on setup with Retry when the credential probe fails (CL-6868)", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ error: "boom" }, 500); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const text = container.textContent ?? ""; - expect(text).not.toContain("Bring your own AI"); - expect(text).not.toContain("or paste a provider API key"); - expect(text).toContain( - "Checking for a connected key hit a snag. Try again in a moment.", - ); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Try again", - ); - expect(retry).not.toBeUndefined(); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("a freshly provisioned bench with no working credential still shows the credential step", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: false, - seedSkipReason: "no operator key configured", - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); -}); - describe("App once onboarding is behind you", () => { test("mounts the shell for an ordinary route", () => { globalThis.fetch = (async () => @@ -1110,17 +671,12 @@ describe("the OpenRouter connect card", () => { // connect actually succeeded — it lands on the same finished state // as a fresh connect would. globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); + if (url === "/api/me/principals") { + return existingMemberPrincipals("ten_1"); } - // The page independently confirms `seeded: true` against a real - // stored credential (CL-6101 review) before hard-skipping — this - // scenario's premise (a real check shows the connect succeeded) - // means that confirmation succeeds. + // The page independently confirms a real stored credential before + // hard-skipping — this scenario's premise (a real check shows the + // connect succeeded) means that confirmation succeeds. if (url === "/api/tenants/ten_1/credentials") { return json({ data: [{ id: "cred_1", status: "active" }], @@ -1333,57 +889,6 @@ describe("the Hugging Face connect card", () => { }); }); -describe("OnboardingPage resuming a bench_unseeded account", () => { - const noop = () => undefined; - const settle = () => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - afterEach(() => { - if (root !== null) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; - }); - - test("an existing member with seeded: false lands on the credential step reading as unfinished, not pre-satisfied", async () => { - // No naming step (CL-6089) — provisioning fires automatically, under - // a default name, the moment the wizard mounts. - globalThis.fetch = (async () => - json({ - kind: "existing-member", - seeded: false, - })) as unknown as typeof fetch; - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(NavigationProvider, { - navigate: noop, - children: createElement(OnboardingPage, { - user: { id: "user_1", name: "Ada", email: "ada@example.com" }, - }), - }), - ); - }); - await settle(); - - // The wizard reads this account as still needing a working - // credential — never "a working key is already in place", the copy - // a fully seeded existing member gets. - expect(container.textContent).toContain("Finish setting up your workbench"); - expect(container.textContent).not.toContain( - "A working key is already in place", - ); - }); -}); - describe("connecting a local Ollama instance from onboarding", () => { const settle = () => act(async () => { @@ -1417,8 +922,11 @@ describe("connecting a local Ollama instance from onboarding", () => { test("picking the Ollama card prefills its base URL, and submitting connects it and marks the tenant as having a usable model", async () => { let completeRequestBody: unknown = null; globalThis.fetch = (async (url: string, init?: RequestInit) => { - if (url === "/api/onboarding/provision") { - return json({ kind: "existing-member", seeded: false }); + if (url === "/api/me/principals") { + return existingMemberPrincipals("ten_1"); + } + if (url === "/api/tenants/ten_1/credentials") { + return json({ data: [], nextCursor: null }); } if (url === "/api/onboarding/complete") { completeRequestBody = JSON.parse((init?.body as string) ?? "{}"); @@ -1507,8 +1015,11 @@ describe("skipping the onboarding credential step", () => { // `bench_unseeded` state, not an error (see `handleSkip`'s own // comment): skipping must never call the credential-complete route. globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ kind: "existing-member", seeded: false }); + if (url === "/api/me/principals") { + return existingMemberPrincipals("ten_1"); + } + if (url === "/api/tenants/ten_1/credentials") { + return json({ data: [], nextCursor: null }); } throw new Error(`unexpected fetch: ${url}`); }) as unknown as typeof fetch; @@ -1642,4 +1153,49 @@ describe("CL-7584 desired-state steps in finishing-setup", () => { expect(calls).toEqual(["/"]); }); + + test("a ready answer drives the Myra deploy over stock routes before handing off", async () => { + // Genesis had to leave the deploy pending (the catalog was still + // empty when it converged), so the handoff itself is the driver: + // resolve the assistant asset from the tenant's own catalog, post + // the deployment, and only then navigate. + const posted: unknown[] = []; + globalThis.fetch = (async ( + url: string, + init?: { method?: string; body?: unknown }, + ) => { + if (url === "/api/onboarding/complete-setup") { + return json({ + kind: "ready", + tenantId: "ten_1", + tenantSlug: "ada-user1", + setupAgentReady: true, + deployed: ["assistant"], + pending: [], + steps: [{ name: "assistant", label: "Myra", status: "present" }], + }); + } + if (url === "/api/tenants/ten_1/assets?kind=workflow&inherited=true") { + return json([{ id: "asst_1", name: "assistant" }]); + } + if (url === "/api/tenants/ten_1/workflows/deployments") { + if (init?.method === "POST") { + posted.push(init.body); + return json({ id: "dep_1" }); + } + return json([]); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const { navigate, calls } = trackedNavigate(); + renderFinishingSetup(navigate); + await settle(50); + + expect(posted.length).toBe(1); + expect( + typeof posted[0] === "string" ? JSON.parse(posted[0]) : posted[0], + ).toEqual({ definitionAssetId: "asst_1", confirmDeployments: false }); + expect(calls).toEqual(["/"]); + }); }); diff --git a/packages/access-policy/package.json b/packages/access-policy/package.json index 557ae865c2..957c788b80 100644 --- a/packages/access-policy/package.json +++ b/packages/access-policy/package.json @@ -16,6 +16,7 @@ "dependencies": { "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", + "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@corbits/hub-api-client": "workspace:*", "arktype": "catalog:", diff --git a/packages/access-policy/src/empty-hub.ts b/packages/access-policy/src/empty-hub.ts new file mode 100644 index 0000000000..2f9c424fa8 --- /dev/null +++ b/packages/access-policy/src/empty-hub.ts @@ -0,0 +1,26 @@ +// The closed-signup empty-hub exception (CL-7578, CL-8085): 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. A native +// read over `user`/`tenant` only; this package declares no tables of its +// own for it. +import { count } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { tenant, user as userTable } from "@intx/db/schema"; + +export type EmptyHubCheck = { + countUsers(): Promise; + countTenants(): Promise; +}; + +export function createEmptyHubCheck(db: DB["db"]): EmptyHubCheck { + return { + async countUsers() { + const [row] = await db.select({ n: count() }).from(userTable); + return row?.n ?? 0; + }, + async countTenants() { + const [row] = await db.select({ n: count() }).from(tenant); + return row?.n ?? 0; + }, + }; +} diff --git a/packages/access-policy/src/index.ts b/packages/access-policy/src/index.ts index e8265fd67f..9f40020c65 100644 --- a/packages/access-policy/src/index.ts +++ b/packages/access-policy/src/index.ts @@ -30,6 +30,9 @@ export type { AccessPolicyStore } from "./store"; export { checkSignupGate } from "./gate"; export type { SignupGateCheckArgs } from "./gate"; +export { createEmptyHubCheck } from "./empty-hub"; +export type { EmptyHubCheck } from "./empty-hub"; + export { createAccessPolicyRoutes } from "./routes"; export type { CreateAccessPolicyRoutesDeps } from "./routes"; diff --git a/packages/access-policy/tsconfig.json b/packages/access-policy/tsconfig.json index 9f1ba34128..c6ef987741 100644 --- a/packages/access-policy/tsconfig.json +++ b/packages/access-policy/tsconfig.json @@ -13,6 +13,9 @@ "include": ["src", "test"], "exclude": [], "references": [ + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, { "path": "../../vendor/intx/hub-api/tsconfig.src.json" }, diff --git a/packages/access-policy/tsconfig.src.json b/packages/access-policy/tsconfig.src.json index 8e7a2ec4c6..f3ee7aaf2b 100644 --- a/packages/access-policy/tsconfig.src.json +++ b/packages/access-policy/tsconfig.src.json @@ -11,6 +11,9 @@ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [ + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, { "path": "../../vendor/intx/hub-api/tsconfig.src.json" }, diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index d5398b6094..24218e6967 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -81,7 +81,7 @@ import { } from "@corbits/connections/persist-credential"; import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import { TENANT_DESIRED_STATE, seededWorkflowNames } from "./desired-state"; -import { personalTenantSlug } from "./provision"; +import { personalTenantSlug } from "./tenant-slug"; /** The onboarding UI's copy for a partial seed: every durable step * (credential, tenant, grants, assets) already succeeded, and the diff --git a/packages/onboarding/src/genesis.ts b/packages/onboarding/src/genesis.ts deleted file mode 100644 index 4299544c32..0000000000 --- a/packages/onboarding/src/genesis.ts +++ /dev/null @@ -1,216 +0,0 @@ -// First empty-hub signup mints the root tenant (native owner = superadmin). -// Later signups join that tenant as member. This path never seeds workflows, -// tools, or grants. - -import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types"; -import { parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { - checkSignupGate, - type AccessPolicyStore, -} from "@workbench/access-policy"; - -export type HubSignupTenancy = { - countUsers(): Promise; - countTenants(): Promise; - findRootTenant(): Promise<{ id: string; slug: string } | null>; - addActiveMember(args: { - tenantId: string; - userId: string; - roleName: "member"; - }): Promise<{ principalId: string }>; -}; - -export type GenesisOrJoinArgs = { - api: ApiCall; - cookies: string[]; - userId: string; - userEmail: string; - userEmailVerified: boolean; - defaultTenantSlug: string; - displayName?: string; - tenancy: HubSignupTenancy; - accessPolicy?: { - store: AccessPolicyStore; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; - allowUnverifiedEmails: boolean; - }; - log: (line: string) => void; -}; - -export type GenesisOrJoinResult = - | { - readonly kind: "genesis"; - readonly tenantId: string; - readonly tenantSlug: string; - } - | { - readonly kind: "joined"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly principalId: string; - } - | { readonly kind: "existing-member" } - | { readonly kind: "needs-onboarding" }; - -export type ProvisionErrorKind = "transient" | "permanent"; - -export class ProvisionError extends Error { - readonly code: string; - readonly errorKind: ProvisionErrorKind; - constructor(code: string, message: string, errorKind: ProvisionErrorKind) { - super(message); - this.name = "ProvisionError"; - this.code = code; - this.errorKind = errorKind; - } -} - -async function fetchPrincipals( - api: ApiCall, - cookies: string[], -): Promise<{ tenantId: string; tenantSlug: string; principalId: string }[]> { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const summary = parseAs( - paginatedSchema(PrincipalSummary), - response.data, - "principals response", - ); - return summary.data.map((p) => ({ - tenantId: p.tenantId, - tenantSlug: p.tenantSlug, - principalId: p.principalId, - })); -} - -async function joinRoot( - args: GenesisOrJoinArgs, - root: { id: string; slug: string }, -): Promise { - const { principalId } = await args.tenancy.addActiveMember({ - tenantId: root.id, - userId: args.userId, - roleName: "member", - }); - args.log( - `joined existing hub tenant ${root.slug} (${root.id}) as member principal ${principalId}`, - ); - return { - kind: "joined", - tenantId: root.id, - tenantSlug: root.slug, - principalId, - }; -} - -/** - * Runs the signup gate before minting anything. `emptyHubException` - * waives only `signup_closed`: with zero tenants somebody has to be - * first, but email trust and the domain allowlist still bind even the - * genesis caller. A rejection is always a 403-shaped `ProvisionError`. - */ -async function requireSignupAllowed( - args: GenesisOrJoinArgs, - options: { readonly emptyHubException: boolean }, -): Promise { - 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, - }); - if (gate.allowed) return; - if (options.emptyHubException && gate.reason === "signup_closed") return; - throw new ProvisionError( - "signup_not_allowed", - `signup gate rejected ${args.userEmail} (${gate.reason})`, - "permanent", - ); -} - -async function requireRoot(tenancy: HubSignupTenancy): Promise<{ - id: string; - slug: string; -}> { - const root = await tenancy.findRootTenant(); - if (root === null) { - throw new ProvisionError( - "root_tenant_missing", - "signup join path found no root tenant (parentId IS NULL)", - "permanent", - ); - } - return root; -} - -export async function genesisOrJoinHubSignup( - args: GenesisOrJoinArgs, -): Promise { - const before = await fetchPrincipals(args.api, args.cookies); - if (before.length > 0) return { kind: "existing-member" }; - - // Empty hub = zero tenants, regardless of user count: user rows - // without a tenant (an invite that never landed, a member removed by - // an operator) must not strand the hub — anyone principal-less may - // still genesis when no tenant exists. - const tenantCount = await args.tenancy.countTenants(); - if (tenantCount > 0) { - await requireSignupAllowed(args, { emptyHubException: false }); - return joinRoot(args, await requireRoot(args.tenancy)); - } - - await requireSignupAllowed(args, { emptyHubException: true }); - - if (args.displayName === undefined || args.displayName.trim().length === 0) { - return { kind: "needs-onboarding" }; - } - - const created = await args.api( - "POST", - "/api/tenants", - { - name: args.displayName.trim(), - slug: args.defaultTenantSlug, - }, - args.cookies, - ); - if (created.status === 409) { - const root = await args.tenancy.findRootTenant(); - if (root !== null) return joinRoot(args, root); - const afterRace = await fetchPrincipals(args.api, args.cookies); - if (afterRace.length > 0) return { kind: "existing-member" }; - throw new ProvisionError( - "slug_conflict_no_principal", - `first-login provisioning hit a slug conflict creating a personal bench, but the caller still has no principal anywhere: ${JSON.stringify(created.data)}`, - "permanent", - ); - } - if (created.status !== 201) { - throw new ProvisionError( - "tenant_create_failed", - `first-login provisioning could not create a personal bench (status ${created.status}): ${JSON.stringify(created.data)}`, - created.status >= 500 ? "transient" : "permanent", - ); - } - const tenant = parseAs(TenantResponse, created.data, "tenant response"); - const after = await fetchPrincipals(args.api, args.cookies); - const membership = after.find((p) => p.tenantId === tenant.id); - if (membership === undefined) { - throw new ProvisionError( - "tenant_created_no_membership", - `personal bench ${tenant.id} was created but the caller has no principal in it`, - "transient", - ); - } - args.log( - `genesis tenant ${tenant.slug} (${tenant.id}) minted for ${args.userEmail}`, - ); - return { - kind: "genesis", - tenantId: tenant.id, - tenantSlug: tenant.slug, - }; -} diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts index ae7e057f13..87c05a3a96 100644 --- a/packages/onboarding/src/index.ts +++ b/packages/onboarding/src/index.ts @@ -1,15 +1,4 @@ -export { - personalTenantSlug, - provisionPersonalTenantIfNeeded, -} from "./provision"; -export type { ProvisionArgs, ProvisionResult } from "./provision"; -export { genesisOrJoinHubSignup, ProvisionError } from "./genesis"; -export type { - GenesisOrJoinArgs, - GenesisOrJoinResult, - HubSignupTenancy, - ProvisionErrorKind, -} from "./genesis"; +export { personalTenantSlug } from "./tenant-slug"; export { completeCredentialSetup, findPersonalTenant, diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts deleted file mode 100644 index a550619e0c..0000000000 --- a/packages/onboarding/src/provision.ts +++ /dev/null @@ -1,126 +0,0 @@ -// The first-login decision. The first signup on an empty hub (zero -// tenants, at most the caller's own user row) mints the root tenant -// through the native tenant-creation route — its creator becomes that -// tenant's owner. Every later signup joins the existing root tenant as -// a plain member. This path never seeds workflows, tools, or grants: -// seeding is the credential-completion step's job, driven by the -// user's own credential, never by signup. - -import { type ApiCall } from "@corbits/hub-api-client"; -import type { AccessPolicyStore } from "@workbench/access-policy"; -import { genesisOrJoinHubSignup, type HubSignupTenancy } from "./genesis"; - -export { ProvisionError } from "./genesis"; -export type { ProvisionErrorKind } from "./genesis"; - -export type ProvisionResult = - | { - /** An account that already belongs somewhere — or has just joined - * the existing root as a plain member. Either way no wizard: the - * tenant facts ride along only when the caller just joined, for - * the member-onboarding UX to surface later (CL-7584). */ - readonly kind: "existing-member"; - readonly tenantId?: string; - readonly tenantSlug?: string; - } - | { readonly kind: "needs-onboarding" } - | { - /** The caller just minted the root tenant as its owner (genesis). - * `seeded` is always `false`: signup provisions membership only; - * the credential step owns seeding. */ - readonly kind: "provisioned"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly seeded: false; - }; - -export type ProvisionArgs = { - api: ApiCall; - cookies: string[]; - userId: string; - userEmail: string; - /** better-auth is configured without `requireEmailVerification` — an - * unverified email must never pass a domain-allowlist meant for - * someone else. See `@workbench/access-policy`'s `evaluateSignupGate` - * doc comment. */ - userEmailVerified: boolean; - /** Slug for the genesis tenant — the first tenant on an empty hub. - * Later signups join the existing root and never read this. */ - defaultTenantSlug: string; - /** Display name for the genesis tenant. Required to mint: when - * omitted (shell membership probe), returns `needs-onboarding` and - * creates nothing. */ - displayName?: string; - tenancy: HubSignupTenancy; - log: (line: string) => void; - /** The closed-by-default access-policy gate. Join consults it - * outright (a closed hub never self-grants membership); genesis on an - * empty hub waives only `signup_closed` — email verification and the - * domain allowlist still bind the first user. */ - accessPolicy?: { - store: AccessPolicyStore; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; - allowUnverifiedEmails: boolean; - }; -}; - -/** A lowercase-kebab personal-bench slug, unique per user without a - * coordinating registry: the local part of the email plus a short - * fragment of the user's own id, which the platform already treats as - * unique. Signup no longer mints personal benches, but the credential - * step still uses this to recognize a bench it provisioned itself. */ -export function personalTenantSlug(email: string, userId: string): string { - const local = email.split("@")[0] ?? email; - const kebab = local - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - const suffix = userId - .replace(/[^a-zA-Z0-9]/g, "") - .slice(-8) - .toLowerCase(); - return `${kebab || "bench"}-${suffix || "personal"}`; -} - -/** - * Runs the first-login hook by delegating the whole decision to - * `genesisOrJoinHubSignup` (see ./genesis.ts). Safe to call on every - * sign-in — an existing member is a single read and nothing else. - */ -export async function provisionPersonalTenantIfNeeded( - args: ProvisionArgs, -): Promise { - const result = await genesisOrJoinHubSignup({ - api: args.api, - cookies: args.cookies, - userId: args.userId, - userEmail: args.userEmail, - userEmailVerified: args.userEmailVerified, - defaultTenantSlug: args.defaultTenantSlug, - tenancy: args.tenancy, - log: args.log, - ...(args.accessPolicy !== undefined - ? { accessPolicy: args.accessPolicy } - : {}), - ...(args.displayName !== undefined - ? { displayName: args.displayName } - : {}), - }); - if (result.kind === "genesis") { - return { - kind: "provisioned", - tenantId: result.tenantId, - tenantSlug: result.tenantSlug, - seeded: false, - }; - } - if (result.kind === "joined") { - return { - kind: "existing-member", - tenantId: result.tenantId, - tenantSlug: result.tenantSlug, - }; - } - return result; -} diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index bcdea835a2..3665f78933 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -5,7 +5,6 @@ // in ./provision.ts. import type { AppEnv } from "@intx/hub-api"; -import { createExpiringMap } from "@corbits/collections"; import { createNoopCredentialCipher } from "@intx/crypto"; import { CredentialResponse, @@ -30,25 +29,18 @@ import { } from "@corbits/hub-api-client"; import { Hono } from "hono"; import { type } from "arktype"; -import type { AccessPolicyStore } from "@workbench/access-policy"; import { generateRefId, makeErrorEnvelope, reportError, } from "@corbits/error-sink"; -import { - personalTenantSlug, - provisionPersonalTenantIfNeeded, - ProvisionError, -} from "./provision"; import { desiredStateSteps, readTenantDesiredStateStatus, TENANT_DESIRED_STATE, } from "./desired-state"; - -import type { HubSignupTenancy } from "./genesis"; +import { personalTenantSlug } from "./tenant-slug"; import { findPersonalTenant, @@ -133,19 +125,8 @@ const SubmitCredential = type({ "baseURL?": "string > 0", }); -const ProvisionBody = type({ - "name?": "string > 0", -}); - export type CreateOnboardingRoutesDeps = { hubUrl: string; - /** Slug for the genesis tenant the first signup on an empty hub - * mints; later signups join the existing root and never read it. */ - defaultTenantSlug: string; - /** Hub tenancy reads/writes for the genesis-or-join decision — see - * ./genesis.ts's `HubSignupTenancy`. Production wiring is - * `createHubSignupTenancy` in apps/hub/src/signup-tenancy.ts. */ - tenancy: HubSignupTenancy; pushWorkflow: WorkflowPusher; log: (line: string) => void; /** Error-level sibling of `log`: every server-side failure path in @@ -170,14 +151,6 @@ export type CreateOnboardingRoutesDeps = { exchange?: typeof exchangeHuggingFaceCodeForToken; connectCredential?: typeof testAndPersistCredential; }; - /** - * CL-7584 revisit kick: fire-and-forget desired-state reconcile for the - * caller's tenant, fired unconditionally — never gated on a pending - * check (the reconciler itself is reads-only when converged). The hub - * wires this to the same reconciler the tenant-create observer uses; - * the route never awaits it. Absent means no kick. - */ - desiredStateKick?: (args: { tenantId: string; cookies: string[] }) => void; /** Test seam for `POST /complete`'s fast half — credential persist and * catalog seed, the only work that call still does inline. */ testAndPersistCredentialFn?: typeof testAndPersistCredential; @@ -191,16 +164,6 @@ export type CreateOnboardingRoutesDeps = { * optional dep here. */ providerHealth?: ProviderHealthStore; - /** The closed-by-default access-policy gate threaded straight into - * `provisionPersonalTenantIfNeeded` — see that function's own - * `accessPolicy` doc comment. Absent means no access-policy package - * 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 * between `/start` and `/callback`, so a hub restart in between * doesn't strand it — see `@corbits/connections`' `pkce.ts`. The same `CredentialCipher` @@ -373,24 +336,6 @@ export function createOnboardingRoutes( const credentialCipher = deps.credentialCipher ?? createNoopCredentialCipher(); - // A simple in-process per-user provision rate limiter. Provisioning is - // idempotent and safe to retry, but a client stuck in a tight retry loop - // (or a runaway script) can pile concurrent tenant creates onto the hub. - // One in-flight or recent provision per user is enough; the window is - // short because successful provisioning resolves immediately. - // - // This router is built once at hub boot and lives for the process — - // every distinct user who has ever attempted a named create would - // otherwise sit in this map forever (CL-7233). A TTL equal to the rate - // limit window itself is exactly the right eviction policy here: an - // entry has no reason to exist past the window it gates, so - // `createExpiringMap`'s own `get()` already encodes the rate-limit - // check — a defined result means "still inside the window". - const PROVISION_RATE_LIMIT_MS = 10_000; - const lastProvisionByUser = createExpiringMap({ - ttlMs: PROVISION_RATE_LIMIT_MS, - }); - /** * Reads where the bench's agents actually stand. Two hub reads, no * writes, no deploys — cheap enough that every provisioning-aware @@ -429,199 +374,10 @@ export function createOnboardingRoutes( }; } - app.post("/provision", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Sign in to continue.", - }), - 401, - ); - } - - // Optional body: the naming wizard sends `{ name }`; the shell's - // membership probe may POST with no body and only wants the read path. - // Parse before rate-limiting so the read probe never burns a create slot. - // Empty body → probe. Present body that is not valid JSON or fails the - // schema → 400 (never silently treated as a probe). - const bodyText = await c.req.text(); - let body: { name?: string } | undefined; - if (bodyText.trim() === "") { - body = undefined; - } else { - let rawBody: unknown; - try { - rawBody = JSON.parse(bodyText) as unknown; - } catch { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "That request wasn't valid. Try again.", - }), - 400, - ); - } - const parsed = ProvisionBody(rawBody); - if (parsed instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "That request wasn't valid. Try again.", - }), - 400, - ); - } - body = parsed; - } - const isCreateAttempt = body?.name !== undefined; - - // Rate-limit only named creates. The two-step first-login flow is - // probe (no name) → naming submit (with name); gating both would 429 - // anyone who types a name within the window of their membership probe. - if (isCreateAttempt) { - const now = Date.now(); - const isRateLimited = lastProvisionByUser.get(user.id) !== undefined; - if (isRateLimited) { - return c.json( - { - error: { - ...makeErrorEnvelope({ - code: "rate_limited", - userMessage: - "Too many attempts. Wait a moment, then try again.", - }).error, - kind: "transient" as const, - }, - }, - 429, - ); - } - lastProvisionByUser.set(user.id, now); - } - - const cookies = cookiesFromHeader(c.req.header("cookie")); - try { - const provisionArgs: Parameters< - typeof provisionPersonalTenantIfNeeded - >[0] = { - api, - cookies, - userId: user.id, - userEmail: user.email, - userEmailVerified: user.emailVerified, - defaultTenantSlug: deps.defaultTenantSlug, - tenancy: deps.tenancy, - log: deps.log, - }; - if (body?.name !== undefined) provisionArgs.displayName = body.name; - if (deps.accessPolicy !== undefined) - provisionArgs.accessPolicy = deps.accessPolicy; - - const result = await provisionPersonalTenantIfNeeded(provisionArgs); - - // CL-7584 revisit kick: fire unconditionally — never await the - // multi-GET status read on this hot path. `reconcileTenantDesiredState` - // is reads-only when converged (it never enters `seedTenant` with - // every pin present), so a pending-check here would only ever delay - // this response; the reconcile itself re-reads the pins behind the - // fire-and-forget boundary. Repeat probes re-kick safely - // (idempotent). - if ( - (result.kind === "provisioned" || result.kind === "existing-member") && - deps.desiredStateKick !== undefined - ) { - const kick = deps.desiredStateKick; - const fire = (tenantId: string) => { - try { - kick({ tenantId, cookies }); - } catch (cause) { - // report-error-ignore: the kick is best-effort — a failed - // kick only ever costs one missed reconcile. - deps.log( - `desired-state kick for user ${user.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - }; - // A just-joined or just-minted tenant carries its id and fires - // synchronously, before this response is even serialized. - const directTenantId = result.tenantId; - if (directTenantId !== undefined) { - fire(directTenantId); - } else { - // A plain existing member carries no id — resolve it the same - // way the connect flow does (first active principal) behind the - // fire-and-forget boundary, so the response never waits on it - // either. - void (async () => { - try { - const found = await findPersonalTenant( - api, - cookies, - personalTenantSlug(user.email, user.id), - { fallbackToFirstPrincipal: true }, - ); - if (found?.tenantId !== undefined) fire(found.tenantId); - } catch (cause) { - // report-error-ignore: the kick lookup is best-effort — a - // failed lookup only ever costs one missed reconcile. - deps.log( - `desired-state kick lookup for user ${user.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - })(); - } - } - - return c.json(result, 200); - } catch (cause) { - // report-error-ignore: both branches below route through - // reportOnboardingError, this package's reportError wrapper. - if (cause instanceof ProvisionError) { - const status = - cause.code === "signup_not_allowed" - ? 403 - : cause.errorKind === "transient" - ? 503 - : 500; - const userMessage = - cause.code === "signup_not_allowed" - ? "Sign-ups aren't open for this account yet. Contact your workspace admin for access." - : "Setting up your workbench hit a snag — we're on it. Try again in a moment."; - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_provision", - userAction: `first-login provisioning for user ${user.id}`, - code: cause.code, - userMessage, - cause, - extra: { userId: user.id, code: cause.code }, - }); - return c.json( - { - error: { ...envelope.error, kind: cause.errorKind }, - }, - status, - ); - } - // An unrecognized error is treated as transient — the hub may have - // been momentarily unavailable, and retrying is safe because - // provisioning is idempotent. - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_provision", - userAction: `first-login provisioning for user ${user.id}`, - code: "provisioning_failed", - userMessage: - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", - cause, - extra: { userId: user.id }, - }); - return c.json( - { error: { ...envelope.error, kind: "transient" as const } }, - 503, - ); - } - }); + // First-login 0→1 no longer lives here (CL-8085): the client's + // needs-list drives tenant creation and Myra deploy directly over + // stock routes (`apps/web/src/needs-converge.ts`). This package keeps + // only the credential-connect surface below. // OAuth connect (OpenRouter, Hugging Face): CL-6028 generalized both // providers' start/callback mechanics into `@corbits/connections`' @@ -630,13 +386,11 @@ export function createOnboardingRoutes( // now, driven by `CONNECTOR_REGISTRY`'s `openrouter`/`huggingface` // entries. What stays here, unchanged: persisting the exchanged // material (`testAndPersistCredential`, the fast half — no probe, no - // workflow deploy), the duplicate-callback recovery lookup - // (`recentlyConnectedCredential`, below), and firing the desired-state - // kick `/complete-setup` and the revisit probe build on (see - // `afterConnected`, below). Every test seam this package's deps already - // exposed (`openrouterConnect`/`huggingfaceConnect` overrides) still - // works — they're threaded into the registry entries' `oauth.exchange` - // below. + // workflow deploy) and the duplicate-callback recovery lookup + // (`recentlyConnectedCredential`, below). Every test seam this + // package's deps already exposed (`openrouterConnect`/ + // `huggingfaceConnect` overrides) still works — they're threaded into + // the registry entries' `oauth.exchange` below. /** * Adapts `packages/onboarding`'s pre-CL-6028 exchange function shape @@ -804,44 +558,6 @@ export function createOnboardingRoutes( }); } - /** Runs only for a connector whose `oauth.deploysDefaultWorkflows` is - * true (both OpenRouter and Hugging Face) — fires the desired-state - * revisit kick for the just-connected bench, so its default workflows - * converge without the redirect waiting on a deploy. Best-effort: - * the onboarding page's own `/complete-setup` call kicks again, and - * the reconciler itself is reads-only when converged. */ - async function afterConnected(args: { - c: import("hono").Context; - connectorId: string; - userId: string; - apiKey: string; - tenantId: string; - tenantSlug: string; - principalId: string; - tenantDomain: string; - }): Promise { - const provider = onboardingOAuthProvider(args.connectorId); - if (provider === undefined) { - throw new Error( - `onboarding only converges its own providers, not ${args.connectorId}`, - ); - } - if (deps.desiredStateKick === undefined) return; - try { - deps.desiredStateKick({ - tenantId: args.tenantId, - cookies: cookiesFromHeader(args.c.req.header("cookie")), - }); - } catch (cause) { - // report-error-ignore: the kick is best-effort — `/complete-setup` - // kicks again when the onboarding page lands, so a failed kick - // here only ever costs one missed reconcile. - deps.log( - `desired-state kick after OAuth connect for user ${args.userId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } - app.route( "/oauth", createOAuthConnectRoutes({ @@ -854,7 +570,6 @@ export function createOnboardingRoutes( }, connectCredential, recentlyConnected, - afterConnected, defaultReturnPath: "/onboarding", // The plugins gallery (CL-6090) reuses this same onboarding OAuth // route for its one-flow connect panel — `/plugins` joins the @@ -911,8 +626,8 @@ export function createOnboardingRoutes( // credential, seed its catalog, answer. Deploying this bench's // default workflows never blocks this response — a request that // waits on it is the 2+ minute "Connecting…" this route exists to - // never reproduce. Convergence is kicked below; the response only - // ever reports status. + // never reproduce. The response only ever reports status; pending + // pins converge from the client's needs-list (CL-8085). const result = await runTestAndPersistCredential( parsed.baseURL !== undefined ? { ...baseCompleteCredentialArgs, baseURLOverride: parsed.baseURL } @@ -952,23 +667,9 @@ export function createOnboardingRoutes( const status = await provisioningStatus(cookies, result); if (status.kind === "ready") return c.json(status, 200); - // Pins still missing: kick the desired-state reconcile for this - // bench (fire-and-forget — the response answers from the status - // read above, never waits on the deploy) and answer the same - // status, so the waiting surface polls `/provisioning-status` for - // live progress. - if (deps.desiredStateKick !== undefined) { - try { - deps.desiredStateKick({ tenantId: result.tenantId, cookies }); - } catch (cause) { - // report-error-ignore: the kick is best-effort — the revisit - // kick on the tenant's next visit covers it, so a failed kick - // here only ever costs one missed reconcile. - deps.log( - `desired-state kick after credential connect for user ${user.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } + // Pins still missing: the client's needs-list convergence + // (`apps/web/src/needs-converge.ts`) deploys them from the + // browser — this route only ever answers status. return c.json(status, 200); } catch (cause) { // Neither `ProvisionError` nor `CliError` messages are safe to show @@ -976,6 +677,8 @@ export function createOnboardingRoutes( // failure whose text names absolute file paths on the hub's own // disk (CL-6360). The raw detail is logged behind a refId; the // client only ever sees a fixed consumer sentence plus that refId. + // report-error-ignore: CL-7234 — reportOnboardingError itself needs + // to call reportError; tracked there rather than at each call site const envelope = reportOnboardingError(deps.logError ?? deps.log, { operation: "onboarding_complete", userAction: `credential setup for user ${user.id}`, @@ -998,9 +701,9 @@ export function createOnboardingRoutes( // error) means the bench holds no active inference credential yet — // nothing this call can converge — and the caller should fall back to // the ordinary credential step rather than treat it as a failure. - // Otherwise the bench has a credential and missing pins, so this call - // kicks the desired-state reconcile (fire-and-forget) and answers the - // status for the waiting surface to poll on. + // Otherwise the bench has a credential and missing pins; the client's + // own needs-list convergence drives the deploy, so this call just + // answers the status for the waiting surface to poll on. app.post("/complete-setup", async (c) => { const user = c.get("user"); if (!user) { @@ -1049,18 +752,8 @@ export function createOnboardingRoutes( ); if (!seeded) return c.json({ kind: "unseeded" }, 200); - if (deps.desiredStateKick !== undefined) { - try { - deps.desiredStateKick({ tenantId: tenant.tenantId, cookies }); - } catch (cause) { - // report-error-ignore: the kick is best-effort — the revisit - // kick on the tenant's next visit covers it, so a failed kick - // here only ever costs one missed reconcile. - deps.log( - `desired-state kick from complete-setup for user ${user.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } + // Pending pins converge from the client's needs-list, not a + // server-side kick — see the `/complete` comment above. return c.json(status, 200); } catch (cause) { // report-error-ignore: CL-7234 — reportOnboardingError itself needs diff --git a/packages/onboarding/src/tenant-slug.ts b/packages/onboarding/src/tenant-slug.ts new file mode 100644 index 0000000000..28f1c78d15 --- /dev/null +++ b/packages/onboarding/src/tenant-slug.ts @@ -0,0 +1,18 @@ +// The personal-bench slug this package's credential-completion path uses +// to recognize a bench it provisioned itself: a lowercase-kebab local part +// of the email plus a short fragment of the user's own id, unique per user +// without a coordinating registry. Signup no longer mints personal +// benches (CL-8085 moved 0→1 to the client's needs-list), but the +// credential step still needs this to find the right tenant. +export function personalTenantSlug(email: string, userId: string): string { + const local = email.split("@")[0] ?? email; + const kebab = local + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const suffix = userId + .replace(/[^a-zA-Z0-9]/g, "") + .slice(-8) + .toLowerCase(); + return `${kebab || "bench"}-${suffix || "personal"}`; +} diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index c848d61359..1ee7530482 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -5,8 +5,9 @@ // here is a status reporter over hub reads, and it has to answer three // cases correctly: every default workflow live (`ready` — answered // from the read alone), a credential with pins still missing -// (`provisioning` — kick the desired-state reconcile fire-and-forget -// and answer the status for the waiting surface to poll on), and +// (`provisioning` — the client's needs-list convergence drives the +// deploy, so this call just answers the status for the waiting surface +// to poll on), and // nothing to converge with (`unseeded`, a 200 and not an error, // telling the caller to fall back to the ordinary credential step). // @@ -20,17 +21,6 @@ import { Hono } from "hono"; import { DEFAULT_WORKFLOWS, inferenceCredentialName } from "@corbits/seeding"; import { createOnboardingRoutes } from "../src/routes"; -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - const TENANT_ID = "ten_1"; const PRINCIPAL_ID = "prn_1"; const TENANT_SLUG = "user-1-user1"; @@ -116,8 +106,6 @@ describe("POST /complete-setup", () => { app.route( "/api/onboarding", createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "https://bench.example.com", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -143,8 +131,6 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -205,8 +191,6 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -254,27 +238,21 @@ describe("POST /complete-setup", () => { })), ), ); - const kicks: string[] = []; const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, - desiredStateKick: ({ tenantId }) => { - kicks.push(tenantId); - }, }), ); - // An already-seeded bench answers from the read alone — and a - // `ready` read kicks nothing, there is nothing to converge. + // An already-seeded bench answers from the read alone — there is + // nothing to converge, and the route starts no work of its own. const response = await app.request("/api/onboarding/complete-setup", { method: "POST", }); @@ -292,7 +270,6 @@ describe("POST /complete-setup", () => { DEFAULT_WORKFLOWS.map((w) => w.assetName).sort(), ); expect(body.pending).toEqual([]); - expect(kicks).toEqual([]); } finally { server.stop(true); } @@ -309,8 +286,6 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -332,7 +307,7 @@ describe("POST /complete-setup", () => { } }); - test("a credential with nothing live reports provisioning and kicks without waiting", async () => { + test("a credential with nothing live reports provisioning, answered from the read alone", async () => { const hub = new Hono(); principalsRoute(hub); credentialsRoute(hub, { inferenceCredential: true }); @@ -341,27 +316,15 @@ describe("POST /complete-setup", () => { c.json([]), ); const server = Bun.serve({ port: 0, fetch: hub.fetch }); - let kickStarted = false; - let kickFinished = false; try { const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, - desiredStateKick: () => { - kickStarted = true; - void new Promise((resolve) => setTimeout(resolve, 5_000)).then( - () => { - kickFinished = true; - }, - ); - }, }), ); @@ -390,70 +353,25 @@ describe("POST /complete-setup", () => { pending: DEFAULT_WORKFLOWS.map((w) => w.assetName), steps: expect.any(Array), }); - // The point of CL-6457, kept by CL-7586: a credential waiting on - // pins is not a licence to do the converge on the request path. - // The kick started, but the route never waited on its five - // seconds — it answered from the status read alone. + // The point of CL-6457, kept by CL-7586 and CL-8085: a credential + // waiting on pins is not a licence to do the converge on the + // request path. The client's needs-list convergence owns the + // deploy from here — this route answers from the status read + // alone, so it stays fast. expect(elapsedMs).toBeLessThan(1_000); - expect(kickStarted).toBe(true); - expect(kickFinished).toBe(false); - } finally { - server.stop(true); - } - }); - - test("a still-provisioning bench kicks the reconcile instead of waiting on it", async () => { - const hub = new Hono(); - principalsRoute(hub); - credentialsRoute(hub, { inferenceCredential: true }); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => - c.json([]), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const kicks: string[] = []; - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - desiredStateKick: ({ tenantId }) => { - kicks.push(tenantId); - }, - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("provisioning"); - // Without the kick a credential with missing pins waits out the - // reconcile's whole tick interval before anything happens — the - // reason a waiting onboarding page calls this route at all. - expect(kicks).toEqual([TENANT_ID]); } finally { server.stop(true); } }); - test("two overlapping calls both report provisioning and each kicks", async () => { + test("two overlapping calls both report provisioning and start no work", async () => { // Two "finish setup" requests racing (a double effect fire, a // retried fetch) used to be this route's sharpest edge, because // both would deploy. Post-CL-6457 the route deploys nothing at all, // so the only thing left to hold is that overlapping callers get // the same honest status and still start no work of their own. The - // route deliberately does not dedupe the kicks either — one - // fire-and-forget nudge per caller, and the reconcile itself - // converges (covered where it lives, in + // client's needs-list convergence owns the deploy from here (the + // converge itself is covered where it lives, in // `./desired-state-reconcile.test.ts`). const hub = new Hono(); credentialsRoute(hub, { inferenceCredential: true }); @@ -504,20 +422,14 @@ describe("POST /complete-setup", () => { ); const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { - const kicks: string[] = []; const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, - desiredStateKick: ({ tenantId }) => { - kicks.push(tenantId); - }, }), ); @@ -536,28 +448,25 @@ describe("POST /complete-setup", () => { const secondBody = (await second.json()) as { kind: string }; expect(firstBody.kind).toBe("provisioning"); expect(secondBody.kind).toBe("provisioning"); - - // One kick per caller — the route never dedupes, the reconcile - // converges on its own. - expect(kicks).toEqual([TENANT_ID, TENANT_ID]); } finally { server.stop(true); } }); // CL-6264, re-homed by CL-6457 and kept by CL-7586: a bench that got - // partway through its workflows must read as still provisioning, and - // must kick the reconcile so the rest actually goes live. The + // partway through its workflows must read as still provisioning. The // convergence itself — deploying only what is missing on a later // pass — is covered by "a non-sidecar failure reports failed, and a - // re-run can converge" in ./desired-state-reconcile.test.ts. + // re-run can converge" in ./desired-state-reconcile.test.ts; the + // deploy from here is the client's needs-list convergence (CL-8085), + // not a server-side kick. // // CL-7074 narrowed DEFAULT_WORKFLOWS to just the setup agent, so // "partway through" no longer means "one of several live, the rest // pending" — it means the one default workflow's asset exists but // has not gone live yet (the sidecar push landed, the deploy // confirmation has not). - test("a bench whose agent is not yet live reports provisioning and kicks", async () => { + test("a bench whose agent is not yet live reports provisioning for the client to converge", async () => { const liveWorkflow = DEFAULT_WORKFLOWS[0]; if (liveWorkflow === undefined) { throw new Error("DEFAULT_WORKFLOWS is empty"); @@ -586,20 +495,14 @@ describe("POST /complete-setup", () => { ); const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { - const kicks: string[] = []; const app = mountAuthenticated( createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, - desiredStateKick: ({ tenantId }) => { - kicks.push(tenantId); - }, }), ); @@ -627,8 +530,8 @@ describe("POST /complete-setup", () => { steps: expect.any(Array), }); - // Not finished yet — and the kick is what finishes it. - expect(kicks).toEqual([TENANT_ID]); + // Not finished yet — the waiting surface keeps polling this + // status while the client's needs-list convergence finishes it. } finally { server.stop(true); } diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index c9d507a3f8..bbeaa64ba7 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -1,14 +1,13 @@ // "Connecting should deploy nothing" (CL-6457), asserted at the route // layer. The live repro: pasting a key sat on "Connecting…" for 2+ // minutes because `POST /complete` deployed five default workflows — -// ~20s each — before it answered. The fix is structural, so the test is -// too: the follow-up work is handed in as a kick seam that takes five -// seconds and records when it ran, and the route has to answer long -// before it could possibly have waited on that. A route that ever awaits -// a deploy again fails here on the clock, not on a mock's call count -// alone. CL-7586 deleted the pending-seed drain the kick used to wake: -// the kick now names the tenant for the desired-state reconcile, and no -// row is parked anywhere. +// ~20s each — before it answered. The fix is structural: the route only +// ever runs the fast half (persist the credential, seed its catalog, +// answer status) and hands convergence to the client's needs-list +// (CL-8085), so a route that ever awaits a deploy again fails here on +// the clock. CL-7586 deleted the pending-seed drain the old kick used to +// wake, and CL-8085 deleted the kick itself: no row is parked anywhere +// and no follow-up work leaves this route at all. import { describe, expect, test } from "bun:test"; import type { AppEnv } from "@intx/hub-api"; import type { MiddlewareHandler } from "hono"; @@ -155,23 +154,9 @@ function activeCredentialRow() { }; } -function routeDeps(args: { - hubUrl: string; - onKick?: (tenantId: string) => void; -}) { +function routeDeps(args: { hubUrl: string }) { return { hubUrl: args.hubUrl, - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), @@ -184,17 +169,6 @@ function routeDeps(args: { principalId: PRINCIPAL_ID, tenantDomain: TENANT_DOMAIN, }), - // CL-7586 deleted the pending-seed drain: the route hands follow-up - // work to the desired-state reconcile as a fire-and-forget kick - // naming the tenant. Tests hand in slow kicks to prove the route - // answers without waiting on them. - ...(args.onKick === undefined - ? {} - : { - desiredStateKick: ({ tenantId }: { tenantId: string }) => { - args.onKick?.(tenantId); - }, - }), }; } @@ -206,25 +180,13 @@ function mountAuthenticated(routes: Hono): Hono { } describe("POST /complete — connecting deploys nothing", () => { - test("answers in a moment even when the kicked reconcile would take five seconds", async () => { + test("answers in a moment and attempts nothing deploy-shaped", async () => { const { hub, requests } = fakeHub(); const server = Bun.serve({ port: 0, fetch: hub.fetch }); - let kickStarted = false; - let kickFinished = false; try { const app = mountAuthenticated( createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - onKick: () => { - kickStarted = true; - void new Promise((resolve) => setTimeout(resolve, 5_000)).then( - () => { - kickFinished = true; - }, - ); - }, - }), + routeDeps({ hubUrl: `http://localhost:${server.port}` }), ), ); @@ -238,11 +200,7 @@ describe("POST /complete — connecting deploys nothing", () => { expect(response.status).toBe(200); expect(elapsedMs).toBeLessThan(1_000); - // The response never waited on the kicked reconcile — the whole - // point: the kick started, but its five seconds are still running. - expect(kickStarted).toBe(true); - expect(kickFinished).toBe(false); - // And nothing deploy-shaped was even attempted against the hub. + // Nothing deploy-shaped was even attempted against the hub. expect( requests.filter( (line) => @@ -290,50 +248,13 @@ describe("POST /complete — connecting deploys nothing", () => { } }); - test("kicks the desired-state reconcile for the bench instead of parking a row", async () => { - const { hub } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const kickedTenants: string[] = []; - try { - const app = mountAuthenticated( - createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - onKick: (tenantId) => { - kickedTenants.push(tenantId); - }, - }), - ), - ); - - const response = await app.request("/api/onboarding/complete", { - method: "POST", - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - headers: { "content-type": "application/json" }, - }); - - expect(response.status).toBe(200); - // No row is parked anywhere: the reconcile learns the tenant from - // the kick's arguments, not from a drain table. - expect(kickedTenants).toEqual([TENANT_ID]); - } finally { - server.stop(true); - } - }); - test("an already-provisioned bench reconnecting reports ready, with nothing left pending", async () => { const { hub } = fakeHub({ seededWorkflows: ALL_WORKFLOWS }); const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const kickedTenants: string[] = []; try { const app = mountAuthenticated( createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - onKick: (tenantId) => { - kickedTenants.push(tenantId); - }, - }), + routeDeps({ hubUrl: `http://localhost:${server.port}` }), ), ); @@ -349,8 +270,6 @@ describe("POST /complete — connecting deploys nothing", () => { expect(body.kind).toBe("ready"); expect(body.pending).toEqual([]); - // Nothing left to reconcile, so no kick either. - expect(kickedTenants).toEqual([]); } finally { server.stop(true); } @@ -474,29 +393,17 @@ describe("GET /provisioning-status", () => { }); }); -describe("POST /complete-setup — kicks instead of deploying", () => { - test("kicks the reconcile and answers provisioning without waiting on it", async () => { +describe("POST /complete-setup — converges instead of deploying", () => { + test("answers provisioning immediately without a reconcile kick", async () => { // The key is already persisted under the setup catalog's name, so - // the route finds something to reconcile toward; the workflows are - // not deployed yet, so the answer is provisioning either way. + // the route finds something to report on; the route never deploys + // anything and never kicks a reconcile — it answers provisioning. const { hub } = fakeHub({ inferenceCredential: true }); const server = Bun.serve({ port: 0, fetch: hub.fetch }); - let kickStarted = false; - let kickFinished = false; try { const app = mountAuthenticated( createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - onKick: () => { - kickStarted = true; - void new Promise((resolve) => setTimeout(resolve, 5_000)).then( - () => { - kickFinished = true; - }, - ); - }, - }), + routeDeps({ hubUrl: `http://localhost:${server.port}` }), ), ); @@ -510,10 +417,6 @@ describe("POST /complete-setup — kicks instead of deploying", () => { expect(response.status).toBe(200); expect(elapsedMs).toBeLessThan(1_000); expect(body.kind).toBe("provisioning"); - // Same structural guarantee as /complete: the kicked reconcile - // started, but the route never waited on its five seconds. - expect(kickStarted).toBe(true); - expect(kickFinished).toBe(false); } finally { server.stop(true); } diff --git a/packages/onboarding/test/genesis.test.ts b/packages/onboarding/test/genesis.test.ts deleted file mode 100644 index 4b83812c20..0000000000 --- a/packages/onboarding/test/genesis.test.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { AccessPolicyStore } from "@workbench/access-policy"; -import { - genesisOrJoinHubSignup, - ProvisionError, - type GenesisOrJoinArgs, - type HubSignupTenancy, -} from "../src/genesis"; - -const TENANT_ID = "ten_root"; -const TENANT_SLUG = "workbench"; -const PRINCIPAL_ID = "prn_owner"; -const MEMBER_PRINCIPAL_ID = "prn_member"; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -/** An ApiCall that lets a genesis mint succeed: the first principals - * read is empty, the create answers 201, and every later principals - * read reports membership in the minted tenant. */ -function genesisApi(): ApiCall { - let principalsCalls = 0; - return async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; -} - -function accessPolicy( - overrides?: Partial< - Pick< - NonNullable, - "envSignupMode" | "envAllowedDomains" | "allowUnverifiedEmails" - > - >, -): NonNullable { - return { - // Env-only evaluation: no operator tenant is threaded here, so the - // store is never read — a throwing store proves that. - store: { - getPolicy: async () => { - throw new Error("signup gate must evaluate env-only here"); - }, - } as unknown as AccessPolicyStore, - envSignupMode: "open", - envAllowedDomains: [], - allowUnverifiedEmails: false, - ...overrides, - }; -} - -function tenancy(state: { - users?: number; - tenants?: number; - root?: { id: string; slug: string } | null; - joins?: { tenantId: string; userId: string; roleName: string }[]; -}): HubSignupTenancy { - const joins = state.joins ?? []; - return { - countUsers: async () => state.users ?? 0, - countTenants: async () => state.tenants ?? 0, - findRootTenant: async () => (state.root === undefined ? null : state.root), - addActiveMember: async (args) => { - joins.push(args); - return { principalId: MEMBER_PRINCIPAL_ID }; - }, - }; -} - -function principalsResponse( - rows: { - principalId: string; - tenantId: string; - tenantSlug: string; - }[], -) { - return { - status: 200, - data: { - data: rows.map((row) => ({ - principalId: row.principalId, - tenantId: row.tenantId, - tenantName: "Workbench", - tenantSlug: row.tenantSlug, - kind: "user", - status: "active", - roles: [], - })), - nextCursor: null, - }, - cookies: [], - }; -} - -function argsFor(partial: { - api: ApiCall; - tenancy: HubSignupTenancy; - displayName?: string; - log?: (line: string) => void; - accessPolicy?: NonNullable; - userEmailVerified?: boolean; - userEmail?: string; -}): GenesisOrJoinArgs { - const args: GenesisOrJoinArgs = { - api: partial.api, - cookies: ["session=abc"], - userId: "user_1", - userEmail: partial.userEmail ?? "alice@example.com", - userEmailVerified: partial.userEmailVerified ?? true, - defaultTenantSlug: TENANT_SLUG, - tenancy: partial.tenancy, - accessPolicy: partial.accessPolicy ?? accessPolicy(), - log: partial.log ?? collector().log, - }; - if (partial.displayName !== undefined) args.displayName = partial.displayName; - return args; -} - -describe("genesisOrJoinHubSignup", () => { - test("nonempty principals is existing-member: no tenant create, no join, no seed", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - let tenantCreates = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - throw new Error("must not mint a tenant for an existing member"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 1, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ kind: "existing-member" }); - expect(tenantCreates).toBe(0); - expect(joins).toEqual([]); - }); - - test("an existing tenant joins the caller as member and never POSTs /api/tenants", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - throw new Error("join must never mint a tenant"); - } - 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, - }), - displayName: "Bob", - }), - ); - - expect(result).toEqual({ - kind: "joined", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: MEMBER_PRINCIPAL_ID, - }); - expect(joins).toEqual([ - { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, - ]); - }); - - test("a closed hub rejects join with signup_not_allowed and never adds a member", 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}`); - }; - - await expect( - genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 2, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Bob", - accessPolicy: accessPolicy({ envSignupMode: "closed" }), - }), - ), - ).rejects.toMatchObject({ - name: "ProvisionError", - code: "signup_not_allowed", - errorKind: "permanent", - }); - 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") { - 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", - userEmailVerified: false, - }), - ), - ).rejects.toMatchObject({ - name: "ProvisionError", - code: "signup_not_allowed", - }); - - // The dev/test escape hatch still admits the genesis caller. - const result = await genesisOrJoinHubSignup( - argsFor({ - api: genesisApi(), - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - userEmailVerified: false, - accessPolicy: accessPolicy({ allowUnverifiedEmails: true }), - }), - ); - 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 }[] = []; - let principalsCalls = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 2, tenants: 0, root: null, joins }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "genesis", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - expect(tenantCreates).toBe(1); - expect(joins).toEqual([]); - }); - - test("zero tenants and no display name returns needs-onboarding and creates nothing", async () => { - let tenantCreates = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - throw new Error("must not create without a display name"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - expect(tenantCreates).toBe(0); - }); - - test("blank display name is needs-onboarding", async () => { - 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: 1, tenants: 0, root: null }), - displayName: " ", - }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - }); - - test("empty hub genesis POSTs /api/tenants with name and default slug and no parentId", async () => { - let principalsCalls = 0; - const bodies: unknown[] = []; - const api: ApiCall = async (method, path, body) => { - if (path.includes("/assets") || path.includes("/workflows")) { - throw new Error(`signup must not seed: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - bodies.push(body); - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "genesis", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - expect(bodies).toEqual([{ name: "Acme", slug: TENANT_SLUG }]); - }); - - test("genesis 409 when a root exists joins as member", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 1, - tenants: 0, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "joined", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: MEMBER_PRINCIPAL_ID, - }); - expect(joins).toHaveLength(1); - }); - - test("genesis 409 with no root and no principal is slug_conflict_no_principal", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await expect( - genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - }), - ), - ).rejects.toMatchObject({ - name: "ProvisionError", - code: "slug_conflict_no_principal", - }); - }); - - 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. - const result = await genesisOrJoinHubSignup( - argsFor({ - api: genesisApi(), - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - accessPolicy: accessPolicy({ envSignupMode: "closed" }), - }), - ); - expect(result.kind).toBe("genesis"); - }); -}); - -test("ProvisionError is constructible for route mapping", () => { - const error = new ProvisionError("tenant_create_failed", "nope", "transient"); - expect(error.errorKind).toBe("transient"); -}); diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index 4fb8f53942..fe8e4460d8 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -192,24 +192,11 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), }; - if (overrides.desiredStateKick !== undefined) - deps.desiredStateKick = overrides.desiredStateKick; if (overrides.omitClientId !== true) { deps.huggingfaceClientId = overrides.huggingfaceClientId ?? "hf_client_1"; } @@ -315,11 +302,7 @@ describe("GET /oauth/huggingface/callback", () => { expiresAt?: string; credentialMetadata?: Record; }[] = []; - const kickedTenants: string[] = []; const app = connectRoutes({ - desiredStateKick: ({ tenantId }) => { - kickedTenants.push(tenantId); - }, huggingfaceConnect: { exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { exchanges.push({ code, codeVerifier, redirectUri, clientId }); @@ -398,15 +381,14 @@ describe("GET /oauth/huggingface/callback", () => { }, ]); - // The callback kicks the desired-state reconcile for the - // just-connected bench and carries the minted token nowhere: no - // pending-seed row (CL-7586 deleted the drain), no cookie (this - // response still clears the connect-state cookies, but never sets - // the pre-CL-6031 `workbench_pending_seed` one), no redirect query - // parameter. + // The callback carries the minted token nowhere: no pending-seed row + // (CL-7586 deleted the drain), no cookie (this response still clears + // the connect-state cookies, but never sets the pre-CL-6031 + // `workbench_pending_seed` one), no redirect query parameter. Client- + // side needs-list convergence (CL-8085) owns deploying onto the + // bench from here, not a server-side kick. const setCookie = response.headers.get("set-cookie") ?? ""; expect(setCookie).not.toContain("workbench_pending_seed="); - expect(kickedTenants).toEqual(["ten_1"]); }); test("a callback whose query state disagrees with the cookie never exchanges", async () => { diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts index 244cea6e5f..7844442654 100644 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ b/packages/onboarding/test/openrouter-connect-routes.test.ts @@ -202,24 +202,11 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), }; - if (overrides.desiredStateKick !== undefined) - deps.desiredStateKick = overrides.desiredStateKick; if (overrides.openrouterConnect !== undefined) deps.openrouterConnect = overrides.openrouterConnect; if (overrides.credentialCipher !== undefined) @@ -315,11 +302,7 @@ describe("GET /oauth/openrouter/callback", () => { apiKey: string; userId: string; }[] = []; - const kickedTenants: string[] = []; const app = connectRoutes({ - desiredStateKick: ({ tenantId }) => { - kickedTenants.push(tenantId); - }, openrouterConnect: { exchange: async ({ code, codeVerifier }) => { exchanges.push({ code, codeVerifier }); @@ -376,15 +359,14 @@ describe("GET /oauth/openrouter/callback", () => { { provider: "openrouter", apiKey: "sk-or-v1-minted", userId: "user_1" }, ]); - // The callback kicks the desired-state reconcile for the - // just-connected bench and carries the minted key nowhere: no - // pending-seed row (CL-7586 deleted the drain), no cookie (this - // response still clears the connect-state cookies, but never sets - // the pre-CL-6031 `workbench_pending_seed` one), no redirect query - // parameter. + // The callback carries the minted key nowhere: no pending-seed row + // (CL-7586 deleted the drain), no cookie (this response still clears + // the connect-state cookies, but never sets the pre-CL-6031 + // `workbench_pending_seed` one), no redirect query parameter. Client- + // side needs-list convergence (CL-8085) owns deploying onto the + // bench from here, not a server-side kick. const setCookie = response.headers.get("set-cookie") ?? ""; expect(setCookie).not.toContain("workbench_pending_seed="); - expect(kickedTenants).toEqual(["ten_1"]); }); test("a callback without the state cookie never exchanges", async () => { diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts deleted file mode 100644 index e1e1335186..0000000000 --- a/packages/onboarding/test/provision.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { HubSignupTenancy } from "../src/genesis"; -import { - personalTenantSlug, - provisionPersonalTenantIfNeeded, -} from "../src/provision"; - -const TENANT_ID = "ten_new"; -const PRINCIPAL_ID = "prn_new"; -const MEMBER_PRINCIPAL_ID = "prn_member"; -const TENANT_SLUG = "workbench"; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -function tenancy(state: { - users?: number; - tenants?: number; - root?: { id: string; slug: string } | null; - joins?: { tenantId: string; userId: string; roleName: string }[]; -}): HubSignupTenancy { - const joins = state.joins ?? []; - return { - countUsers: async () => state.users ?? 0, - countTenants: async () => state.tenants ?? 0, - findRootTenant: async () => (state.root === undefined ? null : state.root), - addActiveMember: async (args) => { - joins.push(args); - return { principalId: MEMBER_PRINCIPAL_ID }; - }, - }; -} - -function principalsResponse( - rows: { - principalId: string; - tenantId: string; - tenantSlug: string; - }[], -) { - return { - status: 200, - data: { - data: rows.map((row) => ({ - principalId: row.principalId, - tenantId: row.tenantId, - tenantName: "Workbench", - tenantSlug: row.tenantSlug, - kind: "user", - status: "active", - roles: [], - })), - nextCursor: null, - }, - cookies: [], - }; -} - -function argsFor(partial: { - api: ApiCall; - tenancy: HubSignupTenancy; - displayName?: string; -}): Parameters[0] { - const args: Parameters[0] = { - api: partial.api, - cookies: ["session=abc"], - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - defaultTenantSlug: TENANT_SLUG, - tenancy: partial.tenancy, - log: collector().log, - }; - if (partial.displayName !== undefined) args.displayName = partial.displayName; - return args; -} - -describe("personalTenantSlug", () => { - test("derives a lowercase-kebab slug from the email and a user-id fragment", () => { - expect(personalTenantSlug("Alice.Smith@example.com", "user_id_1")).toBe( - "alice-smith-userid1", - ); - }); - - test("never produces an empty component", () => { - expect(personalTenantSlug("@example.com", "")).toBe("bench-personal"); - }); -}); - -describe("provisionPersonalTenantIfNeeded", () => { - test("an existing member is left alone: no tenant is created", async () => { - let tenantCreateCalls = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: "ten_existing", - tenantSlug: "existing", - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreateCalls += 1; - throw new Error("unexpected tenant creation for an existing member"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ users: 2, tenants: 1 }), - }), - ); - - expect(result).toEqual({ kind: "existing-member" }); - expect(tenantCreateCalls).toBe(0); - }); - - test("zero principals without a display name: needs-onboarding, nothing created", async () => { - 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 provisionPersonalTenantIfNeeded( - argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - }); - - test("genesis on an empty hub: provisions the root tenant, seeds nothing", async () => { - const bodies: unknown[] = []; - const { lines, log } = collector(); - let principalsCalls = 0; - const api: ApiCall = async (method, path, body) => { - if (path.includes("/assets") || path.includes("/workflows")) { - throw new Error(`signup must not seed: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - bodies.push(body); - return { - status: 201, - data: { - id: TENANT_ID, - name: "Alice's Lab", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - ...argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), - displayName: "Alice's Lab", - log, - }); - - expect(result).toEqual({ - kind: "provisioned", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - seeded: false, - }); - expect(bodies).toEqual([{ name: "Alice's Lab", slug: TENANT_SLUG }]); - expect(lines.some((line) => line.includes("genesis"))).toBe(true); - }); - - test("an occupied hub: the caller joins the root as member, never mints", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - throw new Error("join must never mint a tenant"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ - users: 2, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - }), - ); - - expect(joins).toEqual([ - { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, - ]); - expect(result).toEqual({ - kind: "existing-member", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - }); - - test("a slug conflict that still leaves the caller benchless is a real failure", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await expect( - provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0 }), - displayName: "Alice's Lab", - }), - ), - ).rejects.toThrow(/slug conflict/); - }); -}); - -// NOTE `isFullySeeded` and `seededWorkflowStatus` lived here until -// CL-7584 collapsed them into the doc-status reader: the same -// workflows-live + registry-seeded semantics now come from -// `readTenantDesiredStateStatus` (./desired-state.ts), covered by -// `./desired-state.test.ts`, and the partial-seed catch path in -// `./complete-credential.test.ts` pins the `seededWorkflowNames` -// deployed/pending report. A second seeded-check must not regrow here. diff --git a/packages/onboarding/test/report-error-routing.test.ts b/packages/onboarding/test/report-error-routing.test.ts index fb51a0e5e0..5183205822 100644 --- a/packages/onboarding/test/report-error-routing.test.ts +++ b/packages/onboarding/test/report-error-routing.test.ts @@ -1,7 +1,6 @@ // CL-7234: every caught failure in this package's routes and // env-credential-plant paths must reach @corbits/error-sink's -// reportError with the operation/tenant context it expects — the same -// precedent ../src/provision.ts already sets. This mocks +// reportError with the operation/tenant context it expects. This mocks // @corbits/error-sink and dynamically imports each module under test // afterward (the same recipe packages/workflow-deploy-source and // packages/webhook-triggers already use for this exact kind of @@ -30,17 +29,6 @@ const { createOnboardingRoutes } = await import("../src/routes"); const { plantEnvProviderCredentials } = await import("../src/plant-env-credentials"); -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - const asUser: MiddlewareHandler = async (c, next) => { c.set("user", { id: "user_1", email: "user_1@example.com" } as never); await next(); @@ -56,26 +44,32 @@ function mountAuthenticated(routes: Hono): Hono { describe("routes.ts routes caught errors through reportError", () => { test("a failure with no tenant known yet reports operation + userId, no tenantId", async () => { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, + // The credential step itself blows up, so no tenant is ever known. + testAndPersistCredentialFn: async () => { + throw new Error("connection refused"); + }, }); const app = mountAuthenticated(routes); - const response = await app.request("/provision", { method: "POST" }); + const response = await app.request("/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), + }); - expect(response.status).toBe(503); + expect(response.status).toBe(500); expect(reportErrorCalls).toHaveLength(1); const [, context] = reportErrorCalls[0] as [ unknown, Record, ]; - expect(context.operation).toBe("onboarding_provision"); + expect(context.operation).toBe("onboarding_complete"); expect(context.tenantId).toBeUndefined(); expect((context.extra as { userId: string }).userId).toBe("user_1"); }); @@ -121,8 +115,6 @@ describe("routes.ts routes caught errors through reportError", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -230,8 +222,6 @@ describe("recentlyConnectedCredential reports through reportError and still find const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index f16ae1f9fb..077afea1f6 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -1,7 +1,7 @@ -// The route's own error handling: a provisioning failure must never -// reach the caller as a bare, unhandled 500 — it should come back as -// the same `{ error: { code, userMessage, refId } }` envelope every other hub -// route uses, so the web layer can tell "nothing to do" apart from +// Route-level error-envelope coverage: a failure inside a route must +// never reach the caller as a bare, unhandled 500 — it should come back as +// the same `{ error: { code, userMessage, refId } }` envelope every other +// hub route uses, so the web layer can tell "nothing to do" apart from // "this broke" instead of both looking like silence. import { describe, expect, test } from "bun:test"; @@ -13,17 +13,6 @@ import { testAndPersistCredential } from "../src/complete-credential"; import { createProviderHealthStore } from "@corbits/connections/provider-health"; import { HubApiError } from "@corbits/hub-api-client"; -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - const asUser: MiddlewareHandler = async (c, next) => { c.set("user", { id: "user_1", email: "alice@example.com" } as never); await next(); @@ -36,299 +25,9 @@ function mountAuthenticated(routes: Hono): Hono { return app; } -describe("POST /provision", () => { - test("an unreachable hub surfaces a transient error envelope (503), not a bare 500 body", async () => { - const lines: string[] = []; - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - // Port 0 on loopback refuses every connection immediately, so the - // underlying fetch throws deterministically without a live hub. - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: (line) => lines.push(line), - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { method: "POST" }); - - // An unrecognized failure (connection refused) is transient: the hub - // may come back, and provisioning is idempotent so retry is safe. - expect(response.status).toBe(503); - const body = (await response.json()) as { - error: { code: string; kind: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("provisioning_failed"); - expect(body.error.kind).toBe("transient"); - expect(typeof body.error.userMessage).toBe("string"); - expect(typeof body.error.refId).toBe("string"); - // The raw detail (here, the connection-refused failure) is logged - // behind the refId — never handed to the client as `userMessage`. - expect(body.error.userMessage).not.toContain("ECONNREFUSED"); - expect(lines.some((line) => line.includes("user_1"))).toBe(true); - }); - - test("a permanent provision failure (slug conflict, no principal) maps to 500 with kind permanent", async () => { - // A slug-conflict where the caller still has no principal anywhere is a - // dead end the client cannot retry out of — it must surface as a - // permanent error so the UI offers "contact support", not "try again". - // Body must include a name so provision enters the create path; without - // a name the route returns needs-onboarding and never hits the hub. - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ data: [], nextCursor: null }), - ); - hub.post("/api/tenants", (c) => - c.json({ error: { code: "conflict", message: "Slug taken" } }, 409), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { - error: { code: string; kind: string; message: string }; - }; - expect(body.error.code).toBe("slug_conflict_no_principal"); - expect(body.error.kind).toBe("permanent"); - } finally { - server.stop(true); - } - }); - - test("a nameless membership probe returns needs-onboarding without creating", async () => { - const creates: unknown[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ data: [], nextCursor: null }), - ); - hub.post("/api/tenants", async (c) => { - creates.push(await c.req.json()); - return c.json({ id: "tnt_x" }, 201); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { method: "POST" }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("needs-onboarding"); - expect(creates).toEqual([]); - } finally { - server.stop(true); - } - }); - - test("rapid named create retries from the same user are rate-limited (429)", async () => { - // Rate limit applies only to named creates (the membership probe must not - // burn a slot — otherwise the naming wizard always 429s within 10s of - // first login). - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - const named = { - method: "POST" as const, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }; - - const first = await app.request("/provision", named); - const second = await app.request("/provision", named); - - // The first call runs (and fails transiently against the dead hub). - expect(first.status).toBe(503); - // The second is short-circuited before provisioning runs. - expect(second.status).toBe(429); - const body = (await second.json()) as { - error: { code: string; kind: string; message: string }; - }; - expect(body.error.code).toBe("rate_limited"); - expect(body.error.kind).toBe("transient"); - }); - - test("a membership probe does not rate-limit the following named create", async () => { - // Two-step first-login: shell probe (no name) then naming submit (with name). - // The probe must not consume the create rate-limit slot. - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ data: [], nextCursor: null }), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - - const probe = await app.request("/provision", { method: "POST" }); - expect(probe.status).toBe(200); - expect(((await probe.json()) as { kind: string }).kind).toBe( - "needs-onboarding", - ); - - // Named create reaches the hub (503/500 from incomplete mock is fine); - // the only failure mode this test forbids is 429 from the probe. - const create = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }); - expect(create.status).not.toBe(429); - expect([500, 503]).toContain(create.status); - } finally { - server.stop(true); - } - }); - - test("malformed JSON on /provision is 400, not a silent membership probe", async () => { - const creates: unknown[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ data: [], nextCursor: null }), - ); - hub.post("/api/tenants", async (c) => { - creates.push(await c.req.json()); - return c.json({ id: "tnt_x" }, 201); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{not-json", - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("bad_request"); - expect(creates).toEqual([]); - } finally { - server.stop(true); - } - }); - - test("schema-invalid provision body is 400, not a silent membership probe", async () => { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ data: [], nextCursor: null }), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: 12 }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("bad_request"); - } finally { - server.stop(true); - } - }); - - test("an anonymous request is rejected before provisioning runs", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - }); - - const response = await routes.request("/provision", { method: "POST" }); - - expect(response.status).toBe(401); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("unauthorized"); - }); -}); - describe("POST /complete", () => { test("an anonymous request is rejected before anything is seeded", async () => { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -355,8 +54,6 @@ describe("POST /complete", () => { test("a missing provider is rejected with a specific message, no network call made", async () => { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -397,8 +94,6 @@ describe("POST /complete", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -433,8 +128,6 @@ describe("POST /complete", () => { const providerHealth = createProviderHealthStore(); providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -462,21 +155,17 @@ describe("POST /complete", () => { }); // CL-6457 moved partial-deploy convergence off this route entirely, - // and CL-7586 deleted the pending-seed drain: `/complete` no longer - // deploys anything and parks no row, so there is no half-finished - // deploy for it to report. What the route still owes the waiting - // surface — a fire-and-forget kick naming the tenant for the - // desired-state reconcile — is covered by - // `./connect-deploys-nothing.test.ts` ("hands follow-up work to the - // desired-state reconcile as a fire-and-forget kick"), and the - // convergence that kick buys is covered by + // CL-7586 deleted the pending-seed drain, and CL-8085 deleted the + // server-side desired-state kick with it: `/complete` neither deploys + // anything nor kicks anything, so there is no half-finished deploy for + // it to report. The client's needs-list convergence + // (`apps/web/src/needs-converge.ts`) drives the deploy from the + // browser; the reconcile it replaces is covered by // `./desired-state-reconcile.test.ts` ("a non-sidecar failure reports // failed, and a re-run can converge"). test("a non-sidecar failure during setup still fails loudly with the existing 500 envelope", async () => { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -510,8 +199,6 @@ describe("POST /complete", () => { test("a HubApiError naming an absolute file path never reaches the client", async () => { const lines: string[] = []; const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -598,8 +285,6 @@ describe("POST /complete — seeded-admin fallback", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -637,22 +322,11 @@ describe("POST /complete — seeded-admin fallback", () => { }); }); -// CL-7584: the revisit kick and the doc-derived step list. Every probe on -// a joined or just-minted tenant fires exactly one fire-and-forget -// reconcile kick — unconditionally, without awaiting the multi-GET status -// read (`reconcileTenantDesiredState` is reads-only when converged, so the -// pending check would only ever delay the response); a repeat probe after -// a killed kick re-fires safely (idempotent, no new queue); -// `/provisioning-status` carries the doc-labeled steps a waiting -// surface renders. -describe("CL-7584 desired-state kicks and steps", () => { - const joinedTenancy = { - countUsers: async () => 1, - countTenants: async () => 1, - findRootTenant: async () => ({ id: "ten_root", slug: "workbench" }), - addActiveMember: async () => ({ principalId: "prn_root" }), - }; - +// CL-7584: the doc-derived step list. `/provisioning-status` carries the +// doc-labeled steps a waiting surface renders; the converge itself moved +// to the client's needs-list (CL-8085), so no server-side kick remains +// to test here. +describe("CL-7584 desired-state steps", () => { function assetRow(name: string, kind: string) { return { id: `ast_${name}`, @@ -667,7 +341,7 @@ describe("CL-7584 desired-state kicks and steps", () => { }; } - function mountHub(state: { seeded: boolean; brokenStatus?: boolean }) { + function mountHub(state: { seeded: boolean }) { const hub = new Hono(); hub.get("/api/me/principals", (c) => c.json({ @@ -697,11 +371,6 @@ describe("CL-7584 desired-state kicks and steps", () => { }), ); hub.get("/api/tenants/ten_root/assets", (c) => { - // The latency probe: the first read the old inline pending-check - // awaited. A 500 here must never cost the kick its fire. - if (state.brokenStatus === true) { - return c.json({ error: { code: "boom" } }, 500); - } if (c.req.query("kind") === "workflow") { return c.json(state.seeded ? [assetRow("assistant", "workflow")] : []); } @@ -740,110 +409,22 @@ describe("CL-7584 desired-state kicks and steps", () => { return hub; } - function routesWithKick(hub: Hono, kicks: string[]) { + function mountRoutes(hub: Hono) { const server = Bun.serve({ port: 0, fetch: hub.fetch }); const routes = createOnboardingRoutes({ - tenancy: joinedTenancy, - defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }), log: () => undefined, - desiredStateKick: (args) => kicks.push(args.tenantId), }); return { server, app: mountAuthenticated(routes) }; } - // The kick is fire-and-forget behind the response: a probe that must - // first resolve its tenant (a plain existing member carries no id) - // settles the lookup after the 200 is already serialized. Poll — never - // a fixed sleep — so the suite stays green when the whole monorepo - // test leg runs packages in parallel and loopback slows down. - async function awaitKicks(kicks: string[], count: number) { - const deadline = Date.now() + 2000; - while (kicks.length < count) { - if (Date.now() > deadline) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - test("a joined member's probe fires one kick when pins are pending", async () => { - const hub = mountHub({ seeded: false }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a converged tenant's probe still fires one kick — the reconcile is reads-only when converged", async () => { - const hub = mountHub({ seeded: true }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - // No pending-check gates the kick: converged or not, the probe - // fires and the reconcile itself no-ops on reads alone. - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a failing status read neither delays nor suppresses the kick", async () => { - const hub = mountHub({ seeded: false, brokenStatus: true }); - // With the pending-check inline, this 500 would swallow the kick - // (kicks == []); with the kick unconditional, the response never - // touches the status endpoints. - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a revisit after a killed kick re-fires the reconcile — no new queue", async () => { - const hub = mountHub({ seeded: false }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - // The first kick "dies with the process": the fire is recorded but - // the reconcile never runs, so the pins stay pending — exactly what - // a hub kill between kick and converge leaves behind. - const first = await app.request("/provision", { method: "POST" }); - expect(first.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - // The retry is the same idempotent probe, not a new queue table or - // endpoint: the revisit fires again unconditionally, and the - // reconcile itself re-reads the pins. There is no durable queue - // left — this re-kick is the whole recovery story. - const revisit = await app.request("/provision", { method: "POST" }); - expect(revisit.status).toBe(200); - await awaitKicks(kicks, 2); - expect(kicks).toEqual(["ten_root", "ten_root"]); - } finally { - server.stop(true); - } - }); - test("GET /provisioning-status carries the doc-derived step list", async () => { const hub = mountHub({ seeded: false }); - const { server, app } = routesWithKick(hub, []); + const { server, app } = mountRoutes(hub); try { const response = await app.request( "/provisioning-status?tenantId=ten_root", diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 277150cd70..4e380c825b 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -74,7 +74,6 @@ apps/web/src/last-workbench.ts 1 return null; apps/web/src/onboarding.ts 1 return { kind: "error" }; apps/web/src/onboarding.ts 1 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; apps/web/src/onboarding.ts 2 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; -apps/web/src/onboarding.ts 3 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; apps/web/src/pages/agent-detail-page.tsx 1 // A handle collision is the one failure retrying can never clear, so apps/web/src/pages/agent-detail-page.tsx 1 setLifecycle({ apps/web/src/pages/agent-detail-page.tsx 1 setSave({ kind: "idle" }); @@ -191,7 +190,6 @@ packages/memory-tools/src/tool.ts 3 if (err instanceof MemoryUnavailableError) { packages/mocks/src/ollama/capture.ts 1 return raw; packages/ollama-adapter/src/inline-tool-json.ts 1 return false; packages/ollama-adapter/src/inline-tool-json.ts 1 return { kind: "incomplete" }; -packages/onboarding/src/routes.ts 1 return c.json( packages/presence/src/artifact-persistence.ts 1 deps.onSnapshotError?.(key, error); packages/presence/src/client.ts 1 // A malformed update is dropped rather than crashing the client; packages/presence/src/client.ts 1 return []; diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index 6f47683bdd..512977c57d 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -8,9 +8,9 @@ // turns into an agent-authored workbench message on real machinery, // not merely that the route returns 201. // -// Mirrors `local-rip.test.ts`'s phase A (alice signs up first as -// genesis owner; a tester then joins as a member; the owner connects -// a credential through the key path) +// Mirrors `local-rip.test.ts`'s phase A (alice signs up first and creates +// the root as owner; a tester then joins by invite as a member; the owner +// connects a credential through the key path) // rather than `chat.test.ts`'s zero-credential `seedCatalog` setup: // the greeting mail rides the same host-session delivery path as every // real reply, so it landing with zero user messages sent — on a mint @@ -148,7 +148,7 @@ describe.skipIf(databaseUrl === undefined)( const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const admin = await hop("alice genesis sign-up", async () => { + const admin = await hop("alice creates the root as owner", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { name: "Alice", email: "alice@example.com", @@ -163,59 +163,56 @@ describe.skipIf(databaseUrl === undefined)( "id", "alice sign-up user field", ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + // Client convergence: the first signup creates the root over the + // stock route and becomes its owner. + const tenantSlug = `greeting-${crypto.randomUUID().slice(0, 8)}`; const minted = await api( hub.baseUrl, "POST", - "/api/onboarding/provision", - { name: "Workbench" }, + "/api/tenants", + { name: "Workbench", slug: tenantSlug }, res.cookies, ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; + expectStatus("alice create root tenant", minted, 201); + const tenantId = stringField(minted.data, "id", "create tenant"); + return { cookies: res.cookies, userId, tenantId, tenantSlug }; }); const user = await hop("sign-up", () => signUp(hub.baseUrl, "Greeting Delivery Tester"), ); - // Under the CL-7578 genesis-or-join contract the tester joins the - // genesis root as a plain member — the genesis path is covered - // in-process by `apps/hub/test/signup-genesis.test.ts`. + // Under the CL-8085 client-convergence contract the tester joins + // the root by owner invite + activate — the genesis-or-join hook is + // gone, and the join path is covered in-process by + // `apps/hub/test/signup-genesis.test.ts`. const provisioned = await hop( - "a membership probe joins the genesis root as a member", + "the owner invites the tester, who joins the root as a member", async () => { - const res = await api( + const invited = await api( hub.baseUrl, "POST", - "/api/onboarding/provision", - undefined, - user.cookies, + `/api/tenants/${admin.tenantId}/members/invite`, + { email: user.email }, + admin.cookies, ); - expectStatus("provision probe", res, 200); - const data = res.data as { - kind: string; - tenantSlug: string; - seeded: boolean; - }; - expect(data.kind).toBe("existing-member"); - stringField(data, "tenantSlug", "provision result"); - return data; + expectStatus("invite tester", invited, 201); + const principalId = stringField(invited.data, "id", "invite tester"); + const activated = await api( + hub.baseUrl, + "PATCH", + `/api/tenants/${admin.tenantId}/principals/${principalId}`, + { status: "active" }, + admin.cookies, + ); + expectStatus("activate tester", activated, 200); + return { tenantSlug: admin.tenantSlug }; }, ); // A joined member is read-only by design, so every owner-level // leg below — seeding, chat mint, turns — runs as alice, the - // genesis owner. + // root owner. const tenant = await hop( "the joined root resolves through findPersonalTenant", async () => { diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index cbd32d8133..d1cde3d38c 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -3,10 +3,10 @@ // 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`) → +// Phase A (onboard → connect): alice signs up first and creates the root +// as its owner over the stock tenant route → a tester joins the root by +// owner invite as a plain member (the join path is also covered +// in-process by `apps/hub/test/signup-genesis.test.ts`) → // occupied closed signup is refused → the root's owner (alice) // connects a real inference credential through the key path // (`POST /api/onboarding/complete`'s own machinery, called directly — @@ -23,7 +23,7 @@ // had published a `package-registry`-kind asset named "corbits-tools" // carrying its tarball. CL-7071 moved that publish off `seedTenant` // onto `workbench setup` (the root tenant; descendants inherit). The -// connect flow runs on the genesis root itself, so an explicit +// connect flow runs on the root itself, so an explicit // `runPublishTools` hop onto the root stands in for // setup, then `ensureSeeded` deploys without packing. // @@ -172,7 +172,7 @@ describe.skipIf(databaseUrl === undefined)( const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const admin = await hop("alice genesis sign-up", async () => { + const admin = await hop("alice creates the root as owner", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { name: "Alice", email: "alice@example.com", @@ -187,25 +187,19 @@ describe.skipIf(databaseUrl === undefined)( "id", "alice sign-up user field", ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + // Client convergence: the first signup creates the root over the + // stock route and becomes its owner. + const tenantSlug = `local-rip-${crypto.randomUUID().slice(0, 8)}`; const minted = await api( hub.baseUrl, "POST", - "/api/onboarding/provision", - { name: "Workbench" }, + "/api/tenants", + { name: "Workbench", slug: tenantSlug }, res.cookies, ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; + expectStatus("alice create root tenant", minted, 201); + const tenantId = stringField(minted.data, "id", "create tenant"); + return { cookies: res.cookies, userId, tenantId, tenantSlug }; }); // Occupied closed signup: a hub with WORKBENCH_SIGNUP=closed @@ -247,43 +241,43 @@ describe.skipIf(databaseUrl === undefined)( ); const provisioned = await hop( - "a membership probe joins the genesis root as a member", + "the owner invites the tester, who joins the root as a member", + async () => { + const invited = await api( + hub.baseUrl, + "POST", + `/api/tenants/${admin.tenantId}/members/invite`, + { email: user.email }, + admin.cookies, + ); + expectStatus("invite tester", invited, 201); + const principalId = stringField(invited.data, "id", "invite tester"); + const activated = await api( + hub.baseUrl, + "PATCH", + `/api/tenants/${admin.tenantId}/principals/${principalId}`, + { status: "active" }, + admin.cookies, + ); + expectStatus("activate tester", activated, 200); + return { tenantId: admin.tenantId, tenantSlug: admin.tenantSlug }; + }, + ); + + await hop( + "re-creating the same root slug conflicts outright", async () => { const res = await api( hub.baseUrl, "POST", - "/api/onboarding/provision", - undefined, + "/api/tenants", + { name: "Local Rip Tester's Bench", slug: admin.tenantSlug }, user.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("re-create root tenant", res, 409); }, ); - await hop("re-provisioning the same account is idempotent", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Local Rip Tester's Bench" }, - 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 () => { @@ -342,7 +336,7 @@ describe.skipIf(databaseUrl === undefined)( const assets = assetsRes.data as { name: string }[]; if (assets.some((asset) => asset.name === "assistant")) { throw new Error( - "the genesis root already carries an assistant on a seedless boot — hub boot seeded product state", + "the root already carries an assistant on a seedless boot — hub boot seeded product state", ); } }, @@ -420,7 +414,7 @@ describe.skipIf(databaseUrl === undefined)( } // CL-7071: seedTenant/ensureSeeded no longer pack. The connect - // flow runs on the genesis root itself, so install `corbits-tools` + // flow runs on the root itself, so install `corbits-tools` // onto that already-existing tenant with the publish-tools CLI — // the same sign-in → tenant resolve → publish path an operator // runs (`bun run publish-tools`) — rather than calling the diff --git a/scripts/e2e/smoke-onboarding.test.ts b/scripts/e2e/smoke-onboarding.test.ts index 1b9f594c32..9a316a3acf 100644 --- a/scripts/e2e/smoke-onboarding.test.ts +++ b/scripts/e2e/smoke-onboarding.test.ts @@ -1,14 +1,14 @@ // Smoke scenario 2/5 (CL-6004): provisioning. A signed-up user with no -// tenant yet calls the first-login provisioning hook -// (POST /api/onboarding/provision). The e2e hub boots empty, so under -// the CL-7578 genesis-or-join contract the first signup mints the root -// as owner — the same genesis path covered in-process by -// `apps/hub/test/signup-genesis.test.ts`. This asserts genesis over -// the wire: `kind: "needs-onboarding"` then `kind: "provisioned"` -// naming the minted root via `tenantId`/`tenantSlug`, and an -// idempotent re-provision. +// tenant yet performs client convergence +// (POST /api/tenants). The e2e hub boots empty, so under the CL-8085 +// client-convergence contract the first signup creates the root itself +// and becomes owner — the same path covered in-process by +// `apps/hub/test/signup-genesis.test.ts`. This asserts convergence over +// the wire: a 201 naming the created root via `id`, a real tenant +// membership for the creator, and a re-create of the same slug +// conflicting instead of minting a duplicate. -import { describe, expect, test } from "bun:test"; +import { describe, test } from "bun:test"; import { resetSchema, setupDatabase } from "../db-setup.ts"; import { @@ -43,9 +43,9 @@ function stringField(data: unknown, field: string, what: string): string { } describe.skipIf(databaseUrl === undefined)( - "smoke: onboarding provision", + "smoke: onboarding convergence", () => { - test("a brand-new signup mints the root as owner, unseeded", async () => { + test("a brand-new signup creates the root as owner, unseeded", async () => { const url = databaseUrl; if (url === undefined) throw new Error("unreachable: suite is skipped"); @@ -84,38 +84,19 @@ describe.skipIf(databaseUrl === undefined)( }); const provisioned = await hop( - "the first signup mints the root as owner", + "the first signup creates the root as owner", async () => { - const probe = await api( - baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - cookies, - ); - expectStatus("provision probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe( - "needs-onboarding", - ); + const slug = `smoke-onboarding-${crypto.randomUUID().slice(0, 8)}`; const res = await api( baseUrl, "POST", - "/api/onboarding/provision", - { name: "Smoke Onboarding" }, + "/api/tenants", + { name: "Smoke Onboarding", slug }, cookies, ); - expectStatus("genesis provision", res, 200); - const data = res.data as { - kind: string; - tenantId: string; - tenantSlug: string; - seeded: boolean; - seedSkipReason?: string; - }; - expect(data.kind).toBe("provisioned"); - stringField(data, "tenantId", "provision result"); - stringField(data, "tenantSlug", "provision result"); - return data; + expectStatus("create root tenant", res, 201); + const tenantId = stringField(res.data, "id", "create tenant"); + return { tenantId, tenantSlug: slug }; }, ); @@ -140,17 +121,15 @@ describe.skipIf(databaseUrl === undefined)( }, ); - await hop("re-provisioning the same account is idempotent", async () => { + await hop("re-creating the same slug conflicts outright", async () => { const res = await api( baseUrl, "POST", - "/api/onboarding/provision", - undefined, + "/api/tenants", + { name: "Smoke Onboarding", slug: provisioned.tenantSlug }, cookies, ); - expectStatus("re-provision probe", res, 200); - const data = res.data as { kind: string }; - expect(data.kind).toBe("existing-member"); + expectStatus("re-create root tenant", res, 409); }); }, 60_000); },