Skip to content
Merged
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
1 change: 0 additions & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
"@intx/workflow": "workspace:*",
"@intx/workflow-deploy": "workspace:*",
"@modelcontextprotocol/sdk": "catalog:",
"@workbench/access-policy": "workspace:*",
"@corbits/connections": "workspace:*",
"@corbits/codex-provider": "workspace:*",
"@corbits/xai-provider": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const HubEnv = type({
"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 and onboarding's OAuth PKCE connect state; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback",
"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",
Expand Down
35 changes: 22 additions & 13 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
user as userTable,
workflowDefinition,
} from "@intx/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import { and, count, eq, inArray } from "drizzle-orm";
import {
createEnvKeyCredentialCipher,
createNoopCredentialCipher,
Expand Down Expand Up @@ -293,6 +293,7 @@ import {
mintRepoGrantViaHttp,
} from "./native-repo-grants";
import { createSignInAttemptLimiter } from "./sign-in-rate-limit";
import { createSetupStatusRoutes } from "./setup-status";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, Hono, type Next } from "hono";

Expand Down Expand Up @@ -402,14 +403,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
* (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`).
* 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
Expand All @@ -425,9 +421,8 @@ 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 —",
"so the hub refuses to boot without it. Generate one and",
"add it to .env:",
"",
" openssl rand -hex 32",
Expand All @@ -438,7 +433,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) 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 @@ -1348,6 +1343,20 @@ export async function createHub(config: HubConfig) {
// agents never produce text. `config.baseUrl` (not `localhost`) is
// what makes the URL usable from a sidecar on another machine.
app.route("/api/chat/noop-inference", createNoopInferenceRoutes());
// Native cold-boot setup status (CL-8112). Mounted outside the tenant
// prefix like the noop-inference route: an empty hub has no tenant to
// scope to, and the first-login hook must read it before any bench
// exists. Counts come straight off the native user/tenant tables — no
// workbench package involved.
app.route(
"/api/setup",
createSetupStatusRoutes({
countUsers: async () =>
(await db.select({ n: count() }).from(userTable))[0]?.n ?? 0,
countTenants: async () =>
(await db.select({ n: count() }).from(tenantTable))[0]?.n ?? 0,
}),
);
const selfApi = createHubAPI(config.baseUrl);
// The chat platform's invite-launch fallback: a definition with no
// model requirements of its own resolves the tenant-catalog default.
Expand Down
74 changes: 74 additions & 0 deletions apps/hub/src/setup-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// The native cold-boot status read (CL-8112): empty hub demands setup,
// any tenant means setup is behind us, and a count failure answers the
// hub's error envelope (never a leak, never a bare 500).
import { describe, expect, test } from "bun:test";

import {
createSetupStatusRoutes,
type SetupStatusCounts,
} from "./setup-status";

function counts(counters: SetupStatusCounts) {
return createSetupStatusRoutes(counters);
}

describe("createSetupStatusRoutes", () => {
test("an empty hub reports setupRequired with zero counts", async () => {
const res = await counts({
countUsers: async () => 0,
countTenants: async () => 0,
}).request("/status");

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
setupRequired: true,
userCount: 0,
tenantCount: 0,
});
});

test("a hub with tenants reports setupRequired false with real counts", async () => {
const res = await counts({
countUsers: async () => 3,
countTenants: async () => 2,
}).request("/status");

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
setupRequired: false,
userCount: 3,
tenantCount: 2,
});
});

test("users with zero tenants still require setup — no bench exists", async () => {
const res = await counts({
countUsers: async () => 1,
countTenants: async () => 0,
}).request("/status");

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
setupRequired: true,
userCount: 1,
tenantCount: 0,
});
});

test("a count failure answers the error envelope with a refId", async () => {
const res = await counts({
countUsers: async () => 0,
countTenants: async () => {
throw new Error("connection refused");
},
}).request("/status");

expect(res.status).toBe(500);
const body = (await res.json()) as {
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("setup_status_failed");
expect(body.error.userMessage).not.toContain("connection refused");
expect(body.error.refId.length).toBeGreaterThan(0);
});
});
53 changes: 53 additions & 0 deletions apps/hub/src/setup-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Native cold-boot setup status (CL-8112). A read-only, unauthenticated
// `GET /status` reporting whether this hub has ever been set up, derived
// from native user/tenant counts — the same genesis rule the seed path
// uses: zero tenants means no bench has ever been minted, so the first
// login must route into setup. Replaces the web first-login hook's old
// provision-POST read, which belonged to the unmounted onboarding router. No policy, no auth logic (those are
// T6/T7) — just counts.

import { Hono } from "hono";
import { makeErrorEnvelope, reportError } from "@corbits/error-sink";

export type SetupStatusCounts = {
/** Native user-row count. */
countUsers(): Promise<number>;
/** Native tenant-row count. */
countTenants(): Promise<number>;
};

export type SetupStatus = {
readonly setupRequired: boolean;
readonly userCount: number;
readonly tenantCount: number;
};

export function createSetupStatusRoutes(deps: SetupStatusCounts): Hono {
const app = new Hono();
app.get("/status", async (c) => {
try {
const [userCount, tenantCount] = await Promise.all([
deps.countUsers(),
deps.countTenants(),
]);
const body: SetupStatus = {
setupRequired: tenantCount === 0,
userCount,
tenantCount,
};
return c.json(body);
} catch (error) {
const refId = reportError(error, { operation: "hub.setup_status" });
return c.json(
makeErrorEnvelope({
code: "setup_status_failed",
userMessage:
"Checking your workbench hit a snag. Try again in a moment.",
refId,
}),
500,
);
}
});
return app;
}
3 changes: 3 additions & 0 deletions apps/hub/src/templates/block-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
// (mounted in `apps/hub`) imports this; it is deliberately not
// re-exported from the package root.
import { deployableCatalogWorkflow } from "@workbench/onboarding/tenant-seed";
// TODO(CL-8113, hub-zero T2): this tenant-seed import is the hub's last
// `@workbench/onboarding` edge — cut it over to the native catalog when
// T2 lands, then drop the dependency from `apps/hub/package.json`.

export interface BlockWorkflowBuildInput {
readonly tenantDomain: string;
Expand Down
1 change: 0 additions & 1 deletion apps/hub/test/credential-cipher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ describe("credentialCipherFrom", () => {
const message = (error as Error).message;
expect(message).toContain("CREDENTIAL_ENCRYPTION_KEY");
expect(message).toContain("webhook-trigger signing secrets");
expect(message).toContain("onboarding's OAuth PKCE connect state");
expect(message).toContain("openssl rand -hex 32");
expect(message).toContain("ALLOW_PLAINTEXT_SECRETS");
}
Expand Down
18 changes: 6 additions & 12 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,14 @@ function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) {

/**
* Onboarding renders above the shell entirely — no rail, no col2, no bench
* dock, nothing that implies a workbench already exists. A signed-in
* account with no completed onboarding must never see "Select a workbench";
* the wizard is the only thing on screen until it hands off to `/`.
* dock, nothing that implies a workbench already exists. An account the
* hub reports as setup-required must never see "Select a workbench"; the
* setup screen is the only thing on screen until it hands off to `/`.
*/
function OnboardingGate({
navigate,
user,
}: {
readonly navigate: Navigate;
readonly user: SessionUser;
}) {
function OnboardingGate({ navigate }: { readonly navigate: Navigate }) {
return (
<NavigationProvider navigate={navigate}>
<OnboardingPage user={user} />
<OnboardingPage />
</NavigationProvider>
);
}
Expand Down Expand Up @@ -197,7 +191,7 @@ export function App({
return <LoginBounceHome navigate={navigate} />;
}
if (path === ONBOARDING_PATH) {
return <OnboardingGate navigate={navigate} user={session.user} />;
return <OnboardingGate navigate={navigate} />;
}
return (
<Shell
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ export function refreshWorkbenchLists(
* from "this template genuinely doesn't exist here" without parsing
* `message` text: the first is very often a still-provisioning bench
* (CL-6457's background deploy hasn't finished, or never started
* without a credential) that the caller should check
* `fetchAgentReadiness` over before treating as a dead end; the second
* never resolves itself and should surface as-is.
* without a credential) — `createWorkbenchFromTemplate` throws it only
* after its own native definitions read found no setup agent, so that
* read already is the readiness check and the caller can dead-end
* honestly; the second never resolves itself and should surface as-is.
*/
export class WorkbenchPreconditionError extends Error {
readonly kind: "setup-agent-missing" | "template-unavailable";
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ function Root() {
);

// The first-login hook: once per session that reaches signed-in, ask
// the hub whether this is a session with zero principals anywhere.
// Without a display name the hub does not mint a bench — it returns
// needs-onboarding so we route into the naming wizard. Existing members
// cost one read. A failure blocks the shell entirely.
// the hub's native setup-status route whether any bench exists yet. An
// empty hub reports setup-required so we route into the setup screen;
// a hub with tenants loads the shell normally. Read-only on purpose
// (CL-8112) — this never mints anything. A failure blocks the shell
// entirely rather than leaving the user silently benchless.
const [provisioningError, setProvisioningError] = useState<{
message: string;
refId?: string | undefined;
Expand All @@ -70,7 +71,7 @@ function Root() {
setProvisioningError(null);
void triggerFirstLoginProvisioning().then((result) => {
if (cancelled) return;
if (result.kind === "needs-onboarding" || result.kind === "provisioned") {
if (result.kind === "needs-onboarding") {
navigate(ONBOARDING_PATH);
} else if (result.kind === "error") {
setProvisioningError({ message: result.message, refId: result.refId });
Expand Down
Loading
Loading