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,497 changes: 1,489 additions & 8 deletions control-plane/package-lock.json

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "npm run build && npm run test:node",
"test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\""
"test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"",
"cf:dev": "wrangler dev",
"cf:deploy": "wrangler deploy",
"cf:typecheck": "tsc -p tsconfig.worker.json --noEmit",
"cf:typegen": "node scripts/gen-cf-typegen.mjs"
},
"dependencies": {
"hono": "^4.12.27"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260721.1",
"@types/node": "^22.20.0",
"c8": "^10.1.3",
"prettier": "3.9.4",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"wrangler": "^4.112.0"
}
}
27 changes: 27 additions & 0 deletions control-plane/scripts/gen-cf-typegen.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Regenerates worker-configuration.d.ts from wrangler.jsonc and strips trailing whitespace -- wrangler's
// raw `wrangler types` output has trailing whitespace on several lines, which fails this repo's own
// `git diff --check` whitespace gate the moment it's committed (mirrors
// packages/discovery-index/scripts/gen-cf-typegen.mjs, found the hard way on that package's own #7167/#4250
// PR). Simpler than the root repo's scripts/gen-cf-typegen.mjs: this package's Env has no `vars`-derived
// Pick<Cloudflare.Env, ...> union to reformat (only a KV binding + ambient-declared secrets), so only the
// whitespace-stripping half of that script's job applies here.
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";

const require = createRequire(import.meta.url);

function resolveLocalWranglerBin() {
const pkgJsonPath = require.resolve("wrangler/package.json");
const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
const binRelativePath = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.wrangler;
return join(dirname(pkgJsonPath), binRelativePath);
}

const OUTPUT_PATH = "worker-configuration.d.ts";

execFileSync(process.execPath, [resolveLocalWranglerBin(), "types", OUTPUT_PATH], { stdio: "inherit" });
const stripped = readFileSync(OUTPUT_PATH, "utf8").replace(/[ \t]+$/gm, "");
writeFileSync(OUTPUT_PATH, stripped);
33 changes: 33 additions & 0 deletions control-plane/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { timingSafeEqual } from "node:crypto";

/**
* Constant-time `Authorization: Bearer <secret>` check. Returns false on a missing/malformed header or any
* mismatch. Length-checks before timingSafeEqual (which throws on unequal-length buffers) — the length leak is
* acceptable for a fixed-length shared secret. Mirrors packages/discovery-index/src/auth.ts's verifyBearer
* exactly (itself mirroring review-enrichment/src/auth.ts) -- this service is a separate deployable with no
* shared-code dependency on either, so the ~25-line utility is duplicated rather than factored into a new
* shared package for three callers.
*/
export function verifyBearer(header: string | undefined, secret: string): boolean {
const expectedSecret = normalizeSharedSecret(secret);
if (!expectedSecret) return false;
const match = header?.match(/^Bearer\s+(.+)$/i);
const headerToken = normalizeSharedSecret(match?.[1]);
if (!headerToken) return false;
const token = Buffer.from(headerToken);
const expected = Buffer.from(expectedSecret);
if (token.length !== expected.length) return false;
return timingSafeEqual(token, expected);
}

export function normalizeSharedSecret(value: string | undefined): string | undefined {
if (typeof value !== "string") return undefined;
let normalized = value.trim();
if (!normalized) return undefined;
const first = normalized[0];
const last = normalized[normalized.length - 1];
if (normalized.length >= 2 && ((first === '"' && last === '"') || (first === "'" && last === "'"))) {
normalized = normalized.slice(1, -1).trim();
}
return normalized || undefined;
}
20 changes: 20 additions & 0 deletions control-plane/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Wrangler secrets don't appear in wrangler.jsonc (never committed) so `wrangler types` can't discover their
// names -- declared here via ambient global merge with the generated Env interface
// (worker-configuration.d.ts), mirroring the main app's own src/env.d.ts pattern (and
// packages/discovery-index/src/env.d.ts's identical one). Set real values via `npx wrangler secret put
// <name>`; never given a real value in this repo.
declare global {
interface Env {
/** Admin Bearer token every `/v1/tenants/*` route requires (#7654). Genuinely sensitive -- always a
* wrangler secret, never a plain var. */
ADMIN_TOKEN: string;
/** Selects the real Neon-backed database driver (#7653) when both this and NEON_PROJECT_ID are set;
* createTenantProvisioningDriver falls back to the fake driver otherwise. Genuinely sensitive. */
NEON_API_KEY?: string;
/** Same routing-key secret name/shape as the main app's own src/env.d.ts (#7667's PagerDuty mirror) --
* grants the ability to trigger a real page, so it's a secret here too, not a plain var. */
PAGERDUTY_ROUTING_KEY?: string;
}
}

