diff --git a/.env.example b/.env.example index 435b6cd2bf..a767904753 100644 --- a/.env.example +++ b/.env.example @@ -44,10 +44,9 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 HUB_STATIC_DIR=../web/dist # The administrator identity `bun run dev` seeds for local development: -# the dev script signs this account up and makes it the owner of the root -# tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh checkout can sign in -# immediately. Unset values fall back to the defaults shown — fine for -# local development, set both for real deployments. +# the dev script signs this account up on the fresh hub, so a fresh +# checkout can sign in immediately. Unset values fall back to the defaults +# shown — fine for local development, set both for real deployments. # HUB_ADMIN_EMAIL=alice@example.com # HUB_ADMIN_PASSWORD=password123 @@ -55,16 +54,6 @@ HUB_STATIC_DIR=../web/dist # feature it configures off; the hub never treats a partially-set group # as configured — it fails loudly at boot instead. -# Slug of the root tenant first signup mints. Every self-served -# personal bench parents under it, and `workbench setup`/`seed` resolve -# the same slug. Unset falls back -# to "workbench". ORG_SLUG is an alias when this is unset — set only one. -# Upgrading a deploy whose existing root was not "workbench": set this -# to that slug (do not leave OPERATOR_TENANT_ID; the hub refuses to boot -# while that stale key is set). -# WORKBENCH_DEFAULT_TENANT=workbench -# ORG_SLUG=workbench - # Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60 # seconds when unset. # SIGNUP_RATE_LIMIT_WINDOW_SECONDS=60 @@ -79,15 +68,10 @@ HUB_STATIC_DIR=../web/dist # SIGNIN_RATE_LIMIT_WINDOW_SECONDS=60 # SIGNIN_RATE_LIMIT_MAX=10 -# Self-serve signup mode. Hub default is closed (owner adds users or -# shares a copy-link invite). Local `bun run dev` opens signup when this -# is unset so the first admin can seed — set closed explicitly to test -# the closed gate. See docs/TENANCY.md. -# WORKBENCH_SIGNUP=open - -# When WORKBENCH_SIGNUP=open, optionally restrict which email domains -# may register (comma-separated). Empty/unset = any domain. -# WORKBENCH_ALLOWED_EMAIL_DOMAINS=acme.example +# Self-serve signup is ungated: stock Interchange composition carries no +# signup mode, domain allowlist, or email-verification gate, so anyone +# can register (rate-limited only — see SIGNUP_RATE_LIMIT_* above). Gate +# access in the composition that embeds the hub if a deployment needs it. # Provider API keys are no longer read from the environment: hub boot # plants nothing, and setting ANTHROPIC_API_KEY or any other provider's @@ -271,15 +255,6 @@ HUB_STATIC_DIR=../web/dist # even if this file is copied verbatim. ALLOW_PLAINTEXT_SECRETS=1 -# Opt-out of @workbench/access-policy's email-verification requirement. -# The hub has no transactional email wired yet, so NO account can ever -# become verified — leaving this unset makes `bun run setup` and every -# self-signup provisioning fail with signup_not_allowed. It therefore -# ships ON for local development. Turn it off (and wire verification) -# before exposing a deployment where invite redemption must prove -# address ownership. -ALLOW_UNVERIFIED_EMAILS=1 - # Artifacts plane (@corbits/artifacts) uses DATABASE_URL too — different # schema only (`artifacts.*` FKs into public.tenant/principal). Nothing to # configure here; `bun run dev` mounts Library automatically. diff --git a/README.md b/README.md index fa044a4565..02fe6bf2b0 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,8 @@ in any order: | File | Proves | | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `smoke-auth.test.ts` | Email sign-up mints a session cookie that authorizes a session-gated route | -| `smoke-onboarding.test.ts` | `POST /api/onboarding/provision` mints a personal bench and reports it unseeded when no seed model is configured | +| `smoke-onboarding.test.ts` | A brand-new signup starts benchless and mints its first tenant through an ordinary `POST /api/tenants` | | `smoke-chat.test.ts` | A workbench is created, a message posts and reads back intact, and the invited-agent listing has its documented shape | -| `smoke-library.test.ts` | An artifact uploads through the multipart route and round-trips through list and get-by-id | | `smoke-webhook.test.ts` | A signed delivery to the public webhook ingress route launches a real run for a routine bound to that trigger | **Needs:** a reachable Postgres named by `DATABASE_URL` in `.env` (see diff --git a/apps/hub/README.md b/apps/hub/README.md index bc55337ad4..2d34dad658 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -18,15 +18,18 @@ native tenant middleware. explicit import plus one `app.route(...)` call, inside or outside the tenant prefix as the extension requires. `src/index.ts` is the single file where all of this comes together. -- `src/*-mount.ts` files (`artifacts-mount.ts`, `memory-mount.ts`, - `skills-mount.ts`, `slack-tag-mount.ts`) are wiring helpers that resolve - and mount one extension's routes with its own DB handle/config. +- There are no local `src/*-mount.ts` wiring helpers: each extension's + routes mount directly in `src/index.ts` with the shared DB + handle/config passed inline at the `app.route(...)` call site. - Serves the built `apps/web` SPA from its own origin (`HUB_STATIC_DIR` points at `apps/web/dist`), so every `/api` call the interface makes is same-origin. -- `credential-expiry-sweep.ts`, `cron-due.ts`, `workflow-scheduler.ts`, - `tenant-create-guard.ts` are host-level - background jobs and guards wired at boot, alongside the route mounts. +- `credential-expiry-sweep.ts`, `workflow-scheduler.ts`, and + `inbox-unsnooze-sweep.ts` are host-level periodic sweeps wired at boot, + alongside the route mounts; `cron-due.ts` is a re-export seam onto + `@corbits/workflows`' own cron matcher, not a second implementation. + Tenancy is the stock platform's own middleware — there is no local + tenant-create guard. - `in-flight-requests.ts` and `shutdown.ts` bound SIGINT/SIGTERM: the hub waits for Hono handlers that have not yet returned a Response (a request still in a Postgres transaction, a git write), then @@ -47,7 +50,6 @@ bun run start cd apps/hub && bun test ``` -Several suites (`artifacts-mount.test.ts`, `memory-mount.test.ts`, -`memory-workflow-routes.test.ts`) exercise mount wiring against a real -database: +Suites under `test/` exercise composition and wiring; the DB-backed +ones run against a real database: `DATABASE_URL=postgres://localhost:5432/workbench_e2e bun test`. diff --git a/apps/hub/src/artifacts-mount.test.ts b/apps/hub/src/artifacts-mount.test.ts deleted file mode 100644 index 27c93abfc7..0000000000 --- a/apps/hub/src/artifacts-mount.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { InlineContentStore, type ArtifactDb } from "@corbits/artifacts"; - -import { mountArtifacts } from "./artifacts-mount"; - -// URL-resolution tests observe the mount through the injected engine seam -// instead of bun's `mock.module`, whose process-wide registry swap cannot -// be undone for later files in the same `bun test` process (it starved -// test/artifact-doc-persistence.test.ts of the real `createArtifactDb`). -const createArtifactDbCalls: string[] = []; - -const stubEngine = { - createArtifactDb: (databaseUrl: string) => { - createArtifactDbCalls.push(databaseUrl); - return { db: {} as ArtifactDb, close: async () => undefined }; - }, - runArtifactMigrations: async () => undefined, - contentStore: InlineContentStore, -}; - -const KEYS = ["DATABASE_URL"] as const; -type EnvKey = (typeof KEYS)[number]; -const saved: Partial> = {}; - -function clearEnvKey(key: EnvKey): void { - // Assigning `undefined` would store the string "undefined"; the key has to - // go. `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function stashEnv(): void { - for (const key of KEYS) { - saved[key] = process.env[key]; - clearEnvKey(key); - } - createArtifactDbCalls.length = 0; -} - -afterEach(() => { - for (const key of KEYS) { - const value = saved[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - saved[key] = undefined; - } - createArtifactDbCalls.length = 0; -}); - -describe("mountArtifacts URL resolution", () => { - test("returns undefined when no URL is available", async () => { - stashEnv(); - const handle = await mountArtifacts({ engine: stubEngine }); - expect(handle).toBeUndefined(); - expect(createArtifactDbCalls).toEqual([]); - }); - - test("mounts against DATABASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench_control"; - const handle = await mountArtifacts({ engine: stubEngine }); - expect(handle).toBeDefined(); - expect(createArtifactDbCalls).toEqual([ - "postgres://localhost:5432/workbench_control", - ]); - }); - - test("explicit options.databaseUrl wins over DATABASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/control"; - const handle = await mountArtifacts({ - databaseUrl: "postgres://localhost:5432/explicit", - engine: stubEngine, - }); - expect(handle).toBeDefined(); - expect(createArtifactDbCalls).toEqual([ - "postgres://localhost:5432/explicit", - ]); - }); -}); diff --git a/apps/hub/src/artifacts-mount.ts b/apps/hub/src/artifacts-mount.ts deleted file mode 100644 index 2a2969939a..0000000000 --- a/apps/hub/src/artifacts-mount.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Hub-side artifacts engine mount — the host's own analog of - * `@corbits/dock`'s `mountArtifacts`. - * - * Same Postgres URL as the hub control plane, different schema: the package - * owns `artifacts.*` (tables, migrations, ledger) and never writes the host - * search_path. Hard FKs from `artifacts.artifact` into `public.tenant` / - * `public.principal` require that shared URL — a separate database would - * break those FKs. - * - * URL resolution: explicit `databaseUrl` option (tests/DI) → `DATABASE_URL`. - * There is exactly one Postgres URL for this hub. When neither is set, the - * mount is skipped (logged once, never thrown) — same optional contract as - * the dock mount. - * - * This module lands the mount + factory only. Tenant-scoped HTTP - * list/get/upload routes live in `@corbits/artifacts-hub` and are - * registered from the hub composition root when the mount succeeds. - */ -import { getLogger } from "@intx/log"; -import { - createArtifactDb, - InlineContentStore, - runArtifactMigrations, - type ArtifactDb, - type ContentStore, -} from "@corbits/artifacts"; - -const log = getLogger(["hub", "artifacts-mount"]); - -export type MountArtifactsOptions = { - /** Explicit database URL. Defaults to `process.env.DATABASE_URL`. */ - databaseUrl?: string; - /** - * Engine seam for tests: lets URL-resolution tests observe the mount - * without a live Postgres or process-wide module mocking (bun's - * `mock.module` swap is irreversible for later files in the same - * test process). - */ - engine?: { - createArtifactDb: typeof createArtifactDb; - runArtifactMigrations: typeof runArtifactMigrations; - contentStore: ContentStore; - }; -}; - -/** - * Handle returned by a successful mount. The `db` is the engine's own - * drizzle handle; `contentStore` is the byte sink used by upload routes. - */ -export type ArtifactsMountHandle = { - db: ArtifactDb; - contentStore: ContentStore; -}; - -export async function mountArtifacts( - options: MountArtifactsOptions = {}, -): Promise { - const databaseUrl = options.databaseUrl ?? process.env["DATABASE_URL"]; - if (!databaseUrl) { - log.info("No DATABASE_URL — artifacts will not be persisted"); - return undefined; - } - - const engine = options.engine ?? { - createArtifactDb, - runArtifactMigrations, - contentStore: InlineContentStore, - }; - const { db } = engine.createArtifactDb(databaseUrl); - await engine.runArtifactMigrations(db); - log.info( - "Artifacts engine mounted — artifacts persist as versioned rows by kind", - ); - return { db, contentStore: engine.contentStore }; -} diff --git a/apps/hub/src/bench-session.ts b/apps/hub/src/bench-session.ts deleted file mode 100644 index ec824c43c8..0000000000 --- a/apps/hub/src/bench-session.ts +++ /dev/null @@ -1,110 +0,0 @@ -// The one thing a background loop cannot inherit: a session. The bench -// provisioner (`@workbench/onboarding`'s `createBenchProvisioner`) runs -// with no request to borrow cookies from, yet everything it drives — -// `seedTenant`'s asset creates, deployments, grants, and the git push -// underneath them — speaks the hub's own HTTP API as the bench's owner. -// This mints that session in-process. -// -// It has to be the user's own session, not an administrator's: the hub -// resolves a tenant by looking up a principal for (tenantId, user), and -// an account with no principal in that tenant is refused outright. A -// parent-org admin does not inherit rights over a child bench — RBAC -// resolves grants within a single tenant — so "just use the operator -// account" would 403 on every call. The owner's own session is the only -// identity that can provision their bench, which is also the honest -// one: the work is theirs, done on their behalf, and it shows up in the -// session table as such (tagged by user agent, so these are greppable -// and never mistaken for a human sign-in). -// -// Sessions are cached per user and re-minted well before expiry, so a -// drain tick every few seconds does not write a session row every few -// seconds. - -import { makeSignature } from "better-auth/crypto"; -import type { SessionForUser } from "@workbench/onboarding"; - -/** Re-mint this far ahead of a cached session's own expiry, so a long - * provisioning pass can never have its session expire mid-flight. */ -const REMINT_LEAD_MS = 60 * 60 * 1000; - -const PROVISIONER_USER_AGENT = "workbench-bench-provisioner"; - -type MintedSession = { - readonly cookies: string[]; - readonly expiresAtMs: number; -}; - -/** - * The better-auth surface this needs, named structurally so the wiring - * is testable without standing up a whole auth instance. - */ -export type BenchSessionAuth = { - $context: Promise<{ - secret: string; - authCookies: { sessionToken: { name: string } }; - internalAdapter: { - createSession( - userId: string, - dontRememberMe?: boolean, - override?: Record, - ): Promise<{ token: string; expiresAt: Date } | null>; - }; - }>; -}; - -/** - * Builds the `sessionFor` seam the bench provisioner takes. Returns the - * bare `name=value` cookie pairs `ApiCall` sends, signed exactly the way - * better-auth's own cookie writer signs them — the same HMAC helper the - * library uses, rather than a hand-rolled copy that could drift from the - * verifier. - * - * `undefined` means "no session could be minted right now" (an account - * since deleted, an auth backend briefly unavailable). The provisioner - * treats that as a reason to hold the bench for a later pass, never as a - * reason to discard its pending work. - */ -export function createBenchSessionMinter(args: { - auth: BenchSessionAuth; - log: (line: string) => void; - now?: () => number; -}): SessionForUser { - const now = args.now ?? Date.now; - const cache = new Map(); - - return async ({ userId }) => { - const cached = cache.get(userId); - if (cached !== undefined && cached.expiresAtMs - REMINT_LEAD_MS > now()) { - return cached.cookies; - } - - try { - const context = await args.auth.$context; - const session = await context.internalAdapter.createSession( - userId, - true, - { userAgent: PROVISIONER_USER_AGENT }, - ); - if (session === null) { - cache.delete(userId); - return undefined; - } - - const signature = await makeSignature(session.token, context.secret); - const value = encodeURIComponent(`${session.token}.${signature}`); - const cookies = [`${context.authCookies.sessionToken.name}=${value}`]; - cache.set(userId, { - cookies, - expiresAtMs: session.expiresAt.getTime(), - }); - return cookies; - } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - args.log( - `could not mint a provisioning session for user ${userId}: ${message}`, - ); - cache.delete(userId); - return undefined; - } - }; -} diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index aaac6a14e7..ef770ae638 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -41,7 +41,7 @@ function isLoopbackBaseUrl(baseUrl: string): boolean { const HubEnv = type({ DATABASE_URL: type(/^postgres(ql)?:\/\/.+$/).describe( - "a Postgres connection URL, e.g. postgres://workbench:workbench@localhost:5432/workbench", + "a Postgres connection URL, e.g. postgres://hub:hub@localhost:5432/hub", ), BASE_URL: type(HTTP_URL).describe( "an http(s) origin, e.g. http://localhost:3000", @@ -58,11 +58,6 @@ const HubEnv = type({ HUB_STATIC_DIR: type("string > 0").describe( "a directory of built user-interface files the hub serves, e.g. apps/hub/public", ), - "WORKBENCH_DEFAULT_TENANT?": type( - /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, - ).describe( - 'slug of the root tenant the hub ensures at boot; every self-served personal bench parents under it, and setup/seed resolve the same slug — ORG_SLUG is an alias when this is unset; default "workbench"', - ), "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( "the per-IP sign-up rate-limit window, in seconds, e.g. 60", ), @@ -75,18 +70,9 @@ const HubEnv = type({ "SIGNIN_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe( "the maximum failed sign-in attempts a single account may accrue per window before further failures are rejected, e.g. 10 — keyed on the target email, not client IP (see sign-in-rate-limit.ts); a correct password always succeeds regardless of this budget", ), - "WORKBENCH_SIGNUP?": type("'open' | 'closed'").describe( - "open = self-serve email signup allowed; closed (default) = owner adds users or copy-link invite only", - ), - "WORKBENCH_ALLOWED_EMAIL_DOMAINS?": type("string").describe( - "comma-separated email domains allowed when WORKBENCH_SIGNUP=open, e.g. acme.example", - ), "ROUTINE_SCHEDULER_POLL_INTERVAL_MS?": type(/^[1-9]\d*$/).describe( "dev/test-only override for the routine scheduler's poll interval, in milliseconds — unset (default) runs the real 30s production cadence; the e2e harness sets this to a fast interval so a scheduled-routine test doesn't wait out the real cadence", ), - "ORG_SLUG?": type(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).describe( - 'alias for WORKBENCH_DEFAULT_TENANT when that is unset — the same root/operator slug setup and seed resolve; default "workbench"', - ), "GOOGLE_CLIENT_ID?": type("string > 0").describe( "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", ), @@ -126,14 +112,11 @@ const HubEnv = type({ "ALLOW_PLAINTEXT_SECRETS?": type("'1' | 'true'").describe( "dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY or PRINCIPAL_KEY_ENCRYPTION_KEY, storing secrets and signing keys at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident", ), - "ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe( - "dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks normally require emailVerified; never set this for a real deployment", - ), "HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe( "opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch", ), "SIDECAR_PROVISIONERS?": type("string").describe( - "comma-separated sidecar-allocation backend ids to register for workbenches placed on their own exclusive sidecar: 'process' (a child process on this host), 'docker', or 'e2b'; unset or empty (default) registers 'process' alone, so a single-server install provisions exclusive sidecars with no operator configuration", + "comma-separated sidecar-allocation backend ids to register for chats placed on their own exclusive sidecar: 'process' (a child process on this host), 'docker', or 'e2b'; unset or empty (default) registers 'process' alone, so a single-server install provisions exclusive sidecars with no operator configuration", ), "SIDECAR_DEFAULT_PROVISIONER?": type("string > 0").describe( "which id listed in SIDECAR_PROVISIONERS is the default backend exclusive placements provision on; required when SIDECAR_PROVISIONERS lists more than one id, optional (defaults to that one id) when it lists exactly one", @@ -159,8 +142,8 @@ const HubEnv = type({ "HUB_SIDECAR_WEBSOCKET_URL?": type(/^wss?:\/\/.+$/).describe( "the ws(s):// URL a provisioned sidecar container dials back to reach this hub; unset (default) derives it from BASE_URL, which is wrong for a docker sidecar provisioner — that container's own localhost is itself, not the hub host — so set this whenever SIDECAR_PROVISIONER=docker", ), - "WORKBENCH_CHAT_IDLE_REAP_MS?": type("string").describe( - "how long a chat resident (workbench host or invited agent) may sit idle before the hub reaps it via a state-preserving undeploy, in milliseconds; unset defaults to 30 minutes", + "HUB_CHAT_IDLE_REAP_MS?": type("string").describe( + "how long a chat resident (host or invited agent) may sit idle before the hub reaps it via a state-preserving undeploy, in milliseconds; unset defaults to 30 minutes", ), }); @@ -182,7 +165,7 @@ const DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS = 60; const DEFAULT_SIGNIN_RATE_LIMIT_MAX = 10; /** - * Production default for `WORKBENCH_CHAT_IDLE_REAP_MS`: 30 minutes, + * Production default for `HUB_CHAT_IDLE_REAP_MS`: 30 minutes, * matching the old sidecar-side `WORKBENCH_CHILD_IDLE_REAP_MS` default * this replaces for chat. */ @@ -209,21 +192,16 @@ function parsePositiveMsEnv( return n; } -const DEFAULT_TENANT_SLUG = "workbench"; - // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own // package, add a member here with its settings, add its id to // `SIDECAR_PROVISIONER_IDS`, and register it in // `sidecarProvisionerFrom`'s per-id parsing below. // -// CL-7324: this is the CLOSED dispatchable-backend set — dispatchable -// work may only ever land on one of these hub-provisioned backends, and -// only ever tenant-bound (the anchor run's tenant; see -// `./dispatch-tenant-guard.ts`). No schema change carries that rule: it is -// enforced at the dispatch seams, not declared in config, so a new backend -// id added here is provisionable but never implicitly dispatchable -// cross-tenant. +// The ids here are the provisionable set exclusive placements may land +// on. Tenant binding is the caller's composition concern, not declared +// in config, so a new backend id added here is provisionable but never +// implicitly dispatchable cross-tenant. export type DockerSidecarProvisionerConfig = { readonly id: "docker"; readonly image: string; @@ -276,7 +254,6 @@ export type HubConfig = { readonly sessionSecret: string; readonly hubDataDir: string; readonly hubStaticDir: string; - readonly defaultTenantSlug: string; readonly signupRateLimit: { readonly windowSeconds: number; readonly max: number; @@ -285,10 +262,6 @@ export type HubConfig = { readonly windowSeconds: number; readonly max: number; }; - /** Self-serve signup. Default closed — see docs/TENANCY.md. */ - readonly signupMode: "open" | "closed"; - /** Domains allowed when signupMode is open. Empty = any domain. */ - readonly allowedEmailDomains: readonly string[]; readonly socialProviders: Readonly< Partial> >; @@ -307,9 +280,6 @@ export type HubConfig = { readonly principalKeyEncryptionKeyHex?: string; /** Dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY. */ readonly allowPlaintextSecrets: boolean; - /** Dev/test-only opt-in to skip @workbench/access-policy's email- - * verification requirement. */ - readonly allowUnverifiedEmails: boolean; /** Opt-in to initialize git-on-disk state inside an existing git work tree. */ readonly allowGitInsideWorkTree?: boolean; /** Dev/test-only override for the routine scheduler's poll interval — @@ -534,14 +504,6 @@ function sidecarProvisionerConfigFor( export function readHubConfig( env: Record, ): HubConfig { - if (env.OPERATOR_TENANT_ID !== undefined) { - throw new Error( - "OPERATOR_TENANT_ID is no longer read: first signup mints the root tenant by slug. " + - "Set WORKBENCH_DEFAULT_TENANT to your existing root tenant's slug " + - '(or remove OPERATOR_TENANT_ID to keep the default slug "workbench"), then restart.', - ); - } - const parsed = HubEnv(env); if (parsed instanceof type.errors) { throw new Error( @@ -555,32 +517,14 @@ export function readHubConfig( const socialProviders = socialProvidersFrom(parsed); const sidecarProvisioners = sidecarProvisionersFrom(parsed); - const allowedEmailDomains = - parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS === undefined || - parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS.trim() === "" - ? [] - : parsed.WORKBENCH_ALLOWED_EMAIL_DOMAINS.split(",") - .map((d) => d.trim()) - .filter((d) => d.length > 0); - - // One deployment fact shared by first-signup genesis, setup, and seed. - // WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the alias when that is - // unset. - const defaultTenantSlug = - parsed.WORKBENCH_DEFAULT_TENANT ?? parsed.ORG_SLUG ?? DEFAULT_TENANT_SLUG; - const hubConfig: { -readonly [K in keyof HubConfig]: HubConfig[K] } = { databaseUrl: parsed.DATABASE_URL, baseUrl: parsed.BASE_URL, sessionSecret: parsed.SESSION_SECRET, hubDataDir: parsed.HUB_DATA_DIR, hubStaticDir: parsed.HUB_STATIC_DIR, - defaultTenantSlug, socialProviders, - signupMode: parsed.WORKBENCH_SIGNUP ?? "closed", - allowedEmailDomains, allowPlaintextSecrets: parsed.ALLOW_PLAINTEXT_SECRETS !== undefined, - allowUnverifiedEmails: parsed.ALLOW_UNVERIFIED_EMAILS !== undefined, sidecarProvisioners: sidecarProvisioners.provisioners, signupRateLimit: { windowSeconds: parsed.SIGNUP_RATE_LIMIT_WINDOW_SECONDS @@ -599,8 +543,8 @@ export function readHubConfig( : DEFAULT_SIGNIN_RATE_LIMIT_MAX, }, chatIdleReapMs: parsePositiveMsEnv( - parsed.WORKBENCH_CHAT_IDLE_REAP_MS, - "WORKBENCH_CHAT_IDLE_REAP_MS", + parsed.HUB_CHAT_IDLE_REAP_MS, + "HUB_CHAT_IDLE_REAP_MS", DEFAULT_CHAT_IDLE_REAP_MS, ), }; diff --git a/apps/hub/src/connection-live-reconcile.test.ts b/apps/hub/src/connection-live-reconcile.test.ts deleted file mode 100644 index 61a9387f98..0000000000 --- a/apps/hub/src/connection-live-reconcile.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// The hub connect hook must relaunch a live assistant the moment a -// `feedsTools` connector (Manus) is stored — not wait for a new invite, -// and not persist-only. `createHub` is not booted here; this is the -// hook body `apps/hub/src/index.ts` fires from `settleServiceConnection`. -import { describe, expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; -import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; - -describe("reconcilePinnedToolPackagesAfterConnect", () => { - test("manus connect relaunches live assistants that pin @corbits/manus-tools", async () => { - const calls: { tenantId: string; packages: readonly string[] }[] = []; - const result = await reconcilePinnedToolPackagesAfterConnect( - { - reconcilePinnedToolPackages: async (tenantId, packageNames) => { - calls.push({ tenantId, packages: [...packageNames] }); - return { scanned: 1, relaunched: 1 }; - }, - }, - { tenantId: "ten_1", connectorId: "manus" }, - ); - - expect(CONNECTOR_REGISTRY["manus"]?.feedsTools).toEqual([ - "@corbits/manus-tools", - ]); - expect(result).toEqual({ scanned: 1, relaunched: 1 }); - expect(calls).toEqual([ - { tenantId: "ten_1", packages: ["@corbits/manus-tools"] }, - ]); - }); - - test("an inference-only connector does not relaunch for tool-package pins", async () => { - const calls: unknown[] = []; - const result = await reconcilePinnedToolPackagesAfterConnect( - { - reconcilePinnedToolPackages: async (...args) => { - calls.push(args); - return { scanned: 0, relaunched: 0 }; - }, - }, - { tenantId: "ten_1", connectorId: "anthropic" }, - ); - - expect(result).toBeUndefined(); - expect(calls).toEqual([]); - }); -}); diff --git a/apps/hub/src/connection-live-reconcile.ts b/apps/hub/src/connection-live-reconcile.ts deleted file mode 100644 index d4cb839fba..0000000000 --- a/apps/hub/src/connection-live-reconcile.ts +++ /dev/null @@ -1,30 +0,0 @@ -// After a connector's credential lands, live assistants that already -// pin that connector's `feedsTools` packages still run the snapshot -// deployed at launch. Bindings for those packages -// (`pinnedPackageCredentialBindingsFor`) fold only at deploy, so a Myra -// launched at signup before Manus was pasted cannot `resolve("manus")` -// until this pass relaunches it. Persist-only (stamp the pin onto the -// launch row and leave the run) is the bug: the sidecar keeps the old -// snapshot. -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; - -export type PinnedToolPackageReconcile = { - reconcilePinnedToolPackages( - tenantId: string, - packageNames: readonly string[], - ): Promise<{ scanned: number; relaunched: number }>; -}; - -/** - * Relaunches live participants in `tenantId` whose pins include this - * connector's `feedsTools`. Returns `undefined` when the connector - * feeds no tool packages (inference-only, webhook, …). - */ -export async function reconcilePinnedToolPackagesAfterConnect( - platform: PinnedToolPackageReconcile, - info: { readonly tenantId: string; readonly connectorId: string }, -): Promise<{ scanned: number; relaunched: number } | undefined> { - const feedsTools = CONNECTOR_REGISTRY[info.connectorId]?.feedsTools ?? []; - if (feedsTools.length === 0) return undefined; - return platform.reconcilePinnedToolPackages(info.tenantId, feedsTools); -} diff --git a/apps/hub/src/credential-expiry-sweep.ts b/apps/hub/src/credential-expiry-sweep.ts index 8ca491bdcd..8cc0f8c8bd 100644 --- a/apps/hub/src/credential-expiry-sweep.ts +++ b/apps/hub/src/credential-expiry-sweep.ts @@ -53,7 +53,7 @@ const POLL_INTERVAL_MS = 5 * 60 * 1000; // is refreshed — a token expiring before the *next* tick is refreshed // this tick, so a live session never sees the gap. const MCP_REFRESH_LEAD_MS = POLL_INTERVAL_MS; -const MCP_OAUTH_CLIENT_NAME = "Corbits Workbench"; +const MCP_OAUTH_CLIENT_NAME = "Corbits Hub"; const log = getLogger(["hub", "credential-expiry-sweep"]); // Mail-first-claim-after (see `mailThenClaimExpiry`) leaves a still-due diff --git a/apps/hub/src/credential-material-refresh.ts b/apps/hub/src/credential-material-refresh.ts index f311626b6f..de86a1e2fd 100644 --- a/apps/hub/src/credential-material-refresh.ts +++ b/apps/hub/src/credential-material-refresh.ts @@ -162,7 +162,7 @@ export function createServingRefresh(deps: ServingRefreshDeps): ServingRefresh { `/api/tenants/${args.tenantId}/mcp-servers/oauth/${mcpSlugOf(args.providerName)}/callback`, deps.hubUrl, ).toString(), - clientName: "Corbits Workbench", + clientName: "Corbits Hub", }); if (!result.ok) throw new Error(result.message); return { diff --git a/apps/hub/src/crypto-provider-cache-wiring.test.ts b/apps/hub/src/crypto-provider-cache-wiring.test.ts index 79800b8b34..4b65746ff0 100644 --- a/apps/hub/src/crypto-provider-cache-wiring.test.ts +++ b/apps/hub/src/crypto-provider-cache-wiring.test.ts @@ -74,6 +74,8 @@ describe("hub crypto-provider cache wiring", () => { expect(firstCall(source, "launchWebhookTrigger")).toContain( `cryptoProviderCache: ${assigned}`, ); - expect(firstCall(source, "runOneShotPrompt")).toContain(assigned); + // The planner draft route's `runOneShotPrompt` mail sender was cut + // with the stock-hub cutover (its inventory read through the deleted + // memory/skills mounts), so only the two senders above remain pinned. }); }); diff --git a/apps/hub/src/dispatch-tenant-guard.test.ts b/apps/hub/src/dispatch-tenant-guard.test.ts deleted file mode 100644 index 333258248f..0000000000 --- a/apps/hub/src/dispatch-tenant-guard.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -// CL-7324: hermetic tests for the Workbench-side dispatch containment -// (`dispatch-tenant-guard.ts`). No database: the service wrapper gets a -// stub anchor-tenant resolver and a stub delegate, and the route guard -// gets a stub tenancy loader in front of a counting inner app — proving -// the decision logic itself, not Postgres. -import { expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import type { SidecarAllocation } from "@intx/db"; -import type { WorkflowAllocationService } from "@intx/hub-sessions"; - -import { - ALLOCATION_TENANT_MISMATCH_CODE, - AllocationTenantMismatchError, - TENANT_MISMATCH_FAILURE_CODE, - withDispatchTenantGuard, - withTenantBoundAllocationService, -} from "./dispatch-tenant-guard"; - -function allocationFor(args: { - id?: string; - anchorRunId?: string; - tenantId?: string; -}): SidecarAllocation { - return { - id: args.id ?? "sal_test", - anchorRunId: args.anchorRunId ?? "wfr_anchor", - tenantId: args.tenantId ?? "ten_a", - provisionerId: "process", - provisionerApiVersion: 1, - provisionerBindingFingerprint: "fp", - status: "allocated", - generation: 1, - ensureAttempts: 0, - destroyAttempts: 0, - createdAt: new Date(0), - updatedAt: new Date(0), - }; -} - -function stubAllocationService(): WorkflowAllocationService & { - deployCalls: SidecarAllocation[]; -} { - const deployCalls: SidecarAllocation[] = []; - const service = { - deployCalls, - async prepareProvisionedDeployment() { - throw new Error("not implemented in stub"); - }, - async deployReadyAllocation(allocation: SidecarAllocation) { - deployCalls.push(allocation); - return null; - }, - } as unknown as WorkflowAllocationService & { - deployCalls: SidecarAllocation[]; - }; - return service; -} - -test("deployReadyAllocation delegates when allocation and anchor tenants match", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => "ten_a", - }); - const allocation = allocationFor({ tenantId: "ten_a" }); - const result = await guarded.deployReadyAllocation(allocation); - expect(result).toBeNull(); - expect(inner.deployCalls).toEqual([allocation]); -}); - -test("deployReadyAllocation throws tenant_mismatch without delegating on a foreign allocation", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => "ten_a", - }); - const thrown = await guarded - .deployReadyAllocation(allocationFor({ tenantId: "ten_b" })) - .then( - () => null, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AllocationTenantMismatchError); - expect((thrown as AllocationTenantMismatchError).code).toBe( - TENANT_MISMATCH_FAILURE_CODE, - ); - // Never falls through to another tenant: the delegate never ran, so the - // allocation can never deploy — the reconciler retries instead. - expect(inner.deployCalls).toEqual([]); -}); - -test("deployReadyAllocation fails closed when the anchor row is gone", async () => { - const inner = stubAllocationService(); - const guarded = withTenantBoundAllocationService(inner, { - resolveAnchorTenantId: async () => undefined, - }); - const thrown = await guarded - .deployReadyAllocation(allocationFor({ tenantId: "ten_a" })) - .then( - () => null, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AllocationTenantMismatchError); - expect(inner.deployCalls).toEqual([]); -}); - -function guardedTestApp( - loadAnchorDispatch: ( - anchorRunId: string, - ) => Promise< - | { anchorTenantId: string; allocationTenantIds: readonly string[] } - | undefined - >, -): { app: Hono; state: { innerHits: number } } { - const state = { innerHits: 0 }; - const inner = new Hono(); - inner.all("*", (c) => { - state.innerHits += 1; - return c.json({ reached: true }, 200); - }); - const app = withDispatchTenantGuard(inner, { loadAnchorDispatch }); - return { app, state }; -} - -async function postApp( - app: Hono, - path: string, -): Promise<{ status: number; body: unknown; app: Hono }> { - const res = await app.request(path, { method: "POST" }); - return { status: res.status, body: await res.json(), app }; -} - -test("mail trigger with a foreign allocation answers 403 allocation_tenant_mismatch without reaching the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_b"], - })); - const { status, body } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(403); - expect(body).toMatchObject({ - error: expect.objectContaining({ code: ALLOCATION_TENANT_MISMATCH_CODE }), - }); - expect(state.innerHits).toBe(0); -}); - -test("signals route with a foreign allocation answers 403 without reaching the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_a", "ten_b"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/signals", - ); - expect(status).toBe(403); - expect(state.innerHits).toBe(0); -}); - -test("matching anchor and allocation tenants fall through to the inner app", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_a"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("a pre-provisioning anchor with no allocation rows falls through", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: [], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/signals", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("an absent anchor falls through so the native lookup owns the 404", async () => { - const { app, state } = guardedTestApp(async () => undefined); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_missing/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("an anchor owned by another tenant falls through so the native lookup owns the 404", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_other", - allocationTenantIds: ["ten_other"], - })); - const { status } = await postApp( - app, - "/api/tenants/ten_a/workflows/wfr_anchor/mail", - ); - expect(status).toBe(200); - expect(state.innerHits).toBe(1); -}); - -test("non-dispatch paths are never intercepted", async () => { - const { app, state } = guardedTestApp(async () => ({ - anchorTenantId: "ten_a", - allocationTenantIds: ["ten_b"], - })); - const res = await app.request("/api/tenants/ten_a/workflows", { - method: "GET", - }); - expect(res.status).toBe(200); - expect(state.innerHits).toBe(1); -}); diff --git a/apps/hub/src/dispatch-tenant-guard.ts b/apps/hub/src/dispatch-tenant-guard.ts deleted file mode 100644 index 6ab5690a95..0000000000 --- a/apps/hub/src/dispatch-tenant-guard.ts +++ /dev/null @@ -1,247 +0,0 @@ -// [Intx gap] CL-7324: the native dispatch path joins anchor + allocation -// on `anchorRunId` alone — neither -// `vendor/intx/hub-api/src/run-grant-materialization.ts`'s -// `lockDispatchableAllocation` nor the signal route's deployment query -// (`vendor/intx/hub-api/src/routes/workflows.ts`) filters the allocation -// leg by tenant. A sidecar allocation row bound to an anchor but carrying -// another tenant's id would therefore dispatch — and materialize grants — -// cross-tenant. Filed upstream; vendor is read-only, so this hub contains -// at its own seams instead of patching the join: -// -// 1. `withTenantBoundAllocationService` wraps the allocation service this -// composition root hands to `createApp` (and every Workbench-owned call -// site): `deployReadyAllocation` throws `AllocationTenantMismatchError` -// before delegating whenever the allocation's tenant is not the anchor -// run's tenant. The reconciler's `onReady` catch-all turns any throw -// into `scheduleRetry` (`sidecar_initialization_failed`), so a mismatch -// is retryable, never terminal, and never falls through to another -// tenant — the allocation simply never deploys. -// 2. `withDispatchTenantGuard` outer-wraps the fully-built app (the same -// composition as `tenant-create-guard.ts`'s `guardedHubApp`) and denies -// the two dispatch routes — `POST -// /api/tenants/:tenantId/workflows/:runId/mail` and `POST -// /api/tenants/:tenantId/workflows/:runId/signals` — with 403 -// `allocation_tenant_mismatch` when an allocation bound to the anchor -// carries a foreign tenant, before the native handler can enqueue or -// materialize grants. An absent anchor (or an anchor owned by another -// tenant) falls through: the native tenant-scoped lookup owns that 404. -// -// Bench-owner, hub-provisioned, tenant-bound dispatch only (operator -// ruling 2026-09-15, Option A). No enrollment or allowlist surfaces live -// here — those were deferred (B/C), so there is nothing to consult: any -// cross-tenant join is denied, not admitted. -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; -import type { DB } from "@intx/db"; -import { sidecarAllocation, workflowRun } from "@intx/db/schema"; -import type { AppEnv } from "@intx/hub-api"; -import type { SidecarAllocation } from "@intx/db"; -import type { WorkflowAllocationService } from "@intx/hub-sessions"; -import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; - -/** Error-code surfaced at the route seam on a foreign-tenant allocation. */ -export const ALLOCATION_TENANT_MISMATCH_CODE = "allocation_tenant_mismatch"; -/** Reconciler failure code a mismatch surfaces as: scheduled for retry, - * never terminal. */ -export const TENANT_MISMATCH_FAILURE_CODE = "tenant_mismatch"; - -/** - * Thrown (never returned) when an allocation's tenant is not its anchor - * run's tenant. Retryable by construction: nothing downstream treats this - * as terminal, and the throw precedes any delegate call, so a mismatched - * allocation can never deploy or dispatch into another tenant. - */ -export class AllocationTenantMismatchError extends Error { - readonly code = TENANT_MISMATCH_FAILURE_CODE; - readonly anchorRunId: string; - readonly anchorTenantId: string | undefined; - readonly allocationId: string | undefined; - readonly allocationTenantId: string; - - constructor(args: { - anchorRunId: string; - anchorTenantId: string | undefined; - allocationId: string | undefined; - allocationTenantId: string; - }) { - super( - `allocation ${args.allocationId ?? ""} carries tenant ` + - `${args.allocationTenantId} but anchors run ${args.anchorRunId} of ` + - `tenant ${args.anchorTenantId ?? ""}`, - ); - this.name = "AllocationTenantMismatchError"; - this.anchorRunId = args.anchorRunId; - this.anchorTenantId = args.anchorTenantId; - this.allocationId = args.allocationId; - this.allocationTenantId = args.allocationTenantId; - } -} - -function mismatchContext(error: AllocationTenantMismatchError): { - operation: string; - tenantId: string; - extra: Record; -} { - const extra: Record = { - anchorRunId: error.anchorRunId, - allocationTenantId: error.allocationTenantId, - }; - if (error.allocationId !== undefined) { - extra["allocationId"] = error.allocationId; - } - if (error.anchorTenantId !== undefined) { - extra["anchorTenantId"] = error.anchorTenantId; - } - return { - operation: "workflow-dispatch-tenant-guard", - tenantId: error.allocationTenantId, - extra, - }; -} - -/** Anchor run's owning tenant — `undefined` when the anchor row is gone. */ -export async function resolveAnchorTenantId( - db: DB["db"], - anchorRunId: string, -): Promise { - const [row] = await db - .select({ tenantId: workflowRun.tenantId }) - .from(workflowRun) - .where(eq(workflowRun.id, anchorRunId)) - .limit(1); - return row?.tenantId; -} - -export type TenantBoundAllocationServiceDeps = { - /** Anchor run's owning tenant (`undefined` when the row is gone), kept - * injectable so the wrapper's decision is testable without a database. */ - resolveAnchorTenantId: (anchorRunId: string) => Promise; -}; - -/** - * Wraps the allocation service so `deployReadyAllocation` enforces the - * anchor/allocation tenant join: on a mismatch (or a missing anchor row) - * it reports through the error sink and throws - * `AllocationTenantMismatchError` WITHOUT delegating — the reconciler - * retries, the allocation never deploys, and dispatch never crosses - * tenants. Every other method passes straight through. - */ -export function withTenantBoundAllocationService( - service: WorkflowAllocationService, - deps: TenantBoundAllocationServiceDeps, -): WorkflowAllocationService { - return { - ...service, - async deployReadyAllocation(allocation: SidecarAllocation) { - const anchorTenantId = await deps.resolveAnchorTenantId( - allocation.anchorRunId, - ); - if ( - anchorTenantId === undefined || - anchorTenantId !== allocation.tenantId - ) { - const mismatch = new AllocationTenantMismatchError({ - anchorRunId: allocation.anchorRunId, - anchorTenantId, - allocationId: allocation.id, - allocationTenantId: allocation.tenantId, - }); - reportError(mismatch, mismatchContext(mismatch)); - throw mismatch; - } - return service.deployReadyAllocation(allocation); - }, - }; -} - -export type AnchorDispatchTenancy = { - /** Owning tenant of the anchor run named in the URL. */ - readonly anchorTenantId: string; - /** Tenant of every allocation row bound to that anchor (empty when the - * deployment has no allocation yet — pre-provisioning deploys). */ - readonly allocationTenantIds: readonly string[]; -}; - -export type DispatchTenantGuardDeps = { - /** Anchor + bound-allocation tenancy (`undefined` when the anchor row is - * gone), injected so the guard's decision stays DB-free in tests. */ - loadAnchorDispatch: ( - anchorRunId: string, - ) => Promise; -}; - -/** Production read: anchor row plus every allocation row bound to it. */ -export async function loadAnchorDispatch( - db: DB["db"], - anchorRunId: string, -): Promise { - const [anchor] = await db - .select({ tenantId: workflowRun.tenantId }) - .from(workflowRun) - .where(eq(workflowRun.id, anchorRunId)) - .limit(1); - if (anchor === undefined) return undefined; - const rows = await db - .select({ tenantId: sidecarAllocation.tenantId }) - .from(sidecarAllocation) - .where(eq(sidecarAllocation.anchorRunId, anchorRunId)); - return { - anchorTenantId: anchor.tenantId, - allocationTenantIds: rows.map((row) => row.tenantId), - }; -} - -const DISPATCH_GUARD_PATHS = [ - "/api/tenants/:tenantId/workflows/:runId/mail", - "/api/tenants/:tenantId/workflows/:runId/signals", -] as const; - -/** - * Wraps the fully-built hub app in an outer app that denies the two - * dispatch routes with 403 `allocation_tenant_mismatch` when any - * allocation bound to the anchor carries a tenant other than the URL - * tenant — before the native handler joins anchor+allocation and - * materializes grants or enqueues. Every other path, an anchor with no - * foreign allocation, and an absent/foreign anchor (the native - * tenant-scoped lookup owns that 404) fall straight through. - */ -export function withDispatchTenantGuard( - nativeApp: Hono, - deps: DispatchTenantGuardDeps, -): Hono { - const guarded = new Hono(); - - for (const path of DISPATCH_GUARD_PATHS) { - guarded.use(path, async (c, next) => { - if (c.req.method !== "POST") return next(); - const tenantId = c.req.param("tenantId"); - const anchorRunId = c.req.param("runId"); - const anchor = await deps.loadAnchorDispatch(anchorRunId); - if (anchor === undefined || anchor.anchorTenantId !== tenantId) { - return next(); - } - const foreign = anchor.allocationTenantIds.find( - (allocationTenantId) => allocationTenantId !== tenantId, - ); - if (foreign === undefined) return next(); - const mismatch = new AllocationTenantMismatchError({ - anchorRunId, - anchorTenantId: anchor.anchorTenantId, - allocationId: undefined, - allocationTenantId: foreign, - }); - reportError(mismatch, mismatchContext(mismatch)); - return c.json( - makeErrorEnvelope({ - code: ALLOCATION_TENANT_MISMATCH_CODE, - userMessage: - "This deployment's allocation belongs to another workbench and cannot be dispatched here.", - }), - 403, - ); - }); - } - - guarded.route("/", nativeApp); - return guarded; -} diff --git a/apps/hub/src/draft-route-envelope.test.ts b/apps/hub/src/draft-route-envelope.test.ts deleted file mode 100644 index 1f30610808..0000000000 --- a/apps/hub/src/draft-route-envelope.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -// The hub composition root must serve the package draft routes (CL-6749) -// rather than a local `{ code, message }` 422 the client cannot parse. - -import { readFileSync } from "node:fs"; -import { expect, test } from "bun:test"; - -test("hub mounts package draft routes rather than a local {code, message} 422", () => { - const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); - expect(source).toContain("createAgentDefinitionDraftRoutes"); - expect(source).not.toMatch(/code:\s*"drafting_failed"/); -}); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 3dd2ca7892..5caf7dfd15 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -12,7 +12,6 @@ import { createDB, createGrantStore, createPrincipalKeyStore, - createPrincipalStore, createSidecarAllocationStore, createSignalCorrelationStore, createWorkflowRunDispatchStore, @@ -23,7 +22,6 @@ import { } from "@intx/db"; import { asset as assetTable, - model, modelPricing, tenant as tenantTable, user as userTable, @@ -51,23 +49,7 @@ import { WorkflowDefinitionInvalidError } from "@intx/workflow-deploy"; // projection `installAndApproveWorkflowSource` returns on `grants_not_approved` // — the gate itself only stamps this hash on the `ok:true` arm. -import { - createAgentDefinitionDraftRoutes, - createAgentDefinitionRoutes, - createDefinitionAssetHistory, - createWorkflowAgentCreateRoutes, - createWorkflowCapabilityRoutes, - createWorkflowSkillPinRoutes, - type CapabilityInventoryProvider, - createMyraAgentDefinitionDrafting, - isPlannerCreatedDefinitionName, - resolveMyraDefinitionIdFromDb, - type InventoryAgent, - type InventoryModel, - type InventorySources, - type InventoryToolPackage, - runOneShotPrompt, -} from "@corbits/agent-directory"; +import { isPlannerCreatedDefinitionName } from "@corbits/agent-directory"; import { DEFAULT_TURN_CLAIM_TTL_MS, @@ -76,16 +58,15 @@ import { createInMemoryTurnClaimStore, createWorkbenchHostInferencePreferencesResolver, createWorkbenchSubscriberRegistry, - createWorkbenchTenancyRoutes, createWorkbenchTurnQueue, createTurnCancelRegistry, createChatOrchestrator, createChatRoutes, joinRunParticipant, createDrizzleBlockResponseStore, - createDrizzleWorkbenchTenancyStore, createDrizzleChatStore, createDrizzleClientIdStore, + createDrizzleNativePrincipalStore, createDrizzlePinStore, createDrizzleReactionStore, createDrizzleRoomMessageStore, @@ -162,7 +143,6 @@ import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes" import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; -import { createHubSignupTenancy } from "./signup-tenancy"; import { createInMemoryMailboxEventBus, createMailboxDb, @@ -190,8 +170,6 @@ import { import { deliveryWorkbenchRequiredForWorkflowName, isConversationalWorkflowName, - workflowDisplayName, - workbenchTemplateLibraryEntries, } from "@workbench/templates"; import { createConnectGithubRoutes } from "@corbits/connections/connect-github-routes"; import { webhookTriggerName } from "@corbits/connections/connect-github-setup"; @@ -258,41 +236,12 @@ import { readProcessProvisionerConfig, type ProcessProvisionerRole, } from "@corbits/process-provisioner"; -import { getArtifact, writeArtifactVersion } from "@corbits/artifacts"; -import { - createArtifactDbStore, - createArtifactRoutes, - createTemplateLibraryDbStore, - createTemplateLibraryRoutes, - createTemplateLibrarySeeder, - createUnavailableArtifactRoutes, - createUnavailableTemplateLibraryRoutes, - createUnavailableWorkflowArtifactRoutes, - createWorkflowArtifactDbStore, - createWorkflowArtifactRoutes, - createWorkflowRunAuthenticator, -} from "@corbits/artifacts-hub"; +import { createWorkflowRunAuthenticator } from "@corbits/artifacts-hub"; import { - createArtifactDocPersistence, createPresenceRoomRegistry, createPresenceRoutes, - type PresenceRoomKey, } from "@corbits/presence"; -import { supportedCredentialProviders } from "@corbits/connections/credential-test"; -import { createGitWorkflowPusher } from "@corbits/connections/workflow-push"; import { createHubAPI } from "@corbits/hub-api-client"; -import { - createDrizzlePendingSeedStore, - createBenchProvisioner, - createOnboardingRoutes, -} from "@workbench/onboarding"; -// The deployable-through-the-catalog-instantiate-route set, by asset -// name (CL-7585): `@workbench/onboarding`'s `tenant-seed` -// `CATALOG_WORKFLOWS`, strings only — the Routines picker admits a -// tenant's installed asset only when the same installed-asset registry -// says so (see `installedAssetNames` on the scheduled routes' catalog -// below). -import { CATALOG_WORKFLOW_ASSET_NAMES } from "@workbench/onboarding/tenant-seed"; import { createConnectionRoutes, isInferenceProvider, @@ -314,39 +263,16 @@ import { createProviderHealthPort, createProviderHealthStore, } from "@corbits/connections/provider-health"; -import { - applyAccessPolicyMigrations, - createAccessPolicyRoutes, - createDrizzleAccessPolicyStore, -} from "@workbench/access-policy"; -import { guardedHubApp, resolveCallerRoleNames } from "./tenant-create-guard"; -import { - loadAnchorDispatch, - resolveAnchorTenantId, - withDispatchTenantGuard, - withTenantBoundAllocationService, -} from "./dispatch-tenant-guard"; -import { createTenantCreateObserver } from "./tenant-create-onboard"; import { createInMemoryNotifyDispatchStore, createSinkRegistry, } from "@corbits/notify"; -import { mountMemory } from "./memory-mount"; -import { mountSkills } from "./skills-mount"; -import { - createUnavailableWorkflowMemoryRoutes, - createWorkflowMemoryRoutes, - createWorkflowMemoryStore, -} from "@corbits/memory-hub"; -import { createSkillRoutes, createWorkflowSkillRoutes } from "@corbits/skills"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes, WorkflowAuthorError, type WorkflowDeployer, } from "@corbits/workflows"; -import { mountArtifacts } from "./artifacts-mount"; -import { mountWorkbenchSlackTag } from "./slack-tag-mount"; import { createCredentialExpirySweep, createDrizzleCredentialExpirySweepStore, @@ -362,7 +288,6 @@ import { import { type } from "arktype"; import { betterAuth } from "better-auth"; -import { createBenchSessionMinter } from "./bench-session"; import { hasRepoGrantViaHttp, mintRepoGrantViaHttp, @@ -390,7 +315,6 @@ import { type ScheduledDeliveryJoinDeps, } from "./workflow-scheduler"; import { createToolGrantsForPins } from "./tool-grants"; -import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; import { drainHubServer, shutdownHub } from "./shutdown"; import { createInFlightRequestTracker, @@ -401,8 +325,8 @@ import { const MAX_TARBALL_BYTES = 10 * 1024 * 1024; // In-repo tool packages (`packages/granola-tools`, `packages/linear-tools`, // `packages/skills-tools`) are unpublished to npm and stay that way: -// they are workbench-specific integration bundles, not general-purpose -// npm packages, so publishing them to a public registry would be the +// they are integration bundles for this product's own routes, not +// general-purpose npm packages, so publishing them to a public registry would be the // wrong distribution surface for what they are. `@intx/hub-sessions` // already resolves any `package-registry`-kind asset visible to a // tenant as a named tool-package registry (see `session-service.ts`'s @@ -435,11 +359,9 @@ const SignInEmailBody = type({ email: "string" }); // later `wakeByAddress` relaunch resumes the same run rather than // starting a fresh one. -// Signup mode is operator-controlled (WORKBENCH_SIGNUP). Default closed: -// self-serve email signup is rejected; owners add users or share a -// copy-link invite (docs/TENANCY.md). Open mode keeps email+password -// signup and the existing rate limit. Email delivery of invites is out -// of scope. +// Email+password signup stays available through better-auth; the hub +// applies only coarse throttling around it, never an operator gate. +// Email delivery of invites is out of scope. // Email+password sign-in is always wired up. Google/GitHub OAuth are // wired up too, but only the providers `readHubConfig` found a full // credential pair for — better-auth's own `socialProviders` map is @@ -676,11 +598,6 @@ export async function createHub(config: HubConfig) { // Per-principal signing keys are sealed under their own operator key — // see `principalKeyStoreFrom`. const principalKeyStore = principalKeyStoreFrom(config, db, log); - // The genesis-or-join first-signup decision's tenancy reads/writes — - // also read by the sign-up gate (empty-hub exception) and the - // tenant-create guard below. See ./signup-tenancy.ts. - const signupTenancy = createHubSignupTenancy(db, principalKeyStore); - const auth = betterAuth({ baseURL: config.baseUrl, secret: config.sessionSecret, @@ -697,8 +614,8 @@ export async function createHub(config: HubConfig) { // `trustedProxies` configured (Railway publishes no stable edge CIDR // list to populate one with) a single-value header is trusted verbatim // regardless of who set it. That's an acceptable, low-stakes gap for - // sign-up's coarse throttling — a closed-by-default, operator-gated - // path — but not for brute-force resistance on sign-in, which is why + // sign-up's coarse throttling — an ungated path that needs only + // brute-force resistance on sign-in, which is why // sign-in has its own account-keyed limiter instead (see // `sign-in-rate-limit.ts`). advanced: { @@ -726,25 +643,6 @@ export async function createHub(config: HubConfig) { [SIGN_IN_EMAIL_PATH]: false, }, }, - // No mailer is wired up anywhere in this stack, so better-auth can - // never actually verify an address -- `emailVerified` would stay - // false forever and every self-serve signup would dead-end at - // @workbench/access-policy's gate. `allowUnverifiedEmails` - // (ALLOW_UNVERIFIED_EMAILS, dev/test only) auto-verifies at the - // source instead of leaving each downstream consumer of - // `emailVerified` to separately special-case it. - databaseHooks: config.allowUnverifiedEmails - ? { - user: { - create: { - before: async (user: { email: string }) => { - log.info`ALLOW_UNVERIFIED_EMAILS is set: auto-verifying ${user.email} at account creation (dev/test only)`; - return { data: { ...user, emailVerified: true } }; - }, - }, - }, - } - : undefined, }); // Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts` // for why this replaces better-auth's own IP-keyed sign-in enforcement @@ -766,7 +664,7 @@ export async function createHub(config: HubConfig) { // for the process, read here ahead of `workflow_run` and written to // there off every `agent.deploy.ack`. const runKeyHistoryStore = createDrizzleRunKeyHistoryStore(db); - // A workbench agent is a native provisioned deployment. Reconnect + // A chat agent is a native provisioned deployment. Reconnect // ownership is Interchange's live run + `@corbits/run-key-history`. // Completed means dead; wake is a fresh provision, not a folded-run // idle settle. @@ -807,8 +705,8 @@ export async function createHub(config: HubConfig) { principalKeyStore, grantStore: createGrantStore(db), }); - // Hoisted ahead of their other uses below (`mountMemory`'s neighbors, - // the room timeline store at CL-6327) so `createHubMailboxResolveRefs` + // Hoisted ahead of their other uses below (chat routes, the room + // timeline store at CL-6327) so `createHubMailboxResolveRefs` // can share these two instances rather than constructing its own just // for the mailbox wiring. const chatStore = createDrizzleChatStore(db); @@ -1114,7 +1012,7 @@ export async function createHub(config: HubConfig) { // boundary, mirroring @intx/hub-sessions's own reference wiring. An // install that configures nothing registers the `process` backend // (`@corbits/process-provisioner`) as the sole default, so a - // workbench's "run this workbench on its own sidecar" setting works on + // chat's "run this chat on its own sidecar" setting works on // one server with no operator setup; `SIDECAR_PROVISIONERS` is the one // variable that changes where sidecars run. A deployment whose // definition declares sidecar capabilities no registered provisioner @@ -1142,7 +1040,7 @@ export async function createHub(config: HubConfig) { : {}), }); const sidecarPlugins = buildSidecarPlugins("deployment"); - const nativeWorkflowAllocationService = createWorkflowAllocationService({ + const workflowAllocationService = createWorkflowAllocationService({ db, deploymentPlugins: sidecarPlugins, probePlugins: buildSidecarPlugins("probe"), @@ -1151,19 +1049,6 @@ export async function createHub(config: HubConfig) { allocationRouter: sidecarRouter, hubWebSocketUrl, }); - // [Intx gap] CL-7324: the native dispatch path joins anchor + allocation - // on `anchorRunId` alone, so a foreign-tenant allocation bound to an - // anchor would dispatch cross-tenant. Vendor is read-only — wrap the - // service here, once, so `createApp`, the reconciler `onReady`, and - // every Workbench-owned call site below all share the tenant-bound - // instance (see ./dispatch-tenant-guard.ts). - const workflowAllocationService = withTenantBoundAllocationService( - nativeWorkflowAllocationService, - { - resolveAnchorTenantId: (anchorRunId) => - resolveAnchorTenantId(db, anchorRunId), - }, - ); const sidecarAllocationStore = createSidecarAllocationStore(db); const workflowDispatchService = createWorkflowDispatchService({ dispatchStore: createWorkflowRunDispatchStore(db), @@ -1287,63 +1172,6 @@ export async function createHub(config: HubConfig) { return result ? { user: result.user, session: result.session } : null; }, authHandler: async (c) => { - // Gate self-serve email signup. Sign-in stays open; only the - // sign-up/email path is product-controlled (docs/TENANCY.md). - if (c.req.method === "POST" && c.req.path.endsWith(SIGN_UP_EMAIL_PATH)) { - if (config.signupMode === "closed") { - // Empty-hub exception (CL-7578): with zero users and zero - // tenants, someone has to be first — the signup that opens a - // brand-new hub is allowed even when signup is closed, since - // the genesis path makes that caller the root tenant's owner. - // Everywhere else on the hub "empty" means zero tenants only; - // here a user row also counts, because it means the 0→1 - // signup already happened. - const [users, tenants] = await Promise.all([ - signupTenancy.countUsers(), - signupTenancy.countTenants(), - ]); - if (users > 0 || tenants > 0) { - return c.json( - { - error: "signup_closed", - message: - "Self-serve signup is disabled. Ask an owner for an invite.", - }, - 403, - ); - } - } - if (config.allowedEmailDomains.length > 0) { - let email = ""; - try { - const body: unknown = await c.req.raw.clone().json(); - if ( - body !== null && - typeof body === "object" && - "email" in body && - typeof (body as { email: unknown }).email === "string" - ) { - email = (body as { email: string }).email.toLowerCase(); - } - } catch { - email = ""; - } - const at = email.lastIndexOf("@"); - const domain = at >= 0 ? email.slice(at + 1) : ""; - const allow = new Set( - config.allowedEmailDomains.map((d) => d.toLowerCase()), - ); - if (!allow.has(domain)) { - return c.json( - { - error: "email_domain_not_allowed", - message: "That email domain is not allowed to sign up.", - }, - 403, - ); - } - } - } // Account-keyed sign-in brute-force protection (CL-6494, hardened // CL-6521) — see `sign-in-rate-limit.ts` for why this fully replaces // better-auth's own IP-keyed enforcement for this path instead of @@ -1415,26 +1243,8 @@ export async function createHub(config: HubConfig) { // nothing reported. See `hubErrorHandler`'s own doc comment. app.onError(hubErrorHandler()); - // One in-process presence room registry for this process, constructed - // here in the composition root — the same pattern `workbenchSubscribers` - // above uses. Presence rooms are ephemeral and process-local by design - // (see `@corbits/presence`'s docs/presence.md); the registry is built - // here rather than inside `createPresenceRoutes` itself so the - // co-editing doc-persistence wiring below (which needs the artifacts - // engine, mounted further down once its own DB handle resolves) can - // share the exact same registry the routes below serve traffic - // through — the same way `startWorkflowCommand` shares - // `workbenchSubscribers`. + // Presence rooms are ephemeral and process-local by design. const presenceRoomRegistry = createPresenceRoomRegistry(); - // Indirection so the join route can call into artifact-doc seeding - // before the artifacts engine (mounted later, once its DB handle is - // known) exists. `createPresenceRoutes` is constructed once, here, so - // its `onJoin` hook has to be a stable function that reads whatever - // `artifactSeedOnJoin` currently points to — `undefined` (a no-op) - // until the artifacts mount below assigns it, or forever if the - // artifacts plane never mounts. - let artifactSeedOnJoin: - ((key: PresenceRoomKey, principalId: string) => Promise) | undefined; // Chat's own grant store/condition registry, built the same way // `createApp` builds its default when none is supplied (see @@ -1507,36 +1317,16 @@ export async function createHub(config: HubConfig) { }, ); } - // Mounted here (not up with the registry construction above) because - // its `/update` route's grant gate needs `chatGrantStore`/ - // `chatConditionRegistry`, which don't exist yet up there — the same - // reason `artifactSeedOnJoin`'s indirection exists, just for a - // dependency that's ready sooner. app.route( `${TENANT_PREFIX}/presence`, createPresenceRoutes({ registry: presenceRoomRegistry, - onJoin: (key, principalId) => artifactSeedOnJoin?.(key, principalId), requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), }), ); - // Memory plane (optional): firm-memory HTTP under - // `/api/tenants/:tenantId/memory/*`, same `DATABASE_URL` as the control - // plane, isolated in its own `memory` schema. Degrades when EMBED_* is - // unset — see memory-mount.ts. Captured (not discarded) here, before - // `chatOrchestrator`/`createArtifactDeliveryHandler` below, so the - // in-process `Memory` handle can be threaded into both: a finalized - // turn's persisted artifact and the bounded daily transcript digest - // (CL-5852) both write through this same handle, never a second - // connection or the plane's own tenant-session-gated HTTP routes. - const memoryHandle = await mountMemory({ - app, - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }); const threadStore = createDrizzleThreadStore(db); const blockResponseStore = createDrizzleBlockResponseStore(db); const reactionStore = createDrizzleReactionStore(db); @@ -1559,25 +1349,9 @@ export async function createHub(config: HubConfig) { // what makes the URL usable from a sidecar on another machine. app.route("/api/chat/noop-inference", createNoopInferenceRoutes()); const selfApi = createHubAPI(config.baseUrl); - const sessionFor = createBenchSessionMinter({ - auth, - log: (line) => log.warn`${line}`, - }); - const chatTenancy = createDrizzleWorkbenchTenancyStore(db, { - conditionRegistry: chatConditionRegistry, - api: selfApi, - principalStore: createPrincipalStore(db, principalKeyStore), - }); - // Mounted outside the tenant prefix, like `/api/onboarding`: the bench - // switcher asks this across every tenant a signed-in user belongs to, - // not one tenant at a time (see `apps/web/src/bench-context.tsx`). - app.route( - "/api/workbench-tenancies", - createWorkbenchTenancyRoutes({ tenancy: chatTenancy }), - ); // The chat platform's invite-launch fallback: a definition with no // model requirements of its own resolves the tenant-catalog default. - const workbenchHostInferencePreferencesResolver = + const chatHostInferencePreferencesResolver = createWorkbenchHostInferencePreferencesResolver((tenantId) => listDefaultInferencePreferences(db, tenantId), ); @@ -1588,10 +1362,10 @@ export async function createHub(config: HubConfig) { // most of them hangs off it. const relaunchNoticeRef: RelaunchNoticePort = {}; // One CryptoProviderCache for the whole hub process (CL-7284). Chat - // sendMail keys by workbench id; webhook, routine, and agent-definition + // sendMail keys by chat id; webhook, routine, and agent-definition // drafting first-turn mail key by the launched run's instance id. New - // workbenches and run ids are `run_` (`generateId("workflowRun")`); - // older workbenches are `ins_` (`generateId("instance")`). They share a + // chats and run ids are `run_` (`generateId("workflowRun")`); + // older chats are `ins_` (`generateId("instance")`). They share a // string shape — a second cache for the same id would mint a different // signing key. generateId uniqueness keeps distinct entities from // colliding; sharing the cache keeps the same entity from rotating keys @@ -1630,7 +1404,7 @@ export async function createHub(config: HubConfig) { }), // Chat residents are undeployed on idle again (see the comment above // this function): `chatIdleReapMs` (env-overridable via - // `WORKBENCH_CHAT_IDLE_REAP_MS`, default 30 minutes) is + // `HUB_CHAT_IDLE_REAP_MS`, default 30 minutes) is // state-preserving (`IDLE_HIBERNATE_UNDEPLOY_REASON`), unlike a // destructive undeploy. lifecycle: { idleSleepMs: config.chatIdleReapMs }, @@ -1639,43 +1413,42 @@ export async function createHub(config: HubConfig) { // (see `@corbits/agent-directory`'s `createAgentDefinitionCore` // doc) still launches on invite by falling back to this same // tenant-catalog default, instead of 409ing `not_launchable`. - workbenchHostInferencePreferences: - workbenchHostInferencePreferencesResolver, + workbenchHostInferencePreferences: chatHostInferencePreferencesResolver, relaunchNotice: relaunchNoticeRef, }); wireMailRedelivery({ sidecarRouter, chatPlatform }); - // The one SSE subscriber registry for this process's workbench events + // The one SSE subscriber registry for this process's chat events // (see `@corbits/chat`'s `workbench-events.ts`), constructed here in // the composition root and shared by every consumer below: the chat // router bridges it onto `/workbenches/:id/stream`, the // workflow-command path publishes through the same instance so a // command-started workflow's join event reaches an open stream // immediately (exactly like `POST .../invite`'s does), and the - // orchestrator publishes every message it posts onto a workbench's + // orchestrator publishes every message it posts onto a chat's // timeline. - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - // One in-flight turn per workbench (CL-6331), shared by every send - // surface below the same way `workbenchSubscribers` is: the chat - // router, the workflow-participant router (a workflow child's own - // sends), and the Slack tag mount all route through this one queue, - // so a burst arriving through any of them for the same workbench + const chatSubscribers = createWorkbenchSubscriberRegistry(); + // One in-flight turn per chat (CL-6331), shared by every send + // surface below the same way `chatSubscribers` is: the chat + // router and the workflow-participant router (a workflow child's own + // sends) all route through this one queue, + // so a burst arriving through any of them for the same chat // still serializes against the others rather than each queue only // seeing its own slice of the traffic. const turnQueue = createWorkbenchTurnQueue({ claims: createInMemoryTurnClaimStore({ ttlMs: DEFAULT_TURN_CLAIM_TTL_MS }), - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }); // The live abort seam a running turn is reachable through (CL-7201) — // shared the same way `turnQueue` above is, so a cancel request lands - // wherever a workbench's turn was actually dispatched from. + // wherever a chat's turn was actually dispatched from. const turnCancellation = createTurnCancelRegistry(); relaunchNoticeRef.current = createRelaunchNoticePoster({ store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }); // Built once, beside the platform, for the process's lifetime: turns - // an invited agent's `connector.reply` events into workbench messages, + // an invited agent's `connector.reply` events into chat messages, // and a gate-blocked run's approval park into an in-chat approve // block, by subscribing to the sidecar's own event stream, replacing // the old per-agent reply-bridge machinery armed (and re-armed) from @@ -1692,7 +1465,7 @@ export async function createHub(config: HubConfig) { agentTurns, store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, platform: chatPlatform, events: sidecarRouter.events, approvals: createApprovalStore(db), @@ -1702,9 +1475,6 @@ export async function createHub(config: HubConfig) { turnMailCorrelation, connectorRegistry: CONNECTOR_REGISTRY, }; - if (memoryHandle !== undefined) { - chatOrchestratorDeps.memory = memoryHandle.memory; - } const chatOrchestrator = createChatOrchestrator(chatOrchestratorDeps); // CL-6644: a loud, unconditional boot confirmation that message intake // is actually wired — a composition-root mistake here (an import @@ -1774,15 +1544,13 @@ export async function createHub(config: HubConfig) { }); // Now that `chatStore`/`chatPlatform` exist, arm the finalized-turn // artifact-delivery ref declared beside `eventCollectors` above. - // `memory` (absent when the plane isn't mounted) lets this handler - // also record a memory entry for each persisted artifact (CL-5852). const artifactDeliveryHandlerDeps: Parameters< typeof createArtifactDeliveryHandler >[0] = { db, store: chatStore, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, platform: chatPlatform, events: sidecarRouter.events, approvals: createApprovalStore(db), @@ -1793,9 +1561,6 @@ export async function createHub(config: HubConfig) { providerHealth: createProviderHealthPort(providerHealthStore), listConnectedProviders: (tenantId) => listConnectedProviders(db, tenantId), }; - if (memoryHandle !== undefined) { - artifactDeliveryHandlerDeps.memory = memoryHandle.memory; - } artifactDeliveryHandlerRef.current = createArtifactDeliveryHandler( artifactDeliveryHandlerDeps, ); @@ -1806,7 +1571,7 @@ export async function createHub(config: HubConfig) { // no re-registration step. `startWorkflow` is `@corbits/chat`'s own // `startWorkflowCommand`, sharing the exact invite-then-send core // `POST .../invite` uses, including its live `publish` — bound to - // `workbenchSubscribers` above, the same registry `createChatRoutes` + // `chatSubscribers` above, the same registry `createChatRoutes` // is given below. const commandRegistry = createCommandRegistry(); commandRegistry.registerCommandPlugin( @@ -1819,7 +1584,7 @@ export async function createHub(config: HubConfig) { store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, }, input, ), @@ -1830,7 +1595,7 @@ export async function createHub(config: HubConfig) { // picker that offers agents to a person and by a routine's `"agent"`-kind // trigger-field validation below: a catalog workflow whose entry says // `conversational: false` (routine/automation material — Echo, "Last 30 - // days research report", …) and workbench-host anchor definitions + // days research report", …) and chat-host anchor definitions // (chat's own plumbing, never a person-facing agent) belong in neither. // `isConversationalWorkflowName`, not `isAutomatableWorkflowName`: a // non-automatable utility workflow (Echo, the research report a routine @@ -1846,10 +1611,8 @@ export async function createHub(config: HubConfig) { // see `@corbits/agent-directory`'s `stale-task-agent-naming.ts`) // existed for exactly one now-retired task; any that still linger // must stay out of a picker meant for agents a person deliberately - // keeps around. Wired into every picker surface: - // chat's invite/new-chat dialogs (`chatDeps.isInvitableDefinition` below) - // and the agent-definition drafting inventory - // (`listMyraConversationalAgents` below). + // keeps around. Wired into the picker surface: chat's invite/new-chat + // dialogs (`chatDeps.isInvitableDefinition` below). const isPickerListableDefinition = (definition: { name: string }) => isConversationalAgentDefinition(definition) && !isPlannerCreatedDefinitionName(definition.name); @@ -1858,7 +1621,7 @@ export async function createHub(config: HubConfig) { store: chatStore, roomMessages, platform: chatPlatform, - tenancy: chatTenancy, + principals: createDrizzleNativePrincipalStore(db), threads: threadStore, turnMailCorrelation, agentTurns, @@ -1868,7 +1631,7 @@ export async function createHub(config: HubConfig) { reactions: reactionStore, pins: pinStore, clientIds: createDrizzleClientIdStore(db), - workbenchSubscribers, + workbenchSubscribers: chatSubscribers, turnQueue, turnCancellation, requireGrant: createRequireGrant({ @@ -1895,7 +1658,7 @@ export async function createHub(config: HubConfig) { // The same native undeploy call the idle-sleep lifecycle uses to // tear an invited agent's instance down (see `chatPlatform`'s own // `lifecycle.undeploy` above) — wired here too so removing an agent - // from a workbench's participants releases its running instance the + // from a chat's participants releases its running instance the // same way, rather than leaving it deployed with nothing routing // messages to it. releaseAgentInstance: (address, reason) => @@ -1941,52 +1704,21 @@ export async function createHub(config: HubConfig) { // workflow-run-authenticated counterpart to browser chat routes, // self-WORKBENCH scoped — see `@corbits/chat`'s // `workflow-participant-routes.ts` for the [Intx/repo gap] this resolves - // around (no direct run-address -> workbench index; resolved by scanning - // the tenant's workbench participant lists). + // around (no direct run-address -> chat index; resolved by scanning + // the tenant's chat participant lists). app.route( "/api/workflow-chat", createWorkflowParticipantRoutes({ store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, turnQueue, turnCancellation, turnMailCorrelation, authenticator: createWorkflowRunAuthenticator({ db }), - tenancy: chatTenancy, - sessionFor, }), ); - // Slack tag ingress (CL-5288 Phase 1): mounted OUTSIDE the tenant - // prefix and outside session auth, like the webhook ingress below — - // Slack is not a principal, and this route resolves its own - // Interchange identity per message (see `./slack-tag-mount.ts` and - // `@corbits/slack-tag`'s signature-verification-gated dispatch). A - // missing SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET pair is a valid - // configuration — the mount is silently skipped. - const slackTagMount = await mountWorkbenchSlackTag({ - app, - db, - databaseUrl: config.databaseUrl, - chatStore, - chatPlatform, - roomMessages, - chatTenancy, - sessionFor, - workbenchSubscribers, - turnQueue, - turnCancellation, - turnMailCorrelation, - }); - // Tells the routine trigger popover whether a Slack-bound webhook - // trigger is honestly offerable in this deployment — no session or - // tenant required to ask, the same reasoning as `/api/auth-config` - // above. Only a boolean crosses this route, never the credential pair - // itself. - app.get("/api/deployment-capabilities", (c) => - c.json({ slackConfigured: slackTagMount.mounted }), - ); // Product inbox over `@corbits/mailbox` — three groups, mark-all-read // (mentions + deliveries only), clear-done. The raw package surface // (including SSE events) mounts under `/mailbox` for hosts and tools @@ -2017,7 +1749,7 @@ export async function createHub(config: HubConfig) { latencyStore: insightsLatency.store, // Same `db` handle every other platform-table reader in this file // uses — lets /usage, /activity, /tools, and /scope roll up a - // workspace parent's child workbenches (see resolveScope in + // workspace parent's child chats (see resolveScope in // @corbits/insights' routes.ts). db, }), @@ -2027,7 +1759,7 @@ export async function createHub(config: HubConfig) { // Mounted alongside — not inside — the vendored // `createWorkflowDefinitionRoutes` (`vendor/intx/hub-api/src/app.ts` // already mounts that one at this same `/workflows/definitions` - // prefix): this GET is a Workbench-owned read composed over native + // prefix): this GET is a hub-composed read over native // rows, so it lives in `@corbits/workflows`'s `./detail`, not the // vendored tree. app.route( @@ -2041,15 +1773,15 @@ export async function createHub(config: HubConfig) { }), ); const scheduledDeliveryJoinDeps: ScheduledDeliveryJoinDeps = { - deliveryWorkbenchRequired: deliveryWorkbenchRequiredForWorkflowName, - resolveDeliveryWorkbench: async (tenantId) => { + deliveryChatRequired: deliveryWorkbenchRequiredForWorkflowName, + resolveDeliveryChat: async (tenantId) => { const rows = await chatStore.listWorkbenchSettings(tenantId); const first = [...rows].sort((a, b) => a.workbenchId.localeCompare(b.workbenchId), )[0]; return first?.workbenchId; }, - joinDeliveryWorkbench: (input) => + joinDeliveryChat: (input) => joinRunParticipant({ store: chatStore }, input), }; app.route( @@ -2060,7 +1792,6 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - catalogAssetNames: [...CATALOG_WORKFLOW_ASSET_NAMES], runNow: async (args) => runNowScheduledDefinition( { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, @@ -2190,41 +1921,6 @@ export async function createHub(config: HubConfig) { app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); } - // Agent definitions a person authors by hand from the Agents page's - - // create form, materialized the same way the platform's own starter - // agents are (see `@corbits/agent-directory`'s doc comment). Shares - // `chatGrantStore`/`chatConditionRegistry` with every other extension - // mounted here — there is nothing chat-specific about that pair, it - // is just this composition root's one db-backed grant store. - // The skill registry over native `kind:"skill"` assets, plus the two - // surfaces it serves: the tenant-session one the Skills settings - // section calls, and the run-authenticated one a workflow child's - // `@corbits/tools-skills` bundle calls (mounted outside the tenant - // prefix below, beside `/api/workflow-memory`). - const skills = mountSkills({ - db, - assetService, - repoStore: agentRepoStore.repoStore, - }); - app.route( - `${TENANT_PREFIX}/skills`, - createSkillRoutes({ - registry: skills.registry, - pinnedBy: skills.pinnedBy, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - app.route( - "/api/workflow-skills", - createWorkflowSkillRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - registry: skills.registry, - }), - ); // CL-7361: the `deploy` half of the run-authenticated deployer this // route's registry calls — the SAME `prepareProvisionedDeployment` the // native `POST /workflows/deployments` route drives. Inference sources @@ -2284,7 +1980,7 @@ export async function createHub(config: HubConfig) { // deployment mints no opening message of its own, but the // trigger that eventually reconciles a principal onto it runs // entirely through Interchange's own native route, with no - // Workbench-owned hook downstream to record the session + // hub-owned hook downstream to record the session // afterward — so this is the only chance to record it at all. await recordAgentSessionAtProvision({ db, @@ -2345,107 +2041,6 @@ export async function createHub(config: HubConfig) { }), }), ); - // The guided-capability-add fail-closed check reuses the exact same - // listers `plannerInventorySources` (below) wires — both this provider - // and the drafting inventory read `InventorySources`/`PlannerInventory` - // from `@corbits/agent-directory` directly, and the tenant's live - // inventory of usable tool packages, skills, and models is never - // assembled twice: both share the same - // `listMyraUsableToolPackages`/`listMyraModels`/`skills.registry.list` - // functions (declared further down this file, hoisted). - const capabilityInventory: CapabilityInventoryProvider = { - async resolve({ tenantId, principalId }) { - const [toolPackages, tenantSkills, models] = await Promise.all([ - listMyraUsableToolPackages(tenantId), - skills.registry.list({ tenantId, principalId }), - listMyraModels(tenantId), - ]); - return { - toolPackages: toolPackages.map((entry) => ({ name: entry.name })), - skills: tenantSkills.map((entry) => ({ name: entry.name })), - models: models.map((entry) => ({ - canonicalName: entry.canonicalName, - })), - }; - }, - }; - - app.route( - `${TENANT_PREFIX}/agent-definitions`, - createAgentDefinitionRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - history: createDefinitionAssetHistory({ - repoStore: agentRepoStore.repoStore, - }), - capabilityInventory, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - // A definition created with no `model` still declares one — the - // same tenant-catalog default a fresh workbench host resolves — - // rather than staying empty and 409ing `not_launchable` at - // invite time. - tenantDefaultModel: async (tenantId) => - (await workbenchHostInferencePreferencesResolver(tenantId))[0]?.model, - }), - ); - // Myra's own agent-creation surface (`@corbits/agent-directory-tools`' - // `create_agent`/`list_agents`): the workflow-run-authenticated - // counterpart to the tenant-session mount just above, self-TENANT - // scoped (Myra may create an agent anywhere in her own tenant). See - // `@corbits/agent-directory`'s `workflow-create-routes.ts` for the - // authorization reasoning. - app.route( - "/api/workflow-agent-directory", - createWorkflowAgentCreateRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - capabilityInventory, - authenticator: createWorkflowRunAuthenticator({ db }), - tenantDefaultModel: async (tenantId) => - (await workbenchHostInferencePreferencesResolver(tenantId))[0]?.model, - }), - ); - // The workflow-run-authenticated variant of the capabilities route - // just above (CL-6086): a workflow child has no browser session, only - // its sidecar bearer token and its own run address, so it reaches - // `POST /:definitionId/capabilities` through this surface instead, - // mirroring `/api/workflow-skills`/`/api/workflow-memory`. See - // `@corbits/agent-directory`'s `workflow-capability-routes.ts` for the - // deliberate, documented authorization decision this route enforces - // in place of a `requireGrant` check (CL-6085 tracks the durable fix). - app.route( - "/api/workflow-capabilities", - createWorkflowCapabilityRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - capabilityInventory, - authenticator: createWorkflowRunAuthenticator({ db }), - }), - ); - // Myra's own skill-pin surface (`@corbits/skills-tools`' `pin_skill`): - // self-TENANT scoped (unlike `/api/workflow-capabilities` above, which - // is self-definition scoped) — Myra may pin a skill onto any - // definition in her own tenant. See `@corbits/agent-directory`'s - // `workflow-skill-pin-routes.ts` for the authorization reasoning. - app.route( - "/api/workflow-skill-pins", - createWorkflowSkillPinRoutes({ - db, - assetService, - deployer: workflowDeployer, - skillIndex: skills.skillIndex, - authenticator: createWorkflowRunAuthenticator({ db }), - }), - ); app.route( `${TENANT_PREFIX}/chat`, createCommandRoutes({ @@ -2454,10 +2049,9 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - workbenchBelongsToTenant: async (tenantId, workbenchId) => - (await chatStore.getWorkbenchSettings(tenantId, workbenchId)) !== - undefined || - (await chatStore.hasLaunchedInstance(tenantId, workbenchId)), + workbenchBelongsToTenant: async (tenantId, chatId) => + (await chatStore.getWorkbenchSettings(tenantId, chatId)) !== + undefined || (await chatStore.hasLaunchedInstance(tenantId, chatId)), }), ); @@ -2552,7 +2146,7 @@ export async function createHub(config: HubConfig) { store: chatStore, platform: chatPlatform, roomMessages, - publish: workbenchSubscribers.publish, + publish: chatSubscribers.publish, agentTurns, }, { @@ -2575,17 +2169,6 @@ export async function createHub(config: HubConfig) { }); }); } - void reconcilePinnedToolPackagesAfterConnect(chatPlatform, info) - .then((result) => { - if (result === undefined) return; - log.info`tool-package connector ${info.connectorId} changed on tenant ${info.tenantId}: re-checked ${String(result.scanned)} live agents, relaunched ${String(result.relaunched)}`; - }) - .catch((cause: unknown) => { - reportError(cause, { - operation: "connections.reconcile-pinned-tool-packages", - tenantId: info.tenantId, - }); - }); }; // Connections: the settings surface's tenant-scoped credential // test-and-store, mounted under the same tenant prefix and reusing @@ -2624,35 +2207,6 @@ export async function createHub(config: HubConfig) { ? { github: config.githubApiBaseUrl } : {}, onConnected: settleServiceConnection, - // CL-6568's other half: a tenant whose only provider is one it - // connected itself through Settings — never an operator-configured - // hub key — must converge on Myra and the default workflow set the - // same way an onboarding-connected one does. `pendingSeedStore` and - // `benchProvisioner` are declared further down this function, but - // this closure only runs on a future request, well after both are - // constructed below — the same forward-reference this file already - // relies on for `onboardingDeps`. - onInferenceCredentialUsable: async (info) => { - const provider = supportedCredentialProviders().find( - (candidate) => candidate.id === info.provider, - )?.id; - if (provider === undefined) { - log.error`onInferenceCredentialUsable fired for an unsupported provider ${info.provider} on tenant ${info.tenantId}; skipping the pending-seed row`; - return; - } - await pendingSeedStore.put({ - userId: info.userId, - tenantId: info.tenantId, - principalId: info.principalId, - tenantDomain: info.tenantDomain, - provider, - apiKey: info.apiKey, - ...(info.baseURLOverride !== undefined - ? { baseURLOverride: info.baseURLOverride } - : {}), - }); - benchProvisioner.wake(); - }, }), ); // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` @@ -2687,7 +2241,7 @@ export async function createHub(config: HubConfig) { }), onConnected: settleServiceConnection, defaultReturnPath: "/settings/connections", - // `/w/` is the workbench room prefix: the in-room connect card + // `/w/` is the chat room prefix: the in-room connect card // (CL-6393) starts OAuth from a room and must land back in it. returnPathAllowlist: [ ...DEFAULT_RETURN_PATH_ALLOWLIST, @@ -2815,8 +2369,8 @@ export async function createHub(config: HubConfig) { trigger.name === triggerName, ); }, - getTemplateSettings: async (tenantId, workbenchId) => { - const row = await chatStore.getWorkbenchSettings(tenantId, workbenchId); + getTemplateSettings: async (tenantId, chatId) => { + const row = await chatStore.getWorkbenchSettings(tenantId, chatId); const settings = row?.settings ?? {}; const pendingConnections = settings["template/pendingConnections"]; const selectedRepos = settings["template/selectedRepos"]; @@ -2829,34 +2383,26 @@ export async function createHub(config: HubConfig) { : [], }; }, - persistSelectedRepos: async ( - tenantId, - workbenchId, - principalId, - patch, - ) => { - const existing = await chatStore.getWorkbenchSettings( - tenantId, - workbenchId, - ); + persistSelectedRepos: async (tenantId, chatId, principalId, patch) => { + const existing = await chatStore.getWorkbenchSettings(tenantId, chatId); const row = await chatStore.updateWorkbenchSettings({ tenantId, - workbenchId, + workbenchId: chatId, settings: { ...(existing?.settings ?? {}), ...patch }, updatedBy: principalId, }); - workbenchSubscribers.publish(workbenchId, { + chatSubscribers.publish(chatId, { type: "chat.settings", data: { updatedBy: principalId, settings: row.settings }, }); }, onReviewingStarted: async ( tenantId, - workbenchId, + chatId, _principalId, introductions, ) => { - const row = await chatStore.getWorkbenchSettings(tenantId, workbenchId); + const row = await chatStore.getWorkbenchSettings(tenantId, chatId); const participants = parseParticipants( row?.settings["chat/participants"], ); @@ -2866,10 +2412,10 @@ export async function createHub(config: HubConfig) { ); if (participant === undefined) continue; await postRoomMessage( - { roomMessages, publish: workbenchSubscribers.publish }, + { roomMessages, publish: chatSubscribers.publish }, { tenantId, - workbenchId, + workbenchId: chatId, sender: { name: null, address: participant.address }, runId: localPartOf(participant.address), parts: [{ kind: "text", text: introduction.text }], @@ -2895,7 +2441,7 @@ export async function createHub(config: HubConfig) { }), log: (line) => log.info`${line}`, inferencePreferences: (tenantId) => - workbenchHostInferencePreferencesResolver(tenantId), + chatHostInferencePreferencesResolver(tenantId), deployWorkflowSource: async ({ tenantId, principalId, @@ -3123,17 +2669,12 @@ export async function createHub(config: HubConfig) { bus: mailboxBus, }); - // One-shot Myra prompt (agent-definition drafting): provisions a run - // through Interchange, awaits its single reply, and tears the run - // down immediately — never a resident that outlives the request, so no - // idle-sleep lifecycle is needed for it. - - // Every genuine top-level deployment run, workbench-hosted and invited-agent + // Every genuine top-level deployment run, chat-hosted and invited-agent // runs excluded — the scoped listing CL-6061 adds so the Agent Directory // and the shell's "Running" bands stop deriving that exclusion - // client-side from a tenant's workbenches alone (see + // client-side from a tenant's chats alone (see // `@corbits/run-scope`'s `scope-routes.ts`, which a non-top-level run - // with no workbench involved silently slipped past). + // with no chat involved silently slipped past). app.route( `${TENANT_PREFIX}/top-level-runs`, createTopLevelRunRoutes({ @@ -3164,495 +2705,11 @@ export async function createHub(config: HubConfig) { : {}), }); - // The inventory Myra is offered when drafting a new agent definition - // (`plannerInventorySources` below, `@corbits/agent-directory`'s own - // `InventorySources` seam). Every inventory lister below generalizes a - // pattern that already lives elsewhere in this composition root - // (`isConversationalAgentDefinition`, `workbenchHostInferencePreferencesResolver`'s - // per-tenant connected-provider derivation) — this package owns the - // inventory's shape, never the listing logic. - const memoryToolPackageName = "@corbits/memory-tools"; - // `@corbits/capability-tools` (CL-6084/CL-6086)'s `request_capability` - // tool needs no per-tenant credential either, like memory-tools: the - // sidecar now threads its own `definitionId` into a step's tool env - // (`apps/sidecar/src/workflow-substrate-factory/step-env.ts`), and - // `/api/workflow-capabilities` (mounted below) gives it a - // workflow-run-authenticated path to the capabilities route the same - // way `/api/workflow-skills` and `/api/workflow-memory` do. Both gaps - // that used to keep it out of this lister are closed. - const capabilityToolPackageName = "@corbits/capability-tools"; - - async function listMyraConversationalAgents( - tenantId: string, - ): Promise { - const rows = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.status, "deployed"), - ), - }); - return rows - .filter((row) => isPickerListableDefinition(row)) - .map((row) => { - const agent = { - id: row.id, - name: row.name, - displayName: workflowDisplayName(row.name, row.description), - }; - if (row.description !== null) { - return { ...agent, description: row.description }; - } - return agent; - }); - } - - async function listMyraUsableToolPackages( - tenantId: string, - ): Promise { - const connectedConnectorIds = await listConnectedProviders(db, tenantId); - const entries: InventoryToolPackage[] = []; - for (const connectorId of connectedConnectorIds) { - const descriptor = CONNECTOR_REGISTRY[connectorId]; - if (descriptor === undefined) continue; - for (const toolPackageName of descriptor.feedsTools) { - // This listing is already scoped to connections registry ∩ - // tenant credentials that exist (`listConnectedProviders`), so - // every entry it returns necessarily has a live credential — - // the binding mirrors `workflows/granola-call`'s - // `GRANOLA_CALL_CREDENTIAL_BINDINGS` exactly: `handle`/`provider` - // both equal the connector id. - entries.push({ - name: toolPackageName, - connectorId: descriptor.id, - credentialBinding: { - package: toolPackageName, - handle: descriptor.id, - provider: descriptor.id, - locator: "tenant", - }, - }); - } - } - if (memoryHandle !== undefined) { - entries.push({ - name: memoryToolPackageName, - connectorId: "memory", - credentialBinding: null, - }); - } - entries.push({ - name: capabilityToolPackageName, - connectorId: "capability", - credentialBinding: null, - }); - // MCP tools are fed by MCP server connections, not by the classic - // connector registry above — without this the inventory rejects - // `@corbits/mcp-tools` on a bench with live MCP servers, so created - // specialists cannot search (CL-6206's live 400). Credential - // bindings for this package come from `mcpCredentialBindingsFor` - // at launch, never from an inventory row. - const mcpServers = await listMcpServerConnections(db, tenantId); - if (mcpServers.length > 0) { - entries.push({ - name: "@corbits/mcp-tools", - connectorId: "mcp", - credentialBinding: null, - }); - } - // The ask_user interaction card needs no credential at all — it is - // always offerable, exactly like capability-tools. - entries.push({ - name: "@corbits/interaction-tools", - connectorId: "interaction", - credentialBinding: null, - }); - // Workflow-source authoring needs no credential either: every write is - // authorized against the run's own asset grants by - // `/api/workflow-workflow-authoring` (mounted above). - entries.push({ - name: "@corbits/workflow-authoring-tools", - connectorId: "workflow-authoring", - credentialBinding: null, - }); - return entries; - } - - async function listMyraModels( - tenantId: string, - ): Promise { - const rows = await db.query.model.findMany({ - where: and(eq(model.tenantId, tenantId), eq(model.disabled, false)), - }); - return rows.map((row) => { - const entry = { canonicalName: row.canonicalName }; - if (row.displayName !== null) { - return { ...entry, displayName: row.displayName }; - } - return entry; - }); - } - - const plannerInventorySources: InventorySources = { - listConversationalAgents: listMyraConversationalAgents, - listUsableToolPackages: listMyraUsableToolPackages, - listSkills: (caller) => skills.registry.list(caller), - memoryAvailable: memoryHandle !== undefined, - listModels: listMyraModels, - }; - - // The create-agent panel's "Describe" step (CL-6074): a real one-shot - // Myra call that proposes a starting system prompt/tool pins/skills - // from a name + plain-language purpose, offering her the same - // inventory `capabilityInventory` above reads through. Never deploys - // on its own — the panel submits the validated draft through the - // ordinary create-agent-definition path once the person confirms. - // Failure copy is the package route's `makeErrorEnvelope` + - // `reportError` (CL-6749), not a local `{ code, message }` body. - const plannerRoutes = createAgentDefinitionDraftRoutes({ - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - draftAgentDefinition: (input) => - createMyraAgentDefinitionDrafting({ - resolveMyraDefinitionId: (tenantId) => - resolveMyraDefinitionIdFromDb(db, tenantId), - runner: { - run: (runnerInput) => - runOneShotPrompt( - { - db, - events: sidecarRouter.events, - cryptoProviders, - undeploy: (address, reason) => - sidecarRouter.sendAgentUndeploy(address, reason), - repoStore: agentRepoStore.repoStore, - workflowAllocationService, - sessionService, - eventCollectors, - isRoutable: isSidecarRoutable, - }, - runnerInput, - ), - }, - inventorySources: plannerInventorySources, - }).propose(input), - }); - app.route(`${TENANT_PREFIX}/planner`, plannerRoutes); - - // The sanctioned path for a workflow run to reach the memory plane - // (CL-5852), mirroring `/api/workflow-artifacts` immediately above: - // mounted OUTSIDE `TENANT_PREFIX` since a workflow-process child has - // no browser session, every request authenticates via the same - // `WorkflowRunAuthenticator` (sidecar bearer token + run address) - // against this hub's own control-plane `db`. Serves through - // `memoryHandle.memory` — the SAME in-process plane instance - // `mountMemory` mounted above, never a second connection. - if (memoryHandle !== undefined) { - app.route( - "/api/workflow-memory", - createWorkflowMemoryRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - store: createWorkflowMemoryStore(memoryHandle.memory), - }), - ); - } else { - app.route("/api/workflow-memory", createUnavailableWorkflowMemoryRoutes()); - } - - // Closed-by-default access policy: a per-tenant policy row layered - // over native tenancy/RBAC (see `@workbench/access-policy`). Migrated - // at hub start like insights/preferences/bench-settings; mounted - // tenant-scoped for the settings panel, and threaded into the - // onboarding hook below so first-login provisioning honors it without - // patching any vendor route. - await applyAccessPolicyMigrations(config.databaseUrl); - const accessPolicyStore = createDrizzleAccessPolicyStore(db); - app.route( - `${TENANT_PREFIX}/access-policy`, - createAccessPolicyRoutes({ - store: accessPolicyStore, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - api: selfApi, - }), - ); - - // The first-login hook mounts outside the tenant prefix, since the - // session it serves belongs to no tenant yet. The route is - // `@workbench/onboarding`'s; what it decides is documented in that - // package's provision.ts. - // Connecting a provider deploys nothing (CL-6457): the onboarding - // routes persist the credential and hand the workflow deploys to this - // drain, which converges every bench with a pending row — including - // one a previous process died halfway through, since the row itself is - // the durable work item. - const pendingSeedStore = createDrizzlePendingSeedStore(db, credentialCipher); - const benchProvisioner = createBenchProvisioner({ - api: selfApi, - hubUrl: config.baseUrl, - store: pendingSeedStore, - pushWorkflow: createGitWorkflowPusher(), - sessionFor, - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - }); - benchProvisioner.start(); - - // CL-7584: the tenant-create trigger. A 201 from the native - // `POST /api/tenants` route kicks a fire-and-forget desired-state - // reconcile for the new tenant under the creator's minted session — - // the revisit kick below and the drain above share this one - // reconciler. No durable row: the pending_seed row stays the only - // durable work item, and a kick lost to a restart is re-covered by - // the revisit kick on the tenant's next visit. The observer itself is - // composed just before the guard wrap, after every route mount: Hono - // copies routes at `.route()` time, so wrapping earlier would strand - // everything mounted after it. - const observerRef: { - current?: ReturnType; - } = {}; - - const onboardingDeps: Parameters[0] = { - hubUrl: config.baseUrl, - defaultTenantSlug: config.defaultTenantSlug, - tenancy: signupTenancy, - pushWorkflow: createGitWorkflowPusher(), - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - credentialCipher, - pendingSeedStore, - benchProvisioner, - desiredStateKick: (args) => { - // Fire-and-forget; the route already decided pins are pending. - void observerRef.current - ?.kick({ tenantId: args.tenantId, cookies: args.cookies }) - .catch(() => undefined); - }, - accessPolicy: { - store: accessPolicyStore, - envSignupMode: config.signupMode, - envAllowedDomains: config.allowedEmailDomains, - allowUnverifiedEmails: config.allowUnverifiedEmails, - }, - // Same provider-health store `@corbits/connections`' own routes - // report to and clear (CL-6092) — a successful `/complete` here must - // clear the same record the shell banner's zero-provider "Fix it" - // routed someone to onboarding to fix. - providerHealth: providerHealthStore, - }; - if (config.huggingfaceOAuthClientId !== undefined) - onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId; - - app.route("/api/onboarding", createOnboardingRoutes(onboardingDeps)); - - // Artifacts engine: mounts `@corbits/artifacts` against the same - // Postgres cluster as this hub's control plane (its - // `artifact`/`artifact_version` tables FK into `public.tenant` / - // `public.principal`). Uses DATABASE_URL — the same URL as everything - // else — so local `bun run dev` mounts Library with no extra env var. - // When it's unset (or mount fails), degrades to 503 routes. When - // mounted, tenant-scoped list + get + upload routes serve Library - // under `/artifacts`. - // - // The mount runs migrations against the configured DB; if the URL is - // present but points at an unreachable/invalid cluster the migration - // would otherwise throw and take the whole hub down at boot. We catch - // that here so the hub comes up in a degraded (no-artifacts) mode and - // surfaces the failure as a warning rather than a crash. - let artifactsHandle: Awaited>; - try { - artifactsHandle = await mountArtifacts(); - } catch (error) { - log.warn( - `Artifacts mount failed — continuing without artifacts persistence: ${error}`, - ); - artifactsHandle = undefined; - } - if (artifactsHandle !== undefined) { - app.route( - `${TENANT_PREFIX}/artifacts`, - createArtifactRoutes({ - store: createArtifactDbStore( - artifactsHandle.db, - artifactsHandle.contentStore, - ), - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - - // The bench library's template shelf (CL-6344): what the - // new-workbench picker instantiates from — seeded rows, never a - // hardcoded import. Reading the shelf is what seeds it (CL-6458), so - // a bench created at any point after boot carries the shipped - // manifests the first time its picker opens. - app.route( - `${TENANT_PREFIX}/library/templates`, - createTemplateLibraryRoutes({ - store: createTemplateLibraryDbStore(artifactsHandle.db), - seeder: createTemplateLibrarySeeder({ - db: artifactsHandle.db, - entries: workbenchTemplateLibraryEntries(), - log: (line) => log.info`${line}`, - }), - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - log: (line) => log.error`${line}`, - }), - ); - - // Co-editing persistence (CL-5958 phase 2): debounced snapshots of a - // presence room's Y.Text into a real artifact version, layered on top - // of the presence registry mounted above without changing its own - // "ephemeral, no storage" default. `writeArtifactVersion`/`getArtifact` - // are the engine's own versioned-row seam — the same one a workflow's - // artifact revision goes through — so a co-edited text artifact's - // history reads identically to any other revision. `anonymousIdentity` - // is not used here: `writeArtifactVersion` only needs a `{tenantId, - // principalId}` scope, not a resolved `Identity`. - const artifactDb = artifactsHandle.db; - const artifactPersistence = createArtifactDocPersistence({ - registry: presenceRoomRegistry, - loadArtifactContent: async (tenantId, artifactId) => { - const row = await getArtifact(artifactDb, artifactId); - if (row === null || row.tenantId !== tenantId) return null; - return row.content; - }, - writeArtifactSnapshot: async ( - tenantId, - artifactId, - authorPrincipalId, - content, - ) => { - const written = await writeArtifactVersion(artifactDb, { - scope: { tenantId, principalId: authorPrincipalId }, - artifactId, - content, - }); - return { version: written.version }; - }, - onSnapshotError: (key, error) => { - log.warn( - `Co-editing snapshot failed for ${key.tenantId}/${key.surface}: ${error}`, - ); - }, - }); - artifactSeedOnJoin = artifactPersistence.seedOnJoin; - } else { - log.info("Artifacts handle unavailable (degraded mode)"); - app.route( - `${TENANT_PREFIX}/artifacts`, - createUnavailableArtifactRoutes( - createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ), - ); - app.route( - `${TENANT_PREFIX}/library/templates`, - createUnavailableTemplateLibraryRoutes( - createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ), - ); - } - - // The sanctioned path for a workflow run to persist and read Library - // artifacts (CL-6000): mounted OUTSIDE `TENANT_PREFIX` since a - // workflow-process child has no browser session — every request here - // authenticates via `createWorkflowRunAuthenticator` (the sidecar's own - // bearer token plus the run's own address) against this hub's own - // control-plane `db`, never the artifacts engine's db. - if (artifactsHandle !== undefined) { - app.route( - "/api/workflow-artifacts", - createWorkflowArtifactRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - store: createWorkflowArtifactDbStore( - artifactsHandle.db, - artifactsHandle.contentStore, - ), - }), - ); - } else { - app.route( - "/api/workflow-artifacts", - createUnavailableWorkflowArtifactRoutes(), - ); - } - - // Tells the signed-out screen which OAuth buttons to draw, without - // exposing the credentials themselves — just which providers a full - // pair was configured for. No session or tenant is required to ask, - // since this decides what the sign-in screen even offers. - const enabledSocialProviders = Object.keys(config.socialProviders); - app.get("/api/auth-config", (c) => - c.json({ - socialProviders: enabledSocialProviders, - signupMode: config.signupMode, - allowedEmailDomains: config.allowedEmailDomains, - }), - ); - app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); - // [Intx gap] CL-6041: the native POST /api/tenants route is ungated — - // wrap the fully-built app in a guard that enforces - // @workbench/access-policy in front of it. See - // ./tenant-create-guard.ts's module comment for why this has to be an - // outer wrap rather than an `app.use()` added here: the native route - // is already registered by the time `createApp()` returns above, and - // Hono composes handlers in registration order. - const guardDeps: Parameters[1] = { - store: accessPolicyStore, - resolveCallerRoleNames: (tenantId, userId) => - resolveCallerRoleNames(db, tenantId, userId), - countTenants: signupTenancy.countTenants, - envSignupMode: config.signupMode, - envAllowedDomains: config.allowedEmailDomains, - allowUnverifiedEmails: config.allowUnverifiedEmails, - getSessionUser: async (headers) => { - const result = await auth.api.getSession({ headers }); - return result - ? { - id: result.user.id, - email: result.user.email, - emailVerified: result.user.emailVerified, - } - : undefined; - }, - }; - const observer = createTenantCreateObserver( - { - api: selfApi, - hubUrl: config.baseUrl, - pushWorkflow: createGitWorkflowPusher(), - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - }, - app, - ); - observerRef.current = observer; - const guardedApp = guardedHubApp(observer.app, guardDeps); - // [Intx gap] CL-7324: deny foreign-tenant allocations at the two dispatch - // routes (403 `allocation_tenant_mismatch`) before the native handler can - // enqueue or materialize grants — same outer-wrap composition as the - // tenant-create guard above. See ./dispatch-tenant-guard.ts. - const dispatchGuardedApp = withDispatchTenantGuard(guardedApp, { - loadAnchorDispatch: (anchorRunId) => loadAnchorDispatch(db, anchorRunId), - }); + // Stock Interchange currently leaves tenant creation and dispatch ungated. const inFlight = createInFlightRequestTracker(); - const servingApp = withInFlightRequestTracking(dispatchGuardedApp, inFlight); + const servingApp = withInFlightRequestTracking(app, inFlight); return { app: servingApp, @@ -3660,16 +2717,6 @@ export async function createHub(config: HubConfig) { db, close: async () => { sidecarAllocationReconciliationStopped = true; - // Let any in-flight tenant-create reconcile reach its next HTTP - // call before the server stops — the call then fails and the kick - // logs it, so a fire-and-forget reconcile never races the DB - // teardown. Bounded: a kick stuck on an already-dying connection - // must not stall shutdown (CL-7584). - observerRef.current?.stop(); - await Promise.race([ - observerRef.current?.whenIdle(), - new Promise((resolve) => setTimeout(resolve, 250)), - ]); if (sidecarAllocationReconciliationTimer !== undefined) { clearTimeout(sidecarAllocationReconciliationTimer); } @@ -3693,7 +2740,6 @@ export async function createHub(config: HubConfig) { workflowScheduler.stop(); credentialExpirySweep.stop(); inboxUnsnoozeSweep.stop(); - benchProvisioner.stop(); await insightsUsage.close(); await insightsLatency.close(); await preferences.close(); diff --git a/apps/hub/src/launch-caches.ts b/apps/hub/src/launch-caches.ts index 0ca019d7cf..a030627ed0 100644 --- a/apps/hub/src/launch-caches.ts +++ b/apps/hub/src/launch-caches.ts @@ -1,4 +1,4 @@ -// CL-6225: workbench mint is slow because every agent launch re-reads every +// CL-6225: chat mint is slow because every agent launch re-reads every // tarball in a tenant's `corbits-tools` package-registry asset and rebuilds // a full git pack for every asset attachment — see // `AssetRegistrySource.#buildPackuments` in diff --git a/apps/hub/src/mailbox-persist.ts b/apps/hub/src/mailbox-persist.ts index 6d37382a6f..3232db07a9 100644 --- a/apps/hub/src/mailbox-persist.ts +++ b/apps/hub/src/mailbox-persist.ts @@ -143,28 +143,28 @@ export function createHubMailboxAuthorizeSender( /** * Build the `resolveRefs` seam: stamps - * `refs: [{ kind: "workbench", id: workbenchId }]` onto every recipient row + * `refs: [{ kind: "workbench", id: chatId }]` onto every recipient row * of one frame. * - * `workbenchId` is NOT `senderAuthorization.tenantId`. An agent run is + * The chat id is NOT `senderAuthorization.tenantId`. An agent run is * launched in its parent BENCH tenant -- that tenant is what * `authorizeSender` resolves, and it is also what the resulting * `principal_mail` rows themselves are scoped under (same tenant the - * addressed human principals belong to). The workbench the run is a + * addressed human principals belong to). The chat the run is a * participant of is a separate id `@corbits/chat` tracks inside that same * bench tenant, on `workbench_settings.workbenchId` -- one bench tenant - * hosts many workbenches. Stamping the bench's tenant id here instead would - * point every row at an id no workbench thread read can ever resolve. + * hosts many chats. Stamping the bench's tenant id here instead would + * point every row at an id no chat thread read can ever resolve. * * Header-first (CL-7449): an agent that participates in several - * workbenches at once has no single "the" workbench a bare participant + * chats at once has no single "the" chat a bare participant * scan can name honestly, so `@corbits/chat`'s * `resolveWorkbenchIdForAgentFrame` reads the frame's own `In-Reply-To` / * `References` and maps that Message-ID back to the timeline row it * answers -- that row's `workbenchId` is authoritative. Only when the * frame carries no such header does it fall back to the participant scan, * and only takes that scan's answer when it is unambiguous (exactly one - * workbench); this seam is a thin adapter handing that helper the two + * chat); this seam is a thin adapter handing that helper the two * stores it needs (`chatStore`, `roomMessages`) plus the frame's own * decoded headers. */ @@ -175,7 +175,7 @@ export function createHubMailboxResolveRefs( return async ({ senderAddress, senderAuthorization, decoded }) => { const inReplyTo = decoded?.headers.get("in-reply-to") ?? undefined; const references = decoded?.references; - const workbenchId = await resolveWorkbenchIdForAgentFrame( + const chatId = await resolveWorkbenchIdForAgentFrame( { chatStore, roomMessages }, senderAuthorization.tenantId, { @@ -184,8 +184,8 @@ export function createHubMailboxResolveRefs( ...(references !== undefined ? { references } : {}), }, ); - if (workbenchId === undefined) return undefined; - const ref: MailboxRef = { kind: "workbench", id: workbenchId }; + if (chatId === undefined) return undefined; + const ref: MailboxRef = { kind: "workbench", id: chatId }; return [ref]; }; } diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts deleted file mode 100644 index ca664ebcbc..0000000000 --- a/apps/hub/src/memory-mount.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { Hono } from "hono"; -import { createInMemoryGrantStore } from "@intx/authz"; - -import { - applyResolvedEmbedToProcessEnv, - mountMemory, - resolveMemoryEmbed, -} from "./memory-mount"; - -const KEYS = [ - "DATABASE_URL", - "EMBED_BASE_URL", - "EMBED_MODEL", - "EMBED_API_STYLE", - "EMBED_API_KEY", - "RERANK_BASE_URL", - "RERANK_MODEL", - "OLLAMA_BASE_URL", -] as const; - -type EnvKey = (typeof KEYS)[number]; - -const originals: Record = { - DATABASE_URL: process.env["DATABASE_URL"], - EMBED_BASE_URL: process.env["EMBED_BASE_URL"], - EMBED_MODEL: process.env["EMBED_MODEL"], - EMBED_API_STYLE: process.env["EMBED_API_STYLE"], - EMBED_API_KEY: process.env["EMBED_API_KEY"], - RERANK_BASE_URL: process.env["RERANK_BASE_URL"], - RERANK_MODEL: process.env["RERANK_MODEL"], - OLLAMA_BASE_URL: process.env["OLLAMA_BASE_URL"], -}; - -function clearEnvKey(key: EnvKey): void { - // Must actually remove the key: `process.env[key] = undefined` stores the - // *string* "undefined" (Bun >= 1.4 matches Node here), which reads as a - // configured value and makes every DATABASE_URL/EMBED_BASE_URL gate in this - // app's suites fire against postgres.js's localhost:5432 default. - // `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function restoreOriginalEnv(): void { - for (const key of KEYS) { - const value = originals[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - } -} - -afterEach(restoreOriginalEnv); - -function stashEnv(): void { - for (const key of KEYS) { - clearEnvKey(key); - } -} - -describe("resolveMemoryEmbed", () => { - test("returns undefined when neither EMBED_BASE_URL nor OLLAMA_BASE_URL is set", () => { - expect(resolveMemoryEmbed({})).toBeUndefined(); - }); - - test("explicit EMBED_BASE_URL wins over OLLAMA_BASE_URL", () => { - expect( - resolveMemoryEmbed({ - EMBED_BASE_URL: "https://api.openai.com/v1", - EMBED_MODEL: "text-embedding-3-small", - OLLAMA_BASE_URL: "http://localhost:11434", - }), - ).toEqual({ - embedBaseUrl: "https://api.openai.com/v1", - embedModel: "text-embedding-3-small", - embedApiStyle: "openai", - source: "EMBED_BASE_URL", - }); - }); - - test("OLLAMA_BASE_URL is a local embed path with nomic-embed-text / ollama style", () => { - expect( - resolveMemoryEmbed({ - OLLAMA_BASE_URL: "http://localhost:11434", - }), - ).toEqual({ - embedBaseUrl: "http://localhost:11434", - embedModel: "nomic-embed-text", - embedApiStyle: "ollama", - source: "OLLAMA_BASE_URL", - }); - }); - - test("treats a blank OLLAMA_BASE_URL as unset", () => { - expect(resolveMemoryEmbed({ OLLAMA_BASE_URL: "" })).toBeUndefined(); - }); - - test("blank EMBED_MODEL / EMBED_API_STYLE on OLLAMA path resolve to nomic-embed-text / ollama", () => { - expect( - resolveMemoryEmbed({ - OLLAMA_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "", - EMBED_API_STYLE: " ", - }), - ).toEqual({ - embedBaseUrl: "http://localhost:11434", - embedModel: "nomic-embed-text", - embedApiStyle: "ollama", - source: "OLLAMA_BASE_URL", - }); - }); - - test("afterEach restores KEYS after a non-stash resolveMemoryEmbed test", () => { - for (const key of KEYS) { - expect(process.env[key]).toBe(originals[key]); - } - }); -}); - -describe("applyResolvedEmbedToProcessEnv", () => { - test("plants OLLAMA defaults when EMBED_MODEL and EMBED_API_STYLE are blank", () => { - stashEnv(); - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - process.env["EMBED_MODEL"] = ""; - process.env["EMBED_API_STYLE"] = " "; - const resolved = resolveMemoryEmbed(process.env); - expect(resolved).toBeDefined(); - if (resolved === undefined) return; - applyResolvedEmbedToProcessEnv(resolved); - expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); - expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); - - test("does not overwrite a non-blank EMBED_MODEL", () => { - stashEnv(); - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - process.env["EMBED_MODEL"] = "custom-embed"; - const resolved = resolveMemoryEmbed(process.env); - expect(resolved).toBeDefined(); - if (resolved === undefined) return; - applyResolvedEmbedToProcessEnv(resolved); - expect(process.env["EMBED_MODEL"]).toBe("custom-embed"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); -}); - -describe("mountMemory", () => { - test("returns undefined when EMBED_BASE_URL and OLLAMA_BASE_URL are unset (optional)", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - const app = new Hono(); - const handle = await mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeUndefined(); - }); - - test("throws when optional is false and EMBED_BASE_URL and OLLAMA_BASE_URL are missing", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - optional: false, - }), - ).rejects.toThrow(/EMBED_BASE_URL or OLLAMA_BASE_URL/); - }); - - test("fails loudly at config parse when EMBED_BASE_URL is set but blank, rather than silently treating it as unset", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = ""; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/EMBED_BASE_URL/); - }); - - test("throws when RERANK_BASE_URL is set without RERANK_MODEL, rather than reranking silently failing later", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - process.env["RERANK_BASE_URL"] = "http://localhost:8080"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); - }); - - test("throws when RERANK_MODEL is set without RERANK_BASE_URL", async () => { - stashEnv(); - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - process.env["RERANK_MODEL"] = "bge-reranker-v2-m3"; - const app = new Hono(); - await expect( - mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }), - ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); - }); -}); - -// DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's -// existing convention for tests that talk to a real Postgres (see -// apps/hub/test/artifact-doc-persistence.test.ts). Proves the actual cutover: -// mounting with only DATABASE_URL (no second memory-plane URL) lands the -// memory engine's tables in its own `memory` schema, never `public`. -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("mountMemory: schema isolation against a real database", () => { - afterAll(async () => { - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - await sql.unsafe(`DROP SCHEMA IF EXISTS "memory" CASCADE`); - } finally { - await sql.end(); - } - }); - - test("mounts off DATABASE_URL alone and creates tables under `memory`, not `public`", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - - const app = new Hono(); - const handle = await mountMemory({ - app, - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - const memoryTables = await sql<{ table_name: string }[]>` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'memory' - `; - expect(memoryTables.length).toBeGreaterThan(0); - - const publicLeaks = await sql<{ table_name: string }[]>` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('document', 'raw_capture', '_migrations') - `; - expect(publicLeaks.length).toBe(0); - } finally { - await sql.end(); - } - }); - - test("mounts from OLLAMA_BASE_URL when EMBED_BASE_URL is unset", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; - - const handle = await mountMemory({ - app: new Hono(), - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); - expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); - expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); - }); -}); diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts deleted file mode 100644 index 46b0681e21..0000000000 --- a/apps/hub/src/memory-mount.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Hub-side memory plane mount — host analog of Scout dock's `mountKnowledge` - * (`packages/agent-dock/src/knowledge.ts`). `@corbits/memory` (git pin) owns - * the firm-memory plane + HTTP under `/api/tenants/:tenantId/memory/*`, - * isolated in its own `memory` Postgres schema (drizzle `pgSchema`) — the - * same idiom `@corbits/mailbox` uses for `mailbox` and `@corbits/artifacts` - * for `artifacts`. There is exactly one Postgres URL for this hub: - * `DATABASE_URL`, and `@corbits/memory`'s own `loadMemoryConfig()` reads it - * directly, so this module just calls it. - * - * Embed resolution: explicit `EMBED_BASE_URL` wins. Otherwise a configured - * `OLLAMA_BASE_URL` is a local embed path (same origin the hub already - * plants as an inference credential), so local/dev does not leave the plane - * dark when Ollama is already wired. Missing both means "no memory plane", - * logged once at boot, never thrown — same optional-engine contract as the - * artifacts mount. When an embed URL is present, boot fails loudly if - * migrate/create throws so a half-wired deploy is never silent. - * - * This module lands the mount + factory only. Capture/ingestion glue is a - * later ticket; agents that want firm memory ask the returned handle. - * - * Boundary casts (`as never`) match Scout: `@corbits/memory` ships against its - * own Hono/authz type copies, and Hono env + ConditionRegistry are invariant - * across package roots — cast only at the mount boundary. - */ -import type { Hono } from "hono"; -import { type } from "arktype"; -import type { ConditionRegistry, GrantStore } from "@intx/authz"; -import { getLogger } from "@intx/log"; -import { - createMemory, - loadMemoryConfig, - runMemoryMigrations, - type Memory, -} from "@corbits/memory"; - -const log = getLogger(["hub", "memory-mount"]); - -// The boundary this module parses: whether the memory plane is configured -// at all, and — when a reranker is in play — whether it's configured -// completely. `"string > 0"` rejects a blank value the same as an absent -// one, while catching a non-string env value at the arktype boundary -// rather than letting a falsy check quietly wave through something -// unexpected. -// -// RERANK_BASE_URL and RERANK_MODEL must be set together: `@corbits/memory` -// treats each independently optional and would otherwise let a -// half-configured reranker surface as a confusing runtime failure deep in -// its rerank client, rather than a boot-time error naming the missing -// half — the same "fail loud on a half-configured pair" contract this -// hub already applies to GOOGLE_CLIENT_ID/SECRET (see ../config.ts). -// Reranking itself stays a soft-fail enhancement once configured (a -// reranker outage degrades search quietly, never breaks it) — this check -// only guards against shipping a pair that can never work at all. -const MemoryMountEnv = type({ - "EMBED_BASE_URL?": "string > 0", - "RERANK_BASE_URL?": "string > 0", - "RERANK_MODEL?": "string > 0", -}); - -type ParsedMemoryMountEnv = typeof MemoryMountEnv.infer; - -function omitUndefined( - env: Record, - keys: readonly string[], -): Record { - // Build the input object with unset keys OMITTED rather than present - // with an `undefined` value: arktype's optional-key check is keyed off - // property presence, and `process.env` (and this suite's env stashing) - // both sometimes leave an unset variable as a present-but-`undefined` - // own property rather than an absent one. - const input: Record = {}; - for (const key of keys) { - const value = env[key]; - if (value !== undefined) input[key] = value; - } - return input; -} - -function parseMemoryMountEnv( - env: Record, -): ParsedMemoryMountEnv { - const input = omitUndefined(env, [ - "EMBED_BASE_URL", - "RERANK_BASE_URL", - "RERANK_MODEL", - ]); - const parsed = MemoryMountEnv(input); - if (parsed instanceof type.errors) { - throw new Error(`invalid memory-plane environment: ${parsed.summary}`); - } - if ( - (parsed.RERANK_BASE_URL === undefined) !== - (parsed.RERANK_MODEL === undefined) - ) { - throw new Error( - [ - "invalid memory-plane environment: RERANK_BASE_URL and RERANK_MODEL must be set together to enable reranking; only one is set", - "Set both in .env, or unset both to search without reranking; see .env.example.", - ].join("\n"), - ); - } - return parsed; -} - -const LOCAL_OLLAMA_EMBED_MODEL = "nomic-embed-text"; -const LOCAL_OLLAMA_EMBED_STYLE = "ollama"; - -export type ResolvedMemoryEmbed = { - readonly embedBaseUrl: string; - readonly embedModel: string; - readonly embedApiStyle: string; - readonly source: "EMBED_BASE_URL" | "OLLAMA_BASE_URL"; -}; - -function nonemptyEnv( - env: Record, - key: string, -): string | undefined { - const value = env[key]; - if (value === undefined || value.trim() === "") return undefined; - return value; -} - -/** - * Explicit `EMBED_BASE_URL` wins. Otherwise `OLLAMA_BASE_URL` is the local - * embed path — nomic-embed-text / ollama style, matching `setup:memory`. - */ -export function resolveMemoryEmbed( - env: Record, -): ResolvedMemoryEmbed | undefined { - const embedBaseUrl = nonemptyEnv(env, "EMBED_BASE_URL"); - if (embedBaseUrl !== undefined) { - return { - embedBaseUrl, - embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? "", - embedApiStyle: nonemptyEnv(env, "EMBED_API_STYLE") ?? "openai", - source: "EMBED_BASE_URL", - }; - } - const ollamaBaseUrl = nonemptyEnv(env, "OLLAMA_BASE_URL"); - if (ollamaBaseUrl === undefined) return undefined; - return { - embedBaseUrl: ollamaBaseUrl, - embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? LOCAL_OLLAMA_EMBED_MODEL, - embedApiStyle: - nonemptyEnv(env, "EMBED_API_STYLE") ?? LOCAL_OLLAMA_EMBED_STYLE, - source: "OLLAMA_BASE_URL", - }; -} - -export function applyResolvedEmbedToProcessEnv( - resolved: ResolvedMemoryEmbed, -): void { - if (process.env["EMBED_BASE_URL"] === undefined) { - process.env["EMBED_BASE_URL"] = resolved.embedBaseUrl; - } - // Blank `EMBED_MODEL=` / `EMBED_API_STYLE=` is unset — same as a missing - // key — so OLLAMA defaults actually land instead of being skipped as - // "already configured". - if ( - nonemptyEnv(process.env, "EMBED_MODEL") === undefined && - resolved.embedModel !== "" - ) { - process.env["EMBED_MODEL"] = resolved.embedModel; - } - if (nonemptyEnv(process.env, "EMBED_API_STYLE") === undefined) { - process.env["EMBED_API_STYLE"] = resolved.embedApiStyle; - } -} - -export type MountMemoryOptions = { - /** Hub Hono app (routes register under tenant memory paths). */ - app: Hono; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; - /** - * When true (default), skip mount if neither `EMBED_BASE_URL` nor - * `OLLAMA_BASE_URL` is set. Tests can force a mount attempt by setting - * env + `optional: false`. - */ - optional?: boolean; -}; - -export type MemoryMountHandle = { - memory: Memory; -}; - -/** - * Returns a memory handle when the plane is configured and mounted; - * `undefined` when optional and neither `EMBED_BASE_URL` nor - * `OLLAMA_BASE_URL` is present. - */ -export async function mountMemory( - options: MountMemoryOptions, -): Promise { - const optional = options.optional !== false; - parseMemoryMountEnv(process.env); - const resolved = resolveMemoryEmbed(process.env); - if (resolved === undefined) { - if (optional) { - log.info( - "EMBED_BASE_URL / OLLAMA_BASE_URL not set — memory plane will not be mounted", - ); - return undefined; - } - throw new Error( - "EMBED_BASE_URL or OLLAMA_BASE_URL is required to mount memory", - ); - } - applyResolvedEmbedToProcessEnv(resolved); - if (resolved.source === "OLLAMA_BASE_URL") { - log.info( - `OLLAMA_BASE_URL set — mounting memory plane with embed ${resolved.embedBaseUrl} (${resolved.embedModel})`, - ); - } - - const config = loadMemoryConfig(); - await runMemoryMigrations(config.memory.databaseUrl, { - ftsLanguage: config.memory.ftsLanguage, - }); - const memory = createMemory({ - app: options.app as never, - config, - grantStore: options.grantStore as never, - conditionRegistry: options.conditionRegistry as never, - }); - - log.info("Memory plane mounted at /api/tenants/:tenantId/memory/*"); - return { memory }; -} diff --git a/apps/hub/src/memory-workflow-routes.test.ts b/apps/hub/src/memory-workflow-routes.test.ts deleted file mode 100644 index d1c267e8b3..0000000000 --- a/apps/hub/src/memory-workflow-routes.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// DB-gated integration test for CL-5852 M2/M3's real seam: a workflow -// run reaching the memory plane through `createWorkflowMemoryRoutes` -// (`@corbits/memory-hub`), backed by the SAME in-process `Memory` -// instance `mountMemory` mounts for real (not a fake store) — proving -// tenant isolation and the degraded-embedding lexical fallback against -// an actual Postgres `memory` schema, the same convention -// `memory-mount.test.ts` uses (`describeIfDb`, skipped when -// `DATABASE_URL` is unreachable). -// -// "No EMBED_BASE_URL" per CL-5852's test brief means no WORKING -// embedding backend, not a literally-unset env var: `@corbits/memory`'s -// own `loadMemoryConfig()` requires the var to be set at all (a vendored -// contract this repo cannot change), and the hub's own optional-mount -// gate (`memory-mount.ts`) treats a genuinely unset var as "don't mount -// at all" — not a degraded-but-present plane. `EMBED_BASE_URL` here -// points at `localhost:9` (the "discard" port, always closed), the exact -// trick `memory-mount.test.ts` already uses: every embed call fails, -// forcing the plane's real runtime degrade -// (`services/search.ts`'s `degraded: ["dense_unavailable"]`) rather than -// a fake standing in for it — zero real keys, and the ONLY inference this -// test performs is the plane's own lexical (Postgres FTS) fallback. -import { afterAll, afterEach, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { createInMemoryGrantStore } from "@intx/authz"; -import { - createWorkflowMemoryRoutes, - createWorkflowMemoryStore, -} from "@corbits/memory-hub"; -import type { ResolvedWorkflowRunScope } from "@corbits/artifacts-hub"; - -import { mountMemory } from "./memory-mount"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const KEYS = [ - "DATABASE_URL", - "EMBED_BASE_URL", - "EMBED_MODEL", - "EMBED_API_STYLE", - "EMBED_API_KEY", - "OLLAMA_BASE_URL", -] as const; - -type EnvKey = (typeof KEYS)[number]; - -const saved: Partial> = {}; - -function clearEnvKey(key: EnvKey): void { - // Assigning `undefined` would store the string "undefined"; the key has to - // go. `Reflect.deleteProperty` because eslint forbids dynamic `delete`. - Reflect.deleteProperty(process.env, key); -} - -function stashEnv(): void { - for (const key of KEYS) { - saved[key] = process.env[key]; - clearEnvKey(key); - } -} - -afterEach(() => { - for (const key of KEYS) { - const value = saved[key]; - if (value === undefined) clearEnvKey(key); - else process.env[key] = value; - saved[key] = undefined; - } -}); - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT_A: ResolvedWorkflowRunScope = { - tenantId: "ten_memory_a", - principalId: "prn_a", - runId: "run_a", -}; -const TENANT_B: ResolvedWorkflowRunScope = { - tenantId: "ten_memory_b", - principalId: "prn_b", - runId: "run_b", -}; -const TOKEN_A = "token-a"; -const TOKEN_B = "token-b"; - -describeIfDb( - "createWorkflowMemoryRoutes against a real memory plane (degraded, no working embed)", - () => { - afterAll(async () => { - const postgres = (await import("postgres")).default; - const sql = postgres(databaseUrl as string, { max: 1 }); - try { - await sql.unsafe(`DROP SCHEMA IF EXISTS "memory" CASCADE`); - } finally { - await sql.end(); - } - }); - - test("writes tenant-isolated rows and finds them lexically with the embed backend unreachable", async () => { - stashEnv(); - process.env["DATABASE_URL"] = databaseUrl; - process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; - process.env["EMBED_MODEL"] = "test-embedding-model"; - - const handle = await mountMemory({ - app: new Hono(), - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }); - expect(handle).toBeDefined(); - if (handle === undefined) throw new Error("expected a mounted plane"); - - const app = createWorkflowMemoryRoutes({ - authenticator: { - async resolve(token) { - if (token === TOKEN_A) return TENANT_A; - if (token === TOKEN_B) return TENANT_B; - return null; - }, - }, - store: createWorkflowMemoryStore(handle.memory), - }); - - const addFor = (token: string, title: string, text: string) => - app.request("/add", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "x-workflow-run-address": "irrelevant-for-this-fake-authenticator", - "content-type": "application/json", - }, - body: JSON.stringify({ title, text }), - }); - - const searchFor = (token: string, query: string) => - app.request("/search", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "x-workflow-run-address": "irrelevant-for-this-fake-authenticator", - "content-type": "application/json", - }, - body: JSON.stringify({ query }), - }); - - const addA = await addFor( - TOKEN_A, - "Decision A", - "Tenant A decided to ship the memory tools this week.", - ); - expect(addA.status).toBe(201); - - const addB = await addFor( - TOKEN_B, - "Decision B", - "Tenant B decided to ship the memory tools this week.", - ); - expect(addB.status).toBe(201); - - // Tenant A's search must find its own entry, and never tenant B's, - // even though both entries share the same words — real Postgres - // row-level tenant scoping, not a test double. - const searchA = await searchFor(TOKEN_A, "ship the memory tools"); - expect(searchA.status).toBe(200); - const bodyA = (await searchA.json()) as { - data: { items: { title: string }[] }; - }; - // The embed backend at localhost:9 is unreachable (proven by the - // "embedding pass failed" warning the plane itself logs on add), - // yet the search below still finds tenant A's own entry — the - // plane's real lexical (Postgres full-text) fallback, exercised - // end to end rather than mocked. - expect(bodyA.data.items.map((i) => i.title)).toEqual(["Decision A"]); - - const searchB = await searchFor(TOKEN_B, "ship the memory tools"); - const bodyB = (await searchB.json()) as { - data: { items: { title: string }[] }; - }; - expect(bodyB.data.items.map((i) => i.title)).toEqual(["Decision B"]); - }, 20000); - }, -); diff --git a/apps/hub/src/native-workflow-routine-launch.test.ts b/apps/hub/src/native-workflow-routine-launch.test.ts index 3fed7ba762..185807ec4e 100644 --- a/apps/hub/src/native-workflow-routine-launch.test.ts +++ b/apps/hub/src/native-workflow-routine-launch.test.ts @@ -43,7 +43,7 @@ const LIVE_ANCHOR: FakeRow = { definitionId: "wfd_multistep", tenantId: "ten_1", anchorRunId: "wfr_anchor1", - address: "wfr_anchor1@acme.workbench.test", + address: "wfr_anchor1@acme.hub.test", status: "deployed", createdAt: new Date("2026-01-01T00:00:00Z"), }; @@ -53,7 +53,7 @@ function baseParams(overrides: Record = {}) { tenantId: "ten_1", definitionId: "wfd_multistep", principalId: "usr_1", - fromDomain: "acme.workbench.test", + fromDomain: "acme.hub.test", content: "Run this routine now.", ...overrides, }; @@ -77,13 +77,13 @@ describe("triggerNativeWorkflowRoutineRun", () => { expect(result).toEqual({ runId: "wfr_anchor1", - address: "wfr_anchor1@acme.workbench.test", + address: "wfr_anchor1@acme.hub.test", }); expect(routeMailCalls).toHaveLength(1); const [call] = routeMailCalls as [ { address: string; base64: string; messageId: string }, ]; - expect(call.address).toBe("wfr_anchor1@acme.workbench.test"); + expect(call.address).toBe("wfr_anchor1@acme.hub.test"); expect(typeof call.base64).toBe("string"); expect(call.base64.length).toBeGreaterThan(0); }); diff --git a/apps/hub/src/routability-wiring.test.ts b/apps/hub/src/routability-wiring.test.ts index d06dbbf02c..62439bdf60 100644 --- a/apps/hub/src/routability-wiring.test.ts +++ b/apps/hub/src/routability-wiring.test.ts @@ -1,9 +1,10 @@ -// CL-7543 follow-up: the planner's one-shot runner and the webhook -// launch path must both consult the hub's live sidecar routing table -// through the shared `isSidecarRoutable` local. A regression replacing -// either wiring with a constant (`() => true`) or dropping the hoisted -// predicate would still typecheck; this source scan is the pin, in the -// same style as `crypto-provider-cache-wiring.test.ts`. +// CL-7543 follow-up: the webhook launch path must consult the hub's live +// sidecar routing table through the shared `isSidecarRoutable` local. A +// regression replacing the wiring with a constant (`() => true`) or +// dropping the hoisted predicate would still typecheck; this source scan +// is the pin, in the same style as `crypto-provider-cache-wiring.test.ts`. +// (The planner's one-shot runner pinned alongside it was cut with the +// stock-hub cutover, leaving this single launch path.) import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import path from "node:path"; @@ -44,9 +45,6 @@ describe("hub sidecar routability wiring", () => { ); expect(definitionCount).toHaveLength(1); - expect(firstCall(source, "runOneShotPrompt")).toContain( - "isRoutable: isSidecarRoutable", - ); expect(firstCall(source, "launchWebhookTrigger")).toContain( "isRoutable: isSidecarRoutable", ); diff --git a/apps/hub/src/signup-tenancy.ts b/apps/hub/src/signup-tenancy.ts deleted file mode 100644 index 89eb90eb0e..0000000000 --- a/apps/hub/src/signup-tenancy.ts +++ /dev/null @@ -1,95 +0,0 @@ -// The hub's `HubSignupTenancy` adapter (see -// packages/onboarding/src/genesis.ts): the native reads and writes the -// genesis-or-join first-signup decision needs — user/tenant counts, -// the root-tenant lookup, and the member-role join. Mirrors -// packages/chat/src/workbench-tenancy.ts's `addWorkbenchMember` -// (principalStore.create + principalRole "member") so a joined signup -// holds exactly the membership shape a workbench member holds. Reads -// native tables only; declares none of its own (see -// scripts/checks/no-product-tenancy.ts). -import { and, count, eq, isNull } from "drizzle-orm"; -import { generateId } from "@intx/hub-common"; -import { - createPrincipalStore, - type DB, - type PrincipalKeyStore, -} from "@intx/db"; -import { - principal, - principalRole, - role, - tenant, - user as userTable, -} from "@intx/db/schema"; -import type { HubSignupTenancy } from "@workbench/onboarding"; - -export function createHubSignupTenancy( - db: DB["db"], - principalKeyStore: PrincipalKeyStore, -): HubSignupTenancy { - const principalStore = createPrincipalStore(db, principalKeyStore); - return { - countUsers: async () => { - const [row] = await db.select({ n: count() }).from(userTable); - return row?.n ?? 0; - }, - countTenants: async () => { - const [row] = await db.select({ n: count() }).from(tenant); - return row?.n ?? 0; - }, - findRootTenant: async () => { - const [row] = await db - .select({ id: tenant.id, slug: tenant.slug }) - .from(tenant) - .where(isNull(tenant.parentId)) - .limit(1); - return row ?? null; - }, - addActiveMember: async ({ tenantId, userId, roleName }) => - db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - ), - ) - .limit(1); - if (existing !== undefined) return { principalId: existing.id }; - - const [memberRole] = await tx - .select({ id: role.id }) - .from(role) - .where(and(eq(role.tenantId, tenantId), eq(role.name, roleName))) - .limit(1); - if (memberRole === undefined) { - throw new Error( - `signup join: tenant "${tenantId}" has no "${roleName}" system role`, - ); - } - - const now = new Date(); - const created = await principalStore.create( - { - id: generateId("principal"), - tenantId, - kind: "user", - refId: userId, - status: "active", - createdAt: now, - updatedAt: now, - }, - tx, - ); - await tx.insert(principalRole).values({ - principalId: created.id, - roleId: memberRole.id, - createdAt: now, - }); - return { principalId: created.id }; - }), - }; -} diff --git a/apps/hub/src/skills-mount.test.ts b/apps/hub/src/skills-mount.test.ts deleted file mode 100644 index d8f246a7f7..0000000000 --- a/apps/hub/src/skills-mount.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Covers the `pinnedBy` resolver `mountSkills` hands the skill registry: -// every definition's pins are read out of its own asset snapshot, so one -// unreadable asset (a pre-cutover retired envelope, a missing blob) skips -// its row — reported, never failing the whole resolve — and the fan-out -// stays bounded no matter how many definitions a tenant carries. -import { expect, test } from "bun:test"; - -import type { DB } from "@intx/db"; -import type { AssetService, RepoStore } from "@intx/hub-sessions"; -import { - agentDefinitionSourceTree, - AGENT_DEFINITION_ENTRY_PATH, - buildAgentDefinitionWorkflow, - reindexPinnedSkills, - RetiredWorkflowEnvelopeError, - serializeAgentDefinitionWorkflow, -} from "@corbits/agent-directory"; - -import { mountSkills } from "./skills-mount"; - -/** Entry-module bytes pinning `names` — the stanza `pinnedBy` reads. */ -function definitionBytesPinning(...names: string[]): Uint8Array { - const tree = agentDefinitionSourceTree({ - handle: "research-buddy", - workflowJson: reindexPinnedSkills( - serializeAgentDefinitionWorkflow( - buildAgentDefinitionWorkflow({ - handle: "research-buddy", - tenantDomain: "acme.example", - description: "", - systemPrompt: "You are a careful research assistant.", - }), - ), - names.map((name) => ({ name, description: `What ${name} does.` })), - ), - }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); -} - -function mountFor( - rows: readonly { - id: string; - tenantId: string; - assetId: string | null; - name: string; - }[], - readAssetBlob: AssetService["readAssetBlob"], -) { - const db = { - query: { - workflowDefinition: { findMany: async () => rows }, - }, - } as unknown as DB["db"]; - return mountSkills({ - db, - assetService: { readAssetBlob } as AssetService, - repoStore: {} as RepoStore, - }); -} - -test("a row on the retired envelope skips while healthy rows still resolve", async () => { - const mount = mountFor( - [ - { - id: "def_healthy", - tenantId: "tnt_1", - assetId: "ast_healthy", - name: "research-buddy", - }, - { - id: "def_retired", - tenantId: "tnt_1", - assetId: "ast_retired", - name: "old-scout", - }, - ], - (params) => - params.assetId === "ast_healthy" - ? Promise.resolve(definitionBytesPinning("research")) - : Promise.reject(new RetiredWorkflowEnvelopeError(params.assetId)), - ); - const pinning = await mount.pinnedBy.resolve("tnt_1", "research"); - expect(pinning).toEqual([ - { definitionId: "def_healthy", name: "research-buddy" }, - ]); -}); - -test("the blob fan-out stays bounded no matter how many definitions pin", async () => { - const COUNT = 20; - const rows = Array.from({ length: COUNT }, (_, index) => ({ - id: `def_${index}`, - tenantId: "tnt_1", - assetId: `ast_${index}`, - name: `buddy-${index}`, - })); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let active = 0; - let peak = 0; - const mount = mountFor(rows, async () => { - active += 1; - peak = Math.max(peak, active); - try { - await gate; - return definitionBytesPinning("research"); - } finally { - active -= 1; - } - }); - const pending = mount.pinnedBy.resolve("tnt_1", "research"); - // Every worker reaches the gate before any read can finish, so the - // peak observed here is the whole fan-out. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(peak).toBeLessThanOrEqual(8); - release(); - const pinning = await pending; - expect(pinning).toHaveLength(COUNT); -}); diff --git a/apps/hub/src/skills-mount.ts b/apps/hub/src/skills-mount.ts deleted file mode 100644 index 9095427d95..0000000000 --- a/apps/hub/src/skills-mount.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Composition for `@corbits/skills`: the registry itself plus the two -// adapters that only this composition root can supply — "which agent -// definitions pin this skill" (read from each definition's own asset -// snapshot, where its pinned-skills stanza lives) and "what index does -// a definition's pinned names resolve to" (read from the registry, on -// behalf of the pushing principal). -import { and, eq } from "drizzle-orm"; - -import type { DB } from "@intx/db"; -import { workflowDefinition } from "@intx/db/schema"; -import { type AssetService, type RepoStore } from "@intx/hub-sessions"; -import { - readAgentDefinitionWorkflowJson, - readPinnedSkillNames, - type PinnedSkillIndexResolver, -} from "@corbits/agent-directory"; -import { reportError } from "@corbits/error-sink"; -import { - createHubSkillAssetStore, - createSkillRegistry, - SkillRegistryError, - type PinnedByResolver, - type SkillRegistry, -} from "@corbits/skills"; - -export type SkillsMount = { - registry: SkillRegistry; - pinnedBy: PinnedByResolver; - skillIndex: PinnedSkillIndexResolver; -}; - -/** At most this many concurrent asset-blob reads while resolving who - * pins a skill — a tenant's definition count is unbounded, and one - * `readAssetBlob` per definition with no cap is a self-inflicted load - * spike against the asset store. */ -const PINNED_BY_READ_CONCURRENCY = 8; - -/** Runs `fn` over `items` with at most `limit` in flight, preserving - * order — the same bounded fan-out every per-row asset read in this - * composition root needs, so a many-definition tenant cannot open a - * blob read per definition at once. */ -async function mapWithConcurrencyLimit( - items: readonly T[], - limit: number, - fn: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - const workers = Array.from( - { length: Math.min(limit, items.length) }, - async () => { - while (next < items.length) { - const index = next; - next += 1; - results[index] = await fn(items[index] as T); - } - }, - ); - await Promise.all(workers); - return results; -} - -export function mountSkills(deps: { - db: DB["db"]; - assetService: AssetService; - repoStore: RepoStore; -}): SkillsMount { - const registry = createSkillRegistry({ - assets: createHubSkillAssetStore({ - db: deps.db, - assetService: deps.assetService, - repoStore: deps.repoStore, - }), - }); - - const pinnedBy: PinnedByResolver = { - async resolve(tenantId, skillName) { - const rows = await deps.db.query.workflowDefinition.findMany({ - where: and(eq(workflowDefinition.tenantId, tenantId)), - }); - const candidates = rows.filter( - (row): row is typeof row & { assetId: string } => row.assetId !== null, - ); - // Pins live in each definition's own asset snapshot — the same - // stanza every agent-directory read goes through. Bounded fan-out - // instead of the old sequential N+1, and one unreadable asset - // (a pre-cutover retired envelope, a missing blob) skips its - // row — reported, never failing the whole resolve. - const matches = await mapWithConcurrencyLimit( - candidates, - PINNED_BY_READ_CONCURRENCY, - async (row) => { - try { - const workflowJson = await readAgentDefinitionWorkflowJson( - deps.assetService, - row.assetId, - ); - return readPinnedSkillNames(workflowJson).includes(skillName) - ? { definitionId: row.id, name: row.name } - : null; - } catch (err) { - reportError(err, { - operation: "skills.pinnedBy.resolve", - tenantId, - extra: { definitionId: row.id, skillName }, - }); - return null; - } - }, - ); - return matches.filter((match) => match !== null); - }, - }; - - const skillIndex: PinnedSkillIndexResolver = { - async resolve(tenantId, principalId, names) { - const visible = await registry.list({ tenantId, principalId }); - const byName = new Map(visible.map((skill) => [skill.name, skill])); - return names.map((name) => { - const skill = byName.get(name); - if (skill === undefined) { - // Pinning a skill the pusher cannot see would advertise a - // skill `skills_load` will refuse to fetch at run time. Reject - // the push instead of shipping an index that lies. - throw new SkillRegistryError( - "not_found", - `cannot pin skill "${name}": it is not in this workbench's registry, or not visible to you`, - ); - } - return { name: skill.name, description: skill.description }; - }); - }, - }; - - return { registry, pinnedBy, skillIndex }; -} diff --git a/apps/hub/src/slack-channel-mint-session.test.ts b/apps/hub/src/slack-channel-mint-session.test.ts deleted file mode 100644 index c0e721eda8..0000000000 --- a/apps/hub/src/slack-channel-mint-session.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { configureSync, resetSync } from "@intx/log"; -import { createInMemoryWorkbenchTenancyStore } from "@corbits/chat"; - -import { - mintSlackChannelWorkbench, - slackChannelMintSession, -} from "./slack-channel-mint-session"; - -const TENANT_ID = "tnt_bench"; -const OWNER_USER_ID = "user_owner"; -const MEMBER_REF_ID = "user_slack_member"; -const OWNER_COOKIES = ["session=owner"]; - -let records: { properties: Record }[]; - -function installCapturingSink(): void { - records = []; - configureSync({ - reset: true, - sinks: { - capture: (record) => { - records.push(record as { properties: Record }); - }, - }, - loggers: [ - { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, - { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, - ], - }); -} - -beforeEach(() => installCapturingSink()); -afterEach(() => resetSync()); - -describe("slackChannelMintSession", () => { - test("mints as the bench owner, never as a Slack member", async () => { - const sessionArgs: { userId: string; tenantId: string }[] = []; - const result = await slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async (tenantId) => { - expect(tenantId).toBe(TENANT_ID); - return OWNER_USER_ID; - }, - sessionFor: async (args) => { - sessionArgs.push(args); - return OWNER_COOKIES; - }, - }); - - expect(result).toEqual({ - ownerUserId: OWNER_USER_ID, - cookies: OWNER_COOKIES, - }); - expect(sessionArgs).toEqual([ - { userId: OWNER_USER_ID, tenantId: TENANT_ID }, - ]); - }); - - test("fails closed when the bench has no owner user id", async () => { - await expect( - slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => undefined, - sessionFor: async () => OWNER_COOKIES, - }), - ).rejects.toThrow(`no owner user id for tenant "${TENANT_ID}"`); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - }); - - test("fails closed when the owner session cannot be minted", async () => { - await expect( - slackChannelMintSession({ - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => OWNER_USER_ID, - sessionFor: async () => undefined, - }), - ).rejects.toThrow( - `could not mint a session for bench owner "${OWNER_USER_ID}"`, - ); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - expect(records[0]?.properties.extra).toEqual({ - ownerUserId: OWNER_USER_ID, - }); - }); -}); - -describe("mintSlackChannelWorkbench", () => { - test("a member-role Slack identity provisions via the owner session", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT_ID); - tenancy.grantManageInTenant(OWNER_USER_ID, TENANT_ID); - - const mintCalls: { - creatorUserId: string; - cookies: string[]; - parentTenantId: string; - }[] = []; - const sessionArgs: { userId: string; tenantId: string }[] = []; - - const result = await mintSlackChannelWorkbench( - { - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: (id) => tenancy.getWorkbenchOwnerUserId(id), - sessionFor: async (args) => { - sessionArgs.push(args); - if (args.userId === MEMBER_REF_ID) return ["session=member"]; - if (args.userId === OWNER_USER_ID) return OWNER_COOKIES; - return undefined; - }, - chatTenancy: { - createWorkbenchTenant: async (input) => { - mintCalls.push({ - creatorUserId: input.creatorUserId, - cookies: input.cookies, - parentTenantId: input.parentTenantId, - }); - return tenancy.createWorkbenchTenant(input); - }, - addWorkbenchMember: (input) => tenancy.addWorkbenchMember(input), - }, - }, - { name: "#eng", creatorRefId: MEMBER_REF_ID }, - ); - - expect(sessionArgs).toEqual([ - { userId: OWNER_USER_ID, tenantId: TENANT_ID }, - ]); - expect(mintCalls).toEqual([ - { - creatorUserId: OWNER_USER_ID, - cookies: OWNER_COOKIES, - parentTenantId: TENANT_ID, - }, - ]); - expect(result.ownerUserId).toBe(OWNER_USER_ID); - expect(result.channelId.length).toBeGreaterThan(0); - - const member = await tenancy.getTenantPrincipalByRefId( - result.workbenchTenantId, - MEMBER_REF_ID, - ); - expect(member?.refId).toBe(MEMBER_REF_ID); - }); - - test("fails closed without minting when the owner session cannot be minted", async () => { - const mintCalls: unknown[] = []; - const memberCalls: unknown[] = []; - - await expect( - mintSlackChannelWorkbench( - { - tenantId: TENANT_ID, - getWorkbenchOwnerUserId: async () => OWNER_USER_ID, - sessionFor: async () => undefined, - chatTenancy: { - createWorkbenchTenant: async (input) => { - mintCalls.push(input); - throw new Error("should not mint"); - }, - addWorkbenchMember: async (input) => { - memberCalls.push(input); - return undefined; - }, - }, - }, - { name: "#eng", creatorRefId: MEMBER_REF_ID }, - ), - ).rejects.toThrow( - `could not mint a session for bench owner "${OWNER_USER_ID}"`, - ); - - expect(mintCalls).toEqual([]); - expect(memberCalls).toEqual([]); - expect(records[0]?.properties.operation).toBe("slack.provisionChannel"); - }); -}); diff --git a/apps/hub/src/slack-channel-mint-session.ts b/apps/hub/src/slack-channel-mint-session.ts deleted file mode 100644 index 7b4df3f01d..0000000000 --- a/apps/hub/src/slack-channel-mint-session.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { generateId } from "@intx/hub-common"; -import { reportError } from "@corbits/error-sink"; -import type { WorkbenchTenancyStore } from "@corbits/chat"; -import type { SessionForUser } from "@workbench/onboarding"; - -/** - * Slack auto-provisioned identities are members. POST /api/tenants is - * guarded by tenancyCreation (default: owners), so channel mint must - * run as the bench owner — same pattern as agent-DM mint. - */ -export async function slackChannelMintSession(args: { - tenantId: string; - getWorkbenchOwnerUserId: (tenantId: string) => Promise; - sessionFor: SessionForUser; -}): Promise<{ ownerUserId: string; cookies: string[] }> { - const ownerUserId = await args.getWorkbenchOwnerUserId(args.tenantId); - if (ownerUserId === undefined) { - const cause = new Error( - `no owner user id for tenant "${args.tenantId}" — cannot mint a Slack channel workbench`, - ); - reportError(cause, { - operation: "slack.provisionChannel", - tenantId: args.tenantId, - }); - throw cause; - } - const cookies = await args.sessionFor({ - userId: ownerUserId, - tenantId: args.tenantId, - }); - if (cookies === undefined) { - const cause = new Error( - `could not mint a session for bench owner "${ownerUserId}" to provision a Slack channel`, - ); - reportError(cause, { - operation: "slack.provisionChannel", - tenantId: args.tenantId, - extra: { ownerUserId }, - }); - throw cause; - } - return { ownerUserId, cookies }; -} - -export type MintSlackChannelWorkbenchDeps = { - readonly tenantId: string; - readonly getWorkbenchOwnerUserId: ( - tenantId: string, - ) => Promise; - readonly sessionFor: SessionForUser; - readonly chatTenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "addWorkbenchMember" - >; -}; - -export type MintSlackChannelWorkbenchInput = { - readonly name: string; - readonly creatorRefId: string; -}; - -/** - * Mints the child workbench as the bench owner, then adds the Slack - * member via the existing membership path so they can use the channel. - */ -export async function mintSlackChannelWorkbench( - deps: MintSlackChannelWorkbenchDeps, - input: MintSlackChannelWorkbenchInput, -): Promise<{ - readonly channelId: string; - readonly ownerUserId: string; - readonly workbenchTenantId: string; -}> { - const { ownerUserId, cookies } = await slackChannelMintSession({ - tenantId: deps.tenantId, - getWorkbenchOwnerUserId: deps.getWorkbenchOwnerUserId, - sessionFor: deps.sessionFor, - }); - - const channelId = generateId("workflowRun"); - const channelTenant = await deps.chatTenancy.createWorkbenchTenant({ - parentTenantId: deps.tenantId, - workbenchId: channelId, - name: input.name, - creatorUserId: ownerUserId, - cookies, - }); - - await deps.chatTenancy.addWorkbenchMember({ - workbenchId: channelId, - refId: input.creatorRefId, - }); - - return { - channelId, - ownerUserId, - workbenchTenantId: channelTenant.tenantId, - }; -} diff --git a/apps/hub/src/slack-tag-mount.ts b/apps/hub/src/slack-tag-mount.ts deleted file mode 100644 index 87a81804a3..0000000000 --- a/apps/hub/src/slack-tag-mount.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Slack tag ingress mount (CL-5288 Phase 1): the thin, env-gated glue -// between `@corbits/slack-tag`'s composition and this hub's already- -// running `@corbits/chat` instances. The overwhelming majority of the -// behavior lives in the package — this module only supplies the -// host-resident functions the package's `MountWorkbenchSlackDeps` -// wants injected (per AGENTS.md: apps stay generic, packages own the -// domain), plus the env-var gate. -// -// Absent `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` is a valid -// configuration, not an error: the hub runs fine with no Slack app -// installed, and this mount is silently skipped — mirrors every other -// optional integration mount in `./index.ts` (e.g. `mountMemory`, -// `mountArtifacts`). -import { eq } from "drizzle-orm"; -import type { Hono } from "hono"; -import type { DB } from "@intx/db"; -import { principal, tenant } from "@intx/db/schema"; -import { getLogger } from "@intx/log"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { AppEnv } from "@intx/hub-api"; - -import { - launchAndJoinAgent, - presetForKind, - sendWorkbenchMessage, - type WorkbenchSubscriberRegistry, - type WorkbenchTenancyStore, - type WorkbenchTurnQueue, - type TurnCancelRegistry, - type TurnMailCorrelationStore, - type ChatPlatform, - type ChatStore, - type RoomMessageStore, -} from "@corbits/chat"; -import type { SessionForUser } from "@workbench/onboarding"; -import { - createAutoProvisionPrincipalResolver, - createDrizzleSlackChannelBindingStore, - mountWorkbenchSlack, - resolveThreadState, - applySlackTagMigrations, -} from "@corbits/slack-tag"; -import { mintSlackChannelWorkbench } from "./slack-channel-mint-session"; - -const log = getLogger(["hub", "slack-tag"]); - -/** The one role every auto-provisioned Slack principal is granted. - * Every bench mints this system role at creation (see - * `@corbits/chat`'s `workbench-tenancy.ts`), so it always resolves. */ -const SLACK_PRINCIPAL_ROLE_NAMES = ["member"] as const; - -export type MountWorkbenchSlackTagDeps = { - readonly app: Hono; - readonly db: DB["db"]; - readonly databaseUrl: string; - readonly chatStore: Pick< - ChatStore, - | "getWorkbenchSettings" - | "getBenchSettings" - | "createWorkbenchSettings" - | "updateWorkbenchSettings" - | "mutateWorkbenchParticipants" - >; - readonly chatPlatform: ChatPlatform; - readonly roomMessages: RoomMessageStore; - readonly chatTenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "getWorkbenchOwnerUserId" | "addWorkbenchMember" - >; - readonly sessionFor: SessionForUser; - readonly workbenchSubscribers: WorkbenchSubscriberRegistry; - /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` - * is given (CL-6331) — shared, never a second instance, so a Slack - * send and a person's own message for the same channel serialize - * against each other too. */ - readonly turnQueue: WorkbenchTurnQueue; - /** The same cancellation registry `createChatRoutes` is given - * (CL-7201) — shared, never a second instance. */ - readonly turnCancellation: TurnCancelRegistry; - /** The same dispatch-mail correlation `createChatRoutes` is given - * (CL-6314) — shared, never a second instance. */ - readonly turnMailCorrelation?: TurnMailCorrelationStore; -}; - -export type MountedWorkbenchSlackTag = { readonly mounted: boolean }; - -/** - * Reads `SLACK_WORKBENCH_TENANT_SLUG` and `SLACK_DEFAULT_AGENT_DEFINITION_ID` - * alongside the credential pair: which bench a Slack workspace's messages - * land in, and which deployed agent definition a freshly bound channel - * launches with, are both irreducible to this mount — there is no honest - * default. Set together with the credential pair, or the mount fails - * loudly rather than silently answering nobody. - */ -export async function mountWorkbenchSlackTag( - deps: MountWorkbenchSlackTagDeps, -): Promise { - const botToken = process.env["SLACK_BOT_TOKEN"]; - const signingSecret = process.env["SLACK_SIGNING_SECRET"]; - if (!botToken || !signingSecret) { - log.info( - "Slack tag ingress not mounted — SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET unset", - ); - return { mounted: false }; - } - - const tenantSlug = process.env["SLACK_WORKBENCH_TENANT_SLUG"]; - const definitionId = process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"]; - if (!tenantSlug || !definitionId) { - throw new Error( - "SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET are set, but SLACK_WORKBENCH_TENANT_SLUG " + - "and/or SLACK_DEFAULT_AGENT_DEFINITION_ID are not — both name the bench and " + - "agent a Slack message resolves to, and there is no honest default for either.", - ); - } - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenant.slug, tenantSlug), - }); - if (tenantRow === undefined) { - throw new Error( - `SLACK_WORKBENCH_TENANT_SLUG "${tenantSlug}" does not name a real tenant`, - ); - } - - await applySlackTagMigrations(deps.databaseUrl); - - const resolvePrincipal = createAutoProvisionPrincipalResolver( - deps.db, - tenantSlug, - SLACK_PRINCIPAL_ROLE_NAMES, - ); - const bindings = createDrizzleSlackChannelBindingStore(deps.db); - const state = await resolveThreadState(createMemoryState()); - - // `mountWorkbenchSlack` (like `corbits-tag/slack`'s own `mountSlackTag`) - // deliberately types its `app` param against the default Hono env: the - // route lives outside the hub's session auth and reads nothing from - // `AppEnv`'s context. The cast reflects that, not a real env mismatch. - const { path } = mountWorkbenchSlack(deps.app as unknown as Hono, { - tenantId: tenantRow.id, - slack: { botToken, signingSecret }, - state, - bindings, - resolvePrincipal, - subscribeToChannel: deps.chatPlatform.subscribeToWorkbench, - provisionChannel: async (input) => { - const creatorPrincipal = await deps.db.query.principal.findFirst({ - where: eq(principal.id, input.creatorPrincipalId), - }); - if (creatorPrincipal === undefined) { - throw new Error( - `Slack-provisioned principal "${input.creatorPrincipalId}" vanished before its channel could be provisioned`, - ); - } - - const minted = await mintSlackChannelWorkbench( - { - tenantId: tenantRow.id, - getWorkbenchOwnerUserId: (id) => - deps.chatTenancy.getWorkbenchOwnerUserId(id), - sessionFor: deps.sessionFor, - chatTenancy: deps.chatTenancy, - }, - { - name: input.name, - creatorRefId: creatorPrincipal.refId, - }, - ); - const channelId = minted.channelId; - - const preset = presetForKind("chat"); - const settingsRow = await deps.chatStore.createWorkbenchSettings({ - tenantId: tenantRow.id, - workbenchId: channelId, - settings: { - "chat/kind": "chat", - "chat/pinned": preset.pinned, - "chat/participants": [], - "chat/name": input.name, - }, - updatedBy: input.creatorPrincipalId, - }); - - await launchAndJoinAgent( - { - store: deps.chatStore, - platform: deps.chatPlatform, - roomMessages: deps.roomMessages, - publish: deps.workbenchSubscribers.publish, - }, - { - tenantId: tenantRow.id, - principalId: input.creatorPrincipalId, - workbenchId: channelId, - definitionId, - existingSettings: settingsRow.settings, - invitable: await deps.chatPlatform.listInvitableDefinitions( - tenantRow.id, - ), - }, - ); - - log.info( - "Provisioned channel {channelId} for tenant {tenantId} ({channelTenant})", - { - channelId, - tenantId: tenantRow.id, - channelTenant: minted.workbenchTenantId, - }, - ); - return { channelId }; - }, - sendMessage: async (input) => { - const sent = await sendWorkbenchMessage( - { - store: deps.chatStore, - platform: deps.chatPlatform, - roomMessages: deps.roomMessages, - publish: deps.workbenchSubscribers.publish, - turnQueue: deps.turnQueue, - turnCancellation: deps.turnCancellation, - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - }, - { - tenantId: input.tenantId, - principalId: input.principalId, - senderAddress: `${input.principalId}@${tenantRow.domain}`, - workbenchId: input.channelId, - messageParts: [{ kind: "text", text: input.text }], - }, - ); - return { id: sent.id }; - }, - }); - - log.info("Slack tag ingress mounted at {path} for tenant {tenantSlug}", { - path, - tenantSlug, - }); - return { mounted: true }; -} diff --git a/apps/hub/src/templates/template-block-routes.ts b/apps/hub/src/templates/template-block-routes.ts index d363386efd..e444f0d0ef 100644 --- a/apps/hub/src/templates/template-block-routes.ts +++ b/apps/hub/src/templates/template-block-routes.ts @@ -27,7 +27,7 @@ export type TemplateBlockRoutesDeps = { * `userMessage`, the raw detail lands here. */ log: (line: string) => void; /** The tenant's default inference preferences — the same resolution a - * fresh workbench host launches against (`@corbits/chat`'s + * fresh chat host launches against (`@corbits/chat`'s * `createWorkbenchHostInferencePreferencesResolver`), so a deployed * block runs on the model the bench actually connected. */ inferencePreferences( diff --git a/apps/hub/src/tenant-create-guard.ts b/apps/hub/src/tenant-create-guard.ts deleted file mode 100644 index 85047f74e4..0000000000 --- a/apps/hub/src/tenant-create-guard.ts +++ /dev/null @@ -1,256 +0,0 @@ -// [Intx gap] CL-6041: the native `POST /api/tenants` route -// (vendor/intx/hub-api/src/routes/tenants.ts) is ungated — any -// authenticated user can call it directly, pick an arbitrary -// `parentId`, and become owner of a freshly-minted child under ANY -// tenant, or mint a top-level tenant, entirely bypassing -// `@workbench/access-policy`. Filed upstream; vendor is read-only, so -// this hub composes a guard in front of the whole native app instead of -// patching the route. `@workbench/access-policy`'s own -// `POST .../child-tenants` wrapper (mounted by -// `createAccessPolicyRoutes`) stays as the polished UI surface — a -// clean pre-flight 403 with a helpful message — but the actual -// enforcement lives here, in front of the primitive itself, since a -// caller can always skip the wrapper and hit the native route directly. -// -// Registered by wrapping: a fresh outer Hono app gets this guard -// middleware for the exact `/api/tenants` path registered FIRST, then -// mounts the fully-built native+extension app underneath via -// `.route("/", nativeApp)`. Hono composes handlers in registration -// order, so a request the guard denies never reaches the mounted app at -// all; one it allows falls through via `next()`. -import { and, eq, inArray } from "drizzle-orm"; -import { Hono } from "hono"; -import { principal, principalRole, role } from "@intx/db/schema"; -import type { DB } from "@intx/db"; -import type { AppEnv } from "@intx/hub-api"; -import { - canCreateTenancy, - checkSignupGate, - type AccessPolicyStore, -} from "@workbench/access-policy"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const NATIVE_TENANT_CREATE_PATH = "/api/tenants"; - -export type TenantCreateGuardDeps = { - store: AccessPolicyStore; - /** Native role names the caller holds in `tenantId`, or `undefined` - * when they have no principal there at all (not a member). Production - * wiring is `(tenantId, userId) => resolveCallerRoleNames(db, tenantId, userId)`; - * kept as an injected function (rather than a raw `db` handle) so the - * guard's own decision logic is testable without a database. */ - resolveCallerRoleNames: ( - tenantId: string, - userId: string, - ) => Promise; - /** Total native tenant count — the hub's signup-tenancy adapter's own - * read, injected so the decision stays DB-free in tests. */ - countTenants: () => Promise; - operatorTenantId?: string; - envSignupMode: "open" | "closed"; - envAllowedDomains: readonly string[]; - allowUnverifiedEmails: boolean; - getSessionUser: ( - headers: Headers, - ) => Promise< - { id: string; email: string; emailVerified: boolean } | undefined - >; -}; - -export type TenantCreateGuardVerdict = - | { allowed: true } - | { allowed: false; status: 401 | 403; code: string; message: string }; - -/** Native role names this user's principal holds in `tenantId` — - * `undefined` when they have no principal there at all (not a member), - * matching exactly what `vendor/intx/hub-api/src/routes/principals.ts`'s - * `loadRolesForPrincipal` reads, just filtered down to names. Reads - * native tables directly; declares none of its own (see - * `scripts/checks/no-product-tenancy.ts`). */ -export async function resolveCallerRoleNames( - db: DB["db"], - tenantId: string, - userId: string, -): Promise { - const principalRow = await db.query.principal.findFirst({ - where: and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - ), - }); - if (principalRow === undefined) return undefined; - - const assignments = await db.query.principalRole.findMany({ - where: eq(principalRole.principalId, principalRow.id), - }); - if (assignments.length === 0) return []; - - const roleIds = assignments.map((a) => a.roleId); - const roles = await db.query.role.findMany({ - where: inArray(role.id, roleIds), - }); - return roles.map((r) => r.name); -} - -/** - * The decision itself, given already-resolved inputs. A `parentId` - * equal to the operator tenant (or no `parentId` at all) is the - * self-service landing zone — governed by the signup gate, the same - * decision `packages/onboarding`'s first-login hook makes, so a - * hand-crafted request answers identically to the real onboarding flow. - * Any other `parentId` is an explicit sub-workbench create and requires - * the caller to already be a member of that exact tenant with a role - * this tenant's own `tenancyCreation` policy accepts — an attacker who - * is not a member of the target tenant can never satisfy this, however - * they picked the id. - */ -export async function decideTenantCreate( - deps: TenantCreateGuardDeps, - request: { - parentId?: string; - userId: string; - userEmail: string; - userEmailVerified: boolean; - }, -): Promise { - if ( - request.parentId === undefined || - request.parentId === deps.operatorTenantId - ) { - // Genesis on an empty hub (CL-7578): with zero tenants there is no - // policy, no operator tenant, and nobody to invite anyone — the - // first signup's unparented create is the one path that bypasses - // the signup gate, because the sign-up route's own empty-hub - // exception already admitted this caller. Empty means zero tenants - // only (the one emptiness rule shared with genesis.ts; user rows - // alone never make a hub occupied). - if (request.parentId === undefined && (await deps.countTenants()) === 0) { - return { allowed: true }; - } - type MutableSignupGateArgs = { - -readonly [K in keyof Parameters[0]]: Parameters< - typeof checkSignupGate - >[0][K]; - }; - const gateArgs: MutableSignupGateArgs = { - store: deps.store, - envSignupMode: deps.envSignupMode, - envAllowedDomains: deps.envAllowedDomains, - email: request.userEmail, - emailVerified: request.userEmailVerified, - allowUnverifiedEmails: deps.allowUnverifiedEmails, - }; - if (deps.operatorTenantId !== undefined) { - gateArgs.operatorTenantId = deps.operatorTenantId; - } - const gate = await checkSignupGate(gateArgs); - if (!gate.allowed) { - return { - allowed: false, - status: 403, - code: "signup_not_allowed", - message: - gate.reason === "email_unverified" - ? "This account's email address isn't verified, and this hub " + - "requires verified emails before provisioning. For local " + - "development set ALLOW_UNVERIFIED_EMAILS=1 in .env and " + - "restart." - : "This workbench isn't open for self-serve sign-up right now. " + - "Ask an owner for an invite.", - }; - } - return { allowed: true }; - } - - const parentId = request.parentId; - const roleNames = await deps.resolveCallerRoleNames(parentId, request.userId); - if (roleNames === undefined) { - return { - allowed: false, - status: 403, - code: "not_a_member", - message: - "You need to already belong to that workbench to create a sub-workbench under it.", - }; - } - - const policy = await deps.store.getPolicy(parentId); - if (!canCreateTenancy(policy, roleNames)) { - return { - allowed: false, - status: 403, - code: "tenancy_creation_forbidden", - message: - "This workbench's policy doesn't allow you to create a sub-workbench here.", - }; - } - return { allowed: true }; -} - -function parseParentId(body: unknown): string | undefined { - if (typeof body !== "object" || body === null) return undefined; - const parentId = (body as { parentId?: unknown }).parentId; - return typeof parentId === "string" && parentId.length > 0 - ? parentId - : undefined; -} - -/** - * Wraps `nativeApp` (the fully-built hub app, native tenant route - * included) inside a fresh outer app that guards `POST /api/tenants` - * ahead of it. Every other path falls straight through unguarded. - */ -export function guardedHubApp( - nativeApp: Hono, - deps: TenantCreateGuardDeps, -): Hono { - const guarded = new Hono(); - - guarded.use(NATIVE_TENANT_CREATE_PATH, async (c, next) => { - if (c.req.method !== "POST") return next(); - - const user = await deps.getSessionUser(c.req.raw.headers); - if (user === undefined) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Authentication required", - }), - 401, - ); - } - - // Malformed JSON: let the native route produce its own 400 — there - // is no parentId to branch on either way, so this falls into the - // self-service signup-gate branch same as "no parentId". - const body: unknown = await c.req.raw - .clone() - .json() - .catch(() => ({})); - - const parentId = parseParentId(body); - const decideRequest: Parameters[1] = { - userId: user.id, - userEmail: user.email, - userEmailVerified: user.emailVerified, - }; - if (parentId !== undefined) { - decideRequest.parentId = parentId; - } - const verdict = await decideTenantCreate(deps, decideRequest); - if (!verdict.allowed) { - return c.json( - makeErrorEnvelope({ - code: verdict.code, - userMessage: verdict.message, - }), - verdict.status, - ); - } - return next(); - }); - - guarded.route("/", nativeApp); - return guarded; -} diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts deleted file mode 100644 index 934a82df52..0000000000 --- a/apps/hub/src/tenant-create-onboard.ts +++ /dev/null @@ -1,212 +0,0 @@ -// CL-7584: the tenant-create trigger. Wrapped beside the access-policy -// guard (./tenant-create-guard.ts), this outer layer watches the native -// `POST /api/tenants` route: a 201 means a real tenant now exists that -// should converge onto the tenant desired-state document. The reconcile -// runs fire-and-forget under the creator's own session — the cookies -// that made the create are replayed, so no extra session is minted and -// the kick never touches the DB directly — so a fresh tenant gets Myra -// and the core pins without hub boot seeding anything. -// -// There is no durable work item here on purpose: the pending_seed row -// stays the only durable queue (a connect's credential), and a -// tenant-create kick that dies with the process is re-covered by the -// revisit kick (`POST /api/onboarding/provision`) on the next visit. -// In-process dedupe by tenantId is the same class of optimization the -// provisioner's in-flight map is: never a fact the system needs correct. -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client"; -import { - reconcileTenantDesiredState, - resolveTenantModelSource, - resolveTenantDeployer, - TENANT_DESIRED_STATE, - type ReconcileReport, -} from "@workbench/onboarding/desired-state"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; - -export type TenantCreateOnboardDeps = { - api: ApiCall; - hubUrl: string; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; - logError?: (line: string) => void; - /** - * The convergence step. Production resolves the tenant's deploy model - * from its resolved catalog plus the creator's deploy identity (owner - * principal + domain, via `resolveTenantDeployer` — a fresh native - * tenant has no pending-seed row to read those from) and delegates to - * `reconcileTenantDesiredState`; with no offerings it reports the - * workflow pins blocked (logged, never thrown). Tests replace the - * whole thing. - */ - reconcileFn?: (args: { - tenantId: string; - cookies: string[]; - }) => Promise; - /** - * Test seam for the reconcile step itself. The production default - * resolves the tenant's deploy model plus the creator's deploy identity - * and delegates to `reconcileTenantDesiredState`; tests inject a spy - * here to prove `principalId`/`tenantDomain` reach the seam without - * standing up the reconcile's full status-read surface. The blocked - * branches (no catalog offerings, no deployable principal) return or - * throw before this is ever entered. - */ - reconcileStateFn?: typeof reconcileTenantDesiredState; -}; - -export type TenantCreateObserver = { - /** The composed app: observes `POST /api/tenants` 201s, then falls - * through to the wrapped app. */ - app: Hono; - /** Kick a reconcile for one tenant directly (the revisit-kick wiring - * shares this with the observer). Deduped per tenant in-process. */ - kick(args: { tenantId: string; cookies: string[] }): Promise; - /** Stops accepting new kicks. In-flight ones are not interrupted — - * they run to their next HTTP call, which fails once the server has - * stopped, and the failure is caught and logged. Hub shutdown calls - * this before closing the DB and bounds the wait with `whenIdle`. */ - stop(): void; - /** Resolves when every in-flight kick has finished or bailed. */ - whenIdle(): Promise; -}; - -export function createTenantCreateObserver( - deps: TenantCreateOnboardDeps, - wrapped: Hono, -): TenantCreateObserver { - const logError = deps.logError ?? deps.log; - // In-process tenantId dedupe, same pattern as the provisioner's - // in-flight map: an optimization against double kicks, never a fact. - const kicked = new Set(); - const inFlight = new Set>(); - let stopped = false; - - async function runReconcile(args: { - tenantId: string; - cookies: string[]; - }): Promise { - if (stopped) return; - const cookies = args.cookies; - const reconcile = - deps.reconcileFn ?? - (async (reconcileArgs: { tenantId: string; cookies: string[] }) => { - const model = await resolveTenantModelSource( - deps.api, - reconcileArgs.cookies, - reconcileArgs.tenantId, - ); - if (model === undefined) { - // No catalog offerings yet — nothing is launchable. Report the - // workflow pins blocked; the next trigger (a connect's drain - // pass, a revisit probe) sees the pins still pending and - // re-kicks. - deps.log( - `tenant-create onboarding for ${reconcileArgs.tenantId} is blocked: no catalog offerings to deploy against yet`, - ); - return { - tenantId: reconcileArgs.tenantId, - ready: false, - pins: TENANT_DESIRED_STATE.workflows.map((pin) => ({ - name: pin.assetName, - kind: "workflow" as const, - status: "blocked" as const, - })), - } satisfies ReconcileReport; - } - // A fresh native tenant has no pending-seed row to read deploy - // identity from — resolve the creator's owner principal (minted - // in the create transaction) plus the tenant domain before - // reconciling, so the deploy lands under a real identity. - const deployer = await resolveTenantDeployer( - deps.api, - reconcileArgs.cookies, - reconcileArgs.tenantId, - ); - if (deployer === undefined) { - throw new Error( - `tenant-create onboarding for ${reconcileArgs.tenantId} has no deployable principal: the session holds no active user principal on the new tenant`, - ); - } - const reconcileState = - deps.reconcileStateFn ?? reconcileTenantDesiredState; - return reconcileState({ - api: deps.api, - cookies: reconcileArgs.cookies, - hubUrl: deps.hubUrl, - tenant: { - tenantId: reconcileArgs.tenantId, - principalId: deployer.principalId, - domain: deployer.tenantDomain, - }, - model, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - }); - }); - const report = await reconcile({ tenantId: args.tenantId, cookies }); - deps.log( - `tenant-create onboarding for ${args.tenantId}: ${report.pins.length} pins, ready=${report.ready}`, - ); - } - - function kick(args: { tenantId: string; cookies: string[] }): Promise { - if (stopped || kicked.has(args.tenantId)) return Promise.resolve(); - kicked.add(args.tenantId); - const operation = runReconcile(args) - .catch((cause: unknown) => { - logError( - `tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`, - ); - }) - .finally(() => { - kicked.delete(args.tenantId); - inFlight.delete(operation); - }); - inFlight.add(operation); - return operation; - } - - function stop(): void { - stopped = true; - } - - function whenIdle(): Promise { - return Promise.allSettled([...inFlight]).then(() => undefined); - } - - const app = new Hono(); - app.use("*", async (c, next) => { - await next(); - if ( - c.req.method !== "POST" || - c.req.path !== "/api/tenants" || - c.res.status !== 201 - ) { - return; - } - // The creator's own session cookies are replayed for the kick, so - // it acts under the same session that made the create without - // minting (or ever touching) anything of its own. - const cookies = cookiesFromHeader(c.req.header("cookie")); - if (cookies.length === 0) return; - const body = (await c.res - .clone() - .json() - .catch(() => undefined)) as - { id?: unknown; tenantId?: unknown } | undefined; - const tenantId = - typeof body?.id === "string" - ? body.id - : typeof body?.tenantId === "string" - ? body.tenantId - : undefined; - if (tenantId === undefined) return; - // Fire-and-forget: a 201 must answer immediately. - void kick({ tenantId, cookies }); - }); - app.route("/", wrapped); - - return { app, kick, stop, whenIdle }; -} diff --git a/apps/hub/src/workflow-scheduler.ts b/apps/hub/src/workflow-scheduler.ts index 5c5f5e5f63..1951f56296 100644 --- a/apps/hub/src/workflow-scheduler.ts +++ b/apps/hub/src/workflow-scheduler.ts @@ -38,40 +38,40 @@ export type ScheduledDefinition = { }; export type ScheduledDeliveryJoinDeps = { - deliveryWorkbenchRequired?: (name: string) => boolean | Promise; - resolveDeliveryWorkbench?: (tenantId: string) => Promise; - joinDeliveryWorkbench?: (input: JoinRunParticipantInput) => Promise; + deliveryChatRequired?: (name: string) => boolean | Promise; + resolveDeliveryChat?: (tenantId: string) => Promise; + joinDeliveryChat?: (input: JoinRunParticipantInput) => Promise; }; /** - * After a scheduled tick launches, join the run to a workbench when the - * catalog says this workflow delivers there. No workbench → still launched + * After a scheduled tick launches, join the run to a chat when the + * catalog says this workflow delivers there. No chat → still launched * (join omitted). Inbox-mode workflows skip the join. A join throw is the * caller's to log; this helper rethrows. */ -export async function joinScheduledDefinitionToWorkbench( +export async function joinScheduledDefinitionToChat( deps: ScheduledDeliveryJoinDeps, def: ScheduledDefinition, address: string, ): Promise { if (def.creatorPrincipalId === null) return; if ( - deps.deliveryWorkbenchRequired !== undefined && - (await deps.deliveryWorkbenchRequired(def.name)) !== true + deps.deliveryChatRequired !== undefined && + (await deps.deliveryChatRequired(def.name)) !== true ) { return; } if ( - deps.resolveDeliveryWorkbench === undefined || - deps.joinDeliveryWorkbench === undefined + deps.resolveDeliveryChat === undefined || + deps.joinDeliveryChat === undefined ) { return; } - const workbenchId = await deps.resolveDeliveryWorkbench(def.tenantId); - if (workbenchId === undefined) return; - await deps.joinDeliveryWorkbench({ + const chatId = await deps.resolveDeliveryChat(def.tenantId); + if (chatId === undefined) return; + await deps.joinDeliveryChat({ tenantId: def.tenantId, - workbenchId, + workbenchId: chatId, principalId: def.creatorPrincipalId, address, handle: handleFromName(def.name, address), @@ -250,10 +250,10 @@ export function launchScheduledDefinitionFromDb( content: SCHEDULE_TICK_CONTENT, }); try { - await joinScheduledDefinitionToWorkbench(deps, def, triggered.address); + await joinScheduledDefinitionToChat(deps, def, triggered.address); } catch (error) { reportError(error, { - operation: "workflow-scheduler.join-workbench", + operation: "workflow-scheduler.join-chat", tenantId: def.tenantId, extra: { definitionId: def.definitionId, @@ -275,7 +275,7 @@ export type RunNowScheduledDefinitionArgs = { }; /** - * Fire a scheduled definition now, then join the run to a workbench the + * Fire a scheduled definition now, then join the run to a chat the * same way the poller does. Join failures are reported and do not fail * the launch — the caller still gets `{ runId }`. */ @@ -291,7 +291,7 @@ export async function runNowScheduledDefinition( content: args.content, }); try { - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( deps, { definitionId: args.definitionId, @@ -305,7 +305,7 @@ export async function runNowScheduledDefinition( ); } catch (error) { reportError(error, { - operation: "scheduled-workflow.run-now.join-workbench", + operation: "scheduled-workflow.run-now.join-chat", tenantId: args.tenantId, extra: { definitionId: args.definitionId, diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 0443642225..2a1e646247 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -1,7 +1,8 @@ // Process-boot and createHub proof that hub production boot does not // mint a root tenant or an admin account. An empty database is a valid // hub: /status, health, and auth mechanics serve with zero tenant rows. -// First signup (signup-genesis.test.ts) is the 0→1 path. +// First self-serve signup plus a stock POST /api/tenants call is the +// 0→1 path. // // DB-gated: boots against its own scratch database so a reachable // DATABASE_URL is required and the suite skips without one. @@ -169,14 +170,10 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; @@ -201,7 +198,7 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { // CL-7579: hub boot must never plant provider credentials from // environment variables. Operators connect providers through the -// onboarding/connect flow instead, so a hub booted with curated +// client-driven connect flow instead, so a hub booted with curated // provider env vars set (e.g. OLLAMA_BASE_URL) inserts zero credential // rows and zero catalog offerings — verified against a local fake // Ollama whose probe the old env-plant would have passed. @@ -269,23 +266,24 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { expect(signUp.status).toBe(200); const cookies = signUp.headers.getSetCookie(); expect(cookies.length).toBeGreaterThan(0); - const provision = await fetch(`${baseUrl}/api/onboarding/provision`, { + const createTenant = await fetch(`${baseUrl}/api/tenants`, { method: "POST", headers: { "content-type": "application/json", cookie: cookies.join("; "), }, - body: JSON.stringify({ name: "Workbench" }), + body: JSON.stringify({ slug: "workbench", name: "Workbench" }), }); - expect(provision.status).toBe(200); + expect(createTenant.status).toBe(201); } test("process boot with OLLAMA_BASE_URL set inserts no credential or offering rows", async () => { await withScratchDatabase("boot_env_key_no_plant", async (url) => { - // Phase 1: mint the operator identity (signup genesis) so the old - // plant's target — admin sign-in + root tenant by slug — resolves - // immediately on the second boot. Without it the plant would sit - // in its retry loop and the test would prove nothing. + // Phase 1: mint the operator identity (self-serve signup plus the + // stock first-tenant create) so the old plant's target — an + // existing operator bench — resolves immediately on the second + // boot. Without it the plant would sit in its retry loop and the + // test would prove nothing. const genesis = await hop("hub process boot", async () => startHub({ databaseUrl: url, @@ -324,7 +322,7 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { }); }, 90_000); - test("createHub with a resolved operator bench plants no credential rows", async () => { + test("createHub with an existing operator tenant plants no credential rows", async () => { await withScratchDatabase("create_hub_env_key_no_plant", async (url) => { const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-plant-")); const staticDir = path.join(root, "static"); @@ -332,8 +330,8 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { writeFileSync(path.join(staticDir, "index.html"), "shell"); mkdirSync(path.join(root, "data"), { recursive: true }); - // Onboarding routes reach the hub over HTTP, so the composed app - // must be served on a real port and `baseUrl` must name it. + // Phase 1 works in-process: the signup and tenant routes are + // exercised through the composed app directly. const server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }), @@ -345,19 +343,15 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; - // Phase 1: mint the operator bench the old plant targeted. + // Phase 1: mint the operator tenant the old plant targeted. const genesis = await createHub(baseConfig); server.reload({ fetch: genesis.app.fetch }); const signUp = await genesis.app.request("/api/auth/sign-up/email", { @@ -373,15 +367,15 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { }), }); expect(signUp.status).toBe(200); - const provision = await genesis.app.request("/api/onboarding/provision", { + const createTenant = await genesis.app.request("/api/tenants", { method: "POST", headers: { "content-type": "application/json", cookie: signUp.headers.getSetCookie().join("; "), }, - body: JSON.stringify({ name: "Workbench" }), + body: JSON.stringify({ slug: "workbench", name: "Workbench" }), }); - expect(provision.status).toBe(200); + expect(createTenant.status).toBe(201); await genesis.close(); const hub = await createHub({ diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index 734026eb5a..c58a8856bc 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -29,16 +29,12 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 95435477f5..f7e4127bb7 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -31,16 +31,12 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; @@ -133,41 +129,6 @@ describeIfDb("extension mounting", () => { const outside = await hub.app.request("/chat/workbenches"); expect(await outside.text()).toBe("shell"); }); - - test("the first-login onboarding hook is gated the same way", async () => { - const hub = await bootHub(); - - const gated = await hub.app.request("/api/onboarding/provision", { - method: "POST", - }); - expect(gated.status).toBe(401); - // Onboarding answers in CL-6360's envelope: a consumer-language - // `userMessage` and a `refId` that ties the response to the log - // line, never a raw internal `message`. - const body = (await gated.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("unauthorized"); - expect(body.error.userMessage).toBe("Sign in to continue."); - expect(body.error.refId).toMatch(/\S/); - }); - - test("the workbench-tenancy kind lookup the bench switcher uses is mounted and gated", async () => { - const hub = await bootHub(); - - const gated = await hub.app.request("/api/workbench-tenancies/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: [] }), - }); - expect(gated.status).toBe(401); - const kindsBody = (await gated.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(kindsBody.error.code).toBe("unauthorized"); - expect(kindsBody.error.userMessage).toBe("Authentication required"); - expect(kindsBody.error.refId).toMatch(/\S/); - }); }); describeIfDb( @@ -273,7 +234,6 @@ describeIfDb( test("an attacker exhausting the account's budget with wrong guesses never blocks the owner's correct password, and a successful sign-in resets the budget", async () => { const hub = await createHub({ ...config, - signupMode: "open", signInRateLimit: { windowSeconds: 60, max: 2 }, }); closers.push(hub.close); @@ -341,12 +301,9 @@ describeIfDb( }, ); -describeIfDb("dev-mode email verification", () => { - test("ALLOW_UNVERIFIED_EMAILS auto-verifies a fresh self-serve signup, so it never 403s on the unverified-email gate", async () => { - const hub = await createHub({ - ...config, - signupMode: "open", - }); +describeIfDb("stock signup carries no email-verification gate", () => { + test("a fresh self-serve signup can create a tenant without verifying anything", async () => { + const hub = await createHub(config); closers.push(hub.close); const email = `first-run-${crypto.randomUUID()}@example.com`; @@ -360,12 +317,6 @@ describeIfDb("dev-mode email verification", () => { }), }); expect(signUp.status).toBe(200); - const body = (await signUp.json()) as { user: { emailVerified: boolean } }; - // No mailer exists anywhere in this stack, so better-auth itself - // would leave this false forever without the dev-mode auto-verify - // hook -- this is the exact condition that used to dead-end fresh - // signup at signup_not_allowed. - expect(body.user.emailVerified).toBe(true); const cookie = signUp.headers.get("set-cookie"); expect(cookie).not.toBeNull(); @@ -377,8 +328,9 @@ describeIfDb("dev-mode email verification", () => { }, body: JSON.stringify({ slug: `bench-${crypto.randomUUID().slice(0, 8)}`, + name: "First Bench", }), }); - expect(createTenant.status).not.toBe(403); + expect(createTenant.status).toBe(201); }); }); diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index bc524ea43d..4ebae17dd3 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -28,14 +28,10 @@ describe("readHubConfig", () => { sessionSecret: validEnv.SESSION_SECRET, hubDataDir: validEnv.HUB_DATA_DIR, hubStaticDir: validEnv.HUB_STATIC_DIR, - defaultTenantSlug: "workbench", socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, allowPlaintextSecrets: false, - allowUnverifiedEmails: false, sidecarProvisioners: [{ id: "process" }], defaultSidecarProvisionerId: "process", chatIdleReapMs: 30 * 60_000, @@ -54,18 +50,14 @@ describe("readHubConfig", () => { expect(Object.keys(config).sort()).toEqual( [ "allowPlaintextSecrets", - "allowUnverifiedEmails", - "allowedEmailDomains", "baseUrl", "chatIdleReapMs", "databaseUrl", "defaultSidecarProvisionerId", - "defaultTenantSlug", "hubDataDir", "hubStaticDir", "sessionSecret", "signInRateLimit", - "signupMode", "signupRateLimit", "socialProviders", "sidecarProvisioners", @@ -133,49 +125,6 @@ describe("readHubConfig", () => { }); }); - test("WORKBENCH_DEFAULT_TENANT defaults to workbench and accepts an explicit slug", () => { - expect(readHubConfig(validEnv).defaultTenantSlug).toBe("workbench"); - expect( - readHubConfig({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "acme" }) - .defaultTenantSlug, - ).toBe("acme"); - }); - - test("ORG_SLUG aliases WORKBENCH_DEFAULT_TENANT when the latter is unset", () => { - const config = readHubConfig({ ...validEnv, ORG_SLUG: "acme" }); - expect(config.defaultTenantSlug).toBe("acme"); - }); - - test("WORKBENCH_DEFAULT_TENANT wins over ORG_SLUG when both are set", () => { - const config = readHubConfig({ - ...validEnv, - WORKBENCH_DEFAULT_TENANT: "root", - ORG_SLUG: "acme", - }); - expect(config.defaultTenantSlug).toBe("root"); - }); - - test("OPERATOR_TENANT_ID fails loudly with an actionable message", () => { - const message = readExpectingError({ - ...validEnv, - OPERATOR_TENANT_ID: "tnt_stale", - }); - expect(message).toContain("OPERATOR_TENANT_ID"); - expect(message).toContain("WORKBENCH_DEFAULT_TENANT"); - }); - - test("WORKBENCH_DEFAULT_TENANT rejects a non-slug value", () => { - expect( - readExpectingError({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "" }), - ).toContain("WORKBENCH_DEFAULT_TENANT"); - expect( - readExpectingError({ - ...validEnv, - WORKBENCH_DEFAULT_TENANT: "Not A Slug", - }), - ).toContain("WORKBENCH_DEFAULT_TENANT"); - }); - test("the signup rate limit is configurable and defaults sanely", () => { const config = readHubConfig({ ...validEnv, @@ -210,22 +159,6 @@ describe("readHubConfig", () => { expect(config.signInRateLimit).toEqual({ windowSeconds: 30, max: 2 }); }); - test("WORKBENCH_SIGNUP defaults closed and accepts open", () => { - expect(readHubConfig(validEnv).signupMode).toBe("closed"); - expect( - readHubConfig({ ...validEnv, WORKBENCH_SIGNUP: "open" }).signupMode, - ).toBe("open"); - }); - - test("WORKBENCH_ALLOWED_EMAIL_DOMAINS parses a comma list", () => { - expect( - readHubConfig({ - ...validEnv, - WORKBENCH_ALLOWED_EMAIL_DOMAINS: "acme.example, corp.example", - }).allowedEmailDomains, - ).toEqual(["acme.example", "corp.example"]); - }); - test("huggingfaceOAuthClientId is absent by default", () => { expect(readHubConfig(validEnv).huggingfaceOAuthClientId).toBeUndefined(); }); @@ -316,10 +249,6 @@ describe("readHubConfig", () => { expect(readHubConfig(validEnv).allowPlaintextSecrets).toBe(false); }); - test("allowUnverifiedEmails is false by default", () => { - expect(readHubConfig(validEnv).allowUnverifiedEmails).toBe(false); - }); - test("allowGitInsideWorkTree is omitted by default", () => { expect(readHubConfig(validEnv).allowGitInsideWorkTree).toBeUndefined(); }); @@ -343,17 +272,6 @@ describe("readHubConfig", () => { expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE"); }); - test("ALLOW_UNVERIFIED_EMAILS='1' or 'true' opts in", () => { - expect( - readHubConfig({ ...validEnv, ALLOW_UNVERIFIED_EMAILS: "1" }) - .allowUnverifiedEmails, - ).toBe(true); - expect( - readHubConfig({ ...validEnv, ALLOW_UNVERIFIED_EMAILS: "true" }) - .allowUnverifiedEmails, - ).toBe(true); - }); - test("ALLOW_PLAINTEXT_SECRETS='1' or 'true' opts in", () => { expect( readHubConfig({ ...validEnv, ALLOW_PLAINTEXT_SECRETS: "1" }) @@ -554,14 +472,14 @@ describe("readHubConfig", () => { }); describe("chatIdleReapMs", () => { - test("defaults to 30 minutes when WORKBENCH_CHAT_IDLE_REAP_MS is unset", () => { + test("defaults to 30 minutes when HUB_CHAT_IDLE_REAP_MS is unset", () => { expect(readHubConfig(validEnv).chatIdleReapMs).toBe(30 * 60_000); }); - test("is read from WORKBENCH_CHAT_IDLE_REAP_MS when set", () => { + test("is read from HUB_CHAT_IDLE_REAP_MS when set", () => { const config = readHubConfig({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "5000", + HUB_CHAT_IDLE_REAP_MS: "5000", }); expect(config.chatIdleReapMs).toBe(5000); }); @@ -569,17 +487,17 @@ describe("readHubConfig", () => { test("rejects zero", () => { const message = readExpectingError({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "0", + HUB_CHAT_IDLE_REAP_MS: "0", }); - expect(message).toContain("WORKBENCH_CHAT_IDLE_REAP_MS"); + expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); }); test("rejects a non-integer value", () => { const message = readExpectingError({ ...validEnv, - WORKBENCH_CHAT_IDLE_REAP_MS: "not-a-number", + HUB_CHAT_IDLE_REAP_MS: "not-a-number", }); - expect(message).toContain("WORKBENCH_CHAT_IDLE_REAP_MS"); + expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); }); }); diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 547fd0c312..1d79fb7cfc 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -18,14 +18,10 @@ const baseConfig: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: ".data/hub", hubStaticDir: "apps/hub/public", - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: false, - allowUnverifiedEmails: false, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/eval-runs-mount.test.ts b/apps/hub/test/eval-runs-mount.test.ts index 0ac28ff631..649b4077bf 100644 --- a/apps/hub/test/eval-runs-mount.test.ts +++ b/apps/hub/test/eval-runs-mount.test.ts @@ -30,14 +30,10 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts index f83adda75e..2c92436b39 100644 --- a/apps/hub/test/presence-mount.test.ts +++ b/apps/hub/test/presence-mount.test.ts @@ -29,14 +29,10 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index 8991601706..8cfe3256b6 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -34,15 +34,11 @@ const config: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, - defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, - allowUnverifiedEmails: true, sidecarProvisioners: [], chatIdleReapMs: 30 * 60_000, - signupMode: "closed", - allowedEmailDomains: [], allowPlaintextSecrets: true, }; diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts deleted file mode 100644 index c202084c50..0000000000 --- a/apps/hub/test/signup-genesis.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -// CL-7578 end-to-end proof of the 0→1 contract: a hub booted on an -// empty database starts truly empty, and the first signup — not a CLI, -// not a boot-time seed — mints the root tenant and becomes its owner. -// The second signup joins that root as a plain member. Signup never -// seeds workflows, tools, or grants. -// -// DB-gated: each test boots a full hub against its own scratch -// database, so a reachable DATABASE_URL is required and the suite -// skips without one. -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { - principal, - principalRole, - role, - tenant, - user as userTable, -} from "@intx/db/schema"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { setupDatabase } from "../../../scripts/db-setup"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const closers: (() => Promise)[] = []; -afterAll(async () => { - let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); - // 60s, not bun's 5s default hook timeout: each closer shuts a whole - // booted hub (server close + DB pool drain) sequentially, and under CI - // load that chain exceeds 5s — the timeout is the teardown budget, not - // a symptom being hidden. -}, 60_000); - -function scratchUrlFor(label: string): string { - const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_signup_genesis_${label}`; - return url.toString(); -} - -async function withScratchDatabase( - scratchUrl: string, - run: () => Promise, -): Promise { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl); - try { - await run(); - } finally { - const cleanup = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await cleanup.unsafe( - `DROP DATABASE IF EXISTS "${scratchDatabase}" WITH (FORCE)`, - ); - } finally { - await cleanup.end(); - } - } -} - -async function bootEmptyHub(args: { - scratchUrl: string; - signupMode: "open" | "closed"; -}): Promise<{ - baseUrl: string; - db: Awaited>["db"]; - stop: () => Promise; -}> { - const root = mkdtempSync(path.join(tmpdir(), "hub-signup-genesis-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - - // The onboarding routes reach the hub over HTTP (`createHubAPI`), so - // the composed app must be served on a real port — bind first to - // learn the port, boot the hub against that base URL, then hand the - // server the app. - const server = Bun.serve({ - port: 0, - fetch: () => new Response("booting", { status: 503 }), - }); - const baseUrl = `http://localhost:${server.port}`; - const config: HubConfig = { - databaseUrl: args.scratchUrl, - baseUrl, - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - signupMode: args.signupMode, - allowedEmailDomains: [], - allowPlaintextSecrets: true, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - }; - const hub = await createHub(config); - server.reload({ fetch: hub.app.fetch }); - let stopped = false; - const stop = async () => { - if (stopped) return; - stopped = true; - server.stop(true); - await hub.close(); - rmSync(root, { recursive: true, force: true }); - }; - closers.push(stop); - return { baseUrl, db: hub.db, stop }; -} - -// Distinct client IP per sign-up: better-auth's rate-limit storage is -// shared across every hub instance in this test process and keyed on -// the resolved client IP, so without this the suite's sign-ups all -// land in one budget bucket and can starve sibling suites -// (composition.test.ts signs up against the same bucket). -let signUpIpCounter = 0; - -async function signUp( - baseUrl: string, - args: { name: string; email: string; password: string }, -): Promise { - signUpIpCounter += 1; - const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-real-ip": `198.51.100.${signUpIpCounter}`, - }, - body: JSON.stringify(args), - }); - expect(response.status).toBe(200); - const cookies = response.headers.getSetCookie(); - expect(cookies.length).toBeGreaterThan(0); - return cookies; -} - -async function provision( - baseUrl: string, - cookies: string[], - name?: string, -): Promise<{ - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; -}> { - const response = await fetch(`${baseUrl}/api/onboarding/provision`, { - method: "POST", - headers: { - "content-type": "application/json", - cookie: cookies.join("; "), - }, - ...(name !== undefined ? { body: JSON.stringify({ name }) } : {}), - }); - expect(response.status).toBe(200); - return (await response.json()) as { - kind: string; - tenantId?: string; - tenantSlug?: string; - seeded?: boolean; - }; -} - -async function roleNamesFor( - db: Awaited>["db"], - args: { tenantId: string; email: string }, -): Promise { - const [userRow] = await db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, args.email)) - .limit(1); - expect(userRow).toBeDefined(); - const userId: string = userRow?.id ?? ""; - expect(userId).not.toBe(""); - const rows = await db - .select({ roleName: role.name }) - .from(principal) - .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) - .innerJoin(role, eq(role.id, principalRole.roleId)) - .where( - and( - eq(principal.tenantId, args.tenantId), - eq(principal.kind, "user"), - eq(principal.refId, userId), - eq(principal.status, "active"), - ), - ); - return rows.map((r) => r.roleName); -} - -describeIfDb("signup genesis (CL-7578)", () => { - test("a closed empty hub admits the first signup, which mints the root tenant as owner — no seed", async () => { - const scratchUrl = scratchUrlFor("closed"); - await withScratchDatabase(scratchUrl, async () => { - const { baseUrl, db } = await bootEmptyHub({ - scratchUrl, - signupMode: "closed", - }); - - // The empty-hub exception: signup is closed, but zero users and - // zero tenants means this caller is the genesis owner. - const cookies = await signUp(baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - - const probe = await provision(baseUrl, cookies); - expect(probe.kind).toBe("needs-onboarding"); - - const result = await provision(baseUrl, cookies, "Acme"); - expect(result.kind).toBe("provisioned"); - expect(result.tenantSlug).toBe("workbench"); - expect(result.seeded).toBe(false); - expect(typeof result.tenantId).toBe("string"); - const tenantId = result.tenantId; - - const [userCount] = await db.select().from(userTable); - expect(userCount).toBeDefined(); - const roots = await db - .select() - .from(tenant) - .where(isNull(tenant.parentId)); - expect(roots).toHaveLength(1); - expect(roots[0]?.slug).toBe("workbench"); - const rootId: string = roots[0]?.id ?? ""; - expect(rootId).not.toBe(""); - expect((await db.select().from(tenant)).length).toBe(1); - - expect( - await roleNamesFor(db, { - tenantId: rootId, - email: "alice@example.com", - }), - ).toEqual(["owner"]); - - // No seed side effects: the genesis tenant carries no workflow - // assets and no deployments. - const assets = await fetch( - `${baseUrl}/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, - { headers: { cookie: cookies.join("; ") } }, - ); - expect(await assets.json()).toEqual([]); - const deployments = await fetch( - `${baseUrl}/api/tenants/${tenantId}/workflows/deployments`, - { headers: { cookie: cookies.join("; ") } }, - ); - expect(await deployments.json()).toEqual([]); - }); - }, 30_000); - - test("the second signup on an open hub joins the existing root as member, minting nothing", async () => { - const scratchUrl = scratchUrlFor("open"); - await withScratchDatabase(scratchUrl, async () => { - const { baseUrl, db } = await bootEmptyHub({ - scratchUrl, - signupMode: "open", - }); - - const alice = await signUp(baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - const genesis = await provision(baseUrl, alice, "Acme"); - expect(genesis.kind).toBe("provisioned"); - expect(genesis.tenantSlug).toBe("workbench"); - - const bob = await signUp(baseUrl, { - name: "Bob", - email: "bob@example.com", - password: "password123", - }); - // The join path needs no display name: a plain membership probe - // is enough, because the root already exists. - const joined = await provision(baseUrl, bob); - expect(joined.kind).toBe("existing-member"); - expect(joined.tenantSlug).toBe("workbench"); - expect(joined.tenantId).toBe(genesis.tenantId); - - const tenants = await db.select().from(tenant); - expect(tenants).toHaveLength(1); - const tenantId: string = tenants[0]?.id ?? ""; - expect(tenantId).not.toBe(""); - expect((await db.select().from(userTable)).length).toBe(2); - - expect( - await roleNamesFor(db, { - tenantId, - email: "alice@example.com", - }), - ).toEqual(["owner"]); - expect( - await roleNamesFor(db, { - tenantId, - email: "bob@example.com", - }), - ).toEqual(["member"]); - }); - }, 30_000); - - test("an operator-removed member cannot self-rejoin on a closed hub", async () => { - const scratchUrl = scratchUrlFor("rejoin"); - await withScratchDatabase(scratchUrl, async () => { - const open = await bootEmptyHub({ - scratchUrl, - signupMode: "open", - }); - - const alice = await signUp(open.baseUrl, { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - await provision(open.baseUrl, alice, "Acme"); - const bob = await signUp(open.baseUrl, { - name: "Bob", - email: "bob@example.com", - password: "password123", - }); - const joined = await provision(open.baseUrl, bob); - expect(joined.kind).toBe("existing-member"); - - // Operator removal: native removal deletes the member's - // principal rows outright, leaving the account itself alive. - const [bobRow] = await open.db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, "bob@example.com")) - .limit(1); - expect(bobRow).toBeDefined(); - await open.db.delete(principalRole).where( - inArray( - principalRole.principalId, - open.db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ), - ), - ); - await open.db - .delete(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ); - - // The hub flips signup to closed and restarts on the same data. - await open.stop(); - const closed = await bootEmptyHub({ - scratchUrl, - signupMode: "closed", - }); - - const signIn = await fetch(`${closed.baseUrl}/api/auth/sign-in/email`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email: "bob@example.com", - password: "password123", - }), - }); - expect(signIn.status).toBe(200); - const sessionCookies = signIn.headers.getSetCookie(); - - const res = await fetch(`${closed.baseUrl}/api/onboarding/provision`, { - method: "POST", - headers: { - "content-type": "application/json", - cookie: sessionCookies.join("; "), - }, - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("signup_not_allowed"); - - // No principal was minted: the removal sticks. - const [bobAfter] = await closed.db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.kind, "user"), - eq(principal.refId, bobRow?.id ?? ""), - ), - ) - .limit(1); - expect(bobAfter).toBeUndefined(); - }); - }, 30_000); -}); diff --git a/apps/hub/test/slack-tag-mount.test.ts b/apps/hub/test/slack-tag-mount.test.ts deleted file mode 100644 index a612be0c9f..0000000000 --- a/apps/hub/test/slack-tag-mount.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Proves the Slack tag ingress env-gate: absent SLACK_BOT_TOKEN/ -// SLACK_SIGNING_SECRET never registers the webhook route; the pair -// present without SLACK_WORKBENCH_TENANT_SLUG/SLACK_DEFAULT_AGENT_DEFINITION_ID -// fails boot loudly (there is no honest default for either); the full -// config registers the route and inherits real Slack signature -// verification from `@corbits/slack-tag`/`corbits-tag/slack` — request/ -// reply behavior itself is covered by `packages/slack-tag`'s own tests. -import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -// DB-gated: skipped when DATABASE_URL is unset, matching this repo's -// convention for tests that talk to a real Postgres. -const DATABASE_URL = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(DATABASE_URL, import.meta.path); -const SLACK_WEBHOOK_PATH = "/api/tag/slack/webhook"; -const TENANT_SLUG = `slack-tag-test-${crypto.randomUUID().slice(0, 8)}`; - -const root = mkdtempSync(path.join(tmpdir(), "hub-slack-tag-mount-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl: DATABASE_URL, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - signupMode: "closed", - allowedEmailDomains: [], - allowPlaintextSecrets: true, -}; - -const closers: (() => Promise)[] = []; -let tenantId: string | undefined; - -async function seedTenant(): Promise { - const sql = postgres(DATABASE_URL, { max: 1 }); - try { - const id = `tnt_${crypto.randomUUID().replaceAll("-", "")}`; - await sql` - INSERT INTO "tenant" (id, name, slug, domain, created_at, updated_at) - VALUES (${id}, ${"Slack Tag Test Bench"}, ${TENANT_SLUG}, ${`${TENANT_SLUG}.localhost`}, now(), now()) - `; - return id; - } finally { - await sql.end(); - } -} - -async function unseedTenant(id: string): Promise { - const sql = postgres(DATABASE_URL, { max: 1 }); - try { - await sql`DELETE FROM "tenant" WHERE id = ${id}`; - } finally { - await sql.end(); - } -} - -beforeAll(async () => { - if (DATABASE_URL === "") return; - tenantId = await seedTenant(); -}); - -afterAll(async () => { - for (const close of closers) await close(); - if (tenantId !== undefined) await unseedTenant(tenantId); - rmSync(root, { recursive: true, force: true }); -}); - -const SLACK_ENV_KEYS = [ - "SLACK_BOT_TOKEN", - "SLACK_SIGNING_SECRET", - "SLACK_WORKBENCH_TENANT_SLUG", - "SLACK_DEFAULT_AGENT_DEFINITION_ID", -] as const; -const savedEnv = new Map(SLACK_ENV_KEYS.map((key) => [key, process.env[key]])); - -afterEach(async () => { - for (const key of SLACK_ENV_KEYS) { - const saved = savedEnv.get(key); - if (saved === undefined) Reflect.deleteProperty(process.env, key); - else process.env[key] = saved; - } - // Close each test's hub as soon as its test ends rather than letting - // every hub booted in this file stay live (and its background loops - // — relaunch sweep among them — keep running) until `afterAll`. Three - // live hubs contending for the same Postgres connections is exactly - // the kind of self-inflicted race this file should not add on top of - // CL-7453's fix in `apps/hub/src/index.ts`. - let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); -}); - -async function bootHub(): Promise>> { - const hub = await createHub(config); - closers.push(hub.close); - return hub; -} - -describeIfDb("Slack tag ingress env-gate", () => { - test("absent SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET never registers the webhook route", async () => { - for (const key of SLACK_ENV_KEYS) Reflect.deleteProperty(process.env, key); - - const hub = await bootHub(); - const res = await hub.app.request(SLACK_WEBHOOK_PATH, { - method: "POST", - body: "{}", - }); - expect(res.status).toBe(404); - }); - - test("the credential pair without a tenant slug and definition id fails boot loudly", async () => { - process.env["SLACK_BOT_TOKEN"] = "xoxb-test"; - process.env["SLACK_SIGNING_SECRET"] = "shhh-test"; - delete process.env["SLACK_WORKBENCH_TENANT_SLUG"]; - delete process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"]; - - await expect(bootHub()).rejects.toThrow( - /SLACK_WORKBENCH_TENANT_SLUG|SLACK_DEFAULT_AGENT_DEFINITION_ID/, - ); - }); - - test("a fully configured mount registers the route and rejects a bad signature", async () => { - process.env["SLACK_BOT_TOKEN"] = "xoxb-test"; - process.env["SLACK_SIGNING_SECRET"] = "shhh-test"; - process.env["SLACK_WORKBENCH_TENANT_SLUG"] = TENANT_SLUG; - process.env["SLACK_DEFAULT_AGENT_DEFINITION_ID"] = - "wfd_does_not_matter_for_this_test"; - - const hub = await bootHub(); - const res = await hub.app.request(SLACK_WEBHOOK_PATH, { - method: "POST", - body: "{}", - headers: { - "x-slack-request-timestamp": Math.floor(Date.now() / 1000).toString(), - "x-slack-signature": - "v0=0000000000000000000000000000000000000000000000000000000000000000", - }, - }); - expect(res.status).toBe(401); - }); -}); diff --git a/apps/hub/test/tenant-create-guard.test.ts b/apps/hub/test/tenant-create-guard.test.ts deleted file mode 100644 index 3caf71114c..0000000000 --- a/apps/hub/test/tenant-create-guard.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Route-level proof that POST /api/tenants cannot be used to bypass -// @workbench/access-policy — see ./src/tenant-create-guard.ts's module -// comment for the [Intx gap] this guards against (CL-6041). No real DB -// or full hub boot required: `guardedHubApp` wraps a minimal stub -// "native" app standing in for the vendored tenant route, and role -// resolution is injected as a plain function (the same seam -// `apps/hub/src/index.ts` wires to a real `resolveCallerRoleNames(db, ...)` -// in production), so these tests exercise exactly the guard's own -// decision without touching Postgres. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import { createInMemoryAccessPolicyStore } from "@workbench/access-policy"; - -import { - guardedHubApp, - type TenantCreateGuardDeps, -} from "../src/tenant-create-guard"; - -type FakeUser = { id: string; email: string; emailVerified: boolean }; - -function stubNativeApp(): { app: Hono; created: unknown[] } { - const app = new Hono(); - const created: unknown[] = []; - app.post("/api/tenants", async (c) => { - const body: unknown = await c.req.json().catch(() => ({})); - created.push(body); - return c.json({ id: "tnt_new", name: "New", slug: "new" }, 201); - }); - return { app, created }; -} - -/** No membership anywhere — the fake `resolveCallerRoleNames` every - * "not a member" test uses. */ -async function noMembership(): Promise { - return undefined; -} - -function depsFor(args: { - user: FakeUser | undefined; - operatorTenantId?: string; - envSignupMode?: "open" | "closed"; - resolveCallerRoleNames?: TenantCreateGuardDeps["resolveCallerRoleNames"]; - policiesByTenant?: Record< - string, - { tenancyCreation: "owners" | "owners-admins" | "none" } - >; - tenants?: number; -}): TenantCreateGuardDeps { - const store = createInMemoryAccessPolicyStore(); - for (const [tenantId, policy] of Object.entries( - args.policiesByTenant ?? {}, - )) { - void store.upsertPolicy(tenantId, policy); - } - - const deps: TenantCreateGuardDeps = { - store, - resolveCallerRoleNames: args.resolveCallerRoleNames ?? noMembership, - countTenants: async () => args.tenants ?? 1, - envSignupMode: args.envSignupMode ?? "closed", - envAllowedDomains: [], - allowUnverifiedEmails: false, - getSessionUser: async () => args.user, - }; - if (args.operatorTenantId !== undefined) { - deps.operatorTenantId = args.operatorTenantId; - } - return deps; -} - -describe("guardedHubApp — bypass shape A: unauthenticated caller", () => { - test("POST /api/tenants without a session is rejected before the native route ever runs", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const wrapped = guardedHubApp(nativeApp, depsFor({ user: undefined })); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Evil", slug: "evil" }), - }); - - expect(response.status).toBe(401); - expect(created).toHaveLength(0); - }); -}); - -describe("guardedHubApp — bypass shape B: arbitrary parentId under a tenant the caller does not belong to", () => { - test("an authenticated user cannot become owner of a child under a tenant they aren't a member of", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_1", - email: "attacker@evil.example", - emailVerified: true, - }, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Hostile Takeover", - slug: "hostile", - parentId: "tnt_victim", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("not_a_member"); - expect(created).toHaveLength(0); - }); - - test("a genuine owner-member of the parent may create a sub-workbench under it", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_2", email: "owner@acme.example", emailVerified: true }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_2" ? ["owner"] : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("a member without a sufficient role is denied by the parent's own tenancyCreation policy", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_3", - email: "member@acme.example", - emailVerified: true, - }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_3" ? ["member"] : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("tenancy_creation_forbidden"); - expect(created).toHaveLength(0); - }); - - test("an admin is denied by the default owners tenancyCreation policy", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_admin", - email: "admin@acme.example", - emailVerified: true, - }, - resolveCallerRoleNames: async (tenantId, userId) => - tenantId === "tnt_acme" && userId === "usr_admin" - ? ["admin"] - : undefined, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("tenancy_creation_forbidden"); - expect(created).toHaveLength(0); - }); - - test("owners-admins policy also accepts an admin", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_8", email: "admin@acme.example", emailVerified: true }, - resolveCallerRoleNames: async () => ["admin"], - policiesByTenant: { tnt_acme: { tenancyCreation: "owners-admins" } }, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Sub bench", - slug: "sub-bench", - parentId: "tnt_acme", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); -}); - -describe("guardedHubApp — top-level and operator-tenant creation go through the signup gate", () => { - test("no parentId on an empty hub (genesis) is allowed even with signup closed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_first", - email: "first@example.com", - emailVerified: true, - }, - envSignupMode: "closed", - tenants: 0, - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Acme", slug: "workbench" }), - }); - - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("no parentId, signup closed -> denied, fail closed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_4", email: "new@acme.example", emailVerified: true }, - envSignupMode: "closed", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("signup_not_allowed"); - expect(created).toHaveLength(0); - }); - - test("no parentId, signup open and email verified -> allowed", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_5", email: "new@acme.example", emailVerified: true }, - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("exploit: an unverified email cannot self-provision even with signup open", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { - id: "usr_6", - email: "unverified@acme.example", - emailVerified: false, - }, - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Mine", slug: "mine" }), - }); - expect(response.status).toBe(403); - expect( - ((await response.json()) as { error: { code: string } }).error.code, - ).toBe("signup_not_allowed"); - expect(created).toHaveLength(0); - }); - - test("parentId equal to the operator tenant follows the same signup gate as no parentId", async () => { - const { app: nativeApp, created } = stubNativeApp(); - const deps = depsFor({ - user: { id: "usr_7", email: "new@acme.example", emailVerified: true }, - operatorTenantId: "tnt_operator", - envSignupMode: "open", - }); - const wrapped = guardedHubApp(nativeApp, deps); - - const response = await wrapped.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Mine", - slug: "mine", - parentId: "tnt_operator", - }), - }); - expect(response.status).toBe(201); - expect(created).toHaveLength(1); - }); - - test("GET requests to /api/tenants/:id are never intercepted", async () => { - const app = new Hono(); - app.get("/api/tenants/tnt_1", (c) => c.json({ id: "tnt_1" })); - const wrapped = guardedHubApp(app, depsFor({ user: undefined })); - - const response = await wrapped.request("/api/tenants/tnt_1"); - expect(response.status).toBe(200); - }); -}); diff --git a/apps/hub/test/tenant-create-onboard.test.ts b/apps/hub/test/tenant-create-onboard.test.ts deleted file mode 100644 index 1d4ad351be..0000000000 --- a/apps/hub/test/tenant-create-onboard.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -// CL-7584: the tenant-create trigger. A 201 from `POST /api/tenants` -// fires exactly one fire-and-forget desired-state reconcile for the new -// tenant under the creator's minted session; a 403 fires none; -// concurrent kicks for the same tenant dedupe in-process; and a tenant -// with no catalog offerings logs blocked instead of throwing. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { AppEnv } from "@intx/hub-api"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import type { - ReconcileReport, - reconcileTenantDesiredState, -} from "@workbench/onboarding/desired-state"; -import { - createTenantCreateObserver, - type TenantCreateOnboardDeps, -} from "../src/tenant-create-onboard"; - -function harness( - overrides: Omit, "reconcileFn"> & { - reconcileFn?: TenantCreateOnboardDeps["reconcileFn"] | undefined; - reconcileStateFn?: typeof reconcileTenantDesiredState | undefined; - nativeApp?: Hono; - } = {}, -) { - const logged: string[] = []; - const reconciled: string[] = []; - let cookiesSeenValue = ""; - const native = - overrides.nativeApp ?? - new Hono().post("/api/tenants", (c) => - c.json({ id: "ten_new", name: "New" }, 201), - ); - - const { nativeApp, ...depOverrides } = overrides; - const deps = { - api: (async () => { - throw new Error("no hub calls expected with a reconcileFn stub"); - }) as unknown as ApiCall, - hubUrl: "https://hub.example.com", - pushWorkflow: (async () => ({ - outcome: "pushed", - commitSha: "a".repeat(40), - })) as unknown as WorkflowPusher, - log: (line) => logged.push(line), - reconcileFn: async (args) => { - reconciled.push(args.tenantId); - cookiesSeenValue = args.cookies.join(";"); - return { - tenantId: args.tenantId, - ready: true, - pins: [{ name: "assistant", kind: "workflow", status: "installed" }], - } satisfies ReconcileReport; - }, - ...depOverrides, - } as TenantCreateOnboardDeps; - - const cookiesSeen = () => cookiesSeenValue; - const { app, kick } = createTenantCreateObserver(deps, native); - return { app, kick, logged, reconciled, cookiesSeen }; -} - -describe("createTenantCreateObserver", () => { - test("a 201 create fires one reconcile for the new tenant", async () => { - const { app, reconciled, cookiesSeen } = harness(); - - const response = await app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - expect(response.status).toBe(201); - // The kick is fire-and-forget but its collector is synchronous up - // to the first await in the observer's own async work; yield once. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(reconciled).toEqual(["ten_new"]); - expect(cookiesSeen()).toContain("better-auth.session_token=creator"); - }); - - test("a 403 create fires nothing", async () => { - const denied = new Hono().post("/api/tenants", (c) => - c.json({ error: { code: "signup_not_allowed" } }, 403), - ); - const { app, reconciled } = harness({ nativeApp: denied }); - - const response = await app.request("/api/tenants", { method: "POST" }); - expect(response.status).toBe(403); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(reconciled).toEqual([]); - }); - - test("concurrent creates of the same tenant dedupe to one reconcile", async () => { - let releaseFirst: (() => void) | undefined; - const firstKick = new Promise((resolve) => { - releaseFirst = resolve; - }); - let calls = 0; - const { app } = harness({ - reconcileFn: async (args) => { - calls += 1; - if (calls === 1) await firstKick; - return { - tenantId: args.tenantId, - ready: true, - pins: [], - }; - }, - nativeApp: new Hono().post("/api/tenants", (c) => - c.json({ id: "ten_same" }, 201), - ), - }); - - const request = () => - app.request("/api/tenants", { - method: "POST", - headers: { cookie: "better-auth.session_token=creator" }, - }); - const first = request(); - const second = request(); - await Promise.all([first, second]); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls).toBe(1); - releaseFirst?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - test("a tenant with no catalog offerings logs blocked instead of throwing", async () => { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/tenants/ten_new/models") { - return { status: 200, data: [], cookies: [] }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall; - const { app, logged } = harness({ - api, - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - }); - - await app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect( - logged.some( - (line) => line.includes("ten_new") && line.includes("blocked"), - ), - ).toBe(true); - }); -}); - -// CL-7584: the production deployer path, DB-free. Past the -// model===undefined gate the observer resolves the creator's deploy -// identity (`resolveTenantDeployer`: GET /api/me/principals paginated + -// GET /api/tenants/:id) and hands principalId/tenantDomain to the -// reconcile seam — never a void-identity deploy when the session holds -// no deployable principal there. -describe("createTenantCreateObserver production deployer path", () => { - const DEPLOYER_PRINCIPAL_ID = "prn_creator"; - const TENANT_DOMAIN = "new.bench.local"; - - function principalsPayload(entries: { - status: string; - principalId?: string; - }) { - return { - data: [ - { - principalId: entries.principalId ?? DEPLOYER_PRINCIPAL_ID, - tenantId: "ten_new", - tenantName: "New", - tenantSlug: "new", - kind: "user", - status: entries.status, - roles: [], - }, - ], - nextCursor: null, - }; - } - - function productionApi(principals: { data: unknown[]; nextCursor: null }) { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/tenants/ten_new/models") { - return { - status: 200, - data: [ - { - id: "mdl_0", - canonicalName: "llama3.2", - offerings: [ - { - offeringId: "off_0", - providerId: "cpv_1", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }, - ], - cookies: [], - }; - } - if (method === "GET" && path === "/api/me/principals") { - return { status: 200, data: principals, cookies: [] }; - } - if (method === "GET" && path === "/api/tenants/ten_new") { - return { - status: 200, - data: { - id: "ten_new", - name: "New", - slug: "new", - domain: TENANT_DOMAIN, - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall; - return api; - } - - function postCreate(app: Hono) { - return app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: "better-auth.session_token=creator", - }, - body: JSON.stringify({ name: "New" }), - }); - } - - // The kick is fire-and-forget behind the 201: the singleton lookup plus - // paginated-principal fetch settles after the response is serialized. - // Poll — never a fixed sleep — so the suite stays green when the whole - // monorepo test leg runs packages in parallel. - async function awaitSettled(done: () => boolean) { - const deadline = Date.now() + 2000; - while (!done()) { - if (Date.now() > deadline) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - test("the creator's principalId and tenant domain reach the reconcile seam", async () => { - const seen: { - tenantId: string; - principalId: string; - domain: string; - modelDefined: boolean; - }[] = []; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - seen.push({ - tenantId: args.tenant.tenantId, - principalId: args.tenant.principalId, - domain: args.tenant.domain, - modelDefined: args.model !== undefined, - }); - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi(principalsPayload({ status: "active" })), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => seen.length === 1); - expect(seen).toEqual([ - { - tenantId: "ten_new", - principalId: DEPLOYER_PRINCIPAL_ID, - domain: TENANT_DOMAIN, - modelDefined: true, - }, - ]); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(false); - }); - - test("a suspended principal never reaches the seam — blocked, never void-identity", async () => { - let calls = 0; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - calls += 1; - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi(principalsPayload({ status: "suspended" })), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => - logged.some((line) => line.includes("no deployable principal")), - ); - expect(calls).toBe(0); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(true); - }); - - test("no principal on the tenant never reaches the seam — blocked, never void-identity", async () => { - let calls = 0; - const reconcileStateFn: typeof reconcileTenantDesiredState = (async ( - args, - ) => { - calls += 1; - return { - tenantId: args.tenant.tenantId, - ready: true, - pins: [], - }; - }) as typeof reconcileTenantDesiredState; - const { app, logged } = harness({ - api: productionApi({ data: [], nextCursor: null }), - reconcileFn: - undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], - reconcileStateFn, - }); - - const response = await postCreate(app); - expect(response.status).toBe(201); - await awaitSettled(() => - logged.some((line) => line.includes("no deployable principal")), - ); - expect(calls).toBe(0); - expect( - logged.some((line) => line.includes("no deployable principal")), - ).toBe(true); - }); -}); diff --git a/apps/hub/test/tenant-create-shutdown.test.ts b/apps/hub/test/tenant-create-shutdown.test.ts deleted file mode 100644 index f8ab593987..0000000000 --- a/apps/hub/test/tenant-create-shutdown.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// The tenant-create observer's shutdown contract: `stop()` refuses new -// kicks, `whenIdle()` waits out the in-flight ones, and the hub `close()` -// 250ms race bounds that wait so a stuck reconcile can never stall -// teardown. The observer half is DB-free (the reconcile step is injected); -// the `close()` half boots a real hub and so is DB-gated like every other -// suite that does. -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; -import { Hono } from "hono"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { - createTenantCreateObserver, - type TenantCreateOnboardDeps, -} from "../src/tenant-create-onboard.ts"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { setupDatabase } from "../../../scripts/db-setup"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const unreachableApi = (async () => { - throw new Error("observer tests inject reconcileFn; the api is never called"); -}) as unknown as ApiCall; -const unreachablePusher = (async () => { - throw new Error( - "observer tests inject reconcileFn; the pusher is never called", - ); -}) as unknown as WorkflowPusher; - -function mountObserver( - reconcileFn: NonNullable, - logError: (line: string) => void = () => undefined, -) { - const wrapped = new Hono(); - wrapped.get("/ping", (c) => c.text("pong")); - return createTenantCreateObserver( - { - api: unreachableApi, - hubUrl: "http://localhost:1", - pushWorkflow: unreachablePusher, - log: () => undefined, - logError, - reconcileFn, - }, - // The wrapped app's type carries the hub's AppEnv; the observer only - // forwards through it, so a bare Hono app suffices for these tests. - wrapped as never, - ); -} - -describe("tenant-create observer shutdown", () => { - test("stop() refuses new kicks while whenIdle() waits out the in-flight one", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let runs = 0; - const observer = mountObserver(async ({ tenantId }) => { - runs += 1; - await gate; - return { tenantId, ready: true, pins: [] }; - }); - - const first = observer.kick({ tenantId: "ten_a", cookies: [] }); - // Let the kick reach the stuck reconcile before stopping. - await Bun.sleep(20); - observer.stop(); - // A kick after stop resolves without running anything. - await observer.kick({ tenantId: "ten_b", cookies: [] }); - expect(runs).toBe(1); - - // whenIdle is still waiting on the stuck kick, not resolved early. - let idle = false; - const idleWait = observer.whenIdle().then(() => { - idle = true; - }); - await Bun.sleep(20); - expect(idle).toBe(false); - - release(); - await first; - await idleWait; - expect(idle).toBe(true); - }); - - test("a failed kick settles (never rejects) so whenIdle always resolves", async () => { - const lines: string[] = []; - const observer = mountObserver( - async () => { - throw new Error("sidecar exploded mid-reconcile"); - }, - (line) => lines.push(line), - ); - await observer.kick({ tenantId: "ten_a", cookies: [] }); - await observer.whenIdle(); - expect(lines.some((line) => line.includes("ten_a"))).toBe(true); - }); -}); - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("hub close() bound (CL-7584)", () => { - test("close() on an idle hub resolves well inside the teardown budget", async () => { - const scratchUrl = new URL( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const dbName = scratchUrl.pathname.replace(/^\//, ""); - scratchUrl.pathname = `/${dbName}_shutdown`; - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - const scratchName = new URL(scratchUrl).pathname.replace(/^\//, ""); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchName}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchName}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl.toString()); - - const root = mkdtempSync(path.join(tmpdir(), "hub-shutdown-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - const server = Bun.serve({ - port: 0, - fetch: () => new Response("booting", { status: 503 }), - }); - const baseUrl = `http://localhost:${server.port}`; - const config: HubConfig = { - databaseUrl: scratchUrl.toString(), - baseUrl, - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - defaultTenantSlug: "workbench", - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - signupMode: "open", - allowedEmailDomains: [], - allowPlaintextSecrets: true, - allowUnverifiedEmails: true, - sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, - }; - const hub = await createHub(config); - server.reload({ fetch: hub.app.fetch }); - try { - const start = Date.now(); - await hub.close(); - const elapsed = Date.now() - start; - // Measured, not guessed: idle close took ~0.8s locally - // (2026-09-14, scratch Postgres on loopback); the assertion budget - // is 10s (generous: CI teardown of server + pool). The 250ms - // observer race inside close() bounds - // the kick wait by construction, so a stuck kick cannot stall - // teardown — but a stuck-kick-under-load timing proof is still - // open: production wiring has no injection point for a hung - // reconcile, so this covers the idle path only. - expect(elapsed).toBeLessThan(10_000); - } finally { - server.stop(true); - rmSync(root, { recursive: true, force: true }); - const cleanup = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await cleanup.unsafe( - `DROP DATABASE IF EXISTS "${scratchName}" WITH (FORCE)`, - ); - } finally { - await cleanup.end(); - } - } - }, 60_000); -}); diff --git a/apps/hub/test/workflow-scheduler.test.ts b/apps/hub/test/workflow-scheduler.test.ts index 5553e660b5..2a48ade364 100644 --- a/apps/hub/test/workflow-scheduler.test.ts +++ b/apps/hub/test/workflow-scheduler.test.ts @@ -4,7 +4,7 @@ import * as errorSink from "@corbits/error-sink"; import { createWorkflowScheduler, DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, - joinScheduledDefinitionToWorkbench, + joinScheduledDefinitionToChat, runNowScheduledDefinition, tickWorkflowScheduler, type ScheduledDefinition, @@ -239,14 +239,14 @@ describe("createWorkflowScheduler's setInterval wiring", () => { }); }); -describe("joinScheduledDefinitionToWorkbench", () => { - test("joins the first workbench when delivery is required", async () => { +describe("joinScheduledDefinitionToChat", () => { + test("joins the first chat when delivery is required", async () => { const joined: unknown[] = []; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async (input) => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async (input) => { joined.push(input); }, }, @@ -264,13 +264,13 @@ describe("joinScheduledDefinitionToWorkbench", () => { ]); }); - test("omits join when the tenant has no workbench and still returns", async () => { + test("omits join when the tenant has no chat and still returns", async () => { let joined = 0; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => undefined, - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => undefined, + joinDeliveryChat: async () => { joined += 1; }, }, @@ -282,11 +282,11 @@ describe("joinScheduledDefinitionToWorkbench", () => { test("omits join when the catalog says inbox delivery", async () => { let joined = 0; - await joinScheduledDefinitionToWorkbench( + await joinScheduledDefinitionToChat( { - deliveryWorkbenchRequired: async () => false, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => false, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async () => { joined += 1; }, }, @@ -347,15 +347,15 @@ function runNowArgs() { } describe("runNowScheduledDefinition", () => { - test("joins the first workbench after a successful trigger", async () => { + test("joins the first chat after a successful trigger", async () => { const joined: unknown[] = []; const result = await runNowScheduledDefinition( { db: createFakeTriggerDb([LIVE_ANCHOR]) as never, sidecarRouter: { routeMail: () => true } as never, - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async (input) => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async (input) => { joined.push(input); }, }, @@ -381,9 +381,9 @@ describe("runNowScheduledDefinition", () => { { db: createFakeTriggerDb([LIVE_ANCHOR]) as never, sidecarRouter: { routeMail: () => true } as never, - deliveryWorkbenchRequired: async () => true, - resolveDeliveryWorkbench: async () => "chn_1", - joinDeliveryWorkbench: async () => { + deliveryChatRequired: async () => true, + resolveDeliveryChat: async () => "chn_1", + joinDeliveryChat: async () => { throw boom; }, }, @@ -393,7 +393,7 @@ describe("runNowScheduledDefinition", () => { expect(report).toHaveBeenCalledTimes(1); expect(report.mock.calls[0]?.[0]).toBe(boom); expect(report.mock.calls[0]?.[1]).toEqual({ - operation: "scheduled-workflow.run-now.join-workbench", + operation: "scheduled-workflow.run-now.join-chat", tenantId: "t1", extra: { definitionId: "wfd_digest", diff --git a/apps/web/src/auth-screen.tsx b/apps/web/src/auth-screen.tsx index 39a090f415..ea7fc67a5d 100644 --- a/apps/web/src/auth-screen.tsx +++ b/apps/web/src/auth-screen.tsx @@ -4,15 +4,17 @@ import { Button } from "@corbits/react-ui"; import { LoginForm } from "@corbits/react-ui/blocks/login/login-form"; -import { getLogger } from "@corbits/client-log"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { AuthLayout } from "./auth/auth-layout"; -import { fetchAuthConfig, signIn, signInSocial, signUp } from "./session"; +import { + SOCIAL_SIGN_IN_PROVIDERS, + signIn, + signInSocial, + signUp, +} from "./session"; import type { SessionUser, SocialProviderId } from "./session"; -const log = getLogger("web.auth-screen"); - type Mode = "sign-in" | "sign-up"; const COPY = { @@ -41,42 +43,6 @@ export function AuthScreen({ const [mode, setMode] = useState("sign-in"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [socialProviders, setSocialProviders] = useState< - readonly SocialProviderId[] - >([]); - const [authConfigUnavailable, setAuthConfigUnavailable] = useState(false); - - useEffect(() => { - let cancelled = false; - void fetchAuthConfig().then((result) => { - if (cancelled) return; - if (result.kind === "unavailable") { - log.error("Could not load sign-in options", { - message: result.message, - }); - setAuthConfigUnavailable(true); - return; - } - setSocialProviders(result.providers); - }); - return () => { - cancelled = true; - }; - }, []); - - const retryAuthConfig = () => { - setAuthConfigUnavailable(false); - void fetchAuthConfig().then((result) => { - if (result.kind === "unavailable") { - log.error("Could not load sign-in options", { - message: result.message, - }); - setAuthConfigUnavailable(true); - return; - } - setSocialProviders(result.providers); - }); - }; const submit = async (credentials: { readonly email: string; @@ -118,34 +84,19 @@ export function AuthScreen({ error={error} footer={ <> - {authConfigUnavailable && ( -

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

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