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

- {SETTINGS_STRINGS.accessPolicyEnvOverrideNotice} -

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

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

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