export {};
93 changes: 93 additions & 0 deletions control-plane/src/http-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// The real HTTP transport for control-plane's tenant-provisioning API (#7654), matching
// packages/loopover-miner/lib/tenant-client.ts's already-merged contract exactly: `POST /v1/tenants`
// (`{name, product}`), `GET /v1/tenants` (`{tenants: [...]}`), `DELETE /v1/tenants/:name`, all Bearer-authed.
// Factored out as a plain Hono app (not the real Worker entry point, see worker.ts) so it's testable via
// Hono's own `app.request()` against injected fakes under plain `node:test` -- mirrors
// packages/discovery-index/src/app.ts's identical split for the identical reason.
//
// Deliberately never echoes a tenant's database connection details (host/user/password/connectionString) in
// any response: `provisionTenant`'s result carries them (#7653) so a caller doesn't lose them, but this admin
// HTTP surface only returns the safe `{tenant, product, state}` triple. Properly storing/distributing those
// credentials is #7852's job (the generalized secret broker) -- until it lands, this transport intentionally
// does not create a new place for them to leak.
import { Hono } from "hono";
import { normalizeSharedSecret, verifyBearer } from "./auth.js";
import {
deprovisionTenant,
provisionTenant,
type ProvisioningPagerDutyOptions,
} from "./provisioning.js";
import type { TenantProvisioningDriver } from "./tenant-provisioning-driver.js";
import type { TenantRegistry, TenantRegistryRecord } from "./tenant-registry.js";

export type TenantHttpAppDeps = {
driver: TenantProvisioningDriver;
registry: TenantRegistry;
/** The single admin Bearer token every `/v1/tenants/*` route requires. Blank/unset ⇒ every request under
* that prefix fails closed with 503 (matching discovery-index's own "service_not_configured" convention)
* rather than silently accepting an unauthenticated caller. */
adminToken: string | undefined;
pagerDuty?: ProvisioningPagerDutyOptions;
};

function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "state">): Record<string, unknown> {
return { tenant: record.tenant, product: record.product, state: record.state };
}

export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
const app = new Hono();

app.get("/health", (c) => c.json({ status: "ok", service: "control-plane" }));

app.use("/v1/tenants/*", async (c, next) => {
const secret = normalizeSharedSecret(deps.adminToken);
if (!secret) return c.json({ error: "service_not_configured" }, 503);
if (!verifyBearer(c.req.header("authorization"), secret)) return c.json({ error: "unauthorized" }, 401);
await next();
});

app.onError((error, c) => {
// Hono's ErrorHandler type guarantees `error: Error | HTTPResponseError` -- both carry `.message`.
// provisionTenant/deprovisionTenant already page PagerDuty (#7667) and rethrow internally before this ever
// runs, so this handler only logs and answers -- it must not page a second time for the same failure.
console.error(JSON.stringify({ event: "control_plane_http_error", route: c.req.path, message: error.message }));
return c.json({ error: "internal_error" }, 500);
});

app.post("/v1/tenants", async (c) => {
const body: unknown = await c.req.json().catch(() => null);
if (body === null || typeof body !== "object") return c.json({ error: "invalid_json" }, 400);
const { name, product } = body as Record<string, unknown>;
if (typeof name !== "string" || !name.trim()) return c.json({ error: "invalid_request", message: "name is required" }, 400);
if (typeof product !== "string" || !product.trim()) return c.json({ error: "invalid_request", message: "product is required" }, 400);

// Not idempotent by design (tenant-client.ts's own doc comment: "a create is not idempotent, so it must
// not be silently re-sent") -- a currently-active tenant of the same name is a real conflict, not a no-op.
// A previously torn-down tenant may be recreated (its createdAt is NOT preserved -- this is a fresh
// provision, not a resurrection of the old one).
const existing = await deps.registry.get(name);
if (existing && existing.state !== "torn down") return c.json({ error: "tenant_already_exists" }, 409);

const result = await provisionTenant({ name }, product, deps.driver, deps.pagerDuty ?? {});
const now = new Date().toISOString();
await deps.registry.upsert({ tenant: result.tenant, product: result.product, state: result.state, createdAt: now, updatedAt: now });
return c.json(safeRecord(result), 201);
});

