diff --git a/.env.example b/.env.example index 72672f23c7..abfec096cc 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,17 @@ # # cp .env.example .env # -# Every variable up to HUB_STATIC_DIR is required; everything below -# it is optional (see its own comment). Boot validates the whole file -# and reports every missing or malformed value at once — nothing is -# silently defaulted. +# DATABASE_URL, HUB_DATA_DIR, CREDENTIAL_ENCRYPTION_KEY, and +# PRINCIPAL_KEY_ENCRYPTION_KEY are required; everything else is optional +# (see its own comment). `bun run dev` reports the first missing or +# malformed value it hits at boot — nothing is silently defaulted. +# +# The hub itself (apps/hub/src/server.ts) is upstream Interchange's own +# hub shape: it reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME +# individually rather than a single connection string, and mints its +# own session auth with no SESSION_SECRET or social-provider config. +# `bun run dev` derives those DB_* values from DATABASE_URL below so +# this file only needs to state one URL. # Postgres connection URL for your local Postgres (see the README's # "Running locally" section for a one-time setup). Prefer an explicit @@ -13,15 +20,14 @@ # without $USER cannot infer one. DATABASE_URL=postgres://localhost:5432/workbench -# The single public origin the stack serves from. `workbench setup` and -# `workbench seed` also read this to find the hub, unless HUB_URL is -# set separately. +# The origin the Corbits connections/OAuth mount uses to build callback +# URLs (github/gmail/huggingface connect, MCP OAuth). Unset falls back to +# http://localhost:, which is wrong once anything sits in front of +# the hub — set this explicitly behind a reverse proxy (e.g. tailscale +# serve), where BASE_URL is the public origin and PORT is the local port +# the proxy forwards to. BASE_URL=http://localhost:3000 - -# Secret used to sign browser sessions; any string of at least 32 -# characters. The value below is fine for a laptop that only you can -# reach; generate your own for anything else: openssl rand -hex 32 -SESSION_SECRET=insecure-dev-only-session-secret-0000 +# PORT=3000 # Directory where the hub keeps durable repo and asset state. Created # on boot if absent; relative paths resolve against the hub's working @@ -36,11 +42,9 @@ HUB_DATA_DIR=.data/hub # commit can land on the working branch and delete the checkout. HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 -# Directory of built user-interface files the hub serves at BASE_URL. -# Relative paths resolve against the hub's working directory (apps/hub -# under `bun run dev`), so ../web/dist is the web app's build output; -# produce it with `bun run build` before starting the hub. -HUB_STATIC_DIR=../web/dist +# The web app now runs its own dev server (`bun run dev` starts it +# alongside the hub) rather than being served as a static build from the +# hub's own origin — see apps/web/vite.config.ts's /api proxy. # The administrator identity `bun run dev` seeds for local development: # the dev script signs this account up on the fresh hub, so a fresh @@ -53,24 +57,10 @@ 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. -# Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60 -# seconds when unset. -# SIGNUP_RATE_LIMIT_WINDOW_SECONDS=60 -# SIGNUP_RATE_LIMIT_MAX=5 - -# Per-account rate limit on email sign-in, keyed on the target email -# rather than client IP (client IP can't be trusted as a sign-in key in -# this deployment — see sign-in-rate-limit.ts). Defaults to 10 attempts -# per 60 seconds when unset — deliberately looser than better-auth's -# built-in 3-per-10-seconds default, which is tight enough that a mistyped -# password can lock an account out mid-window. See CL-6494. -# SIGNIN_RATE_LIMIT_WINDOW_SECONDS=60 -# SIGNIN_RATE_LIMIT_MAX=10 - -# 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. +# Self-serve signup is ungated: stock Interchange composition (createAuth) +# carries no signup mode, domain allowlist, rate limit, or +# email-verification gate, so anyone can register. 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 @@ -91,40 +81,16 @@ HUB_STATIC_DIR=../web/dist # tooling, never part of a real user's workflow set. # WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS= -# Behind a reverse proxy (e.g. tailscale serve), BASE_URL is the public -# origin and PORT is the local port the proxy forwards to. -# PORT=3000 - -# OAuth sign-in. Each provider is an independent pair — set both values -# to turn its button on at the sign-in screen, leave both unset to -# leave it off. Setting only one half of a pair fails the hub at boot, -# naming the missing value, rather than silently disabling the -# provider. Email/password sign-in always stays available regardless. -# -# Google: create an OAuth client at -# https://console.cloud.google.com/apis/credentials, with an authorized -# redirect URI of /api/auth/callback/google. -# GOOGLE_CLIENT_ID= -# GOOGLE_CLIENT_SECRET= -# -# GitHub: create an OAuth app at -# https://github.com/settings/developers, with an authorization -# callback URL of /api/auth/callback/github. -# GITHUB_CLIENT_ID= -# GITHUB_CLIENT_SECRET= - # The `github` connector stores a personal access token today (Plugins # page and the in-room connect-github card are PAT-first — CL-6345). Create # a token at https://github.com/settings/tokens with the `repo` scope and # paste it in the connect UI; no OAuth App is required for that path. # -# Optional — reserved for a future GitHub App / hosted one-click connect -# (CL-6343, not the shipped welcome mat). A SEPARATE OAuth App from -# GITHUB_CLIENT_ID above (which only signs people in to Workbench itself). -# If you register one early: create it at https://github.com/settings/developers -# with an authorization callback URL of /api/tenants/ — GitHub -# matches any redirect under that prefix; the flow's actual callback would -# be tenant-scoped +# Optional — a hosted one-click GitHub connect (CL-6343, not the shipped +# welcome mat). Create an OAuth App at +# https://github.com/settings/developers with an authorization callback +# URL of /api/tenants/ — GitHub matches any redirect under that +# prefix; the flow's actual callback is tenant-scoped # /api/tenants//connections/oauth/github/callback # (CL-6394). Leave both unset for normal PAT-first deploys. # GITHUB_APP_CLIENT_ID= @@ -132,8 +98,7 @@ HUB_STATIC_DIR=../web/dist # Gmail connector (read, draft, send — sending always waits for approval): # create an OAuth client at https://console.cloud.google.com/apis/credentials -# (a separate client from GOOGLE_CLIENT_ID's sign-in app) with the Gmail -# API enabled. Google requires EXACT-match redirect URIs, so register the +# with the Gmail API enabled. Google requires EXACT-match redirect URIs, so register the # tenant-scoped callback for each tenant that will connect: # /api/tenants//connections/oauth/gmail/callback # The connect asks for the gmail.modify scope with offline access (the @@ -232,27 +197,20 @@ HUB_STATIC_DIR=../web/dist # webhook-trigger signing secrets, and the onboarding OAuth connect state # (PKCE verifier) sealed between /start and /callback so it survives a hub # restart in between. 64 hex characters (32 bytes, AES-256): openssl rand -# -hex 32. Required for any real deployment — the hub refuses to boot -# without it. Leave unset and instead set ALLOW_PLAINTEXT_SECRETS below -# for local dev/test. -# CREDENTIAL_ENCRYPTION_KEY= +# -hex 32. Required unconditionally — the hub refuses to boot without it, +# with no dev-only unencrypted fallback. The value below is fine for a +# laptop that only you can reach; generate your own for anything else. +CREDENTIAL_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # Seals every principal's per-principal signing key at rest, under its own # key so it rotates independently of CREDENTIAL_ENCRYPTION_KEY. 64 hex -# characters (32 bytes, AES-256): openssl rand -hex 32. Required for any -# real deployment — the hub refuses to boot without it. Leave unset and -# instead set ALLOW_PLAINTEXT_SECRETS below for local dev/test. -# PRINCIPAL_KEY_ENCRYPTION_KEY= +# characters (32 bytes, AES-256): openssl rand -hex 32. Required +# unconditionally, same as CREDENTIAL_ENCRYPTION_KEY above. +PRINCIPAL_KEY_ENCRYPTION_KEY=1111111111111111111111111111111111111111111111111111111111111111 -# Dev/test-only opt-out of the CREDENTIAL_ENCRYPTION_KEY and -# PRINCIPAL_KEY_ENCRYPTION_KEY requirements above: -# set to boot without a key, storing those secrets unencrypted with a boot -# warning. Uncommented here so a fresh checkout's `bun run dev` keeps -# working out of the box. Safe to inherit as-is: the hub refuses to boot -# with this set unless BASE_URL is also a loopback address (localhost / -# 127.0.0.1 / ::1), so it can never take effect against a real deployment -# even if this file is copied verbatim. -ALLOW_PLAINTEXT_SECRETS=1 +# Maximum size, in bytes, of a tool-package tarball the package-registry +# PUT endpoint accepts. Defaults to 10 MiB when unset. +# HUB_MAX_TARBALL_BYTES=10485760 # Artifacts plane (@corbits/artifacts) uses DATABASE_URL too — different # schema only (`artifacts.*` FKs into public.tenant/principal). Nothing to @@ -276,17 +234,14 @@ ALLOW_PLAINTEXT_SECRETS=1 # e2b one remote E2B sandbox per allocation. Requires E2B_API_KEY # and E2B_TEMPLATE (see packages/e2b-sandbox-sidecar/README.md). # -# SIDECAR_DEFAULT_PROVISIONER picks which listed id exclusive placements -# provision on; optional when exactly one id is listed, required when -# listing more than one. +# Listing more than one id registers all of them; the first listed is the +# default an exclusive placement provisions on. # -# Adding another backend is three steps, no hub surgery: implement -# SidecarProvisioner in its own package (copy packages/process-provisioner -# — the allocation lifecycle itself comes from @corbits/sandbox-sidecar), -# add its id to SIDECAR_PROVISIONER_IDS in apps/hub/src/config.ts, and add -# a case to buildSidecarProvisioner in apps/hub/src/index.ts. +# Adding another backend is: implement SidecarProvisioner in its own +# package (copy packages/process-provisioner — the allocation lifecycle +# itself comes from @corbits/sandbox-sidecar), then add a case for its id +# to buildSidecarProvisioner in apps/hub/src/server.ts. # SIDECAR_PROVISIONERS=process -# SIDECAR_DEFAULT_PROVISIONER=process # DOCKER_PROVISIONER_IMAGE= # Overrides for the "process" backend, both optional. Unset, it spawns @@ -298,9 +253,10 @@ ALLOW_PLAINTEXT_SECRETS=1 # HUB_SIDECAR_WEBSOCKET_URL overrides the ws(s):// address a provisioned # sidecar dials back to reach this hub. Leave unset to derive it from -# BASE_URL — fine when the hub and its sidecars can reach each other at -# that address, which is always true for the "process" backend (a child -# process on the hub's own host). Set this explicitly whenever +# PORT (ws://127.0.0.1:/api/sidecars/ws) — fine when the hub and its +# sidecars can reach each other at that address, which is always true for +# the "process" backend (a child process on the hub's own host). Set this +# explicitly whenever # SIDECAR_PROVISIONERS includes "docker" or "e2b": a container's own # "localhost" is itself, not the hub host, and an E2B sandbox is a remote # machine that needs a publicly reachable wss:// URL or tunnel. diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts deleted file mode 100644 index 37f179ac83..0000000000 --- a/apps/hub/src/config.ts +++ /dev/null @@ -1,560 +0,0 @@ -// The one boundary that reads the process environment. Everything the -// hub needs from the outside world is an irreducible deployment fact: -// where the database is, what origin the hub serves, how sessions are -// signed, and where durable state and the built interface live on disk. -// Anything else the hub learns is data in the database, never -// configuration. -// -// Provider credentials are never read from the environment: operators -// connect a provider in the UI (or via the same connect API the -// onboarding flow uses), never by setting ANTHROPIC_API_KEY or any -// other provider's env var — hub boot plants nothing. -// -// GOOGLE_CLIENT_ID/SECRET and GITHUB_CLIENT_ID/SECRET are each an -// optional pair: set both to enable that OAuth provider on the sign-in -// screen, leave both unset to leave it off — email/password remains -// available either way. Setting only one half of a pair is a boot-time -// error, never a silently-disabled provider. -// -// HUGGINGFACE_OAUTH_CLIENT_ID is a single optional value, not a pair — -// Hugging Face's connect flow uses a public OAuth app with no client -// secret. Set it to enable the onboarding wizard's Hugging Face connect -// card; leave it unset and Hugging Face stays available only as a -// paste-a-token provider card. - -import { type } from "arktype"; -import { spawnSync } from "node:child_process"; -import { mkdirSync, realpathSync } from "node:fs"; -import path from "node:path"; - -// Refuse to initialize hub git-on-disk state inside an existing git work -// tree. isomorphic-git walks up from a data dir looking for `.git`; when -// HUB_DATA_DIR lives inside this repo (the `.env` default `.data/hub`), a -// missed nested init authors a genesis commit onto the working branch and a -// checkout of that tree can delete the checkout (see AGENTS.md). -const GIT_DIR_VARS = new Set([ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", -]); - -function envWithoutGitDir(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const next: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(env)) { - if (value === undefined || GIT_DIR_VARS.has(key)) continue; - next[key] = value; - } - return next; -} - -function enclosingGitWorkTree(dir: string, env: NodeJS.ProcessEnv): string | null { - const absolute = path.resolve(dir); - mkdirSync(absolute, { recursive: true }); - const canonical = realpathSync(absolute); - const result = spawnSync( - "git", - ["-c", "safe.directory=*", "-C", canonical, "rev-parse", "--show-toplevel"], - { encoding: "utf8", env: envWithoutGitDir(env) }, - ); - if (result.status !== 0) return null; - const toplevel = result.stdout.trim(); - return toplevel === "" ? null : realpathSync(toplevel); -} - -/** - * Called at hub boot (`index.ts`), never from `readHubConfig` itself: - * this does real filesystem/subprocess IO against `dir`, which would - * make every test parsing a config trip it against whatever `.data/hub`- - * shaped path a fixture happens to use. - */ -export function assertHubDataDirGitSafety( - dir: string, - allowInsideWorkTree: boolean, - env: NodeJS.ProcessEnv, -): void { - if (allowInsideWorkTree) return; - const enclosing = enclosingGitWorkTree(dir, env); - if (enclosing === null) return; - throw new Error( - [ - `HUB_DATA_DIR (${dir}) is inside the git work tree at ${enclosing}.`, - "The hub refuses to initialize git-on-disk state there: a nested init", - "that misses its own .git walks up and commits onto the working", - "branch, which can delete the checkout.", - "Point HUB_DATA_DIR at a directory outside any git work tree, or set", - "HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 to opt in.", - ].join(" "), - ); -} - -const HTTP_URL = /^https?:\/\/.+$/; - -const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); - -/** - * Whether `baseUrl` names a loopback address — the one fact that - * distinguishes a developer's own machine from a real deployment - * without trusting an operator to have overridden anything. Used to - * refuse `ALLOW_PLAINTEXT_SECRETS` (see `readHubConfig`) rather than - * relying on an operator to have removed it from an inherited `.env`. - */ -function isLoopbackBaseUrl(baseUrl: string): boolean { - return LOOPBACK_HOSTNAMES.has(new URL(baseUrl).hostname); -} - -const HubEnv = type({ - DATABASE_URL: type(/^postgres(ql)?:\/\/.+$/).describe( - "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"), - "PORT?": type(/^\d{1,5}$/).describe( - "the local port to listen on when it differs from BASE_URL's — set this when a reverse proxy (Tailscale serve, nginx) fronts the hub and BASE_URL is the public https origin", - ), - SESSION_SECRET: type("string >= 32").describe( - "a session-signing secret of at least 32 characters", - ), - HUB_DATA_DIR: type("string > 0").describe( - "a filesystem directory for the hub's durable repo and asset state, e.g. .data/hub", - ), - HUB_STATIC_DIR: type("string > 0").describe( - "a directory of built user-interface files the hub serves, e.g. apps/web/dist", - ), - "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( - "the per-IP sign-up rate-limit window, in seconds, e.g. 60", - ), - "SIGNUP_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe( - "the maximum sign-ups a single IP may make per window, e.g. 5", - ), - "GOOGLE_CLIENT_ID?": type("string > 0").describe( - "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", - ), - "GOOGLE_CLIENT_SECRET?": type("string > 0").describe( - "Google OAuth client secret; set together with GOOGLE_CLIENT_ID to enable Google sign-in", - ), - "GITHUB_CLIENT_ID?": type("string > 0").describe( - "GitHub OAuth client id; set together with GITHUB_CLIENT_SECRET to enable GitHub sign-in", - ), - "GITHUB_CLIENT_SECRET?": type("string > 0").describe( - "GitHub OAuth client secret; set together with GITHUB_CLIENT_ID to enable GitHub sign-in", - ), - "HUGGINGFACE_OAUTH_CLIENT_ID?": type("string > 0").describe( - "Hugging Face public OAuth app client id (huggingface.co/settings/applications, no secret — see docs/onboarding-huggingface-connect.md); optional, enables the onboarding wizard's Hugging Face connect card", - ), - "GITHUB_APP_CLIENT_ID?": type("string > 0").describe( - "GitHub OAuth App client id for the `github` connector's hosted one-click connect (github.com/settings/developers — a separate app from GITHUB_CLIENT_ID's sign-in app); set together with GITHUB_APP_CLIENT_SECRET, or the Plugins/connect-github card falls back to a pasted personal access token", - ), - "GITHUB_APP_CLIENT_SECRET?": type("string > 0").describe( - "GitHub OAuth App client secret; set together with GITHUB_APP_CLIENT_ID to enable the hosted GitHub connect", - ), - "GMAIL_CLIENT_ID?": type("string > 0").describe( - "Google OAuth client id for the `gmail` connector's hosted connect (console.cloud.google.com — a separate OAuth client from GOOGLE_CLIENT_ID's sign-in app); set together with GMAIL_CLIENT_SECRET, or the Gmail connect card renders not-configured", - ), - "GMAIL_CLIENT_SECRET?": type("string > 0").describe( - "Google OAuth client secret; set together with GMAIL_CLIENT_ID to enable the hosted Gmail connect", - ), - "GITHUB_API_BASE_URL?": type(/^https?:\/\/.+$/).describe( - "override for the GitHub REST API origin the `github` connector's PAT probe and credential delivery target, e.g. a fake server recorded for tests/evals; unset (default) targets https://api.github.com — never set this for a real deployment", - ), - "CREDENTIAL_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe( - "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) encrypting secrets at rest through Interchange's CredentialCipher seam — webhook-trigger signing secrets; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", - ), - "PRINCIPAL_KEY_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe( - "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) sealing every principal's per-principal signing key at rest through Interchange's principal-key store — deliberately separate from CREDENTIAL_ENCRYPTION_KEY so the two rotate independently; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", - ), - "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", - ), - "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 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", - ), - "PROCESS_PROVISIONER_SIDECAR_ENTRY?": type("string > 0").describe( - "the sidecar entry point the process backend spawns for each exclusive allocation; unset (default) resolves this repository's own apps/sidecar/src/index.ts", - ), - "PROCESS_PROVISIONER_RUNTIME?": type("string > 0").describe( - "the executable the process backend runs that entry point with; unset (default) reuses the bun binary running the hub", - ), - "E2B_API_KEY?": type("string > 0").describe( - "the E2B API key the e2b sidecar provisioner authenticates with; required when SIDECAR_PROVISIONERS includes 'e2b'", - ), - "E2B_TEMPLATE?": type("string > 0").describe( - "the immutable E2B template id sandboxes are created from; required when SIDECAR_PROVISIONERS includes 'e2b'", - ), - "E2B_SANDBOX_TIMEOUT_MS?": type("string > 0").describe( - "how long an E2B sandbox may live before E2B reclaims it; defaults to 15 minutes", - ), - "DOCKER_PROVISIONER_IMAGE?": type("string > 0").describe( - "the container image the docker sidecar provisioner runs for each exclusive allocation; required when SIDECAR_PROVISIONERS includes 'docker'", - ), - "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", - ), -}); - -const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 60; -const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 5; - -// 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. -// -// 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; -}; - -/** The sandbox backend. Its API key and template come from the process - * environment; its hub-side state directory is derived from `hubDataDir`, - * never configured separately, so it cannot drift from the other backends' - * state or be confused with the sandbox's own `SIDECAR_DATA_DIR`. */ -export type E2BSidecarProvisionerConfig = { - readonly id: "e2b"; - readonly apiKey: string; - readonly template: string; - readonly sandboxTimeoutMs?: string; -}; - -/** The default backend: one `apps/sidecar` child process per allocation - * on this host. It needs no operator settings at all — an unconfigured - * install gets it — and both env keys below are overrides for an operator - * running a sidecar build from somewhere other than this repository. */ -export type ProcessSidecarProvisionerConfig = { - readonly id: "process"; - readonly sidecarEntryPath?: string; - readonly runtimePath?: string; -}; - -export type SidecarProvisionerConfig = - | ProcessSidecarProvisionerConfig - | DockerSidecarProvisionerConfig - | E2BSidecarProvisionerConfig; - -const SIDECAR_PROVISIONER_IDS = ["process", "docker", "e2b"] as const; -type SidecarProvisionerId = (typeof SIDECAR_PROVISIONER_IDS)[number]; - -function isSidecarProvisionerId(value: string): value is SidecarProvisionerId { - return (SIDECAR_PROVISIONER_IDS as readonly string[]).includes(value); -} - -export type SocialProviderId = "google" | "github"; - -export type SocialProviderCredential = { - readonly clientId: string; - readonly clientSecret: string; -}; - -export type HubConfig = { - readonly databaseUrl: string; - readonly baseUrl: string; - readonly listenPort?: number; - readonly sessionSecret: string; - readonly hubDataDir: string; - readonly hubStaticDir: string; - readonly signupRateLimit: { - readonly windowSeconds: number; - readonly max: number; - }; - readonly socialProviders: Readonly>>; - readonly huggingfaceOAuthClientId?: string; - readonly githubAppClientId?: string; - readonly githubAppClientSecret?: string; - readonly gmailClientId?: string; - readonly gmailClientSecret?: string; - /** Override for the GitHub REST API origin the `github` connector's PAT - * probe and credential delivery target. Unset (default) targets - * https://api.github.com; a fake server substitutes cleanly in - * tests/evals — never set this for a real deployment. */ - readonly githubApiBaseUrl?: string; - readonly credentialEncryptionKeyHex?: string; - /** Seals per-principal signing keys at rest; see PRINCIPAL_KEY_ENCRYPTION_KEY. */ - readonly principalKeyEncryptionKeyHex?: string; - /** Dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY. */ - readonly allowPlaintextSecrets: boolean; - /** Opt-in to initialize git-on-disk state inside an existing git work tree. */ - readonly allowGitInsideWorkTree?: boolean; - /** Every sidecar-allocation backend registered for exclusive placement, - * one or more, each addressable by its provisioner id. Never empty: an - * install that configures nothing registers the `process` backend, so - * exclusive placement works on a single server out of the box. */ - readonly sidecarProvisioners: readonly SidecarProvisionerConfig[]; - /** Which registered id exclusive placements provision on. */ - readonly defaultSidecarProvisionerId?: string; - /** Overrides the ws(s):// URL a provisioned sidecar dials back to reach - * this hub. Unset derives it from baseUrl instead. */ - readonly sidecarWebSocketUrl?: string; -}; - -type ParsedHubEnv = typeof HubEnv.infer; - -const SOCIAL_PROVIDER_ENV_KEYS: Record< - SocialProviderId, - { - readonly id: "GOOGLE_CLIENT_ID" | "GITHUB_CLIENT_ID"; - readonly secret: "GOOGLE_CLIENT_SECRET" | "GITHUB_CLIENT_SECRET"; - } -> = { - google: { id: "GOOGLE_CLIENT_ID", secret: "GOOGLE_CLIENT_SECRET" }, - github: { id: "GITHUB_CLIENT_ID", secret: "GITHUB_CLIENT_SECRET" }, -}; - -/** - * Builds the social-provider credential map. A provider is enabled only - * when both its id and secret are set; a half-configured pair (one set, - * the other missing) is a boot-time error — never a silently-disabled - * provider, per the DX mandate that misconfiguration fails loudly. - */ -function socialProvidersFrom( - parsed: ParsedHubEnv, -): Readonly>> { - const providers: Partial> = {}; - const errors: string[] = []; - for (const [providerId, keys] of Object.entries(SOCIAL_PROVIDER_ENV_KEYS) as [ - SocialProviderId, - (typeof SOCIAL_PROVIDER_ENV_KEYS)[SocialProviderId], - ][]) { - const clientId = parsed[keys.id]; - const clientSecret = parsed[keys.secret]; - if (clientId === undefined && clientSecret === undefined) continue; - if (clientId === undefined || clientSecret === undefined) { - errors.push( - `${keys.id} and ${keys.secret} must be set together to enable ${providerId} sign-in; only one is set`, - ); - continue; - } - providers[providerId] = { clientId, clientSecret }; - } - if (errors.length > 0) { - throw new Error( - [ - `invalid hub environment: ${errors.join("; ")}`, - "Set both values in .env, or unset both to leave the provider disabled; see .env.example.", - ].join("\n"), - ); - } - return providers; -} - -type SidecarProvisionersConfig = { - readonly provisioners: readonly SidecarProvisionerConfig[]; - readonly defaultProvisionerId?: string; -}; - -/** - * Resolves every sidecar-provisioner backend named in - * `SIDECAR_PROVISIONERS` plus which one is the default. Each named - * backend's own required settings (e.g. `DOCKER_PROVISIONER_IMAGE` for - * `docker`) must be present, and an unknown id, a duplicate id, or a - * `SIDECAR_DEFAULT_PROVISIONER` naming a backend that isn't listed all - * fail boot loudly. With `SIDECAR_PROVISIONERS` unset or empty, the - * `process` backend is registered as the sole default: an operator who - * configures nothing gets exclusive sidecar placement on the hub's own - * host, which is what makes a single-server install work with many - * chats and workflows on one VPS. Choosing where sidecars run is then - * one variable — `SIDECAR_PROVISIONERS` — and listing it explicitly - * (including `process`) behaves exactly as any other explicit list. - */ -function sidecarProvisionersFrom(parsed: ParsedHubEnv): SidecarProvisionersConfig { - const ids = (parsed.SIDECAR_PROVISIONERS ?? "") - .split(",") - .map((id) => id.trim()) - .filter((id) => id.length > 0); - - if (ids.length === 0) { - if (parsed.SIDECAR_DEFAULT_PROVISIONER !== undefined) { - throw new Error( - [ - `invalid hub environment: SIDECAR_DEFAULT_PROVISIONER=${parsed.SIDECAR_DEFAULT_PROVISIONER} names a backend but SIDECAR_PROVISIONERS is unset`, - "List that id in SIDECAR_PROVISIONERS, or unset SIDECAR_DEFAULT_PROVISIONER to leave exclusive sidecar placement disabled; see .env.example.", - ].join("\n"), - ); - } - return { - provisioners: [sidecarProvisionerConfigFor("process", parsed)], - defaultProvisionerId: "process", - }; - } - - const seen = new Set(); - const provisioners: SidecarProvisionerConfig[] = []; - for (const id of ids) { - if (seen.has(id)) { - throw new Error(`invalid hub environment: SIDECAR_PROVISIONERS lists ${id} more than once`); - } - seen.add(id); - if (!isSidecarProvisionerId(id)) { - throw new Error( - [ - `invalid hub environment: SIDECAR_PROVISIONERS names unknown backend ${id}`, - `Supported ids: ${SIDECAR_PROVISIONER_IDS.join(", ")}.`, - ].join("\n"), - ); - } - provisioners.push(sidecarProvisionerConfigFor(id, parsed)); - } - - const defaultProvisionerId = parsed.SIDECAR_DEFAULT_PROVISIONER; - if (defaultProvisionerId === undefined) { - if (provisioners.length > 1) { - throw new Error( - [ - "invalid hub environment: SIDECAR_DEFAULT_PROVISIONER must be set when SIDECAR_PROVISIONERS lists more than one backend", - "Set SIDECAR_DEFAULT_PROVISIONER to one of the listed ids; see .env.example.", - ].join("\n"), - ); - } - const onlyId = provisioners[0]?.id; - return onlyId === undefined ? { provisioners } : { provisioners, defaultProvisionerId: onlyId }; - } - if (!seen.has(defaultProvisionerId)) { - throw new Error( - [ - `invalid hub environment: SIDECAR_DEFAULT_PROVISIONER=${defaultProvisionerId} is not listed in SIDECAR_PROVISIONERS`, - "Add it to SIDECAR_PROVISIONERS, or point SIDECAR_DEFAULT_PROVISIONER at a listed id; see .env.example.", - ].join("\n"), - ); - } - return { provisioners, defaultProvisionerId }; -} - -function sidecarProvisionerConfigFor( - id: SidecarProvisionerId, - parsed: ParsedHubEnv, -): SidecarProvisionerConfig { - switch (id) { - case "process": - return { - id: "process", - ...(parsed.PROCESS_PROVISIONER_SIDECAR_ENTRY === undefined - ? {} - : { sidecarEntryPath: parsed.PROCESS_PROVISIONER_SIDECAR_ENTRY }), - ...(parsed.PROCESS_PROVISIONER_RUNTIME === undefined - ? {} - : { runtimePath: parsed.PROCESS_PROVISIONER_RUNTIME }), - }; - case "docker": { - if (parsed.DOCKER_PROVISIONER_IMAGE === undefined) { - throw new Error( - [ - "invalid hub environment: DOCKER_PROVISIONER_IMAGE must be set when SIDECAR_PROVISIONERS includes docker", - "Set DOCKER_PROVISIONER_IMAGE in .env, or remove docker from SIDECAR_PROVISIONERS; see .env.example.", - ].join("\n"), - ); - } - return { id: "docker", image: parsed.DOCKER_PROVISIONER_IMAGE }; - } - case "e2b": { - if (parsed.E2B_API_KEY === undefined || parsed.E2B_TEMPLATE === undefined) { - throw new Error( - [ - "invalid hub environment: E2B_API_KEY and E2B_TEMPLATE must both be set when SIDECAR_PROVISIONERS includes e2b", - "Set them in .env (E2B_TEMPLATE is the immutable template id printed by the template build), or remove e2b from SIDECAR_PROVISIONERS; see .env.example.", - ].join("\n"), - ); - } - return parsed.E2B_SANDBOX_TIMEOUT_MS === undefined - ? { - id: "e2b", - apiKey: parsed.E2B_API_KEY, - template: parsed.E2B_TEMPLATE, - } - : { - id: "e2b", - apiKey: parsed.E2B_API_KEY, - template: parsed.E2B_TEMPLATE, - sandboxTimeoutMs: parsed.E2B_SANDBOX_TIMEOUT_MS, - }; - } - } -} - -/** - * Parse the hub's configuration out of an environment map. Throws at - * the call site when any variable is missing or malformed, reporting - * every problem at once and naming each variable and the shape it must - * have, so a misconfigured process dies at boot instead of failing at - * first use. - */ -export function readHubConfig(env: Record): HubConfig { - const parsed = HubEnv(env); - if (parsed instanceof type.errors) { - throw new Error( - [ - `invalid hub environment: ${parsed.summary}`, - "Set the values above in .env; see .env.example for the expected shape of each.", - ].join("\n"), - ); - } - - const socialProviders = socialProvidersFrom(parsed); - const sidecarProvisioners = sidecarProvisionersFrom(parsed); - - 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, - socialProviders, - allowPlaintextSecrets: parsed.ALLOW_PLAINTEXT_SECRETS !== undefined, - sidecarProvisioners: sidecarProvisioners.provisioners, - signupRateLimit: { - windowSeconds: parsed.SIGNUP_RATE_LIMIT_WINDOW_SECONDS - ? Number(parsed.SIGNUP_RATE_LIMIT_WINDOW_SECONDS) - : DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS, - max: parsed.SIGNUP_RATE_LIMIT_MAX - ? Number(parsed.SIGNUP_RATE_LIMIT_MAX) - : DEFAULT_SIGNUP_RATE_LIMIT_MAX, - }, - }; - if (parsed.HUB_ALLOW_GIT_INSIDE_WORK_TREE !== undefined) hubConfig.allowGitInsideWorkTree = true; - if (parsed.PORT !== undefined) hubConfig.listenPort = Number(parsed.PORT); - if (parsed.HUGGINGFACE_OAUTH_CLIENT_ID !== undefined) - hubConfig.huggingfaceOAuthClientId = parsed.HUGGINGFACE_OAUTH_CLIENT_ID; - if (parsed.GITHUB_APP_CLIENT_ID !== undefined) - hubConfig.githubAppClientId = parsed.GITHUB_APP_CLIENT_ID; - if (parsed.GITHUB_APP_CLIENT_SECRET !== undefined) - hubConfig.githubAppClientSecret = parsed.GITHUB_APP_CLIENT_SECRET; - if (parsed.GMAIL_CLIENT_ID !== undefined) hubConfig.gmailClientId = parsed.GMAIL_CLIENT_ID; - if (parsed.GMAIL_CLIENT_SECRET !== undefined) - hubConfig.gmailClientSecret = parsed.GMAIL_CLIENT_SECRET; - if (parsed.GITHUB_API_BASE_URL !== undefined) - hubConfig.githubApiBaseUrl = parsed.GITHUB_API_BASE_URL; - if (parsed.CREDENTIAL_ENCRYPTION_KEY !== undefined) - hubConfig.credentialEncryptionKeyHex = parsed.CREDENTIAL_ENCRYPTION_KEY; - if (parsed.PRINCIPAL_KEY_ENCRYPTION_KEY !== undefined) - hubConfig.principalKeyEncryptionKeyHex = parsed.PRINCIPAL_KEY_ENCRYPTION_KEY; - if (parsed.HUB_SIDECAR_WEBSOCKET_URL !== undefined) - hubConfig.sidecarWebSocketUrl = parsed.HUB_SIDECAR_WEBSOCKET_URL; - if (sidecarProvisioners.defaultProvisionerId !== undefined) - hubConfig.defaultSidecarProvisionerId = sidecarProvisioners.defaultProvisionerId; - - if (hubConfig.allowPlaintextSecrets && !isLoopbackBaseUrl(hubConfig.baseUrl)) { - throw new Error( - [ - `ALLOW_PLAINTEXT_SECRETS is set, but BASE_URL (${hubConfig.baseUrl}) is not a loopback address.`, - "Storing secrets unencrypted at rest is a dev/test-only fallback for", - "http://localhost — refused here because this looks like a real", - "deployment. Generate a real key instead and add it to .env:", - "", - " openssl rand -hex 32", - "", - "then set CREDENTIAL_ENCRYPTION_KEY to it and remove ALLOW_PLAINTEXT_SECRETS.", - ].join("\n"), - ); - } - - return hubConfig; -} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 31b9a80fe0..dba8d52059 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1,1241 +1,3 @@ -// Composition root for the hub, wired in the platform's own idiom: -// config, then database, then auth, then the platform app. The only -// additions to the platform's shape are serving the web interface from -// this origin and mounting each extension's routes — one explicit -// import and one app.route line inside the platform's native tenant -// middleware. +import { createHubServer } from "./server"; -import { mkdirSync } from "node:fs"; -import path from "node:path"; -import { - createDB, - createGrantStore, - createPrincipalKeyStore, - createSidecarAllocationStore, - createWorkflowRunDispatchStore, - type DB, -} from "@intx/db"; -import { sidecar, tenant as tenantTable, workflowDefinition, workflowRun } from "@intx/db/schema"; -import { and, eq } from "drizzle-orm"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher, sha256 } from "@intx/crypto"; -import type { CredentialCipher } from "@intx/types"; -import { - createApp, - createMailTriggeredRunGrantsMaterializer, - createRequireGrant, - type AppEnv, - type TenantEnv, -} from "@intx/hub-api"; -// CL-7362: computes the preview's wire hash from the probed-but-unapproved -// projection `installAndApproveWorkflowSource` returns on `grants_not_approved` -// — the gate itself only stamps this hash on the `ok:true` arm. - -import { reportError } from "@corbits/error-sink"; -import { decodedOrNull } from "@corbits/url-path"; -import { - buildMailFrame, - createInMemoryMailboxEventBus, - createMailboxDb, - createMailboxPersist, - generateMailboxMessageId, - mountMailbox, -} from "@corbits/mailbox"; -import { createMemory, loadMemoryConfig } from "@corbits/memory"; -import { applyCronMigrations, createCronTicker, mountCron } from "@corbits/cron"; -import { - createHubMailboxAuthorizeSender, - createHubPersistMailWithSessionEnsure, -} from "./mailbox-persist"; -import { - createDrizzleWebhookTriggerStore, - createWebhookIngressRoutes, - createWebhookTriggerRoutes, - launchWebhookTrigger, - createCryptoProviderCache, -} from "@corbits/webhook-triggers"; -import { ensureRunSession } from "@corbits/workflows"; -import { - createSidecarProvisioner as createE2BSidecarProvisioner, - readProvisionerConfig as readE2BProvisionerConfig, -} from "@corbits/e2b-sandbox-sidecar"; -import { - createAgentRepoStore, - createAssetService, - createEventCollectorRegistry, - createHubSessionLookups, - createHubSessionOrchestrator, - createSessionService, - createSidecarAllocationReconciler, - createSidecarPluginRegistry, - createSidecarCredentialResolver, - createSidecarRouter, - createWorkflowAllocationService, - createWorkflowDispatchService, - resolveRoutableAddress, - type EventCollectorRegistry, - type WsHandle, -} from "@intx/hub-sessions"; -import { generateKeyPair } from "@intx/crypto"; -import { timeWindowEvaluator } from "@intx/authz"; -import type { ConditionRegistry } from "@intx/types/authz"; - -// The same condition registry `mountHubRoutes` builds by default when no -// registry is supplied (`vendor/intx/hub-api/src/app.ts`) -- kept as one -// local constant so every route factory below shares the identical -// registry, rather than each re-deriving stock's own default. -const grantConditionRegistry: ConditionRegistry = { - time_window: timeWindowEvaluator, -}; -import { getLogger, setup } from "@intx/log"; -import { hexEncode } from "@intx/types"; -import { createDockerSidecarProvisioner } from "@corbits/docker-provisioner"; -import { - createProcessSidecarProvisioner, - readProcessProvisionerConfig, - type ProcessProvisionerRole, -} from "@corbits/process-provisioner"; -import { - InlineContentStore, - mountArtifacts, - mountWorkflowArtifacts, - runArtifactMigrations, - type WorkflowArtifactEnv, -} from "@corbits/artifacts"; -import { - artifactMatchesLibraryKindSegment, - LIBRARY_KIND_SEGMENTS, -} from "@corbits/artifact-ui/kind-filter"; -import { - createConnectionRoutes, - createMcpOAuthRoutes, - createMcpServerRoutes, - createOAuthConnectRoutes, - createTenantConnectCredential, - createWorkflowConnectionRoutes, - DEFAULT_RETURN_PATH_ALLOWLIST, - listMcpServerConnections, -} from "@corbits/connections"; -import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; -import { createProviderHealthStore } from "@corbits/connections/provider-health"; -import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { type Context, Hono, type Next } from "hono"; - -import { upgradeWebSocket, websocket } from "hono/bun"; -import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; -import { - assertHubDataDirGitSafety, - readHubConfig, - type HubConfig, - type SidecarProvisionerConfig, -} from "./config"; -import type { SidecarProvisioner } from "@intx/hub-sessions"; - -// Host policy constants, not configuration. -const MAX_TARBALL_BYTES = 10 * 1024 * 1024; -const REGISTRIES = new Map([["npmjs", { url: "https://registry.npmjs.org" }]]); - -// The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated -// surface in this app takes structurally (agent-directory, chat, connections, -// memory-hub, `@corbits/artifacts`' `mountWorkflowArtifacts`, and stock -// `@intx/hub-api`'s own `workflowRunAuthenticator` deploy mount): a sidecar -// bearer token + run address resolve to the tenant/principal/run it names. -function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { - return { - async resolve(token: string, runAddress: string) { - if (token === "" || runAddress === "") return null; - const tokenHash = await sha256(token); - const sidecarRow = await deps.db.query.sidecar.findFirst({ - where: eq(sidecar.tokenHashSha256, tokenHash), - }); - if (sidecarRow === undefined) return null; - const run = await deps.db.query.workflowRun.findFirst({ - where: eq(workflowRun.address, runAddress), - }); - if (run === undefined || run.principalId === null) return null; - return { - tenantId: run.tenantId, - principalId: run.principalId, - runId: run.id, - }; - }, - }; -} -// In-repo tool packages (`tools/granola`, `tools/linear`, -// `tools/skills-tools`) are unpublished to npm and stay that way: -// 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 -// `buildAndResolve`), ahead of the statically-configured HTTP -// registries on a name collision — the platform-native alternative to -// npm publishing the CL-5999 capability audit called for. Routing the -// `@corbits` scope at this registry name means a `@corbits/*` pin -// resolves only once an operator publishes a `package-registry` asset -// named `CORBITS_TOOLS_REGISTRY` with the package's tarball — -// `@corbits/tool-registry-publish` is the publisher. Descendants -// inherit it, and `seedTenant` does not pack. Until then, resolution fails loud -// rather than silently falling through to npmjs (which could never -// carry an unpublished scope anyway). -const TENANT_PREFIX = "/api/tenants/:tenantId"; -const SIGN_UP_EMAIL_PATH = "/sign-up/email"; -// CL-8187: idle hibernation is the sidecar's own decision now — it tracks -// per-run last activity itself and tears an idle run down with a -// state-preserving teardown, keeping the deployment record, step-state, -// and slug so a later `wakeByAddress` relaunch resumes the same run -// rather than starting a fresh one. The hub no longer drives this at -// all: `createHubChatPlatform` carries no idle-reap wiring. - -// 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 -// literally the set config.socialProviders resolved to, so a provider -// with no credential here never appears on the hub's auth handler no -// matter what the client asks for. OTP verification returns once a -// transactional-email credential and real UI exist for it; wiring it -// in ahead of that would be dead surface that also risks logging a -// verification secret with nowhere honest to send it. -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -// Serves the single-page application from the hub origin: a real file -// when one exists, index.html otherwise so client-side routes deep-link, -// and never anything under /api, which stays with the platform routes. -export function createStaticHandler(staticDir: string) { - return async (c: Context, next: Next) => { - if (c.req.path === "/api" || c.req.path.startsWith("/api/")) return next(); - const decodedPath = decodedOrNull(c.req.path); - if (decodedPath === null) return next(); - const rel = path.normalize(decodedPath).replace(/^[/\\]+/, ""); - if (rel === ".." || rel.startsWith(`..${path.sep}`)) return next(); - const asset = Bun.file(path.join(staticDir, rel)); - if (await asset.exists()) return new Response(asset); - const index = Bun.file(path.join(staticDir, "index.html")); - if (await index.exists()) return new Response(index); - return next(); - }; -} - -/** - * The `CredentialCipher` (see `@intx/types`) every secret-at-rest seam - * in this composition root shares — currently `webhookTriggerStore`'s - * signing secrets (CL-8112 unmounted the onboarding router whose OAuth - * connect state and `pending_seed` table used to share it). - * A real key (`CREDENTIAL_ENCRYPTION_KEY`) builds an AES-256-GCM - * cipher. An unset key hard-fails boot — a self-hosting operator who - * forgets this variable must not silently end up storing those secrets - * in the clear — unless `ALLOW_PLAINTEXT_SECRETS` opts into the - * identity no-op cipher with a boot warning, for dev/test only. - */ -export function credentialCipherFrom( - config: HubConfig, - log: ReturnType, -): CredentialCipher { - if (config.credentialEncryptionKeyHex === undefined) { - if (!config.allowPlaintextSecrets) { - throw new Error( - [ - "CREDENTIAL_ENCRYPTION_KEY is not set.", - "It encrypts secrets at rest — webhook-trigger signing secrets —", - "so the hub refuses to boot without it. Generate one and", - "add it to .env:", - "", - " openssl rand -hex 32", - "", - "For local dev/test only, set ALLOW_PLAINTEXT_SECRETS=1 instead to", - "boot with those secrets stored unencrypted; never do this for a", - "real deployment.", - ].join("\n"), - ); - } - log.warn`No CREDENTIAL_ENCRYPTION_KEY configured; secrets (e.g. webhook-trigger signing secrets) will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`; - return createNoopCredentialCipher(); - } - return createEnvKeyCredentialCipher(Buffer.from(config.credentialEncryptionKeyHex, "hex")); -} - -/** - * Hub-boot mint of the per-principal signing-key store: seals every minted - * signing key's private seed under `PRINCIPAL_KEY_ENCRYPTION_KEY` — a key - * deliberately separate from `CREDENTIAL_ENCRYPTION_KEY` so the two rotate - * independently. An unset key hard-fails boot for the same reason the - * credential cipher does: a hub that forgets this variable must not - * silently persist private keys in the clear — unless - * `ALLOW_PLAINTEXT_SECRETS` opts into the identity no-op cipher with a - * boot warning, for dev/test only. - */ -export function principalKeyStoreFrom( - config: HubConfig, - db: ReturnType["db"], - log: ReturnType, -) { - if (config.principalKeyEncryptionKeyHex === undefined) { - if (!config.allowPlaintextSecrets) { - throw new Error( - [ - "PRINCIPAL_KEY_ENCRYPTION_KEY is not set.", - "It seals every principal's per-principal signing key at rest,", - "so the hub refuses to boot without it. Generate one and add it", - "to .env:", - "", - " openssl rand -hex 32", - "", - "For local dev/test only, set ALLOW_PLAINTEXT_SECRETS=1 instead to", - "boot with signing keys stored unencrypted; never do this for a", - "real deployment.", - ].join("\n"), - ); - } - log.warn`No PRINCIPAL_KEY_ENCRYPTION_KEY configured; per-principal signing keys will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`; - return createPrincipalKeyStore({ - db, - cipher: createNoopCredentialCipher(), - }); - } - return createPrincipalKeyStore({ - db, - cipher: createEnvKeyCredentialCipher(Buffer.from(config.principalKeyEncryptionKeyHex, "hex")), - }); -} - -/** - * Hub-boot mint of the process-wide credential cipher: build from - * config, then runtime-tag the result. Missing or wrong-shape input - * fails closed — the hub does not boot. - */ -export function hubCredentialCipher( - config: HubConfig, - log: ReturnType, -): CredentialCipher { - return credentialCipherFrom(config, log); -} - -/** - * Instantiates one configured sidecar-provisioner backend. This is the - * extension point named in `.env.example` and `apps/hub/src/config.ts`: - * a new backend gets a case here once its config member exists on - * `SidecarProvisionerConfig`. - */ -function buildSidecarProvisioner( - config: SidecarProvisionerConfig, - hubDataDir: string, - hubWebSocketUrl: string, - role: ProcessProvisionerRole, -): SidecarProvisioner { - switch (config.id) { - case "process": - // Same derivation as the other two backends: the hub-side - // allocation state lives under the hub's own data dir, and so do - // the per-allocation directories each spawned sidecar uses as its - // own SIDECAR_DATA_DIR. Probe and deployment instances keep - // separate state so neither can adopt the other's allocations. - return createProcessSidecarProvisioner({ - role, - config: readProcessProvisionerConfig({ - env: { - ...(config.sidecarEntryPath === undefined - ? {} - : { - PROCESS_PROVISIONER_SIDECAR_ENTRY: config.sidecarEntryPath, - }), - ...(config.runtimePath === undefined - ? {} - : { PROCESS_PROVISIONER_RUNTIME: config.runtimePath }), - }, - dataDir: path.resolve( - hubDataDir, - role === "probe" ? "process-provisioner-probe" : "process-provisioner", - ), - hubWebSocketUrl, - }), - }); - case "docker": - return createDockerSidecarProvisioner({ - config: { - image: config.image, - stateFilePath: path.resolve(hubDataDir, "docker-provisioner", "state.json"), - }, - }); - case "e2b": - // Same derivation as docker's: the backend's hub-side allocation - // state (generation fences, destroy tombstones, sandbox refs) lives - // under the hub's own data dir. Distinct from the sandbox's own - // SIDECAR_DATA_DIR, which start-sidecar.ts creates inside the VM. - return createE2BSidecarProvisioner({ - config: readE2BProvisionerConfig( - { - E2B_API_KEY: config.apiKey, - E2B_TEMPLATE: config.template, - ...(config.sandboxTimeoutMs === undefined - ? {} - : { E2B_SANDBOX_TIMEOUT_MS: config.sandboxTimeoutMs }), - }, - path.resolve(hubDataDir, "e2b-provisioner"), - ), - }); - } -} - -export async function createHub(config: HubConfig) { - assertHubDataDirGitSafety(config.hubDataDir, config.allowGitInsideWorkTree === true, process.env); - const { db, close } = createDB(dbConfigFromUrl(config.databaseUrl)); - const { db: mailboxDb, close: closeMailbox } = createMailboxDb(config.databaseUrl); - const mailboxBus = createInMemoryMailboxEventBus(); - const log = getLogger(["hub", "auth"]); - // Built once, tagged, and shared by every secret-at-rest seam in this - // composition root — see `hubCredentialCipher`. - const credentialCipher = hubCredentialCipher(config, log); - // Per-principal signing keys are sealed under their own operator key — - // see `principalKeyStoreFrom`. - const principalKeyStore = principalKeyStoreFrom(config, db, log); - const auth = betterAuth({ - baseURL: config.baseUrl, - secret: config.sessionSecret, - database: drizzleAdapter(db, { provider: "pg" }), - emailAndPassword: { enabled: true }, - socialProviders: config.socialProviders, - // Client-IP resolution for the sign-up rate limit below. Railway's docs - // (docs.railway.com/networking/public-networking/specs-and-limits) list - // `X-Real-IP` as the header its edge sets for the client's address — - // that's the only claim about it this codebase can actually stand - // behind. It is deliberately NOT relied on for sign-in: Railway's - // private networking lets any same-project service (sidecars included) - // reach this hub directly, bypassing the edge, and with no - // `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 — 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: { - ipAddress: { - ipAddressHeaders: ["x-real-ip"], - }, - }, - rateLimit: { - // Explicit and always on: better-auth's own default only enables - // this in production (`enabled ?? isProduction`), which would - // leave it silently untested in dev and CI. Loudly true here - // instead of inferred from NODE_ENV. - enabled: true, - customRules: { - [SIGN_UP_EMAIL_PATH]: { - window: config.signupRateLimit.windowSeconds, - max: config.signupRateLimit.max, - }, - // Sign-in stays on better-auth's own IP-keyed rule (CL-8185: the - // hub's account-keyed limiter working around its private-network - // bypass was deleted; see "Upstream asks" for the gap). - }, - }, - }); - const signingKey = await generateKeyPair(); - const agentRepoStore = createAgentRepoStore({ - dataDir: config.hubDataDir, - signingKey, - }); - const assetService = createAssetService({ - db, - repoStore: agentRepoStore.repoStore, - reservedPackageRegistryNames: new Set(REGISTRIES.keys()), - }); - const baseLookups = createHubSessionLookups({ db, agentRepoStore }); - // A chat agent is a native provisioned deployment. Reconnect - // ownership is Interchange's live run. Completed means dead; wake is a - // fresh provision, not a folded-run idle settle. - // Forward reference: `eventCollectors` (the wrapped - // `EventCollectorRegistry`) isn't constructed until later in this - // composition, but `createHubPersistMailWithSessionEnsure` below needs - // it. Set once `eventCollectors` exists; every persistMail call before - // that point (there are none — the server isn't serving requests yet) - // would just no-op. - const eventCollectorsRef: { - current?: Pick; - } = {}; - // CL-6499 (native multi-step routines): materializes a mail-triggered - // run's authorization grants from its deploy-approved snapshot, so - // ANY plain mail delivered to a workflow deployment's address — not - // only the dedicated `POST /workflows/:id/mail` HTTP trigger route, - // which stages this itself inline — starts a properly authorized - // run. Without this wired, `sidecarRouter.routeMail` alone would - // deliver the mail but leave the run's `runs//grants.json` - // unwritten, and its `onRunStart` barrier would never resolve. This - // is the one piece of plumbing `apps/hub/src/native-workflow-routine-launch.ts` - // relies on to trigger a native multi-step deployment safely. - const mailTriggeredRunGrants = createMailTriggeredRunGrantsMaterializer({ - db, - principalKeyStore, - grantStore: createGrantStore(db), - }); - const lookups = { - ...baseLookups, - materializeMailTriggeredRunGrants: mailTriggeredRunGrants, - // CL-7449: every outbound agent frame also lands a durable - // `principal_mail` row in each addressed human participant's mailbox, - // dual-written alongside `baseLookups.persistMail`'s `session_mail` - // write. Dual-write independence is `createMailboxPersist`'s own - // contract (upstream failing still attempts the mailbox write, and a - // mailbox failure never fails upstream) -- no second try/catch belongs - // here. This is also the seam the mailbox mount's own `deliver` below - // reuses for `POST /me/inbox/send` (CL-8174): both a run's outbound - // mail and a human's own sent mail land recipients through the same - // dual-write path. - persistMail: createMailboxPersist(mailboxDb, { - upstream: createHubPersistMailWithSessionEnsure( - db, - eventCollectorsRef, - baseLookups.persistMail, - ), - authorizeSender: createHubMailboxAuthorizeSender(db), - bus: mailboxBus, - }), - }; - const hubPublicKey = hexEncode(signingKey.publicKey); - // One resolver serves both seams, exactly as @intx/hub-sessions's own - // reference host wires them: `resolve` turns a presented bearer token - // into a verified identity at the handshake, and `isCurrent` - // revalidates that identity at the registration, readiness, and - // routing boundaries. Without the second one the router falls back to - // its always-true default, and a provisioner-issued token stays - // accepted after its allocation was superseded or destroyed — which a - // process-provisioned sidecar reaches easily, since a child that - // outlives its allocation keeps reconnecting to the same hub. - const sidecarCredentials = createSidecarCredentialResolver({ db }); - // CL-7508's loopback login (`oauthLogin` gate + `requestOAuthLogin`) is - // native to `@intx/hub-sessions`'s sidecar router, but it only works when - // the sidecar side answers the hub's `oauth.login.start` frame with an - // `oauthLoginExecutor` wired into `createSidecarOrchestrator`'s hub link - // (`@intx/hub-agent`). Absent that, the link falls back to a fail-closed - // placeholder executor that errors every `oauth.login.start` it receives. - // Upstream's `apps/sidecar` (the byte-for-byte copy this repo now runs) - // wires no such executor, so the gate is dead on arrival. Left unset here - // rather than configured against a sidecar that can never answer it. - const sidecarRouter = createSidecarRouter({ - hubPublicKey, - authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token), - validateSidecarIdentity: sidecarCredentials.isCurrent, - lookups, - }); - const isSidecarRoutable = (address: string) => - sidecarRouter.getRoutableAddresses().includes(address); - // Process-lifetime provider-health signal (CL-6092): the one store - // both the chat orchestrator's classified-failure port and - // `GET .../connections/provider-health` read/write, so a runtime - // failure a turn just reported is visible to the shell banner on its - // very next poll. In-memory by design — see `provider-health.ts`'s own - // header for why this never needs to survive a restart. - const providerHealthStore = createProviderHealthStore(); - // Package-owned artifacts tables (CL-8188): idempotent, advisory-locked, - // safe on every boot of every replica — see @corbits/artifacts' README. - await runArtifactMigrations(db); - // `@corbits/cron`'s own schema (CL-8183): idempotent, its own migration - // ledger — see the package's README. - await applyCronMigrations(config.databaseUrl); - const artifactContentStore = InlineContentStore; - const baseEventCollectors = createEventCollectorRegistry({ - db, - // CL-7418: deliberately no terminal-status settle here. The folded - // routine-fire settle (CL-6778, keyed off routines.routine_run) died - // with the routines cut (CL-4455) and has no native equivalent: a - // scheduled definition owns a single self-anchored workflow_run that - // must stay live across fires — the scheduler launch path throws - // NativeWorkflowDeploymentMissingError for a non-live anchor, and a - // per-tick execution is a repo-local child run with no row of its - // own (vendor's decideTerminalRunFlip skips it as skip_repo_local). - // Stamping the anchor terminal per fire would brick the schedule, - // so a warm-kept fire settles through the runOutcomeStatus / - // FIRE_RUNNING_WINDOW_MS display-side reading instead. - }); - // Wraps `dispatch` for CL-7480: a run's turn events can start arriving - // before anything else ever recorded its session — the first inbound - // trigger that just reconciled its principal races this same dispatch. - // No live collector for the address is exactly that case: ensure the - // session (and, inside it, the collector) before delegating, rather - // than silently dropping the event for a collector that never gets - // created. Every other method passes straight through. - const eventCollectors: EventCollectorRegistry = { - ...baseEventCollectors, - dispatch(agentAddress, event) { - if (!baseEventCollectors.has(agentAddress)) { - void ensureCollectorThenDispatch(agentAddress, event); - return; - } - baseEventCollectors.dispatch(agentAddress, event); - }, - }; - eventCollectorsRef.current = eventCollectors; - // Named so `dispatch`'s no-collector branch above reads clearly; not - // inlined there because it needs to be `async` and `dispatch` itself - // must stay synchronous (the `EventCollectorRegistry` contract). - async function ensureCollectorThenDispatch( - agentAddress: string, - event: Parameters[1], - ): Promise { - try { - const run = await resolveRoutableAddress(db, agentAddress); - if (run !== undefined) { - await ensureRunSession({ - db, - eventCollectors: baseEventCollectors, - runId: run.id, - }); - } - } catch (err) { - reportError(err, { - operation: "hub.eventCollectors.ensureRunSession", - extra: { agentAddress }, - }); - } - baseEventCollectors.dispatch(agentAddress, event); - } - createHubSessionOrchestrator({ - events: sidecarRouter.events, - router: sidecarRouter, - db, - eventCollectors, - }); - // Shared-capacity `deployWorkflowFromSource` / `deployAdoptedWorkflowFromSource` - // are gone on this pin. The provisioned path persists its source in - // `workflow_run_launch_spec`; there is nothing left for a session-service - // wrapper to record. - const sessionService = createSessionService({ - sidecarRouter, - sidecarAllocationRouter: sidecarRouter, - agentRepoStore, - assetService, - db, - toolPackageRegistries: { - httpRegistries: REGISTRIES, - defaultRegistry: "npmjs", - scopeRouting: [{ scope: "@corbits", registry: CORBITS_TOOLS_REGISTRY }], - }, - }); - const hubWebSocketUrl = - config.sidecarWebSocketUrl ?? `${config.baseUrl.replace(/^http/, "ws")}/api/sidecars/ws`; - // Provisioner plugins are injected at the application composition - // 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 - // 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 - // declares fails closed at provisioner selection rather than silently - // landing on an unsuitable sidecar. Adding a new backend here is: - // implement `SidecarProvisioner` in its own package, add a case to - // `buildSidecarProvisioner`, and add its id to - // `apps/hub/src/config.ts`'s `SIDECAR_PROVISIONER_IDS`. - // Probes and deployments get distinct provisioner instances: when they - // match, Interchange adopts the probe's allocation for the deployment, - // and at pin 692c3106 that adopt path never deploys the workflow after - // the sidecar reconnects (CL-7492). - const buildSidecarPlugins = (role: ProcessProvisionerRole) => - createSidecarPluginRegistry({ - provisioners: config.sidecarProvisioners.map((provisionerConfig) => - buildSidecarProvisioner(provisionerConfig, config.hubDataDir, hubWebSocketUrl, role), - ), - ...(config.defaultSidecarProvisionerId !== undefined - ? { defaultProvisionerId: config.defaultSidecarProvisionerId } - : {}), - }); - const sidecarPlugins = buildSidecarPlugins("deployment"); - const workflowAllocationService = createWorkflowAllocationService({ - db, - deploymentPlugins: sidecarPlugins, - probePlugins: buildSidecarPlugins("probe"), - preparedDeployer: sessionService, - credentialCipher, - allocationRouter: sidecarRouter, - hubWebSocketUrl, - }); - const sidecarAllocationStore = createSidecarAllocationStore(db); - const workflowDispatchService = createWorkflowDispatchService({ - dispatchStore: createWorkflowRunDispatchStore(db), - allocationStore: sidecarAllocationStore, - router: sidecarRouter, - resolveAnchorAddress: async (anchorRunId) => { - const row = await db.query.workflowRun.findFirst({ - where: (run, { eq: equals }) => equals(run.id, anchorRunId), - columns: { address: true }, - }); - return row?.address ?? null; - }, - }); - const sidecarAllocationReconciler = createSidecarAllocationReconciler({ - allocationStore: sidecarAllocationStore, - plugins: sidecarPlugins, - router: sidecarRouter, - hubWebSocketUrl, - onReady: async (allocation, reconciliation) => { - await workflowAllocationService.deployReadyAllocation(allocation, reconciliation); - await workflowDispatchService.requeueForReadyAllocation(allocation.anchorRunId); - }, - }); - await workflowAllocationService.initialize?.(); - await sidecarAllocationReconciler.initialize(); - sidecarRouter.events.on("sidecar.disconnect", ({ allocated }) => { - if (allocated === undefined) return; - return sidecarAllocationReconciler.handleDisconnect(allocated); - }); - sidecarRouter.events.on("sidecar.allocated.connected", (allocated) => - sidecarAllocationReconciler.handleConnected(allocated), - ); - sidecarRouter.events.on("mail.inbound.acknowledged", ({ messageId, allocated }) => { - if (allocated === undefined) return; - return workflowDispatchService.acknowledge({ ...allocated, messageId }); - }); - const sidecarAllocationLog = getLogger(["hub", "sidecar-allocation"]); - const ALLOCATION_RECONCILIATION_INTERVAL_MS = 1_000; - const ALLOCATION_CONNECTION_REPAIR_INTERVAL_MS = 30_000; - let nextAllocationConnectionRepairAt = Date.now() + ALLOCATION_CONNECTION_REPAIR_INTERVAL_MS; - let sidecarAllocationReconciliationStopped = false; - let sidecarAllocationReconciliationTimer: ReturnType | undefined; - function scheduleAllocationReconciliation(delayMs: number): void { - if (sidecarAllocationReconciliationStopped) return; - const timer = setTimeout(() => { - void reconcileSidecarAllocations(); - }, delayMs); - timer.unref?.(); - sidecarAllocationReconciliationTimer = timer; - } - async function reconcileSidecarAllocations(): Promise { - try { - await sidecarAllocationReconciler.reconcileUntilIdle(); - await workflowDispatchService.reconcileUntilIdle(); - if (Date.now() >= nextAllocationConnectionRepairAt) { - nextAllocationConnectionRepairAt = Date.now() + ALLOCATION_CONNECTION_REPAIR_INTERVAL_MS; - await sidecarAllocationReconciler.repairUnscheduledConnections(); - } - } catch (error) { - sidecarAllocationLog.error`Sidecar allocation reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; - } finally { - scheduleAllocationReconciliation(ALLOCATION_RECONCILIATION_INTERVAL_MS); - } - } - scheduleAllocationReconciliation(ALLOCATION_RECONCILIATION_INTERVAL_MS); - const app = createApp({ - workflowAllocationService, - workflowDispatchService, - credentialCipher, - principalKeyStore, - getSession: async (headers) => { - const result = await auth.api.getSession({ headers }); - return result ? { user: result.user, session: result.session } : null; - }, - authHandler: async (c) => auth.handler(c.req.raw), - db, - sidecarRouter, - sessionService, - eventCollectors, - assetService, - repoStore: agentRepoStore.repoStore, - maxTarballBytes: MAX_TARBALL_BYTES, - // Lets a workflow-run agent deploy through the same - // `/workflows/deployments` route a human session uses (see - // `@intx/hub-api`'s `workflow-run-deploy-auth` middleware) -- the same - // sidecar-bearer + run-address credential every other workflow-run write - // surface below already authenticates with. - workflowRunAuthenticator: createWorkflowRunAuthenticator({ db }), - sidecarWsHandler: upgradeWebSocket((_c) => { - let handle: WsHandle; - return { - onOpen(_evt, ws) { - handle = { send: (d: string) => ws.send(d), close: () => ws.close() }; - sidecarRouter.handleOpen(handle); - }, - onMessage(evt, _ws) { - if (typeof evt.data === "string") sidecarRouter.handleMessage(handle, evt.data); - }, - onClose: () => sidecarRouter.handleClose(handle), - }; - }), - }); - - // CL-8185: the hub-owned global `app.onError` (`hub-error-handler.ts`) - // was deleted under the hub-sweep's less-is-more ruling. An exception - // escaping a route now falls through to Hono's own built-in handler -- - // a bare 500 with nothing reported. See "Upstream asks". - - // The hub's own grant store, built the same way `createApp` builds its - // default when none is supplied (see `@intx/hub-api`'s - // `mountHubRoutes`). `createRequireGrant` is the published construction - // the platform's own internal instance is not exported for. - const grantStore = createGrantStore(db); - const cryptoProviders = createCryptoProviderCache(); - // The `@corbits/inbox` product-inbox routes (triage groups, mark-all-read, - // clear-done) are retired (CL-8209): they sat on `@corbits/mailbox` APIs - // (`listUserMailbox`, refs, classification) removed in the native 1.0 - // cutover, and no web surface reads `${TENANT_PREFIX}/inbox` any more — - // `/inbox` in the web app only bounces old links home (CL-6151). The - // library's own `/me/inbox*` routes below are the whole inbox surface now. - // Library/artifacts plane (CL-8188): `@corbits/artifacts` mounted directly - // rather than through the retired `@corbits/artifacts-hub` wrapper — the - // library now owns counts, preview, and every other Library HTTP surface - // this app previously reimplemented. `countSegments` keeps the segment - // taxonomy (what makes an artifact a "document" or a "routine") host-owned, - // per the library's README; the predicates themselves live in - // `@corbits/artifact-ui` so the web Files nav and this count walk share one - // mapping. - { - const artifactsApi = new Hono(); - mountArtifacts(artifactsApi, { - db, - contentStore: artifactContentStore, - requireGrant: createRequireGrant({ - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - countSegments: Object.fromEntries( - LIBRARY_KIND_SEGMENTS.map((segment) => [ - segment, - (row: { kind: string; title: string }) => artifactMatchesLibraryKindSegment(row, segment), - ]), - ), - }); - app.route(TENANT_PREFIX, artifactsApi); - } - // Myra's own artifact tools (`@corbits/artifact-tools`, CL-6000) and every - // Routine's own `finalize-tool`/`artifact-client` duplicate: the - // workflow-run-authenticated counterpart to the tenant-session mount just - // above, at the same `/api/workflow-artifacts` prefix the sidecar's - // `hubArtifactsUrl` already points at. Every route lives under this - // mount's own `/artifacts` prefix per `@corbits/artifacts`' fixed shape - // (`POST /artifacts`, `GET /artifacts/recent`, `GET /artifacts/:id`, - // `POST /artifacts/binary`) — callers reach it at - // `/api/workflow-artifacts/artifacts...`. - { - const workflowArtifactsApi = new Hono(); - const workflowRunAuthenticator = createWorkflowRunAuthenticator({ db }); - mountWorkflowArtifacts(workflowArtifactsApi, { - db, - contentStore: artifactContentStore, - resolveRunScope: (token, runAddress) => workflowRunAuthenticator.resolve(token, runAddress), - }); - app.route("/api/workflow-artifacts", workflowArtifactsApi); - } - { - const mailboxApp = new Hono(); - mountMailbox(mailboxApp, { - db: mailboxDb, - bus: mailboxBus, - resolvePrincipal: (ctx) => { - // Mounted under the hub tenant middleware; principal + tenant are set. - const c = ctx as { - get(key: "tenant" | "principal"): { id: string }; - }; - return { - tenantId: c.get("tenant").id, - principalId: c.get("principal").id, - }; - }, - // The caller's own address for `POST /me/inbox/send`'s `From:` — the - // same `@` shape every other mailbox - // writer in this file addresses a human principal under - // (`mailbox-fanout.ts`'s `writeChatMailboxFanout`, - // `native-workflow-routine-launch.ts`'s trigger headers). - senderAddressFor: async (principal) => { - const [tenantRow] = await db - .select({ domain: tenantTable.domain }) - .from(tenantTable) - .where(eq(tenantTable.id, principal.tenantId)) - .limit(1); - if (tenantRow === undefined) { - throw new Error(`no tenant "${principal.tenantId}" to address a mailbox sender from`); - } - return `${principal.principalId}@${tenantRow.domain}`; - }, - // This package only builds the RFC 5322 message and files the - // caller's own `Sent` copy — actually getting `message.raw` to - // `message.to` is this hub's job. Every recipient goes through the - // same `lookups.persistMail` (CL-7449's dual-write wrapper over - // `createMailboxPersist`) every other outbound frame in this hub - // already dispatches through: a live run address is delivered by - // the vendored `baseLookups.persistMail` (`SidecarRouter`-backed - // session/mail routing, the same path - // `native-workflow-routine-launch.ts`'s `routeMail` trigger uses), - // and a human recipient's `principal_mail` copy is appended by the - // wrapped `createMailboxPersist` alongside it. - deliver: async (message) => { - await lookups.persistMail({ - senderAddress: message.from, - recipients: message.to, - raw: message.raw, - }); - }, - }); - app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); - } - - let cronTicker: { start(): void; stop(): void } | undefined; - { - // Cron-to-mail bridge (CL-8183): `@corbits/cron` registers its own - // absolute `/api/tenants/:tenantId/cron` routes, so it gets its own - // `Hono` routed at "/" — matching `mailboxApp` above — - // rather than `TENANT_PREFIX`, which would double the tenant-route - // prefix. `requireTenantMember` reads the tenant middleware's context - // exactly like the mailbox mount's `resolvePrincipal` above. - const cronApp = new Hono(); - mountCron(cronApp, { - db, - requireTenantMember: (ctx, tenantId) => { - const c = ctx as { get(key: "tenant"): { id: string } }; - return c.get("tenant").id === tenantId; - }, - }); - app.route("/", cronApp); - - // The ticker delivers each due schedule as mail through the same - // `lookups.persistMail` every other outbound frame in this hub already - // dispatches through (see the mailbox mount's own `deliver` above). - // `@corbits/cron` hands back only `{to, subject, body, from}`, not a - // raw frame, so this hub builds the RFC 5322 message itself with - // `@corbits/mailbox`'s own `buildMailFrame` — the same builder that - // package uses for every frame it authors. `senderAddressFor` addresses - // the sender as `cron@`, the same domain-scoped shape - // every other mailbox writer in this file uses (see the mailbox - // mount's `senderAddressFor` above), by looking the domain up per - // tenant the way that one does. - cronTicker = createCronTicker({ - db, - intervalMs: 60_000, - senderAddressFor: (tenantId) => `cron@${tenantId}`, - deliver: async (message) => { - // `message.from` is `cron@` from `senderAddressFor` - // above; resolve the real tenant domain to address it from. - const tenantId = message.from.slice("cron@".length); - const [tenantRow] = await db - .select({ domain: tenantTable.domain }) - .from(tenantTable) - .where(eq(tenantTable.id, tenantId)) - .limit(1); - if (tenantRow === undefined) { - throw new Error(`no tenant "${tenantId}" to address cron mail from`); - } - const from = `cron@${tenantRow.domain}`; - await lookups.persistMail({ - senderAddress: from, - recipients: message.to, - raw: buildMailFrame({ - from, - to: message.to.join(", "), - subject: message.subject, - body: message.body, - messageId: generateMailboxMessageId(from), - }), - }); - }, - }); - cronTicker.start(); - } - - { - // Firm memory (CL-8186): `@corbits/memory` registers its own - // absolute `/api/tenants/:tenantId/memory/*` routes on whatever - // Hono app it's given (see its `routes/add.ts`, etc.) rather than - // composing under a mount prefix, so it gets its own `Hono` - // — matching `mailboxApp` above — routed at "/" rather than - // `TENANT_PREFIX`, which would double the tenant-route prefix. - // Guarded by the hub's own `requireGrant`: the same - // `grantStore`/`grantConditionRegistry` every other tenant-scoped - // mount above uses. `loadMemoryConfig` reads - // `DATABASE_URL`/`EMBED_BASE_URL`/`EMBED_MODEL` (and friends) from - // env itself; nothing else in this file constructs the memory plane. - const memoryApp = new Hono(); - createMemory({ - app: memoryApp, - config: loadMemoryConfig(), - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }); - app.route("/", memoryApp); - } - - // Agent-authored workflows (CL-7360): an agent publishes a workflow - // codebase as a native `kind:"workflow"` asset through this - // workflow-run-authenticated surface, then deploys it through stock - // `POST /api/tenants/:tenantId/workflows/deployments` with the same run - // bearer. What is left mounted here is the git half alone — reading and - // writing the asset repo's trees — which no run-bearer credential can do - // against stock git smart-HTTP today (CL-8171). Unlike - // `/api/workflow-skills` above, every write here runs a real - // `grantStore` authorization check (`asset:*`/create, - // `asset:`/write, `workflow:*`/create) before reaching `RepoStore`, - // because authoring source is a side effect, not a markdown skill edit. - app.route( - "/api/workflow-workflow-authoring", - createWorkflowAuthorRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - registry: createWorkflowAuthorRegistry({ - db, - assetService, - repoStore: agentRepoStore.repoStore, - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - }), - ); - - // Webhook triggers: tenant-scoped management (create/list/rotate/ - // enable/disable/delete) mounts under the tenant prefix like chat, - // so it inherits session + tenant-membership resolution and grant - // checks for free. The ingress endpoint that actually receives an - // external delivery (`POST /api/webhooks/:triggerId`) is mounted - // separately below, OUTSIDE the tenant prefix — a webhook sender - // carries no session cookie and is never a tenant member, so it - // must never pass through `resolveTenant`. Its own tenant scoping - // comes from the trigger row the id resolves to, and the only trust - // it is granted comes from the HMAC signature check in - // `createWebhookIngressRoutes` itself. - const webhookTriggerStore = createDrizzleWebhookTriggerStore(db, credentialCipher); - app.route( - `${TENANT_PREFIX}/webhook-triggers`, - createWebhookTriggerRoutes({ - store: webhookTriggerStore, - requireGrant: createRequireGrant({ - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - workflowDefinitionInTenant: async (tenantId, definitionId) => { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { id: true }, - }); - return row !== undefined; - }, - }), - ); - app.route( - "/api/webhooks", - createWebhookIngressRoutes({ - store: webhookTriggerStore, - launch: (trigger, payload) => - launchWebhookTrigger( - { - db, - sidecarRouter, - repoStore: agentRepoStore.repoStore, - workflowAllocationService, - credentialCipher, - eventCollectors, - isRoutable: isSidecarRoutable, - cryptoProviderCache: cryptoProviders, - }, - trigger, - payload, - ), - }), - ); - // Connections: the settings surface's tenant-scoped credential - // test-and-store, mounted under the same tenant prefix and reusing - // the same grant store/condition registry every other credential- - // adjacent extension route does. - app.route( - `${TENANT_PREFIX}/connections`, - createConnectionRoutes({ - hubUrl: config.baseUrl, - registry: CONNECTOR_REGISTRY, - requireGrant: createRequireGrant({ - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - log: (line) => log.info`${line}`, - // Same env bag the OAuth connect flow itself reads below, so - // `GET .../oauth-configured` reports exactly what a Connect click - // would decide. - oauthEnv: { - huggingfaceClientId: config.huggingfaceOAuthClientId, - githubAppClientId: config.githubAppClientId, - githubAppClientSecret: config.githubAppClientSecret, - gmailClientId: config.gmailClientId, - gmailClientSecret: config.gmailClientSecret, - }, - providerHealth: providerHealthStore, - // CL-6403: an operator-set GITHUB_API_BASE_URL lets a fake server - // stand in for api.github.com for the `github` connector's PAT - // probe and stored provider origin; unset in every real deployment, - // so `probeBaseUrls` is empty and every connector probes its own - // fixed production origin. - probeBaseUrls: - config.githubApiBaseUrl !== undefined ? { github: config.githubApiBaseUrl } : {}, - }), - ); - // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` - // (`@corbits/connections`) was exported but never mounted here — every - // provider whose descriptor sets `oauth` (OpenRouter, Hugging Face, and - // the GitHub App path) needs this to complete a one-click connect from - // the settings surface above. Follows #115's `mcp-servers/oauth` mount - // just below: state-param CSRF (real `state()` + exact-match callback - // validation) lives entirely inside the factory; this mount only wires - // the tenant already resolved by the platform's tenant middleware - // through to `createTenantConnectCredential`. - app.route( - `${TENANT_PREFIX}/connections/oauth`, - createOAuthConnectRoutes({ - hubUrl: config.baseUrl, - log: (line) => log.info`${line}`, - credentialCipher, - registry: CONNECTOR_REGISTRY, - // Same env bag `GET .../connections/oauth-configured` reads above. - oauthEnv: { - huggingfaceClientId: config.huggingfaceOAuthClientId, - githubAppClientId: config.githubAppClientId, - githubAppClientSecret: config.githubAppClientSecret, - gmailClientId: config.gmailClientId, - gmailClientSecret: config.gmailClientSecret, - }, - connectCredential: createTenantConnectCredential({ - hubUrl: config.baseUrl, - log: (line) => log.info`${line}`, - registry: CONNECTOR_REGISTRY, - providerHealth: providerHealthStore, - }), - defaultReturnPath: "/settings/connections", - // `/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, "/plugins", "/w/"], - }), - ); - // MCP servers: the tenant-scoped connect/list/disconnect surface - // Plugins drives (CL-6142), mirroring `connections` above but for - // tenant-minted `mcp:` connectors rather than - // `CONNECTOR_REGISTRY`'s fixed set. - app.route( - `${TENANT_PREFIX}/mcp-servers`, - createMcpServerRoutes({ - hubUrl: config.baseUrl, - requireGrant: createRequireGrant({ - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - log: (line) => log.info`${line}`, - presets: MCP_PRESETS, - }), - ); - // MCP servers' OAuth connect flow (CL-6152): discovers and drives a - // preset's (or an ad hoc `?url=&name=`) authorization server per the - // MCP spec, landing back on the same `mcp:` credential storage - // `createMcpServerRoutes` above uses for a pasted token. - app.route( - `${TENANT_PREFIX}/mcp-servers/oauth`, - createMcpOAuthRoutes({ - hubUrl: config.baseUrl, - requireGrant: createRequireGrant({ - grantStore: grantStore, - conditionRegistry: grantConditionRegistry, - }), - log: (line) => log.info`${line}`, - credentialCipher, - presets: MCP_PRESETS, - // `/w/` for the same reason as the connections/oauth mount above. - returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], - }), - ); - // Myra's own connections-visibility surface - // (`@corbits/connections-tools`' `list_connections`/ - // `request_connection`): the workflow-run-authenticated counterpart - // to the tenant-session mount just above. - app.route( - "/api/workflow-connections", - createWorkflowConnectionRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), - }), - ); - // Notify-to-reconnect for an OAuth-connected credential whose token - // expired (Hugging Face today — see docs/onboarding-huggingface-connect.md) - // is now `workflows/credential-expiry` (CL-8181), a schedule-triggered - // workflow deployed like any other Routine rather than a hub-owned - // periodic loop. - - // Snooze is dropped (CL-8185): the unsnooze sweep and its `inbox.snooze` - // table are gone — see `packages/inbox/src/migrations.ts`'s forward-drop. - - // CL-8206: the hub-computed cron auto-fire over a definition's frozen - // wire projection is gone — Interchange 79adc433 retired the - // projection storage `listDeployedCronDefinitions` read the cron - // trigger off of, with no replacement column. Per CL-8181's own - // direction (see the notify-to-reconnect comment above), a - // schedule-triggered routine is deployed like any other workflow now - // rather than driven by a hub-owned periodic loop; this hard cutover - // has no port to a stock equivalent. - - app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); - - // CL-8185: the hub-sweep's less-is-more ruling deleted the tenant-subtree - // bearer widening (`workflow-run-tenant-auth.ts`'s `withWorkflowRunTenantAuth`) - // and the in-flight request tracker (`in-flight-requests.ts`). Stock - // `@intx/hub-api` still authenticates a run bearer on the single - // `/workflows/deployments` mount via `workflowRunAuthenticator` above; a - // run agent reaching any other tenant route now needs a session like - // everything else. See "Upstream asks" for both gaps. - return { - app, - whenRequestsIdle: () => Promise.resolve(), - db, - close: async () => { - sidecarAllocationReconciliationStopped = true; - if (sidecarAllocationReconciliationTimer !== undefined) { - clearTimeout(sidecarAllocationReconciliationTimer); - } - await closeMailbox(); - cronTicker?.stop(); - // The pool end waits on in-flight queries; a query whose socket - // died with the process must never stall shutdown, so bound it. - // (CL-7584: a fire-and-forget reconcile's request can be cut - // mid-query by this very teardown.) - await Promise.race([close(), new Promise((resolve) => setTimeout(resolve, 5_000))]); - }, - }; -} - -if (import.meta.main) { - await setup(); - const config = readHubConfig(process.env); - mkdirSync(config.hubDataDir, { recursive: true }); - const hub = await createHub(config); - const url = new URL(config.baseUrl); - const port = - config.listenPort ?? - (url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port)); - const server = Bun.serve({ - fetch: hub.app.fetch, - websocket, - port, - idleTimeout: 0, - }); - const log = getLogger(["hub"]); - log.info`Hub serving on port ${port}`; - // CL-8185: the bounded-drain shutdown (`shutdown.ts`) and its in-flight - // tracker were deleted under the hub-sweep's less-is-more ruling. A - // signal now force-stops the listener and closes hub resources directly, - // with no drain window for an in-flight request and no reported cause on - // a hang. See "Upstream asks". - const shutdown = () => - void (async () => { - try { - server.stop(true); - await hub.close(); - } finally { - process.exit(0); - } - })(); - process.on("SIGINT", () => void shutdown()); - process.on("SIGTERM", () => void shutdown()); -} +export default await createHubServer(); diff --git a/apps/hub/src/notify-delivery.ts b/apps/hub/src/notify-delivery.ts deleted file mode 100644 index 93f1778987..0000000000 --- a/apps/hub/src/notify-delivery.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The hub's `@corbits/notify` delivery deps, composed once at the root so -// every mail writer (the credential-expiry sweep today) mails through the -// same adapter the inbox read-back repairs. Kept here instead of inline in -// `index.ts` so the composed path is a single construction site the -// crash-window suite can exercise directly. -import { createResolveExistingMailIds, createWorkbenchMailboxDelivery } from "@corbits/inbox"; -import type { MailboxDb, MailboxEventBus } from "@corbits/mailbox"; -import { - createInMemoryNotifyDispatchStore, - createSinkRegistry, - type NotifyDeliveryDeps, - type NotifyDispatchStore, - type SinkRegistry, -} from "@corbits/notify"; - -export interface CreateHubNotifyDeliveryDepsOpts { - mailboxDb: MailboxDb; - bus?: MailboxEventBus; - host: string; - dispatch?: NotifyDispatchStore; - sinks?: SinkRegistry; -} - -export function createHubNotifyDeliveryDeps( - opts: CreateHubNotifyDeliveryDepsOpts, -): NotifyDeliveryDeps { - return { - mail: createWorkbenchMailboxDelivery({ - db: opts.mailboxDb, - ...(opts.bus !== undefined ? { bus: opts.bus } : {}), - }), - addressing: { - inbox: (recipient) => `${recipient.principalId}@inbox.${opts.host}`, - from: (kind) => `${kind}@notify.${opts.host}`, - }, - dispatch: opts.dispatch ?? createInMemoryNotifyDispatchStore(), - sinks: opts.sinks ?? createSinkRegistry(), - // CL-7238: without this read-back a redelivery after a crash between - // the mail write and the dispatch enqueue still loses the dispatch. - // Resolved from the mailbox unique mail key — the same key the write - // dedupes on — so the lookup can never disagree with the delivery. - resolveExistingMailIds: createResolveExistingMailIds(opts.mailboxDb), - }; -} diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts new file mode 100644 index 0000000000..f4c531afde --- /dev/null +++ b/apps/hub/src/server.ts @@ -0,0 +1,816 @@ +import { + createDB, + createGrantStore, + createPrincipalKeyStore, + createSidecarAllocationStore, + createWorkflowRunDispatchStore, + resolveFrameSenderKey, + resolveSenderKey, +} from "@intx/db"; +import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; +import { and, eq } from "drizzle-orm"; +import { createEnvKeyCredentialCipher, sha256 } from "@intx/crypto"; +import { hexDecode, hexEncode, type SidecarCapabilityRule } from "@intx/types"; +import { + createApp, + createAuth, + createMailTriggeredRunGrantsMaterializer, + createRequireGrant, + type TenantEnv, +} from "@intx/hub-api"; +import { + createAgentRepoStore, + createAssetService, + createEventCollectorRegistry, + createHubSessionLookups, + createHubSessionOrchestrator, + createSessionService, + createSidecarAllocationReconciler, + createSidecarPluginRegistry, + createSidecarRouter, + createSidecarCredentialResolver, + createWorkflowAllocationService, + createWorkflowDispatchService, + createReconciliationScheduler, + DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY, + pushCredentialReconcile, + WORKSPACE_BUILTINS_REGISTRY, + type SidecarLookups, + type SidecarProvisioner, + type SidecarProvisionerChooser, + type WsHandle, +} from "@intx/hub-sessions"; +import { generateKeyPair } from "@intx/crypto"; +import { timeWindowEvaluator } from "@intx/authz"; +import type { ConditionRegistry } from "@intx/types/authz"; +import { MAX_SIDECAR_FRAME_BYTES } from "@intx/types/sidecar"; +import { upgradeWebSocket, websocket } from "hono/bun"; +import { setup, getLogger } from "@intx/log"; +import { Hono } from "hono"; + +// --------------------------------------------------------------------- +// Everything above this line is upstream Interchange's own +// apps/hub/src/server.ts, verbatim. Corbits libraries are mounted in one +// delimited block below, after createApp(...) and before the function's +// return -- see AGENTS.md's "Workbench is a plain Interchange tenant" +// ruling. Nothing else in this file is Workbench-specific. +// --------------------------------------------------------------------- +import { + buildMailFrame, + createInMemoryMailboxEventBus, + createMailboxDb, + createMailboxPersist, + generateMailboxMessageId, + mountMailbox, +} from "@corbits/mailbox"; +import { createMemory, loadMemoryConfig } from "@corbits/memory"; +import { applyCronMigrations, createCronTicker, mountCron } from "@corbits/cron"; +import { + createHubMailboxAuthorizeSender, + createHubPersistMailWithSessionEnsure, +} from "./mailbox-persist"; +import { + createDrizzleWebhookTriggerStore, + createWebhookIngressRoutes, + createWebhookTriggerRoutes, + launchWebhookTrigger, + createCryptoProviderCache, +} from "@corbits/webhook-triggers"; +import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; +import { + createSidecarProvisioner as createE2BSidecarProvisioner, + readProvisionerConfig as readE2BProvisionerConfig, +} from "@corbits/e2b-sandbox-sidecar"; +import { createDockerSidecarProvisioner } from "@corbits/docker-provisioner"; +import { + createProcessSidecarProvisioner, + readProcessProvisionerConfig, + type ProcessProvisionerRole, +} from "@corbits/process-provisioner"; +import { + InlineContentStore, + mountArtifacts, + mountWorkflowArtifacts, + runArtifactMigrations, + type WorkflowArtifactEnv, +} from "@corbits/artifacts"; +import { + artifactMatchesLibraryKindSegment, + LIBRARY_KIND_SEGMENTS, +} from "@corbits/artifact-ui/kind-filter"; +import { + createConnectionRoutes, + createMcpOAuthRoutes, + createMcpServerRoutes, + createOAuthConnectRoutes, + createTenantConnectCredential, + createWorkflowConnectionRoutes, + DEFAULT_RETURN_PATH_ALLOWLIST, + listMcpServerConnections, +} from "@corbits/connections"; +import { createProviderHealthStore } from "@corbits/connections/provider-health"; +import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; +import type { DB } from "@intx/db"; +import { sidecar, workflowRun } from "@intx/db/schema"; +import { type } from "arktype"; +import path from "node:path"; + +// The same condition registry `mountHubRoutes` builds by default when no +// registry is supplied -- kept as one local constant so every Corbits +// route factory below shares the identical registry rather than each +// re-deriving stock's own default. +const grantConditionRegistry: ConditionRegistry = { + time_window: timeWindowEvaluator, +}; + +// The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated +// Corbits surface below takes structurally (connections, workflow-authoring, +// `@corbits/artifacts`' `mountWorkflowArtifacts`): a sidecar bearer token + +// run address resolve to the tenant/principal/run it names. +function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { + return { + async resolve(token: string, runAddress: string) { + if (token === "" || runAddress === "") return null; + const tokenHash = await sha256(token); + const sidecarRow = await deps.db.query.sidecar.findFirst({ + where: eq(sidecar.tokenHashSha256, tokenHash), + }); + if (sidecarRow === undefined) return null; + const run = await deps.db.query.workflowRun.findFirst({ + where: eq(workflowRun.address, runAddress), + }); + if (run === undefined || run.principalId === null) return null; + return { + tenantId: run.tenantId, + principalId: run.principalId, + runId: run.id, + }; + }, + }; +} + +// SIDECAR_PROVISIONERS: comma-separated backend ids to register for +// exclusive-sidecar placement. Unset or empty registers "process" alone, +// so a single-server install works with no operator configuration. +const SidecarProvisionersEnv = type({ + "SIDECAR_PROVISIONERS?": type("string"), +}); + +function buildSidecarProvisioner( + id: "process" | "docker" | "e2b", + hubDataDir: string, + hubWebSocketUrl: string, + role: ProcessProvisionerRole, +): SidecarProvisioner { + switch (id) { + case "process": + return createProcessSidecarProvisioner({ + role, + config: readProcessProvisionerConfig({ + env: process.env, + dataDir: path.resolve( + hubDataDir, + role === "probe" ? "process-provisioner-probe" : "process-provisioner", + ), + hubWebSocketUrl, + }), + }); + case "docker": + return createDockerSidecarProvisioner({ + config: { + image: process.env["DOCKER_PROVISIONER_IMAGE"] ?? "", + stateFilePath: path.resolve(hubDataDir, "docker-provisioner", "state.json"), + }, + }); + case "e2b": + return createE2BSidecarProvisioner({ + config: readE2BProvisionerConfig(process.env, path.resolve(hubDataDir, "e2b-provisioner")), + }); + } +} + +export type CreateHubServerOpts = { + /** Provisioners eligible to host frozen workflow deployments. */ + readonly sidecarProvisioners?: readonly SidecarProvisioner[]; + /** Selects among matching deployment provisioners. Defaults to the first. */ + readonly sidecarProvisionerChooser?: SidecarProvisionerChooser; + /** Provisioners eligible to evaluate workflow source code. */ + readonly probeSidecarProvisioners?: readonly SidecarProvisioner[]; + /** Selects among matching probe provisioners. Defaults to the first. */ + readonly probeSidecarProvisionerChooser?: SidecarProvisionerChooser; + readonly probeSidecarCapabilityRules?: readonly SidecarCapabilityRule[]; + /** Maximum simultaneous allocation reconciliations. Defaults to eight. */ + readonly sidecarAllocationConcurrency?: number; + /** Deadline for provider calls, allocation claims, lease validation, and connection waits. Defaults to 120 seconds. */ + readonly sidecarOperationTimeoutMs?: number; +}; + +export async function createHubServer({ + sidecarProvisioners, + sidecarProvisionerChooser, + probeSidecarProvisioners, + probeSidecarProvisionerChooser, + probeSidecarCapabilityRules = [], + sidecarAllocationConcurrency = DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY, + sidecarOperationTimeoutMs, +}: CreateHubServerOpts = {}) { + await setup(); + + const log = getLogger(["hub"]); + const port = Number(process.env["PORT"] ?? 3000); + + // PG_SCHEMA pins the hub to a specific postgres schema. The + // integration-test harness sets this so each spawned hub gets a + // dedicated, droppable schema. Production deployments leave it + // unset and run against postgres' default search_path. + const pgSchema = process.env["PG_SCHEMA"]; + const { db } = createDB({ + host: process.env["DB_HOST"] ?? "localhost", + port: Number(process.env["DB_PORT"] ?? 5432), + user: process.env["DB_USER"] ?? "postgres", + password: process.env["DB_PASSWORD"] ?? "postgres", + database: process.env["DB_NAME"] ?? "interchange", + ...(pgSchema !== undefined && { schema: pgSchema }), + }); + + const auth = createAuth(db); + + const hubDataDir = process.env["HUB_DATA_DIR"]; + if (!hubDataDir) { + throw new Error("HUB_DATA_DIR environment variable is required"); + } + + // Credential secrets are encrypted at rest under this operator-provided key. + const credentialEncryptionKeyHex = process.env["CREDENTIAL_ENCRYPTION_KEY"]; + if (credentialEncryptionKeyHex === undefined || credentialEncryptionKeyHex.trim() === "") { + throw new Error("CREDENTIAL_ENCRYPTION_KEY environment variable is required"); + } + const credentialCipher = createEnvKeyCredentialCipher(hexDecode(credentialEncryptionKeyHex)); + + // Per-principal signing keys are sealed at rest under their own operator key. + const principalKeyEncryptionKeyHex = process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"]; + if (principalKeyEncryptionKeyHex === undefined || principalKeyEncryptionKeyHex.trim() === "") { + throw new Error("PRINCIPAL_KEY_ENCRYPTION_KEY environment variable is required"); + } + const principalKeyStore = createPrincipalKeyStore({ + db, + cipher: createEnvKeyCredentialCipher(hexDecode(principalKeyEncryptionKeyHex)), + }); + + const DEFAULT_HUB_MAX_TARBALL_BYTES = 10 * 1024 * 1024; + const hubMaxTarballBytesRaw = process.env["HUB_MAX_TARBALL_BYTES"]; + const hubMaxTarballBytes = + hubMaxTarballBytesRaw === undefined || hubMaxTarballBytesRaw.trim() === "" + ? DEFAULT_HUB_MAX_TARBALL_BYTES + : Number(hubMaxTarballBytesRaw); + if (!Number.isFinite(hubMaxTarballBytes) || hubMaxTarballBytes <= 0) { + throw new Error( + `HUB_MAX_TARBALL_BYTES must be a positive number; got ${JSON.stringify(hubMaxTarballBytesRaw)}`, + ); + } + + const hubSigningKey = await generateKeyPair(); + log.info("Generated hub deploy signing key"); + + const agentRepoStore = createAgentRepoStore({ + dataDir: hubDataDir, + signingKey: hubSigningKey, + }); + + const httpRegistries = new Map([["npmjs", { url: "https://registry.npmjs.org" }]]); + + const assetService = createAssetService({ + db, + repoStore: agentRepoStore.repoStore, + reservedPackageRegistryNames: new Set(httpRegistries.keys()), + }); + + const grantStore = createGrantStore(db); + + const lookups: SidecarLookups = { + ...createHubSessionLookups({ db, agentRepoStore }), + materializeMailTriggeredRunGrants: createMailTriggeredRunGrantsMaterializer({ + db, + principalKeyStore, + grantStore, + }), + resolveSenderKey: (address) => resolveFrameSenderKey(db, principalKeyStore, address), + resolveSenderKeyStrict: async (address) => + (await resolveSenderKey(db, principalKeyStore, address))?.publicKey ?? null, + }; + + const sidecarCredentials = createSidecarCredentialResolver({ db }); + + const sidecarRouter = createSidecarRouter({ + hubPublicKey: hexEncode(hubSigningKey.publicKey), + authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token), + validateSidecarIdentity: sidecarCredentials.isCurrent, + lookups, + }); + + lookups.resyncCredentials = (agentAddress) => { + void pushCredentialReconcile(db, sidecarRouter, agentAddress, credentialCipher); + }; + + const eventCollectors = createEventCollectorRegistry({ + db, + onTurnFinalized(agentAddress, turn) { + sidecarRouter.dispatchAgentEvent(agentAddress, { + type: "turn.committed", + data: { + turnId: turn.turnId, + status: turn.status, + text: turn.text, + hadReply: turn.hadReply, + hadError: turn.hadError, + errors: turn.errors, + toolCalls: turn.toolCalls, + toolErrors: turn.toolErrors, + }, + }); + }, + }); + + createHubSessionOrchestrator({ + events: sidecarRouter.events, + router: sidecarRouter, + db, + eventCollectors, + }); + + const sessionService = createSessionService({ + sidecarRouter, + sidecarAllocationRouter: sidecarRouter, + agentRepoStore, + assetService, + db, + toolPackageRegistries: { + httpRegistries, + defaultRegistry: "npmjs", + scopeRouting: [{ scope: "@intx", registry: WORKSPACE_BUILTINS_REGISTRY }], + }, + }); + + const hubSidecarWebSocketUrl = + process.env["HUB_SIDECAR_WEBSOCKET_URL"] ?? `ws://127.0.0.1:${String(port)}/api/sidecars/ws`; + + const parsedProvisionerIds = SidecarProvisionersEnv(process.env); + const provisionerIdsRaw: string = + parsedProvisionerIds instanceof type.errors + ? "" + : (parsedProvisionerIds.SIDECAR_PROVISIONERS ?? ""); + const provisionerIds = provisionerIdsRaw + .split(",") + .map((id) => id.trim()) + .filter( + (id): id is "process" | "docker" | "e2b" => + id === "process" || id === "docker" || id === "e2b", + ); + const resolvedProvisionerIds = + provisionerIds.length > 0 ? provisionerIds : (["process"] as const); + const deploymentProvisioners = + sidecarProvisioners ?? + resolvedProvisionerIds.map((id) => + buildSidecarProvisioner(id, hubDataDir, hubSidecarWebSocketUrl, "deployment"), + ); + const probeProvisioners = + probeSidecarProvisioners ?? + resolvedProvisionerIds.map((id) => + buildSidecarProvisioner(id, hubDataDir, hubSidecarWebSocketUrl, "probe"), + ); + + const sidecarPlugins = createSidecarPluginRegistry({ + provisioners: deploymentProvisioners, + ...(sidecarProvisionerChooser !== undefined ? { chooser: sidecarProvisionerChooser } : {}), + }); + const probeSidecarPlugins = createSidecarPluginRegistry({ + provisioners: probeProvisioners, + ...(probeSidecarProvisionerChooser !== undefined + ? { chooser: probeSidecarProvisionerChooser } + : {}), + }); + const workflowAllocationService = createWorkflowAllocationService({ + db, + deploymentPlugins: sidecarPlugins, + probePlugins: probeSidecarPlugins, + preparedDeployer: sessionService, + credentialCipher, + probeCapabilityRules: probeSidecarCapabilityRules, + allocationRouter: sidecarRouter, + hubWebSocketUrl: hubSidecarWebSocketUrl, + ...(sidecarOperationTimeoutMs !== undefined + ? { operationTimeoutMs: sidecarOperationTimeoutMs } + : {}), + }); + const sidecarAllocationStore = createSidecarAllocationStore(db); + const workflowDispatchService = createWorkflowDispatchService({ + dispatchStore: createWorkflowRunDispatchStore(db), + allocationStore: sidecarAllocationStore, + router: sidecarRouter, + resolveAnchorAddress: async (anchorRunId) => { + const row = await db.query.workflowRun.findFirst({ + where: (run, { eq: equals }) => equals(run.id, anchorRunId), + columns: { address: true }, + }); + return row?.address ?? null; + }, + }); + const sidecarAllocationReconciler = createSidecarAllocationReconciler({ + allocationStore: sidecarAllocationStore, + plugins: sidecarPlugins, + router: sidecarRouter, + hubWebSocketUrl: hubSidecarWebSocketUrl, + ...(sidecarOperationTimeoutMs !== undefined + ? { operationTimeoutMs: sidecarOperationTimeoutMs } + : {}), + onReady: async (allocation, reconciliation) => { + await workflowAllocationService.deployReadyAllocation(allocation, reconciliation); + reconciliation.signal.throwIfAborted(); + await workflowDispatchService.requeueForReadyAllocation(allocation.anchorRunId); + }, + }); + + await workflowAllocationService.initialize?.(); + await sidecarAllocationReconciler.initialize(); + sidecarRouter.events.on("sidecar.disconnect", ({ allocated }) => { + if (allocated === undefined) return; + return sidecarAllocationReconciler.handleDisconnect(allocated); + }); + sidecarRouter.events.on("sidecar.allocated.connected", (allocated) => + sidecarAllocationReconciler.handleConnected(allocated), + ); + sidecarRouter.events.on("mail.inbound.acknowledged", ({ messageId, allocated }) => { + if (allocated === undefined) return; + return workflowDispatchService.acknowledge({ ...allocated, messageId }); + }); + + const allocationScheduler = createReconciliationScheduler({ + name: "Sidecar allocation", + concurrency: sidecarAllocationConcurrency, + reconcileNext: () => sidecarAllocationReconciler.reconcileNext(), + }); + const probeCleanupScheduler = createReconciliationScheduler({ + name: "Workflow probe cleanup", + concurrency: 1, + intervalMs: 30_000, + reconcileNext: async () => { + await workflowAllocationService.reconcileReleasingProbes?.(); + return false; + }, + }); + const dispatchScheduler = createReconciliationScheduler({ + name: "Workflow dispatch", + concurrency: 1, + reconcileNext: async () => { + workflowDispatchService.wake(); + return false; + }, + }); + const connectionRepairScheduler = createReconciliationScheduler({ + name: "Sidecar connection repair", + concurrency: 1, + intervalMs: 30_000, + reconcileNext: async () => { + await sidecarAllocationReconciler.repairUnscheduledConnections(); + return false; + }, + }); + + allocationScheduler.start(); + probeCleanupScheduler.start(); + dispatchScheduler.start(); + connectionRepairScheduler.start(); + + const app = createApp({ + getSession: async (headers) => { + const result = await auth.api.getSession({ headers }); + return result ? { user: result.user, session: result.session } : null; + }, + authHandler: (c) => auth.handler(c.req.raw), + db, + sidecarRouter, + sessionService, + workflowAllocationService, + workflowDispatchService, + eventCollectors, + credentialCipher, + principalKeyStore, + assetService, + repoStore: agentRepoStore.repoStore, + maxTarballBytes: hubMaxTarballBytes, + workflowRunAuthenticator: createWorkflowRunAuthenticator({ db }), + sidecarWsHandler: upgradeWebSocket((_c) => { + let handle: WsHandle; + return { + onOpen(_evt, ws) { + handle = { + send(data: string) { + ws.send(data); + }, + close() { + ws.close(); + }, + }; + sidecarRouter.handleOpen(handle); + }, + onMessage(evt, _ws) { + if (typeof evt.data === "string") { + sidecarRouter.handleMessage(handle, evt.data); + } + }, + onClose(_evt, _ws) { + sidecarRouter.handleClose(handle); + }, + }; + }), + }); + + // --------------------------------------------------------------------- + // Corbits mount block -- everything Workbench adds to the stock hub. + // Each library gets its own Hono routed under the tenant + // prefix (or "/" when the library's own routes already carry that + // prefix), reusing the app's own db/grantStore/credentialCipher/router. + // See AGENTS.md: "any other hub mount is cutover debt with a Linear + // issue, never a pattern to extend." + // --------------------------------------------------------------------- + const TENANT_PREFIX = "/api/tenants/:tenantId"; + const corbitsDatabaseUrl = `postgres://${encodeURIComponent(process.env["DB_USER"] ?? "postgres")}:${encodeURIComponent(process.env["DB_PASSWORD"] ?? "postgres")}@${process.env["DB_HOST"] ?? "localhost"}:${String(Number(process.env["DB_PORT"] ?? 5432))}/${process.env["DB_NAME"] ?? "interchange"}`; + const { db: mailboxDb } = createMailboxDb(corbitsDatabaseUrl); + const mailboxBus = createInMemoryMailboxEventBus(); + const eventCollectorsRef: { current?: typeof eventCollectors } = { current: eventCollectors }; + const mailboxLookups = { + ...lookups, + persistMail: createMailboxPersist(mailboxDb, { + upstream: createHubPersistMailWithSessionEnsure( + db, + eventCollectorsRef, + createHubSessionLookups({ db, agentRepoStore }).persistMail, + ), + authorizeSender: createHubMailboxAuthorizeSender(db), + bus: mailboxBus, + }), + }; + + await runArtifactMigrations(db); + await applyCronMigrations(corbitsDatabaseUrl); + const artifactContentStore = InlineContentStore; + + { + const artifactsApi = new Hono(); + mountArtifacts(artifactsApi, { + db, + contentStore: artifactContentStore, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), + countSegments: Object.fromEntries( + LIBRARY_KIND_SEGMENTS.map((segment) => [ + segment, + (row: { kind: string; title: string }) => artifactMatchesLibraryKindSegment(row, segment), + ]), + ), + }); + app.route(TENANT_PREFIX, artifactsApi); + } + { + const workflowArtifactsApi = new Hono(); + const workflowRunAuthenticator = createWorkflowRunAuthenticator({ db }); + mountWorkflowArtifacts(workflowArtifactsApi, { + db, + contentStore: artifactContentStore, + resolveRunScope: (token, runAddress) => workflowRunAuthenticator.resolve(token, runAddress), + }); + app.route("/api/workflow-artifacts", workflowArtifactsApi); + } + { + const mailboxApp = new Hono(); + mountMailbox(mailboxApp, { + db: mailboxDb, + bus: mailboxBus, + resolvePrincipal: (ctx) => { + const c = ctx as { get(key: "tenant" | "principal"): { id: string } }; + return { tenantId: c.get("tenant").id, principalId: c.get("principal").id }; + }, + senderAddressFor: async (principal) => { + const [tenantRow] = await db + .select({ domain: tenantTable.domain }) + .from(tenantTable) + .where(eq(tenantTable.id, principal.tenantId)) + .limit(1); + if (tenantRow === undefined) { + throw new Error(`no tenant "${principal.tenantId}" to address a mailbox sender from`); + } + return `${principal.principalId}@${tenantRow.domain}`; + }, + deliver: async (message) => { + await mailboxLookups.persistMail({ + senderAddress: message.from, + recipients: message.to, + raw: message.raw, + }); + }, + }); + app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); + } + + let cronTicker: { start(): void; stop(): void } | undefined; + { + const cronApp = new Hono(); + mountCron(cronApp, { + db, + requireTenantMember: (ctx, tenantId) => { + const c = ctx as { get(key: "tenant"): { id: string } }; + return c.get("tenant").id === tenantId; + }, + }); + app.route("/", cronApp); + + cronTicker = createCronTicker({ + db, + intervalMs: 60_000, + senderAddressFor: (tenantId) => `cron@${tenantId}`, + deliver: async (message) => { + const tenantId = message.from.slice("cron@".length); + const [tenantRow] = await db + .select({ domain: tenantTable.domain }) + .from(tenantTable) + .where(eq(tenantTable.id, tenantId)) + .limit(1); + if (tenantRow === undefined) { + throw new Error(`no tenant "${tenantId}" to address cron mail from`); + } + const from = `cron@${tenantRow.domain}`; + await mailboxLookups.persistMail({ + senderAddress: from, + recipients: message.to, + raw: buildMailFrame({ + from, + to: message.to.join(", "), + subject: message.subject, + body: message.body, + messageId: generateMailboxMessageId(from), + }), + }); + }, + }); + cronTicker.start(); + } + + { + const memoryApp = new Hono(); + createMemory({ + app: memoryApp, + config: loadMemoryConfig(), + grantStore, + conditionRegistry: grantConditionRegistry, + }); + app.route("/", memoryApp); + } + + app.route( + "/api/workflow-workflow-authoring", + createWorkflowAuthorRoutes({ + authenticator: createWorkflowRunAuthenticator({ db }), + registry: createWorkflowAuthorRegistry({ + db, + assetService, + repoStore: agentRepoStore.repoStore, + grantStore, + conditionRegistry: grantConditionRegistry, + }), + }), + ); + + const webhookTriggerStore = createDrizzleWebhookTriggerStore(db, credentialCipher); + const cryptoProviders = createCryptoProviderCache(); + app.route( + `${TENANT_PREFIX}/webhook-triggers`, + createWebhookTriggerRoutes({ + store: webhookTriggerStore, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), + workflowDefinitionInTenant: async (tenantId, definitionId) => { + const row = await db.query.workflowDefinition.findFirst({ + where: and( + eq(workflowDefinition.id, definitionId), + eq(workflowDefinition.tenantId, tenantId), + ), + columns: { id: true }, + }); + return row !== undefined; + }, + }), + ); + const isSidecarRoutable = (address: string) => + sidecarRouter.getRoutableAddresses().includes(address); + app.route( + "/api/webhooks", + createWebhookIngressRoutes({ + store: webhookTriggerStore, + launch: (trigger, payload) => + launchWebhookTrigger( + { + db, + sidecarRouter, + repoStore: agentRepoStore.repoStore, + workflowAllocationService, + credentialCipher, + eventCollectors, + isRoutable: isSidecarRoutable, + cryptoProviderCache: cryptoProviders, + }, + trigger, + payload, + ), + }), + ); + + const hubUrl = process.env["BASE_URL"] ?? `http://localhost:${String(port)}`; + const providerHealthStore = createProviderHealthStore(); + app.route( + `${TENANT_PREFIX}/connections`, + createConnectionRoutes({ + hubUrl, + registry: CONNECTOR_REGISTRY, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), + log: (line) => log.info`${line}`, + oauthEnv: { + huggingfaceClientId: process.env["HUGGINGFACE_OAUTH_CLIENT_ID"], + githubAppClientId: process.env["GITHUB_APP_CLIENT_ID"], + githubAppClientSecret: process.env["GITHUB_APP_CLIENT_SECRET"], + gmailClientId: process.env["GMAIL_CLIENT_ID"], + gmailClientSecret: process.env["GMAIL_CLIENT_SECRET"], + }, + providerHealth: providerHealthStore, + probeBaseUrls: + process.env["GITHUB_API_BASE_URL"] !== undefined + ? { github: process.env["GITHUB_API_BASE_URL"] } + : {}, + }), + ); + app.route( + `${TENANT_PREFIX}/connections/oauth`, + createOAuthConnectRoutes({ + hubUrl, + log: (line) => log.info`${line}`, + credentialCipher, + registry: CONNECTOR_REGISTRY, + oauthEnv: { + huggingfaceClientId: process.env["HUGGINGFACE_OAUTH_CLIENT_ID"], + githubAppClientId: process.env["GITHUB_APP_CLIENT_ID"], + githubAppClientSecret: process.env["GITHUB_APP_CLIENT_SECRET"], + gmailClientId: process.env["GMAIL_CLIENT_ID"], + gmailClientSecret: process.env["GMAIL_CLIENT_SECRET"], + }, + connectCredential: createTenantConnectCredential({ + hubUrl, + log: (line) => log.info`${line}`, + registry: CONNECTOR_REGISTRY, + providerHealth: providerHealthStore, + }), + defaultReturnPath: "/settings/connections", + returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], + }), + ); + app.route( + `${TENANT_PREFIX}/mcp-servers`, + createMcpServerRoutes({ + hubUrl, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), + log: (line) => log.info`${line}`, + presets: MCP_PRESETS, + }), + ); + app.route( + `${TENANT_PREFIX}/mcp-servers/oauth`, + createMcpOAuthRoutes({ + hubUrl, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), + log: (line) => log.info`${line}`, + credentialCipher, + presets: MCP_PRESETS, + returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], + }), + ); + app.route( + "/api/workflow-connections", + createWorkflowConnectionRoutes({ + authenticator: createWorkflowRunAuthenticator({ db }), + listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), + }), + ); + // --------------------------------------------------------------------- + // End of Corbits mount block. + // --------------------------------------------------------------------- + + log.info("Starting server on port {port}", { port }); + + const sidecarWebsocket: typeof websocket & { maxPayloadLength: number } = { + ...websocket, + maxPayloadLength: MAX_SIDECAR_FRAME_BYTES, + }; + + return { + fetch: app.fetch, + websocket: sidecarWebsocket, + port, + idleTimeout: 0, + }; +} diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index c13f3b1947..6c397bdecf 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -1,157 +1,65 @@ -// Exercises the hub's own wiring: platform routes answering at boot, -// an extension route mounted inside the native tenant middleware, and -// same-origin static serving. Platform behavior behind the mounted -// routes belongs to its own packages and is not re-proven here. Booting -// the hub runs package migrations, so a reachable DATABASE_URL is -// required and the suite skips without one. - -import { afterAll, expect, spyOn, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +// Exercises the hub's own composition: the stock platform routes answer, +// and a Corbits mount (mailbox) sits inside the native tenant middleware +// rather than replacing it. Platform behavior behind stock routes belongs +// to @intx/hub-api and is not re-proven here. Booting the hub runs package +// migrations, so a reachable DATABASE_URL is required and the suite skips +// without one. + +import { afterAll, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -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 databaseUrl = process.env["DATABASE_URL"] ?? ""; const describeIfDb = dbGate(databaseUrl, import.meta.path); -const root = mkdtempSync(path.join(tmpdir(), "hub-composition-")); -const staticDir = path.join(root, "static"); -mkdirSync(path.join(staticDir, "assets"), { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -writeFileSync(path.join(staticDir, "assets", "app.css"), "body{}"); -mkdirSync(path.join(root, "data"), { recursive: true }); +const root = mktempHubDataDir(); +function mktempHubDataDir(): string { + return mkdtempSync(path.join(tmpdir(), "hub-composition-")); +} -const config: HubConfig = { - databaseUrl, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the - // credential-cipher seam, so the dev opt-in keeps boot working. - allowPlaintextSecrets: true, - sidecarProvisioners: [], -}; +process.env["HUB_DATA_DIR"] = root; +process.env["CREDENTIAL_ENCRYPTION_KEY"] ??= "0".repeat(64); +process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"] ??= "1".repeat(64); +process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = "1"; +if (databaseUrl !== "") { + const url = new URL(databaseUrl); + process.env["DB_HOST"] = url.hostname; + process.env["DB_PORT"] = url.port === "" ? "5432" : url.port; + process.env["DB_USER"] = decodeURIComponent(url.username); + process.env["DB_PASSWORD"] = decodeURIComponent(url.password); + process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); +} const closers: (() => Promise)[] = []; -async function bootHub(): Promise>> { - const hub = await createHub(config); - closers.push(hub.close); - return hub; -} - afterAll(async () => { for (const close of closers) await close(); rmSync(root, { recursive: true, force: true }); }); describeIfDb("boot", () => { - test("serves platform health, auth-gated routes, and the interface", async () => { - const hub = await bootHub(); + test("serves stock platform routes", async () => { + const { createHubServer } = await import("../src/server"); + const opts = await createHubServer(); - const status = await hub.app.request("/status"); + const status = await opts.fetch(new Request("http://localhost/status")); expect(status.status).toBe(200); - expect(await status.json()).toEqual({ status: "ok" }); - // The login gate is live: user-scoped platform routes answer 401 - // for an anonymous request instead of 404 or a crash. - const me = await hub.app.request("/api/me/principals"); + const me = await opts.fetch(new Request("http://localhost/api/me/principals")); expect(me.status).toBe(401); - - // The interface serves from the same origin: real files as-is, - // unknown client-side routes as index.html. - const asset = await hub.app.request("/assets/app.css"); - expect(asset.status).toBe(200); - expect(await asset.text()).toBe("body{}"); - const deepLink = await hub.app.request("/settings/profile"); - expect(deepLink.status).toBe(200); - expect(await deepLink.text()).toBe("shell"); - - // Static serving never swallows the API prefix. - const unknownApi = await hub.app.request("/api/no-such-route"); - expect(unknownApi.status).toBe(404); }); -}); - -describeIfDb("shutdown", () => { - test("close() cancels the pending sidecar allocation reconciliation timer", async () => { - const setTimeoutSpy = spyOn(global, "setTimeout"); - const clearTimeoutSpy = spyOn(global, "clearTimeout"); - - const hub = await createHub(config); - // index.ts schedules its reconciliation loop with setTimeout(fn, - // 1000) — the one 1000ms setTimeout call site in the module — so - // this is the pending timer close() must cancel. - const reconciliationCallIndex = setTimeoutSpy.mock.calls.findIndex((call) => call[1] === 1000); - expect(reconciliationCallIndex).toBeGreaterThanOrEqual(0); - const reconciliationTimerId = setTimeoutSpy.mock.results[reconciliationCallIndex] - ?.value as ReturnType; - - await hub.close(); - expect(clearTimeoutSpy.mock.calls.map((call) => call[0])).toContain(reconciliationTimerId); + test("a Corbits mount (mailbox) sits inside the native tenant middleware", async () => { + const { createHubServer } = await import("../src/server"); + const opts = await createHubServer(); - setTimeoutSpy.mockRestore(); - clearTimeoutSpy.mockRestore(); - }); -}); - -describeIfDb("extension mounting", () => { - test("the mailbox mounts inside the native tenant middleware", async () => { - const hub = await bootHub(); - - // Anonymous request to an extension route: the platform's tenant - // middleware answers 401 before the extension's handler runs. - const gated = await hub.app.request("/api/tenants/some-tenant/mailbox/me/inbox"); + // Anonymous request to a Corbits-mounted route: the platform's tenant + // middleware answers 401 before the mailbox library's own handler runs. + const gated = await opts.fetch( + new Request("http://localhost/api/tenants/some-tenant/mailbox/me/inbox"), + ); expect(gated.status).toBe(401); - expect(await gated.json()).toEqual({ - error: { code: "unauthorized", message: "Authentication required" }, - }); - - // The route exists only under the tenant scope; outside it the - // path falls through to the interface shell. - const outside = await hub.app.request("/mailbox/me/inbox"); - expect(await outside.text()).toBe("shell"); - }); -}); - -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`; - const signUp = await hub.app.request("/api/auth/sign-up/email", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email, - password: "password123", - name: "New User", - }), - }); - expect(signUp.status).toBe(200); - - const cookie = signUp.headers.get("set-cookie"); - expect(cookie).not.toBeNull(); - const createTenant = await hub.app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: cookie ?? "", - }, - body: JSON.stringify({ - slug: `bench-${crypto.randomUUID().slice(0, 8)}`, - name: "First Bench", - }), - }); - expect(createTenant.status).toBe(201); }); }); diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts deleted file mode 100644 index 4f4470c2c1..0000000000 --- a/apps/hub/test/config.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readHubConfig } from "../src/config.ts"; - -const validEnv = { - DATABASE_URL: "postgres://workbench:workbench@localhost:5432/workbench", - BASE_URL: "http://localhost:3000", - SESSION_SECRET: "insecure-dev-only-session-secret-0000", - HUB_DATA_DIR: ".data/hub", - HUB_STATIC_DIR: "apps/web/dist", -}; - -function readExpectingError(env: Record): string { - try { - readHubConfig(env); - } catch (error) { - expect(error).toBeInstanceOf(Error); - return (error as Error).message; - } - throw new Error("expected readHubConfig to throw"); -} - -describe("readHubConfig", () => { - test("returns typed config for a valid environment", () => { - const config = readHubConfig(validEnv); - expect(config).toEqual({ - databaseUrl: validEnv.DATABASE_URL, - baseUrl: validEnv.BASE_URL, - sessionSecret: validEnv.SESSION_SECRET, - hubDataDir: validEnv.HUB_DATA_DIR, - hubStaticDir: validEnv.HUB_STATIC_DIR, - socialProviders: {}, - signupRateLimit: { windowSeconds: 60, max: 5 }, - allowPlaintextSecrets: false, - sidecarProvisioners: [{ id: "process" }], - defaultSidecarProvisionerId: "process", - }); - }); - - test("provider env vars are no longer configuration", () => { - const config = readHubConfig({ - ...validEnv, - ANTHROPIC_API_KEY: "sk-ant-test", - OPENAI_API_KEY: "sk-oai-test", - OLLAMA_BASE_URL: "http://localhost:11434", - HUB_ADMIN_EMAIL: "owner@acme.example", - HUB_ADMIN_PASSWORD: "correct-horse-battery", - }); - expect(Object.keys(config).sort()).toEqual( - [ - "allowPlaintextSecrets", - "baseUrl", - "databaseUrl", - "defaultSidecarProvisionerId", - "hubDataDir", - "hubStaticDir", - "sessionSecret", - "signupRateLimit", - "socialProviders", - "sidecarProvisioners", - ].sort(), - ); - }); - - describe("social providers", () => { - test("absent by default", () => { - expect(readHubConfig(validEnv).socialProviders).toEqual({}); - }); - - test("a full Google pair enables google", () => { - const config = readHubConfig({ - ...validEnv, - GOOGLE_CLIENT_ID: "google-id", - GOOGLE_CLIENT_SECRET: "google-secret", - }); - expect(config.socialProviders).toEqual({ - google: { clientId: "google-id", clientSecret: "google-secret" }, - }); - }); - - test("a full GitHub pair enables github, independently of google", () => { - const config = readHubConfig({ - ...validEnv, - GITHUB_CLIENT_ID: "github-id", - GITHUB_CLIENT_SECRET: "github-secret", - }); - expect(config.socialProviders).toEqual({ - github: { clientId: "github-id", clientSecret: "github-secret" }, - }); - }); - - test("both providers can be configured together", () => { - const config = readHubConfig({ - ...validEnv, - GOOGLE_CLIENT_ID: "google-id", - GOOGLE_CLIENT_SECRET: "google-secret", - GITHUB_CLIENT_ID: "github-id", - GITHUB_CLIENT_SECRET: "github-secret", - }); - expect(Object.keys(config.socialProviders).sort()).toEqual(["github", "google"]); - }); - - test("a client id with no secret fails loudly at boot", () => { - const message = readExpectingError({ - ...validEnv, - GOOGLE_CLIENT_ID: "google-id", - }); - expect(message).toContain("GOOGLE_CLIENT_ID"); - expect(message).toContain("GOOGLE_CLIENT_SECRET"); - }); - - test("a client secret with no id fails loudly at boot", () => { - const message = readExpectingError({ - ...validEnv, - GITHUB_CLIENT_SECRET: "github-secret", - }); - expect(message).toContain("GITHUB_CLIENT_ID"); - expect(message).toContain("GITHUB_CLIENT_SECRET"); - }); - }); - - test("the signup rate limit is configurable and defaults sanely", () => { - const config = readHubConfig({ - ...validEnv, - SIGNUP_RATE_LIMIT_WINDOW_SECONDS: "30", - SIGNUP_RATE_LIMIT_MAX: "2", - }); - expect(config.signupRateLimit).toEqual({ windowSeconds: 30, max: 2 }); - }); - - test("huggingfaceOAuthClientId is absent by default", () => { - expect(readHubConfig(validEnv).huggingfaceOAuthClientId).toBeUndefined(); - }); - - test("HUGGINGFACE_OAUTH_CLIENT_ID enables the connect card's client id", () => { - const config = readHubConfig({ - ...validEnv, - HUGGINGFACE_OAUTH_CLIENT_ID: "hf-client-1", - }); - expect(config.huggingfaceOAuthClientId).toBe("hf-client-1"); - }); - - test("githubApiBaseUrl is absent by default", () => { - expect(readHubConfig(validEnv).githubApiBaseUrl).toBeUndefined(); - }); - - test("GITHUB_API_BASE_URL overrides the github connector's API origin", () => { - const config = readHubConfig({ - ...validEnv, - GITHUB_API_BASE_URL: "http://fake-github.test", - }); - expect(config.githubApiBaseUrl).toBe("http://fake-github.test"); - }); - - test("GITHUB_API_BASE_URL rejects a non-http(s) value", () => { - const message = readExpectingError({ - ...validEnv, - GITHUB_API_BASE_URL: "not-a-url", - }); - expect(message).toContain("GITHUB_API_BASE_URL"); - }); - - test("CREDENTIAL_ENCRYPTION_KEY absent by default", () => { - expect(readHubConfig(validEnv).credentialEncryptionKeyHex).toBeUndefined(); - }); - - test("CREDENTIAL_ENCRYPTION_KEY accepts a 64-char hex key", () => { - const key = "a".repeat(64); - const config = readHubConfig({ - ...validEnv, - CREDENTIAL_ENCRYPTION_KEY: key, - }); - expect(config.credentialEncryptionKeyHex).toBe(key); - }); - - test("CREDENTIAL_ENCRYPTION_KEY rejects a key of the wrong length or shape", () => { - const message = readExpectingError({ - ...validEnv, - CREDENTIAL_ENCRYPTION_KEY: "not-hex-and-too-short", - }); - expect(message).toContain("CREDENTIAL_ENCRYPTION_KEY"); - }); - - test("PRINCIPAL_KEY_ENCRYPTION_KEY absent by default", () => { - expect(readHubConfig(validEnv).principalKeyEncryptionKeyHex).toBeUndefined(); - }); - - test("PRINCIPAL_KEY_ENCRYPTION_KEY accepts a 64-char hex key", () => { - const key = "b".repeat(64); - const config = readHubConfig({ - ...validEnv, - PRINCIPAL_KEY_ENCRYPTION_KEY: key, - }); - expect(config.principalKeyEncryptionKeyHex).toBe(key); - }); - - test("PRINCIPAL_KEY_ENCRYPTION_KEY rejects a key of the wrong length or shape", () => { - const message = readExpectingError({ - ...validEnv, - PRINCIPAL_KEY_ENCRYPTION_KEY: "not-hex-and-too-short", - }); - expect(message).toContain("PRINCIPAL_KEY_ENCRYPTION_KEY"); - }); - - test("ALLOW_PLAINTEXT_SECRETS never substitutes a principal key — the key stays unset", () => { - const config = readHubConfig({ - ...validEnv, - BASE_URL: "http://localhost:3000", - ALLOW_PLAINTEXT_SECRETS: "1", - }); - expect(config.allowPlaintextSecrets).toBe(true); - expect(config.principalKeyEncryptionKeyHex).toBeUndefined(); - }); - - test("allowPlaintextSecrets is false by default", () => { - expect(readHubConfig(validEnv).allowPlaintextSecrets).toBe(false); - }); - - test("allowGitInsideWorkTree is omitted by default", () => { - expect(readHubConfig(validEnv).allowGitInsideWorkTree).toBeUndefined(); - }); - - test("HUB_ALLOW_GIT_INSIDE_WORK_TREE='1' or 'true' opts in", () => { - expect( - readHubConfig({ ...validEnv, HUB_ALLOW_GIT_INSIDE_WORK_TREE: "1" }).allowGitInsideWorkTree, - ).toBe(true); - expect( - readHubConfig({ ...validEnv, HUB_ALLOW_GIT_INSIDE_WORK_TREE: "true" }).allowGitInsideWorkTree, - ).toBe(true); - }); - - test("HUB_ALLOW_GIT_INSIDE_WORK_TREE rejects any other value", () => { - const message = readExpectingError({ - ...validEnv, - HUB_ALLOW_GIT_INSIDE_WORK_TREE: "yes", - }); - expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE"); - }); - - test("ALLOW_PLAINTEXT_SECRETS='1' or 'true' opts in", () => { - expect(readHubConfig({ ...validEnv, ALLOW_PLAINTEXT_SECRETS: "1" }).allowPlaintextSecrets).toBe( - true, - ); - expect( - readHubConfig({ ...validEnv, ALLOW_PLAINTEXT_SECRETS: "true" }).allowPlaintextSecrets, - ).toBe(true); - }); - - test("ALLOW_PLAINTEXT_SECRETS rejects any other value", () => { - const message = readExpectingError({ - ...validEnv, - ALLOW_PLAINTEXT_SECRETS: "yes", - }); - expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); - }); - - test("ALLOW_PLAINTEXT_SECRETS boots fine against a loopback BASE_URL", () => { - expect( - readHubConfig({ - ...validEnv, - BASE_URL: "http://localhost:3000", - ALLOW_PLAINTEXT_SECRETS: "1", - }).allowPlaintextSecrets, - ).toBe(true); - expect( - readHubConfig({ - ...validEnv, - BASE_URL: "http://127.0.0.1:3000", - ALLOW_PLAINTEXT_SECRETS: "1", - }).allowPlaintextSecrets, - ).toBe(true); - }); - - test("ALLOW_PLAINTEXT_SECRETS refuses to boot against a non-loopback BASE_URL", () => { - const message = readExpectingError({ - ...validEnv, - BASE_URL: "https://workbench.example.com", - ALLOW_PLAINTEXT_SECRETS: "1", - }); - expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); - expect(message).toContain("BASE_URL"); - }); - - test("a non-loopback BASE_URL boots fine without ALLOW_PLAINTEXT_SECRETS", () => { - const config = readHubConfig({ - ...validEnv, - BASE_URL: "https://workbench.example.com", - CREDENTIAL_ENCRYPTION_KEY: "a".repeat(64), - }); - expect(config.allowPlaintextSecrets).toBe(false); - }); - - test("accepts postgresql:// and https:// URL forms", () => { - const config = readHubConfig({ - ...validEnv, - DATABASE_URL: "postgresql://workbench@localhost:5432/workbench", - BASE_URL: "https://workbench.example.com", - }); - expect(config.databaseUrl).toStartWith("postgresql://"); - expect(config.baseUrl).toStartWith("https://"); - }); - - describe("sidecarProvisioners", () => { - test("an unconfigured install registers the process backend as its sole default", () => { - const config = readHubConfig(validEnv); - expect(config.sidecarProvisioners).toEqual([{ id: "process" }]); - expect(config.defaultSidecarProvisionerId).toBe("process"); - }); - - test("the process backend's optional overrides reach its config", () => { - const config = readHubConfig({ - ...validEnv, - PROCESS_PROVISIONER_SIDECAR_ENTRY: "/opt/sidecar/index.js", - PROCESS_PROVISIONER_RUNTIME: "/usr/bin/node", - }); - expect(config.sidecarProvisioners).toEqual([ - { - id: "process", - sidecarEntryPath: "/opt/sidecar/index.js", - runtimePath: "/usr/bin/node", - }, - ]); - }); - - test("process is a listable id like any other backend", () => { - const config = readHubConfig({ - ...validEnv, - SIDECAR_PROVISIONERS: "process", - }); - expect(config.sidecarProvisioners).toEqual([{ id: "process" }]); - expect(config.defaultSidecarProvisionerId).toBe("process"); - }); - - test("an explicit list without process does not get the default backend", () => { - const config = readHubConfig({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker", - DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest", - }); - expect(config.sidecarProvisioners.map((one) => one.id)).toEqual(["docker"]); - }); - - test("SIDECAR_PROVISIONERS=docker with an image is wired and becomes the default", () => { - const config = readHubConfig({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker", - DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest", - }); - expect(config.sidecarProvisioners).toEqual([ - { id: "docker", image: "ghcr.io/corbits/sidecar:latest" }, - ]); - expect(config.defaultSidecarProvisionerId).toBe("docker"); - }); - - test("SIDECAR_PROVISIONERS=docker without DOCKER_PROVISIONER_IMAGE fails loudly at boot", () => { - const message = readExpectingError({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker", - }); - expect(message).toContain("DOCKER_PROVISIONER_IMAGE"); - expect(message).toContain("SIDECAR_PROVISIONERS includes docker"); - }); - - test("rejects a provisioner id no backend implements", () => { - const message = readExpectingError({ - ...validEnv, - SIDECAR_PROVISIONERS: "firecracker", - }); - expect(message).toContain("unknown backend firecracker"); - }); - - test("rejects a duplicate id", () => { - const message = readExpectingError({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker,docker", - DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest", - }); - expect(message).toContain("more than once"); - }); - - test("SIDECAR_DEFAULT_PROVISIONER selects the default among several ids", () => { - const config = readHubConfig({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker", - SIDECAR_DEFAULT_PROVISIONER: "docker", - DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest", - }); - expect(config.defaultSidecarProvisionerId).toBe("docker"); - }); - - test("SIDECAR_DEFAULT_PROVISIONER naming an unlisted id fails loudly", () => { - const message = readExpectingError({ - ...validEnv, - SIDECAR_PROVISIONERS: "docker", - SIDECAR_DEFAULT_PROVISIONER: "e2b", - DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest", - }); - expect(message).toContain("is not listed in SIDECAR_PROVISIONERS"); - }); - - test("SIDECAR_DEFAULT_PROVISIONER set with SIDECAR_PROVISIONERS unset fails loudly", () => { - const message = readExpectingError({ - ...validEnv, - SIDECAR_DEFAULT_PROVISIONER: "docker", - }); - expect(message).toContain("SIDECAR_PROVISIONERS is unset"); - }); - }); - - describe("sidecarWebSocketUrl", () => { - test("is undefined when HUB_SIDECAR_WEBSOCKET_URL is unset", () => { - expect(readHubConfig(validEnv).sidecarWebSocketUrl).toBeUndefined(); - }); - - test("is read from HUB_SIDECAR_WEBSOCKET_URL when set", () => { - const config = readHubConfig({ - ...validEnv, - HUB_SIDECAR_WEBSOCKET_URL: "ws://sidecar-host.internal:3000/api/sidecars/ws", - }); - expect(config.sidecarWebSocketUrl).toBe("ws://sidecar-host.internal:3000/api/sidecars/ws"); - }); - - test("rejects a value that is not a ws(s):// URL", () => { - const message = readExpectingError({ - ...validEnv, - HUB_SIDECAR_WEBSOCKET_URL: "http://not-a-websocket-url", - }); - expect(message).toContain("HUB_SIDECAR_WEBSOCKET_URL"); - }); - }); - - test("an empty environment reports every variable in one error", () => { - const message = readExpectingError({}); - for (const name of [ - "DATABASE_URL", - "BASE_URL", - "SESSION_SECRET", - "HUB_DATA_DIR", - "HUB_STATIC_DIR", - ]) { - expect(message).toContain(name); - } - }); - - test("a malformed value is rejected naming the variable and the shape", () => { - const message = readExpectingError({ - ...validEnv, - DATABASE_URL: "mysql://nope", - SESSION_SECRET: "too-short", - }); - expect(message).toContain("DATABASE_URL"); - expect(message).toContain("Postgres connection URL"); - expect(message).toContain("SESSION_SECRET"); - expect(message).not.toContain("HUB_DATA_DIR"); - }); -}); diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts deleted file mode 100644 index fecf374ace..0000000000 --- a/apps/hub/test/credential-cipher.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// The composition root's credential-cipher gate: a self-hosting operator who -// never sets CREDENTIAL_ENCRYPTION_KEY must not silently end up with -// webhook-trigger signing secrets stored -// unencrypted. A missing key hard-fails boot unless the operator explicitly -// opts into dev/test behavior with ALLOW_PLAINTEXT_SECRETS. - -import { describe, expect, test } from "bun:test"; -import { getLogger } from "@intx/log"; -import type { HubConfig } from "../src/config.ts"; -import { credentialCipherFrom, hubCredentialCipher } from "../src/index.ts"; - -const log = getLogger(["hub", "test"]); - -const baseConfig: HubConfig = { - databaseUrl: "postgres://workbench:workbench@localhost:5432/workbench", - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: ".data/hub", - hubStaticDir: "apps/web/dist", - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - allowPlaintextSecrets: false, - sidecarProvisioners: [], -}; - -describe("credentialCipherFrom", () => { - test("no key and no dev opt-in hard-fails boot, naming the variable, what it protects, and how to generate a key", () => { - expect(() => credentialCipherFrom(baseConfig, log)).toThrow(/CREDENTIAL_ENCRYPTION_KEY/); - try { - credentialCipherFrom(baseConfig, log); - throw new Error("expected credentialCipherFrom to throw"); - } catch (error) { - const message = (error as Error).message; - expect(message).toContain("CREDENTIAL_ENCRYPTION_KEY"); - expect(message).toContain("webhook-trigger signing secrets"); - expect(message).toContain("openssl rand -hex 32"); - expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); - } - }); - - test("no key with ALLOW_PLAINTEXT_SECRETS set warns and falls back to the noop cipher", async () => { - const cipher = credentialCipherFrom({ ...baseConfig, allowPlaintextSecrets: true }, log); - const encrypted = await cipher.encrypt("secret-value", "aad"); - expect(encrypted).toBe("secret-value"); - }); - - test("a configured key builds a real cipher that actually encrypts", async () => { - const cipher = credentialCipherFrom( - { - ...baseConfig, - credentialEncryptionKeyHex: "a".repeat(64), - }, - log, - ); - const encrypted = await cipher.encrypt("secret-value", "aad"); - expect(encrypted).not.toBe("secret-value"); - }); -}); - -describe("hubCredentialCipher", () => { - test("boot still hard-fails when the key is missing and plaintext is not opted in", () => { - expect(() => hubCredentialCipher(baseConfig, log)).toThrow(/CREDENTIAL_ENCRYPTION_KEY/); - }); -}); diff --git a/apps/hub/test/notify-delivery.test.ts b/apps/hub/test/notify-delivery.test.ts deleted file mode 100644 index 2ede35f804..0000000000 --- a/apps/hub/test/notify-delivery.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// CL-7238 prod closure through the hub's own composed notify deps: the -// credential-expiry sweep mails through `createHubNotifyDeliveryDeps`, so a -// crash between the mail write and the dispatch enqueue must be repaired on -// redelivery — the same crash-window pattern PR #734 proves for the bare -// adapters, here through the exact factory `apps/hub/src/index.ts` composes. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { createDB, schema } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import { - createInMemoryMailboxEventBus, - createMailboxDb, - openNativeMailboxStore, - principalMail, -} from "@corbits/mailbox"; -import { and, eq, like } from "drizzle-orm"; -import { - deliverApprovalMail, - type ApprovalNotification, - type NotifyDispatchStore, - type SinkDeliveryResult, -} from "@corbits/notify"; - -import { setupDatabase } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { createHubNotifyDeliveryDeps } from "../src/notify-delivery"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_hub_notify_delivery_test`; - return url.toString(); -} - -// Parse DATABASE_URL the same way the hub does (apps/hub/src/index.ts): an -// empty user falls through to the postgres client's OS-username default. -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("hub notify delivery repairs the CL-7238 crash window on redelivery", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - const tenantId = generateId("tenant"); - const principalId = generateId("principal"); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - - // The same path the hub boots with: the platform's own migrations, - // then every installed package's, including `@corbits/notify` and - // `@corbits/mailbox`. - await setupDatabase(scratchUrl); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - await db.insert(schema.tenant).values({ - id: tenantId, - name: "Hub Notify Test Bench", - slug: `hub-notify-${tenantId}`, - domain: `hub-notify-${tenantId}.localhost`, - }); - await db.insert(schema.principal).values({ - id: principalId, - tenantId, - kind: "agent", - refId: "not-a-real-agent-instance", - status: "active", - }); - } finally { - await close(); - } - // `setupDatabase` applies the platform's migrations plus every - // installed package's; under `bun run test`'s cross-package - // concurrency that comfortably exceeds bun:test's default 5s hook - // timeout, so it gets an explicit one here rather than a flaky suite. - }, 30000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("a crash-window redelivery through the composed hub deps queues its dispatch", async () => { - const mailboxDb = createMailboxDb(scratchUrl); - try { - // Exactly what the hub composes: the factory's own dispatch store - // and sink registry are the prod path (no sink registered means no - // fan-out, only the mailbox row the sweep's reader sees). The test - // registers one sink so the repaired dispatch has somewhere to go, - // and faults only the dispatch enqueue to open the crash window. - const deps = createHubNotifyDeliveryDeps({ - mailboxDb: mailboxDb.db, - bus: createInMemoryMailboxEventBus(), - host: "hub.test", - }); - const delivered: SinkDeliveryResult = { status: "delivered" }; - deps.sinks.register({ - name: "always", - isEnabledFor: async () => true, - deliver: async () => delivered, - }); - const dispatch = deps.dispatch; - let crashNext = true; - const crashingDispatch: NotifyDispatchStore = { - ...dispatch, - enqueue: async (inputs) => { - if (crashNext) { - crashNext = false; - throw new Error("boom: crash between the mail write and the dispatch enqueue"); - } - await dispatch.enqueue(inputs); - }, - }; - const event: ApprovalNotification = { - kind: "approval", - approvalId: generateId("approval"), - tenantId, - runId: generateId("workflowRun"), - deploymentId: generateId("workflowRun"), - toolName: "quarantine_token", - toolArguments: { repo: "corbitsdev/workbench" }, - recipients: [{ tenantId, principalId }], - createdAt: new Date().toISOString(), - }; - - // The first attempt commits its mail row through the real adapter, - // then dies before the dispatch enqueue — the crash window. - await expect( - deliverApprovalMail({ ...deps, dispatch: crashingDispatch }, event), - ).rejects.toThrow("boom"); - - // The redelivery runs through the pristine composed deps: it - // dedupes on the mail key (no new row), resolves the committed - // row's id through the hub's read-back wiring, and queues the - // dispatch the crash swallowed. Without `resolveExistingMailIds` - // in the composed deps this queues nothing. - const redelivery = await deliverApprovalMail(deps, event); - expect(redelivery.deliveredMailboxRowIds).toEqual([]); - expect(redelivery.queuedDispatchCount).toBe(1); - - const store = await openNativeMailboxStore(mailboxDb.db, { - tenantId, - principalId, - folder: "INBOX", - }); - // Exactly one mail row for the redelivered event — the crash and - // the redelivery deduped onto the first attempt's row. - const matching = store.messages.filter((message) => - message.envelope.subject.includes("quarantine_token"), - ); - expect(matching).toHaveLength(1); - const rows = await mailboxDb.db - .select({ id: principalMail.id }) - .from(principalMail) - .where( - and( - eq(principalMail.tenantId, tenantId), - eq(principalMail.principalId, principalId), - like(principalMail.subject, "%quarantine_token%"), - ), - ); - const mailId = rows[0]?.id; - if (mailId === undefined) throw new Error("expected the redelivered mail row to list"); - expect(await dispatch.listFor(mailId)).toHaveLength(1); - - // A further benign redelivery repairs the same row again, and the - // dispatch store's (mail row, sink) dedupe holds it to one row. - const third = await deliverApprovalMail(deps, event); - expect(third.deliveredMailboxRowIds).toEqual([]); - expect(third.queuedDispatchCount).toBe(1); - expect(await dispatch.listFor(mailId)).toHaveLength(1); - } finally { - await mailboxDb.close(); - } - }, 15000); -}); diff --git a/apps/hub/test/static-handler.test.ts b/apps/hub/test/static-handler.test.ts deleted file mode 100644 index 7e5c18f5e8..0000000000 --- a/apps/hub/test/static-handler.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// `createStaticHandler` serves the SPA from the hub origin and sits ahead -// of every non-/api route, so it sees any attacker-controlled path a -// client cares to send — including one with a malformed percent-escape -// (`GET /%zz`). `decodeURIComponent` throws on that, and until this was -// guarded the throw escaped the handler entirely: a 500 on any -// non-/api request, not the 404/index.html fallback every other -// unresolvable path gets. - -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Hono } from "hono"; -import type { Context } from "hono"; - -import { createStaticHandler } from "../src/index.ts"; - -let staticDir: string; - -beforeAll(() => { - staticDir = mkdtempSync(path.join(tmpdir(), "hub-static-")); - writeFileSync(path.join(staticDir, "index.html"), "spa"); -}); - -afterAll(() => { - rmSync(staticDir, { recursive: true, force: true }); -}); - -function buildApp() { - const app = new Hono(); - app.get("/*", createStaticHandler(staticDir) as never); - app.notFound((c: Context) => c.text("not found", 404)); - return app; -} - -describe("createStaticHandler", () => { - test("serves index.html for a normal SPA route", async () => { - const app = buildApp(); - const response = await app.request("/workbenches/ch_1"); - expect(response.status).toBe(200); - expect(await response.text()).toBe("spa"); - }); - - test("a malformed percent-escape 404s instead of throwing", async () => { - const app = buildApp(); - const response = await app.request("/%zz"); - expect(response.status).toBe(404); - }); - - test("/api paths are left for the platform routes regardless", async () => { - const app = buildApp(); - const response = await app.request("/api/%zz"); - expect(response.status).toBe(404); - }); -}); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts deleted file mode 100644 index 267aa2f5ad..0000000000 --- a/scripts/dev.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Unit gate for dev.ts's skip-rebuild decision: whether the existing -// web bundle already matches every file the build reads, so `bun run -// dev` can skip paying for another full vite build before the hub -// serves it. - -import { describe, expect, test } from "bun:test"; - -import { isWebBuildFresh } from "./dev.ts"; - -describe("isWebBuildFresh", () => { - test("stale when the dist bundle has never been built", () => { - expect(isWebBuildFresh(1_000, null)).toBe(false); - }); - - test("stale when a source file is newer than the dist bundle", () => { - expect(isWebBuildFresh(2_000, 1_000)).toBe(false); - }); - - test("stale when the dist bundle and the source share a timestamp", () => { - expect(isWebBuildFresh(1_000, 1_000)).toBe(false); - }); - - test("fresh when the dist bundle is newer than every source file", () => { - expect(isWebBuildFresh(1_000, 2_000)).toBe(true); - }); -}); diff --git a/scripts/dev.ts b/scripts/dev.ts index 9d96583c0b..65efdd8a1b 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,11 +2,9 @@ // verifies the Postgres in DATABASE_URL is reachable, and starts the hub. // The hub provisions sidecars on demand. Prerequisite failures name the // actual problem and the fix. -import { existsSync, statSync, watch } from "node:fs"; -import { readdir, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { createConnection } from "node:net"; import { join, resolve } from "node:path"; -import { readHubConfig, type HubConfig } from "../apps/hub/src/config.ts"; import { setupDatabase } from "./db-setup.ts"; import { localDevMemoryEmbedEnv } from "./setup-memory.ts"; @@ -30,18 +28,52 @@ function requireEnvFile(): void { ); } -function validateConfig(): HubConfig { - try { - return readHubConfig(process.env); - } catch (error) { - fail(error instanceof Error ? error.message : String(error)); +/** + * The slice of the hub's own env config this script needs to validate + * before spawning anything. The hub itself (apps/hub/src/server.ts) + * reads process.env directly now, upstream-style — this is just the + * subset dev.ts needs for its own preflight checks. + */ +interface DevConfig { + databaseUrl: string; + baseUrl: string; +} + +function validateConfig(): DevConfig { + const databaseUrl = process.env["DATABASE_URL"]; + if (databaseUrl === undefined || databaseUrl === "") { + fail("DATABASE_URL is not set. Set it in .env; see .env.example."); + } + const port = process.env["PORT"] ?? "3000"; + const baseUrl = process.env["BASE_URL"] ?? `http://localhost:${port}`; + for (const [name, hex] of [ + ["CREDENTIAL_ENCRYPTION_KEY", process.env["CREDENTIAL_ENCRYPTION_KEY"]], + ["PRINCIPAL_KEY_ENCRYPTION_KEY", process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"]], + ] as const) { + if (hex === undefined || hex.trim() === "") { + fail(`${name} is not set. Set it in .env; see .env.example.`); + } } + // apps/hub/src/server.ts reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME + // individually (upstream's own shape), while every other script in this + // repo (migrations, the memory/artifacts planes) uses the single + // DATABASE_URL connection string. Derive the former from the latter here + // so .env only needs to state one URL. + const url = new URL(databaseUrl); + mergeHubEnv({ + DB_HOST: url.hostname, + DB_PORT: url.port === "" ? "5432" : url.port, + DB_USER: decodeURIComponent(url.username || process.env["USER"] || "postgres"), + DB_PASSWORD: decodeURIComponent(url.password), + DB_NAME: url.pathname.replace(/^\//, ""), + }); + return { databaseUrl, baseUrl }; } // A DATABASE_URL without a username makes the postgres driver fall back to // the USER environment variable; without that either, the connection dies // deep inside the hub as a Postgres role error. Catch it before boot. -function requireDatabaseUser(config: HubConfig): void { +function requireDatabaseUser(config: DevConfig): void { if (new URL(config.databaseUrl).username !== "") return; if ((process.env["USER"] ?? "") !== "") return; fail( @@ -85,7 +117,7 @@ function probePostgres(host: string, port: number): Promise { }); } -async function requireDatabaseReachable(config: HubConfig): Promise { +async function requireDatabaseReachable(config: DevConfig): Promise { const url = new URL(config.databaseUrl); const host = url.hostname; const port = Number(url.port === "" ? "5432" : url.port); @@ -149,22 +181,20 @@ if (localMemoryEmbed !== undefined) { mergeHubEnv(localMemoryEmbed); } -const apps: App[] = [hubApp]; - -// The hub serves the web app's build output as static files, so dev -// watches and rebuilds that output on every source change — a browser -// refresh then picks up the fresh bundle, no manual build step. Started -// separately from `apps` because a fresh, unchanged checkout can defer -// spawning it entirely — see isWebBuildFresh below. +// The hub is a plain API now (CL-8083): it serves no static build, so +// the web app runs its own dev server, proxying /api to the hub per +// apps/web/vite.config.ts. const webDir = join(repoRoot, "apps", "web"); const webApp: App = { label: "web", dir: webDir, - command: [join(webDir, "node_modules", ".bin", "vite"), "build", "--watch"], + command: [join(webDir, "node_modules", ".bin", "vite")], }; +const apps: App[] = [hubApp, webApp]; + function requireApps(): void { - const missing = [...apps, webApp, { dir: join(repoRoot, "apps", "sidecar") }].filter( + const missing = [...apps, { dir: join(repoRoot, "apps", "sidecar") }].filter( (app) => !existsSync(join(app.dir, "package.json")), ); if (missing.length === 0) return; @@ -178,63 +208,6 @@ function requireApps(): void { ); } -// Everything the web build reads: source, its own manifest, and vite's -// config. If none of these are newer than the last dist/index.html, the -// existing bundle already matches HUB_STATIC_DIR and there's nothing to -// rebuild before the hub can serve it. -const webWatchedPaths = [ - join(webDir, "src"), - join(webDir, "index.html"), - join(webDir, "vite.config.ts"), - join(webDir, "package.json"), -]; - -async function latestMtimeMs(path: string): Promise { - const info = await stat(path).catch(() => null); - if (!info) return 0; - if (!info.isDirectory()) return info.mtimeMs; - const entries = await readdir(path, { withFileTypes: true }); - const childMtimes = await Promise.all( - entries.map((entry) => latestMtimeMs(join(path, entry.name))), - ); - return Math.max(info.mtimeMs, ...childMtimes, 0); -} - -async function latestWebSourceMtimeMs(): Promise { - const mtimes = await Promise.all(webWatchedPaths.map(latestMtimeMs)); - return Math.max(...mtimes, 0); -} - -function webDistMtimeMs(staticDir: string): number | null { - try { - return statSync(join(staticDir, "index.html")).mtimeMs; - } catch { - return null; - } -} - -// Pure decision, unit-tested on its own: a dist bundle newer than every -// file the build reads is already correct, so paying for another full -// vite build before the hub can serve it would be pure waste. -export function isWebBuildFresh(latestSourceMtimeMs: number, distMtimeMs: number | null): boolean { - return distMtimeMs !== null && distMtimeMs > latestSourceMtimeMs; -} - -// Fires `onChange` once, the first time anything under `paths` changes, -// then stops watching. Used to start the web build lazily when the -// existing dist is already fresh, instead of paying for a rebuild that -// would produce byte-identical output. -function watchOnce(paths: string[], onChange: () => void): void { - const watchers = paths.map((path) => watch(path, { recursive: true }, fire)); - let fired = false; - function fire(): void { - if (fired) return; - fired = true; - for (const watcher of watchers) watcher.close(); - onChange(); - } -} - async function forwardWithPrefix( label: string, stream: ReadableStream, @@ -272,7 +245,7 @@ function spawnApp(app: App, running: RunningApp[], onExit: (app: App, code: numb void proc.exited.then((code) => onExit(app, code)); } -async function startApps(webFresh: boolean): Promise { +async function startApps(): Promise { const running: RunningApp[] = []; const stopAll = () => { for (const { proc } of running) proc.kill(); @@ -286,20 +259,9 @@ async function startApps(webFresh: boolean): Promise { process.exit(143); }); - // Promise.resolve is idempotent, so the first process to exit — eager - // or deferred — is the only one that determines the outcome here. const firstExit = await new Promise<{ app: App; code: number }>((resolveExit) => { const onExit = (app: App, code: number) => resolveExit({ app, code }); for (const app of apps) spawnApp(app, running, onExit); - if (webFresh) { - console.log( - "[dev] the web bundle is newer than every file the build reads; " + - "deferring the rebuild watcher until a source file changes", - ); - watchOnce(webWatchedPaths, () => spawnApp(webApp, running, onExit)); - } else { - spawnApp(webApp, running, onExit); - } }); stopAll(); fail(`${firstExit.app.label} exited with code ${firstExit.code}; stopping the other apps.`); @@ -308,7 +270,7 @@ async function startApps(webFresh: boolean): Promise { // Bring the database's schema current before the apps boot: creates // the database and applies the platform migrations when needed, and // reports either way. Failures name the problem and the fix. -async function requireDatabaseSetUp(config: HubConfig): Promise { +async function requireDatabaseSetUp(config: DevConfig): Promise { try { const report = await setupDatabase(config.databaseUrl); if (report.createdDatabase) { @@ -338,7 +300,7 @@ async function requireDatabaseSetUp(config: HubConfig): Promise { * Sign-in is tried first so a re-run against an existing account skips * registration entirely. */ -async function seedDevAccount(config: HubConfig): Promise { +async function seedDevAccount(config: DevConfig): Promise { const email = process.env["HUB_ADMIN_EMAIL"] ?? "alice@example.com"; const password = process.env["HUB_ADMIN_PASSWORD"] ?? "password123"; const name = email.split("@")[0] ?? email; @@ -413,7 +375,7 @@ async function seedDevAccount(config: HubConfig): Promise { // stale state, the new sidecar attaches to it, agents fail their // challenges, and every symptom points somewhere else. Refuse loudly // up front instead. -async function requireHubPortFree(config: HubConfig): Promise { +async function requireHubPortFree(config: DevConfig): Promise { const base = new URL(config.baseUrl); const port = Number(base.port === "" ? (base.protocol === "https:" ? "443" : "80") : base.port); const occupied = await new Promise((resolvePort) => { @@ -454,8 +416,6 @@ if (import.meta.main) { await requireDatabaseReachable(config); await requireDatabaseSetUp(config); requireApps(); - const hubStaticDir = resolve(join(repoRoot, "apps", "hub"), config.hubStaticDir); - const webFresh = isWebBuildFresh(await latestWebSourceMtimeMs(), webDistMtimeMs(hubStaticDir)); void seedDevAccount(config); - await startApps(webFresh); + await startApps(); } diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index 4a9763f285..f7b3493274 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -260,15 +260,13 @@ export async function startHub(options: { extraEnv?: Record; }): Promise { const baseUrl = `http://localhost:${options.port}`; + const dbUrl = new URL(options.databaseUrl); const app = spawnApp("hub", HUB_DIR, { ...osEnv(), - // e2e hubs point at the web build (absent for API-only suites, which - // makes non-/api paths 404) and advertise BASE_URL as their own - // listen address by default; a caller's extraEnv can override any of - // these (e.g. 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). - HUB_STATIC_DIR: "../web/dist", + // BASE_URL feeds only the Corbits connections/OAuth mount's callback + // URLs now; a caller's extraEnv can override it (e.g. a public origin + // that differs from the actual listen port — see PORT below — for a + // browser-driven suite fronted by a dev-server proxy). BASE_URL: baseUrl, // The routine scheduler's real production cadence is a 30s // setInterval (routine-scheduler.ts); waiting that out for real on @@ -279,19 +277,22 @@ export async function startHub(options: { // real cadence — a caller's extraEnv can still opt back into it by // omitting or overriding this key. ...options.extraEnv, - DATABASE_URL: options.databaseUrl, // Always the real bind port, independent of whatever port BASE_URL's // own origin names — this is the same PORT/BASE_URL split the hub's - // own config already documents for a reverse proxy in front of it - // (config.ts's PORT field); it is what lets a caller's extraEnv - // point BASE_URL at a fronting dev server without also moving where - // the hub actually listens. + // own server already documents for a reverse proxy in front of it; + // it is what lets a caller's extraEnv point BASE_URL at a fronting + // dev server without also moving where the hub actually listens. PORT: String(options.port), - SESSION_SECRET: options.sessionSecret, + DB_HOST: dbUrl.hostname, + DB_PORT: dbUrl.port === "" ? "5432" : dbUrl.port, + DB_USER: decodeURIComponent(dbUrl.username), + DB_PASSWORD: decodeURIComponent(dbUrl.password), + DB_NAME: dbUrl.pathname.replace(/^\//, ""), HUB_DATA_DIR: options.dataDir, - // 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", + // The e2e suite never rotates real secrets; a fixed dev-shaped hex + // key is fine here, matching .env.example's own dev key. + CREDENTIAL_ENCRYPTION_KEY: "0".repeat(64), + PRINCIPAL_KEY_ENCRYPTION_KEY: "1".repeat(64), }); const deadline = Date.now() + 30_000; for (;;) { diff --git a/scripts/reset.ts b/scripts/reset.ts index 149c9c460d..cdaed2a748 100644 --- a/scripts/reset.ts +++ b/scripts/reset.ts @@ -21,7 +21,6 @@ import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import path from "node:path"; -import { readHubConfig } from "../apps/hub/src/config.ts"; import { resetSchema } from "./db-setup.ts"; const repoRoot = path.resolve(import.meta.dir, ".."); @@ -86,12 +85,19 @@ export async function resetLocalState( env: Record, deps: ResetDeps = defaultDeps, ): Promise { - const config = readHubConfig(env); - requireLocalDatabase(config.databaseUrl); + const databaseUrl = env["DATABASE_URL"]; + if (databaseUrl === undefined || databaseUrl === "") { + throw new Error("DATABASE_URL is not set. Set it in .env; see .env.example."); + } + requireLocalDatabase(databaseUrl); - await deps.resetSchema(config.databaseUrl); + await deps.resetSchema(databaseUrl); - const dirs = resolveLocalStateDirs(deps.root, config.hubDataDir); + const hubDataDir = env["HUB_DATA_DIR"]; + if (hubDataDir === undefined || hubDataDir === "") { + throw new Error("HUB_DATA_DIR is not set. Set it in .env; see .env.example."); + } + const dirs = resolveLocalStateDirs(deps.root, hubDataDir); const removedDirs: string[] = []; for (const dir of [dirs.hubDataDir]) { if (!deps.exists(dir)) continue; @@ -99,7 +105,7 @@ export async function resetLocalState( removedDirs.push(dir); } - const database = new URL(config.databaseUrl).pathname.replace(/^\//, ""); + const database = new URL(databaseUrl).pathname.replace(/^\//, ""); return { database, removedDirs }; }