Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions apps/hub/src/bench-session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The one thing a background loop cannot inherit: a session. The bench
// provisioner (`@workbench/onboarding`'s `createBenchProvisioner`) runs
// with no request to borrow cookies from, yet everything it drives —
// `seedTenant`'s asset creates, deployments, grants, and the git push
// provisioned by an explicit flow (a desired-state revisit kick, the
// setup CLI) runs with no request to borrow cookies from, yet everything
// it drives — asset creates, deployments, grants, and the git push
// underneath them — speaks the hub's own HTTP API as the bench's owner.
// This mints that session in-process.
//
Expand All @@ -11,17 +11,27 @@
// parent-org admin does not inherit rights over a child bench — RBAC
// resolves grants within a single tenant — so "just use the operator
// account" would 403 on every call. The owner's own session is the only
// identity that can provision their bench, which is also the honest
// identity that can act for their bench, which is also the honest
// one: the work is theirs, done on their behalf, and it shows up in the
// session table as such (tagged by user agent, so these are greppable
// and never mistaken for a human sign-in).
//
// Sessions are cached per user and re-minted well before expiry, so a
// drain tick every few seconds does not write a session row every few
// kick every few seconds does not write a session row every few
// seconds.

import { makeSignature } from "better-auth/crypto";
import type { SessionForUser } from "@workbench/onboarding";

/**
* Mints the hub session an explicit provisioning flow acts under for
* one user's own bench, or `undefined` when no session can be minted
* (an account since deleted, an auth backend briefly unavailable).
* Returning `undefined` skips that run — it never discards anything.
*/
export type SessionForUser = (args: {
userId: string;
tenantId: string;
}) => Promise<string[] | undefined>;

/** Re-mint this far ahead of a cached session's own expiry, so a long
* provisioning pass can never have its session expire mid-flight. */
Expand Down Expand Up @@ -53,16 +63,16 @@ export type BenchSessionAuth = {
};