app.get("/v1/tenants", async (c) => {
const records = await deps.registry.list();
return c.json({ tenants: records.map((record) => ({ ...safeRecord(record), createdAt: record.createdAt, updatedAt: record.updatedAt })) });
});

app.delete("/v1/tenants/:name", async (c) => {
const name = c.req.param("name");
const existing = await deps.registry.get(name);
if (!existing) return c.json({ error: "tenant_not_found" }, 404);

const result = await deprovisionTenant(existing.tenant, existing.product, deps.driver, deps.pagerDuty ?? {});
await deps.registry.upsert({ tenant: result.tenant, product: result.product, state: result.state, createdAt: existing.createdAt, updatedAt: new Date().toISOString() });
return c.json(safeRecord(result));
});

return app;
}
9 changes: 9 additions & 0 deletions control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ export {
createTenantProvisioningDriver,
withRealDatabaseDriver,
} from "./driver-factory.js";
export {
createFakeTenantRegistry,
createKvTenantRegistry,
type KvNamespaceLike,
type TenantRegistry,
type TenantRegistryRecord,
} from "./tenant-registry.js";
export { createTenantHttpApp, type TenantHttpAppDeps } from "./http-app.js";
export { normalizeSharedSecret, verifyBearer } from "./auth.js";
84 changes: 84 additions & 0 deletions control-plane/src/tenant-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Tenant registry for control-plane's real HTTP transport (#7654). `TenantProvisioningDriver` has no
// enumeration concept by design (create/destroy/exists are all per-tenant) -- `GET /v1/tenants` needs a
// distinct, durable list of every tenant this service has been asked to create, independent of whatever a
// given driver internally tracks. Deliberately stores ONLY name/product/lifecycle state/timestamps, never a
// tenant's database connection details or any other secret -- this is an admin-visible inventory, not a
// credential store (that's #7852's job, via the generalized broker).
import type { Product, Tenant, TenantLifecycleState } from "./tenant-provisioning-driver.js";

export type TenantRegistryRecord = {
tenant: Tenant;
product: Product;
state: TenantLifecycleState;
createdAt: string;
updatedAt: string;
};

export interface TenantRegistry {
/** Insert or update a tenant's record. Preserves the original `createdAt` on an update (looked up by the
* caller, not this method -- see `http-app.ts`'s own upsert helper). */
upsert(record: TenantRegistryRecord): Promise<void>;
get(name: string): Promise<TenantRegistryRecord | undefined>;
/** Every tenant this service has ever created, including torn-down ones (mirrors a cloud console showing
* terminated instances rather than making them vanish) -- ordered by `tenant.name` for a stable listing. */
list(): Promise<TenantRegistryRecord[]>;
}

/** In-memory fake for tests -- mirrors `createFakeTenantProvisioningDriver`'s own minimal-fake convention. */
export function createFakeTenantRegistry(): TenantRegistry {
const records = new Map<string, TenantRegistryRecord>();
return {
async upsert(record) {
records.set(record.tenant.name, record);
},
async get(name) {
return records.get(name);
},
async list() {
return [...records.values()].sort((a, b) => a.tenant.name.localeCompare(b.tenant.name));
},
};
}

/** The minimal slice of Cloudflare's real `KVNamespace` this module actually calls -- kept as a small local
* interface (not a `@cloudflare/workers-types` import) so this file stays plain, portable TypeScript,
* testable with a trivial in-memory fake under `node:test` with no Workers-specific tooling. */
export type KvNamespaceLike = {
get(key: string): Promise<string | null>;
put(key: string, value: string): Promise<void>;
list(options?: { prefix?: string; cursor?: string }): Promise<{ keys: Array<{ name: string }>; list_complete: boolean; cursor?: string }>;
};

const KEY_PREFIX = "tenant:";

function keyFor(name: string): string {
return `${KEY_PREFIX}${name}`;
}

/** Real registry backed by Workers KV. `list()` pages through every `tenant:`-prefixed key (KV's own `list()`
* caps each call at 1000 keys) rather than assuming a single page covers the whole registry. */
export function createKvTenantRegistry(kv: KvNamespaceLike): TenantRegistry {
return {
async upsert(record) {
await kv.put(keyFor(record.tenant.name), JSON.stringify(record));
},
async get(name) {
const raw = await kv.get(keyFor(name));
return raw ? (JSON.parse(raw) as TenantRegistryRecord) : undefined;
},
async list() {
const records: TenantRegistryRecord[] = [];
let cursor: string | undefined;
for (;;) {
const page = await kv.list({ prefix: KEY_PREFIX, ...(cursor ? { cursor } : {}) });
const values = await Promise.all(page.keys.map((key) => kv.get(key.name)));
for (const raw of values) {
if (raw) records.push(JSON.parse(raw) as TenantRegistryRecord);
}
if (page.list_complete || !page.cursor) break;
cursor = page.cursor;
}
return records.sort((a, b) => a.tenant.name.localeCompare(b.tenant.name));
},
};
}
26 changes: 26 additions & 0 deletions control-plane/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Cloudflare Worker entry point for control-plane's real HTTP transport (#7654). Pure infra glue: wires the
// real KV-backed tenant registry, the admin Bearer secret, and whichever `TenantProvisioningDriver` env
// selects (real Neon database driver if NEON_API_KEY/NEON_PROJECT_ID are set, the fake otherwise -- see
// driver-factory.ts) into the plain, already-tested Hono app (http-app.ts). Adds NO route logic of its own.
//
// Not unit-tested: exercised only by real Cloudflare Workers/KV infrastructure, matching
// packages/discovery-index/src/worker.ts's own identical exclusion (see scripts/control-plane-coverage.mjs).
import { createTenantProvisioningDriver } from "./driver-factory.js";
import { createTenantHttpApp } from "./http-app.js";
import { createKvTenantRegistry } from "./tenant-registry.js";

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const driver = createTenantProvisioningDriver({ NEON_API_KEY: env.NEON_API_KEY, NEON_PROJECT_ID: env.NEON_PROJECT_ID });
const app = createTenantHttpApp({
driver,
registry: createKvTenantRegistry(env.TENANT_REGISTRY),
adminToken: env.ADMIN_TOKEN,
// provisionTenant/deprovisionTenant's own PagerDuty paging (#7667) defaults to reading `process.env`,
// a real-Node-only assumption -- explicitly forwarding the Worker's own bindings here is what makes
// paging actually configurable in this deployment, rather than silently reading an empty process.env.
pagerDuty: { env: { LOOPOVER_ENABLE_PAGERDUTY: env.LOOPOVER_ENABLE_PAGERDUTY, PAGERDUTY_ROUTING_KEY: env.PAGERDUTY_ROUTING_KEY } },
});
return app.fetch(request, env);
},
};
28 changes: 28 additions & 0 deletions control-plane/test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Mirrors packages/discovery-index/test/auth.test.ts exactly (same source, duplicated per auth.ts's own
// header comment) -- ported to node:test since control-plane uses that runner, not vitest.
import assert from "node:assert/strict";
import { test } from "node:test";

import { normalizeSharedSecret, verifyBearer } from "../dist/index.js";

test("normalizeSharedSecret trims copied whitespace and surrounding quotes", () => {
assert.equal(normalizeSharedSecret(' "sek"\n'), "sek");
assert.equal(normalizeSharedSecret(" 'sek' "), "sek");
assert.equal(normalizeSharedSecret(" \n "), undefined);
assert.equal(normalizeSharedSecret(undefined), undefined);
assert.equal(normalizeSharedSecret('""'), undefined); // quotes stripped from an empty string stay empty
});

test("verifyBearer accepts normalized service secrets and bearer tokens", () => {
assert.equal(verifyBearer("Bearer sek", "sek"), true);
assert.equal(verifyBearer("Bearer sek ", ' "sek"\n'), true);
assert.equal(verifyBearer("bearer sek", "sek"), true);
});

test("verifyBearer rejects missing, malformed, and mismatched headers", () => {
assert.equal(verifyBearer(undefined, "sek"), false);
assert.equal(verifyBearer("Basic sek", "sek"), false);
assert.equal(verifyBearer("Bearer nope", "sek"), false);
assert.equal(verifyBearer("Bearer sek", " \n "), false);
assert.equal(verifyBearer("Bearer sekret", "sek"), false);
});
Loading
Loading