/**
* Builds the `sessionFor` seam the bench provisioner takes. Returns the
* bare `name=value` cookie pairs `ApiCall` sends, signed exactly the way
* better-auth's own cookie writer signs them — the same HMAC helper the
* library uses, rather than a hand-rolled copy that could drift from the
* verifier.
* Builds the `sessionFor` seam the explicit provisioning flows take.
* Returns the bare `name=value` cookie pairs `ApiCall` sends, signed
* exactly the way better-auth's own cookie writer signs them — the same
* HMAC helper the library uses, rather than a hand-rolled copy that
* could drift from the verifier.
*
* `undefined` means "no session could be minted right now" (an account
* since deleted, an auth backend briefly unavailable). The provisioner
* treats that as a reason to hold the bench for a later pass, never as a
* reason to discard its pending work.
* since deleted, an auth backend briefly unavailable). The caller
* treats that as a reason to skip the run, never as a reason to
* discard anything.
*/
export function createBenchSessionMinter(args: {
auth: BenchSessionAuth;
Expand Down
84 changes: 32 additions & 52 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,7 @@ import {
import { supportedCredentialProviders } from "@corbits/connections/credential-test";
import { CATALOG_WORKFLOWS, createGitWorkflowPusher } from "@corbits/seeding";
import { createHubAPI } from "@corbits/hub-api-client";
import {
createDrizzlePendingSeedStore,
createBenchProvisioner,
createOnboardingRoutes,
} from "@workbench/onboarding";
import { createOnboardingRoutes } from "@workbench/onboarding";
import {
createConnectionRoutes,
isInferenceProvider,
Expand Down Expand Up @@ -474,13 +470,9 @@ export function createStaticHandler(staticDir: string) {
/**
* The `CredentialCipher` (see `@intx/types`) every secret-at-rest seam
* in this composition root shares — `webhookTriggerStore`'s signing
* secrets, `@workbench/onboarding`'s in-flight OAuth connect state
* secrets and `@workbench/onboarding`'s in-flight OAuth connect state
* (the PKCE verifier parked between `/start` and `/callback`, sealed
* into the state itself so it survives a restart between the two), and
* (since CL-6031) the same package's `pending_seed` table — a
* just-connected credential's plaintext key, parked server-side
* between the OAuth callback and the onboarding page's own
* `/complete-setup` follow-up (see `packages/onboarding/src/pending-seed.ts`).
* into the state itself so it survives a restart between the two).
* 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
Expand All @@ -496,9 +488,9 @@ export function credentialCipherFrom(
throw new Error(
[
"CREDENTIAL_ENCRYPTION_KEY is not set.",
"It encrypts secrets at rest — webhook-trigger signing secrets,",
"onboarding's OAuth PKCE connect state, and its pending-seed",
"table — so the hub refuses to boot without it. Generate one and",
"It encrypts secrets at rest — webhook-trigger signing secrets",
"and onboarding's OAuth PKCE connect state — so the hub refuses",
"to boot without it. Generate one and",
"add it to .env:",
"",
" openssl rand -hex 32",
Expand All @@ -509,7 +501,7 @@ export function credentialCipherFrom(
].join("\n"),
);
}
log.warn`No CREDENTIAL_ENCRYPTION_KEY configured; secrets (e.g. webhook-trigger signing secrets, onboarding OAuth connect state, onboarding's pending-seed table) will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`;
log.warn`No CREDENTIAL_ENCRYPTION_KEY configured; secrets (e.g. webhook-trigger signing secrets, onboarding OAuth connect state) 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(
Expand Down Expand Up @@ -2621,31 +2613,33 @@ export async function createHub(config: HubConfig) {
// CL-6568's other half: a tenant whose only provider is one it
// connected itself through Settings — never an operator-configured
// hub key — must converge on Myra and the default workflow set the
// same way an onboarding-connected one does. `pendingSeedStore` and
// `benchProvisioner` are declared further down this function, but
// same way an onboarding-connected one does. The drain is gone
// (CL-7586), so this kicks the same desired-state reconcile the
// tenant-create observer and the onboarding routes kick, under the
// connecting user's own minted session — `sessionFor` is declared
// further up this function, and `observerRef` further down, but
// this closure only runs on a future request, well after both are
// constructed below — the same forward-reference this file already
// relies on for `onboardingDeps`.
// constructed below. Best-effort like every other kick: absent or
// unmintable means convergence waits for the revisit probe.
onInferenceCredentialUsable: async (info) => {
const provider = supportedCredentialProviders().find(
(candidate) => candidate.id === info.provider,
)?.id;
if (provider === undefined) {
log.error`onInferenceCredentialUsable fired for an unsupported provider ${info.provider} on tenant ${info.tenantId}; skipping the pending-seed row`;
log.error`onInferenceCredentialUsable fired for an unsupported provider ${info.provider} on tenant ${info.tenantId}; skipping the desired-state kick`;
return;
}
await pendingSeedStore.put({
const cookies = await sessionFor({
userId: info.userId,
tenantId: info.tenantId,
principalId: info.principalId,
tenantDomain: info.tenantDomain,
provider,
apiKey: info.apiKey,
...(info.baseURLOverride !== undefined
? { baseURLOverride: info.baseURLOverride }
: {}),
});
benchProvisioner.wake();
if (cookies === undefined) {
log.error`onInferenceCredentialUsable could not mint a session for user ${info.userId} on tenant ${info.tenantId}; skipping the desired-state kick`;
return;
}
void observerRef.current
?.kick({ tenantId: info.tenantId, cookies })
.catch(() => undefined);
},
}),
);
Expand Down Expand Up @@ -3378,29 +3372,18 @@ export async function createHub(config: HubConfig) {
// session it serves belongs to no tenant yet. The route is
// `@workbench/onboarding`'s; what it decides is documented in that
// package's provision.ts.
// Connecting a provider deploys nothing (CL-6457): the onboarding
// routes persist the credential and hand the workflow deploys to this
// drain, which converges every bench with a pending row — including
// one a previous process died halfway through, since the row itself is
// the durable work item.
const pendingSeedStore = createDrizzlePendingSeedStore(db, credentialCipher);
const benchProvisioner = createBenchProvisioner({
api: selfApi,
hubUrl: config.baseUrl,
store: pendingSeedStore,
pushWorkflow: createGitWorkflowPusher(),
sessionFor,
log: (line) => log.info`${line}`,
logError: (line) => log.error`${line}`,
});
benchProvisioner.start();
// Connecting a provider deploys nothing by itself (CL-6457): the
// onboarding routes persist the credential and kick the desired-state
// reconcile below, which converges the bench on the next kick (tenant
// create, connect, revisit probe) — including one a previous process
// died halfway through, since the reconcile re-reads
// the pins on every kick rather than consuming a durable work item.
// (CL-7586 removed the pending-seed drain that used to do this.)

// CL-7584: the tenant-create trigger. A 201 from the native
// `POST /api/tenants` route kicks a fire-and-forget desired-state
// reconcile for the new tenant under the creator's minted session —
// the revisit kick below and the drain above share this one
// reconciler. No durable row: the pending_seed row stays the only
// durable work item, and a kick lost to a restart is re-covered by
// reconcile for the new tenant under the creator's minted session.
// No durable row anywhere: a kick lost to a restart is re-covered by
// the revisit kick on the tenant's next visit. The observer itself is
// composed just before the guard wrap, after every route mount: Hono
// copies routes at `.route()` time, so wrapping earlier would strand
Expand All @@ -3417,8 +3400,6 @@ export async function createHub(config: HubConfig) {
log: (line) => log.info`${line}`,
logError: (line) => log.error`${line}`,
credentialCipher,
pendingSeedStore,
benchProvisioner,
desiredStateKick: (args) => {
// Fire-and-forget; the route already decided pins are pending.
void observerRef.current
Expand Down Expand Up @@ -3687,7 +3668,6 @@ export async function createHub(config: HubConfig) {
workflowScheduler.stop();
credentialExpirySweep.stop();
inboxUnsnoozeSweep.stop();
benchProvisioner.stop();
await insightsUsage.close();
await insightsLatency.close();
await preferences.close();
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/slack-channel-mint-session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { generateId } from "@intx/hub-common";
import { reportError } from "@corbits/error-sink";
import type { WorkbenchTenancyStore } from "@corbits/chat";
import type { SessionForUser } from "@workbench/onboarding";
import type { SessionForUser } from "./bench-session";

/**
* Slack auto-provisioned identities are members. POST /api/tenants is
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/slack-tag-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
type ChatStore,
type RoomMessageStore,
} from "@corbits/chat";
import type { SessionForUser } from "@workbench/onboarding";
import type { SessionForUser } from "./bench-session";
import {
createAutoProvisionPrincipalResolver,
createDrizzleSlackChannelBindingStore,
Expand Down
32 changes: 15 additions & 17 deletions apps/hub/src/tenant-create-onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
// the kick never touches the DB directly — so a fresh tenant gets Myra
// and the core pins without hub boot seeding anything.
//
// There is no durable work item here on purpose: the pending_seed row
// stays the only durable queue (a connect's credential), and a
// tenant-create kick that dies with the process is re-covered by the
// There is no durable work item here on purpose: nothing is queued, and
// a tenant-create kick that dies with the process is re-covered by the
// revisit kick (`POST /api/onboarding/provision`) on the next visit.
// In-process dedupe by tenantId is the same class of optimization the
// provisioner's in-flight map is: never a fact the system needs correct.
// In-process dedupe by tenantId is purely an optimization against double
// kicks, never a fact the system needs correct.
import { Hono } from "hono";
import type { AppEnv } from "@intx/hub-api";
import { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client";
Expand All @@ -35,10 +34,10 @@ export type TenantCreateOnboardDeps = {
* The convergence step. Production resolves the tenant's deploy model
* from its resolved catalog plus the creator's deploy identity (owner
* principal + domain, via `resolveTenantDeployer` — a fresh native
* tenant has no pending-seed row to read those from) and delegates to
* `reconcileTenantDesiredState`; with no offerings it reports the
* workflow pins blocked (logged, never thrown). Tests replace the
* whole thing.
* tenant carries no credential material to read those from) and
* delegates to `reconcileTenantDesiredState`; with no offerings it
* reports the workflow pins blocked (logged, never thrown). Tests
* replace the whole thing.
*/
reconcileFn?: (args: {
tenantId: string;
Expand Down Expand Up @@ -77,8 +76,8 @@ export function createTenantCreateObserver(
wrapped: Hono<AppEnv>,
): TenantCreateObserver {
const logError = deps.logError ?? deps.log;
// In-process tenantId dedupe, same pattern as the provisioner's
// in-flight map: an optimization against double kicks, never a fact.
// In-process tenantId dedupe against double kicks: an optimization,
// never a fact the system needs correct.
const kicked = new Set<string>();
const inFlight = new Set<Promise<void>>();
let stopped = false;
Expand All @@ -99,9 +98,8 @@ export function createTenantCreateObserver(
);
if (model === undefined) {
// No catalog offerings yet — nothing is launchable. Report the
// workflow pins blocked; the next trigger (a connect's drain
// pass, a revisit probe) sees the pins still pending and
// re-kicks.
// workflow pins blocked; the next trigger (a revisit probe)
// sees the pins still pending and re-kicks.
deps.log(
`tenant-create onboarding for ${reconcileArgs.tenantId} is blocked: no catalog offerings to deploy against yet`,
);
Expand All @@ -115,8 +113,8 @@ export function createTenantCreateObserver(
})),
} satisfies ReconcileReport;
}
// A fresh native tenant has no pending-seed row to read deploy
// identity from — resolve the creator's owner principal (minted
// A fresh native tenant carries no credential material to read
// deploy identity from — resolve the creator's owner principal
// in the create transaction) plus the tenant domain before
// reconciling, so the deploy lands under a real identity.
const deployer = await resolveTenantDeployer(
Expand Down Expand Up @@ -157,7 +155,7 @@ export function createTenantCreateObserver(
const operation = runReconcile(args)
.catch((cause: unknown) => {
logError(
`tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`,
`tenant-create onboarding for ${args.tenantId} failed (the revisit kick will cover it): ${cause instanceof Error ? cause.message : String(cause)}`,
);
})
.finally(() => {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/test/onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1042,9 +1042,10 @@ describe("the OpenRouter connect card", () => {
});

test("a connected return falls back to the credential phase, not a dead end, when completeSetup reports unseeded", async () => {
// The degraded-but-correct path: the pending-seed cookie can be
// missing (a loser duplicate-callback response the browser never
// applied, a cookie that already expired) even though the
// The degraded-but-correct path: nothing is parked server-side for
// the follow-up (CL-7586 deleted the pending-seed drain — a loser
// duplicate-callback response the browser never applied, or an
// already-expired cookie, leaves no row behind) even though the
// credential itself connected fine. `completeSetup` answers
// `unseeded` — not an error — and the wizard must land somewhere a
// person can actually finish from, never stuck on the spinner or a
Expand Down
Loading
Loading