forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bootstrapAlphaSilo, type AlphaSiloBootstrapInput } from "./bootstrap-silo.js";
|
||||
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { readSiloOrganizationId } from "./silo.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.getuid?.() !== 0) throw new Error("Alpha Silo bootstrap must run as root");
|
||||
await requireStoppedSilo();
|
||||
const { configFile, keyringFile } = parseArgs(process.argv.slice(2));
|
||||
await requireRootPrivateFile(configFile, "bootstrap config");
|
||||
await requireRootPrivateFile(keyringFile, "secret keyring");
|
||||
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
||||
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
|
||||
const input = parseConfig(await readFile(configFile, "utf8"));
|
||||
const configuredOrganizationId = readSiloOrganizationId();
|
||||
if (input.organization.id !== configuredOrganizationId) {
|
||||
throw new Error(
|
||||
`bootstrap Organization ${input.organization.id} does not match HUB_SILO_ORGANIZATION_ID ${configuredOrganizationId}`,
|
||||
);
|
||||
}
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
||||
try {
|
||||
const result = await bootstrapAlphaSilo(prisma, secrets, input);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function requireStoppedSilo(): Promise<void> {
|
||||
const unit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
|
||||
if (unit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(unit)) {
|
||||
throw new Error("HUB_SYSTEMD_UNIT must name the Silo being bootstrapped");
|
||||
}
|
||||
let state: string;
|
||||
try {
|
||||
const result = await execFileAsync("systemctl", ["show", "--property=ActiveState", "--value", unit], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
state = result.stdout.trim();
|
||||
} catch (error) {
|
||||
throw new Error(`cannot verify that ${unit} is stopped`, { cause: error });
|
||||
}
|
||||
if (state !== "inactive" && state !== "failed") {
|
||||
throw new Error(`${unit} must be stopped before bootstrap; state=${state || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): { readonly configFile: string; readonly keyringFile: string } {
|
||||
if (argv.length !== 4 || argv[0] !== "--config-file" || argv[2] !== "--keyring-file" ||
|
||||
argv[1] === undefined || argv[1] === "" || argv[3] === undefined || argv[3] === "") {
|
||||
throw new Error("usage: bootstrap-silo --config-file /root/bootstrap.json --keyring-file /root/keyring.json");
|
||||
}
|
||||
return { configFile: argv[1], keyringFile: argv[3] };
|
||||
}
|
||||
|
||||
async function requireRootPrivateFile(path: string, label: string): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${label} is not a regular file: ${path}`);
|
||||
if (metadata.uid !== 0 || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} must be root-owned and inaccessible by group/others: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(source: string): AlphaSiloBootstrapInput {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source);
|
||||
} catch (error) {
|
||||
throw new Error("bootstrap config is not valid JSON", { cause: error });
|
||||
}
|
||||
if (!isRecord(value) || !isRecord(value["organization"]) || !isRecord(value["owner"]) ||
|
||||
!isRecord(value["feishu"]) || !isRecord(value["provider"])) {
|
||||
throw new Error("bootstrap config must contain organization, owner, feishu, and provider objects");
|
||||
}
|
||||
const organization = value["organization"];
|
||||
const owner = value["owner"];
|
||||
const feishu = value["feishu"];
|
||||
const provider = value["provider"];
|
||||
const teams = value["teams"];
|
||||
if (teams !== undefined && !Array.isArray(teams)) throw new Error("bootstrap teams must be an array");
|
||||
return {
|
||||
organization: {
|
||||
id: stringField(organization, "id"),
|
||||
slug: stringField(organization, "slug"),
|
||||
name: stringField(organization, "name"),
|
||||
},
|
||||
owner: {
|
||||
openId: stringField(owner, "openId"),
|
||||
displayName: stringField(owner, "displayName"),
|
||||
...(optionalStringField(owner, "unionId") !== undefined
|
||||
? { unionId: optionalStringField(owner, "unionId")! }
|
||||
: {}),
|
||||
},
|
||||
feishu: {
|
||||
appId: stringField(feishu, "appId"),
|
||||
appSecret: stringField(feishu, "appSecret"),
|
||||
botOpenId: stringField(feishu, "botOpenId"),
|
||||
...(optionalStringField(feishu, "verificationToken") !== undefined
|
||||
? { verificationToken: optionalStringField(feishu, "verificationToken")! }
|
||||
: {}),
|
||||
...(optionalStringField(feishu, "encryptKey") !== undefined
|
||||
? { encryptKey: optionalStringField(feishu, "encryptKey")! }
|
||||
: {}),
|
||||
},
|
||||
provider: {
|
||||
providerId: stringField(provider, "providerId"),
|
||||
baseUrl: stringField(provider, "baseUrl"),
|
||||
authToken: stringField(provider, "authToken"),
|
||||
...(optionalStringField(provider, "anthropicApiKey") !== undefined
|
||||
? { anthropicApiKey: optionalStringField(provider, "anthropicApiKey")! }
|
||||
: {}),
|
||||
},
|
||||
...(Array.isArray(teams)
|
||||
? {
|
||||
teams: teams.map((team, index) => {
|
||||
if (!isRecord(team)) throw new Error(`bootstrap teams[${index}] must be an object`);
|
||||
return {
|
||||
slug: stringField(team, "slug"),
|
||||
name: stringField(team, "name"),
|
||||
...(optionalStringField(team, "description") !== undefined
|
||||
? { description: optionalStringField(team, "description")! }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, name: string): string {
|
||||
const field = value[name];
|
||||
if (typeof field !== "string" || field.trim() === "") throw new Error(`bootstrap ${name} is required`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalStringField(value: Record<string, unknown>, name: string): string | undefined {
|
||||
const field = value[name];
|
||||
if (field === undefined) return undefined;
|
||||
if (typeof field !== "string") throw new Error(`bootstrap ${name} must be a string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(`[bootstrap-silo] failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
fingerprintFeishuAppId,
|
||||
validateFeishuApplicationCredential,
|
||||
type FeishuApplicationCredentialInput,
|
||||
} from "../connections/feishuApplicationConnections.js";
|
||||
import {
|
||||
decryptStoredProviderCredential,
|
||||
ProviderConnectionService,
|
||||
type ProviderCredentialInput,
|
||||
} from "../connections/providerConnections.js";
|
||||
import { probeFeishuApplication, type FeishuReadinessProbe } from "../connections/feishuReadiness.js";
|
||||
import { probeOpenRouterCredential, type ProviderReadinessProbe } from "../connections/providerReadiness.js";
|
||||
import { deterministicFeishuUserId, scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
|
||||
export interface AlphaSiloBootstrapInput {
|
||||
readonly organization: { readonly id: string; readonly slug: string; readonly name: string };
|
||||
readonly owner: { readonly openId: string; readonly displayName: string; readonly unionId?: string };
|
||||
readonly feishu: FeishuApplicationCredentialInput;
|
||||
readonly provider: ProviderCredentialInput & { readonly providerId: string };
|
||||
readonly teams?: readonly {
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AlphaSiloBootstrapResult {
|
||||
readonly organizationId: string;
|
||||
readonly ownerUserId: string;
|
||||
readonly feishuConnectionId: string;
|
||||
readonly initialized: boolean;
|
||||
readonly providerConfigured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently bootstrap the one Organization allowed in an Alpha Silo.
|
||||
* Secrets are accepted only in-memory and persisted as authenticated envelopes.
|
||||
*/
|
||||
export async function bootstrapAlphaSilo(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: AlphaSiloBootstrapInput,
|
||||
probes: {
|
||||
readonly feishu?: FeishuReadinessProbe;
|
||||
readonly provider?: ProviderReadinessProbe;
|
||||
} = {},
|
||||
): Promise<AlphaSiloBootstrapResult> {
|
||||
const normalized = validateInput(input);
|
||||
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
|
||||
let initialized = false;
|
||||
let ownerUserId: string;
|
||||
let feishuConnectionId: string;
|
||||
|
||||
if (existing === null) {
|
||||
await (probes.feishu ?? probeFeishuApplication)(normalized.feishu);
|
||||
const initializedSilo = await initializeSilo(prisma, secrets, normalized);
|
||||
initialized = true;
|
||||
ownerUserId = initializedSilo.ownerUserId;
|
||||
feishuConnectionId = initializedSilo.feishuConnectionId;
|
||||
} else {
|
||||
ownerUserId = existing.ownerUserId;
|
||||
feishuConnectionId = existing.feishuConnectionId;
|
||||
const expectedFingerprint = fingerprintFeishuAppId(normalized.feishu.appId);
|
||||
if (existing.appIdentityFingerprint !== expectedFingerprint) {
|
||||
throw new Error("bootstrap Feishu app identity does not match the existing Silo");
|
||||
}
|
||||
}
|
||||
|
||||
const providerService = new ProviderConnectionService(
|
||||
prisma,
|
||||
secrets,
|
||||
probes.provider ?? probeOpenRouterCredential,
|
||||
);
|
||||
const existingProviders = await providerService.list(normalized.organization.id);
|
||||
const existingProvider = existingProviders.find((connection) => connection.providerId === normalized.provider.providerId);
|
||||
if (existingProvider === undefined) {
|
||||
await providerService.rotateByok({
|
||||
organizationId: normalized.organization.id,
|
||||
actorUserId: ownerUserId,
|
||||
providerId: normalized.provider.providerId,
|
||||
baseUrl: normalized.provider.baseUrl,
|
||||
authToken: normalized.provider.authToken,
|
||||
...(normalized.provider.anthropicApiKey !== undefined
|
||||
? { anthropicApiKey: normalized.provider.anthropicApiKey }
|
||||
: {}),
|
||||
});
|
||||
} else if (existingProvider.status !== "ACTIVE" || existingProvider.mode !== "BYOK") {
|
||||
throw new Error(`bootstrap provider ${normalized.provider.providerId} exists but is not ACTIVE BYOK`);
|
||||
} else {
|
||||
const stored = await loadExistingProviderCredential(
|
||||
prisma,
|
||||
secrets,
|
||||
normalized.organization.id,
|
||||
normalized.provider.providerId,
|
||||
);
|
||||
if (stored.baseUrl !== normalized.provider.baseUrl || stored.authToken !== normalized.provider.authToken ||
|
||||
stored.anthropicApiKey !== (normalized.provider.anthropicApiKey ?? "")) {
|
||||
throw new Error(`bootstrap provider ${normalized.provider.providerId} credential does not match existing Silo`);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBootstrapTeams(prisma, normalized.organization.id, ownerUserId, normalized.teams ?? []);
|
||||
|
||||
return {
|
||||
organizationId: normalized.organization.id,
|
||||
ownerUserId,
|
||||
feishuConnectionId,
|
||||
initialized,
|
||||
providerConfigured: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureBootstrapTeams(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
ownerUserId: string,
|
||||
teams: NonNullable<AlphaSiloBootstrapInput["teams"]>,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const team of teams) {
|
||||
const existing = await tx.team.findFirst({
|
||||
where: { organizationId, slug: team.slug, archivedAt: null },
|
||||
select: { id: true, name: true, description: true },
|
||||
});
|
||||
if (existing !== null && (existing.name !== team.name || existing.description !== (team.description ?? null))) {
|
||||
throw new Error(`bootstrap Team ${team.slug} does not match the existing Silo`);
|
||||
}
|
||||
const teamId = existing?.id ?? (await tx.team.create({
|
||||
data: {
|
||||
organizationId,
|
||||
slug: team.slug,
|
||||
name: team.name,
|
||||
...(team.description !== undefined ? { description: team.description } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
})).id;
|
||||
const membership = await tx.teamMembership.findFirst({
|
||||
where: { teamId, userId: ownerUserId, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) await tx.teamMembership.create({ data: { teamId, userId: ownerUserId } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeSilo(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: AlphaSiloBootstrapInput,
|
||||
): Promise<{ readonly ownerUserId: string; readonly feishuConnectionId: string }> {
|
||||
const connectionId = randomUUID();
|
||||
const secretVersionId = randomUUID();
|
||||
const identityId = randomUUID();
|
||||
const ownerUserId = deterministicFeishuUserId(connectionId, input.owner.openId);
|
||||
const appIdentityFingerprint = fingerprintFeishuAppId(input.feishu.appId);
|
||||
const payload = validateFeishuApplicationCredential(input.feishu);
|
||||
const envelope = secrets.encryptJson({
|
||||
purpose: "feishu-application",
|
||||
organizationId: input.organization.id,
|
||||
connectionId,
|
||||
secretVersionId,
|
||||
}, payload);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const count = await tx.organization.count();
|
||||
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`);
|
||||
await tx.organization.create({ data: { ...input.organization } });
|
||||
await tx.organizationProjectSettings.create({
|
||||
data: { organizationId: input.organization.id, membersCanCreateProjects: true },
|
||||
});
|
||||
await tx.folder.create({
|
||||
data: {
|
||||
organizationId: input.organization.id,
|
||||
name: "Inbox",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
await tx.user.create({
|
||||
data: {
|
||||
id: ownerUserId,
|
||||
feishuOpenId: scopedFeishuPrincipalId("USER", connectionId, input.owner.openId),
|
||||
displayName: input.owner.displayName,
|
||||
},
|
||||
});
|
||||
await tx.organizationFeishuApplicationConnection.create({
|
||||
data: {
|
||||
id: connectionId,
|
||||
organizationId: input.organization.id,
|
||||
appIdentityFingerprint,
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
await tx.feishuUserIdentity.create({
|
||||
data: {
|
||||
id: identityId,
|
||||
connectionId,
|
||||
userId: ownerUserId,
|
||||
openId: input.owner.openId,
|
||||
...(input.owner.unionId !== undefined ? { unionId: input.owner.unionId } : {}),
|
||||
},
|
||||
});
|
||||
await tx.organizationMembership.create({
|
||||
data: { organizationId: input.organization.id, userId: ownerUserId, role: "OWNER" },
|
||||
});
|
||||
for (const team of input.teams ?? []) {
|
||||
await tx.team.create({
|
||||
data: {
|
||||
organizationId: input.organization.id,
|
||||
slug: team.slug,
|
||||
name: team.name,
|
||||
...(team.description !== undefined ? { description: team.description } : {}),
|
||||
memberships: { create: { userId: ownerUserId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.feishuApplicationCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId,
|
||||
version: 1,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: ownerUserId,
|
||||
},
|
||||
});
|
||||
await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersionId,
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organization.id,
|
||||
actorUserId: ownerUserId,
|
||||
action: "alpha_silo.bootstrapped",
|
||||
metadata: {
|
||||
connectionId,
|
||||
ownerIdentityId: identityId,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return { ownerUserId, feishuConnectionId: connectionId };
|
||||
}
|
||||
|
||||
async function loadExistingSilo(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
ownerOpenId: string,
|
||||
): Promise<{
|
||||
readonly ownerUserId: string;
|
||||
readonly feishuConnectionId: string;
|
||||
readonly appIdentityFingerprint: string;
|
||||
} | null> {
|
||||
const organizations = await prisma.organization.findMany({
|
||||
take: 2,
|
||||
include: {
|
||||
memberships: {
|
||||
where: { role: "OWNER", revokedAt: null },
|
||||
take: 2,
|
||||
include: { user: { include: { feishuIdentities: true } } },
|
||||
},
|
||||
feishuApplicationConnection: true,
|
||||
},
|
||||
});
|
||||
if (organizations.length === 0) return null;
|
||||
if (organizations.length !== 1 || organizations[0]!.id !== organizationId) {
|
||||
throw new Error("bootstrap database is not the configured single-Organization Silo");
|
||||
}
|
||||
const organization = organizations[0]!;
|
||||
if (organization.status !== "ACTIVE" || organization.memberships.length !== 1 ||
|
||||
organization.feishuApplicationConnection?.status !== "ACTIVE") {
|
||||
throw new Error("existing Silo is incomplete or inactive; manual repair is required");
|
||||
}
|
||||
const ownerIdentity = organization.memberships[0]!.user.feishuIdentities.find(
|
||||
(identity) => identity.connectionId === organization.feishuApplicationConnection!.id,
|
||||
);
|
||||
if (ownerIdentity?.openId !== ownerOpenId) {
|
||||
throw new Error("bootstrap owner identity does not match the existing Silo");
|
||||
}
|
||||
return {
|
||||
ownerUserId: organization.memberships[0]!.userId,
|
||||
feishuConnectionId: organization.feishuApplicationConnection.id,
|
||||
appIdentityFingerprint: organization.feishuApplicationConnection.appIdentityFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadExistingProviderCredential(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
organizationId: string,
|
||||
providerId: string,
|
||||
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string }> {
|
||||
const connection = await prisma.organizationProviderConnection.findUnique({
|
||||
where: { organizationId_providerId: { organizationId, providerId } },
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
const version = connection?.activeSecretVersion;
|
||||
if (connection === null || connection === undefined || version === null || version === undefined ||
|
||||
connection.status !== "ACTIVE" || version.retiredAt !== null) {
|
||||
throw new Error(`active provider connection not found: ${providerId}`);
|
||||
}
|
||||
return decryptStoredProviderCredential(secrets, {
|
||||
organizationId,
|
||||
connectionId: connection.id,
|
||||
providerId,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
}
|
||||
|
||||
function validateInput(input: AlphaSiloBootstrapInput): AlphaSiloBootstrapInput {
|
||||
const required = (value: string, label: string): string => {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
};
|
||||
const slug = required(input.organization.slug, "Organization slug");
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) {
|
||||
throw new Error("Organization slug must be lowercase alphanumeric with optional hyphens");
|
||||
}
|
||||
if (input.provider.providerId.trim() !== "openrouter") {
|
||||
throw new Error("Alpha Silo currently requires providerId=openrouter");
|
||||
}
|
||||
return {
|
||||
organization: {
|
||||
id: required(input.organization.id, "Organization id"),
|
||||
slug,
|
||||
name: required(input.organization.name, "Organization name"),
|
||||
},
|
||||
owner: {
|
||||
openId: required(input.owner.openId, "Owner Feishu openId"),
|
||||
displayName: required(input.owner.displayName, "Owner displayName"),
|
||||
...(input.owner.unionId !== undefined ? { unionId: required(input.owner.unionId, "Owner unionId") } : {}),
|
||||
},
|
||||
feishu: input.feishu,
|
||||
provider: input.provider,
|
||||
...(input.teams !== undefined
|
||||
? {
|
||||
teams: input.teams.map((team) => {
|
||||
const teamSlug = required(team.slug, "Team slug");
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(teamSlug)) {
|
||||
throw new Error(`invalid Team slug: ${teamSlug}`);
|
||||
}
|
||||
return {
|
||||
slug: teamSlug,
|
||||
name: required(team.name, `Team ${teamSlug} name`),
|
||||
...(team.description !== undefined ? { description: team.description.trim() } : {}),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ interface Options {
|
||||
readonly stateDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly serviceUnit: string;
|
||||
readonly bwrapBin: string;
|
||||
readonly socatBin: string;
|
||||
readonly pgIsReadyBin: string;
|
||||
@@ -79,6 +80,7 @@ function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPrefli
|
||||
stateDir: options.stateDir,
|
||||
cacheDir: options.cacheDir,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
expectedServiceUnit: options.serviceUnit,
|
||||
env,
|
||||
};
|
||||
}
|
||||
@@ -97,6 +99,7 @@ async function canonicalizeInput(input: DeploymentPreflightInput): Promise<Deplo
|
||||
stateDir,
|
||||
cacheDir,
|
||||
workspaceRoot,
|
||||
expectedServiceUnit: input.expectedServiceUnit,
|
||||
env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
|
||||
};
|
||||
}
|
||||
@@ -155,8 +158,8 @@ async function validateNodeVersion(nodeBin: string): Promise<void> {
|
||||
}
|
||||
const version = stdout.trim().replace(/^v/, "");
|
||||
const major = Number(version.split(".")[0]);
|
||||
if (!Number.isInteger(major) || major < 20) {
|
||||
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`);
|
||||
if (!Number.isInteger(major) || major < 24) {
|
||||
throw new Error(`Node.js 24 or newer is required; found ${version || "unknown"}`);
|
||||
}
|
||||
console.log(`[preflight] Node.js: ${version}`);
|
||||
}
|
||||
@@ -479,6 +482,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
"--state-dir",
|
||||
"--cache-dir",
|
||||
"--workspace-root",
|
||||
"--service-unit",
|
||||
"--bwrap-bin",
|
||||
"--socat-bin",
|
||||
"--pg-isready-bin",
|
||||
@@ -508,6 +512,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
stateDir: required(values, "--state-dir"),
|
||||
cacheDir: required(values, "--cache-dir"),
|
||||
workspaceRoot: required(values, "--workspace-root"),
|
||||
serviceUnit: required(values, "--service-unit"),
|
||||
bwrapBin: required(values, "--bwrap-bin"),
|
||||
socatBin: required(values, "--socat-bin"),
|
||||
pgIsReadyBin: required(values, "--pg-isready-bin"),
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DeploymentPreflightInput {
|
||||
readonly stateDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly expectedServiceUnit: string;
|
||||
readonly env: DeploymentEnv;
|
||||
}
|
||||
|
||||
@@ -89,9 +90,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
}
|
||||
|
||||
readRequired(input.env, "FEISHU_APP_ID", problems);
|
||||
readRequired(input.env, "FEISHU_APP_SECRET", problems);
|
||||
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems);
|
||||
readRequired(input.env, "HUB_SILO_ORGANIZATION_ID", problems);
|
||||
const serviceUnit = readRequired(input.env, "HUB_SYSTEMD_UNIT", problems);
|
||||
if (serviceUnit !== undefined && !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
|
||||
problems.push("HUB_SYSTEMD_UNIT must match cph-hub-<instance>.service");
|
||||
} else if (serviceUnit !== undefined && serviceUnit !== input.expectedServiceUnit) {
|
||||
problems.push(`HUB_SYSTEMD_UNIT must equal installed unit ${input.expectedServiceUnit}`);
|
||||
}
|
||||
|
||||
const cphBin = readRequired(input.env, "CPH_BIN", problems);
|
||||
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
|
||||
@@ -99,6 +104,7 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
|
||||
let bind: ServerBinding | undefined;
|
||||
readRequired(input.env, "PORT", problems);
|
||||
try {
|
||||
bind = readServerBinding(input.env);
|
||||
} catch (error) {
|
||||
@@ -116,6 +122,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
|
||||
validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_CONCURRENT_RUNS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_RUN_SECONDS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_HTTP_BODY_LIMIT_BYTES", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_MAX_FILES_PER_MESSAGE", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_MAX_FILE_BYTES", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_HTTP_REQUESTS_PER_MINUTE", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_FEISHU_EVENTS_PER_MINUTE", problems);
|
||||
validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems);
|
||||
|
||||
if (problems.length > 0) {
|
||||
@@ -147,6 +160,14 @@ function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, probl
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const value = readRequired(env, name, problems);
|
||||
if (value === undefined) return;
|
||||
if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
|
||||
problems.push(`${name} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const raw = env[name]?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === "") return;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { readSiloOrganizationId, requireSiloOrganization } from "./silo.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const keyringFile = parseArgs(process.argv.slice(2));
|
||||
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
||||
const workspaceRoot = process.env["HUB_PROJECT_WORKSPACE_ROOT"]?.trim();
|
||||
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required");
|
||||
const workspace = await realpath(workspaceRoot);
|
||||
if (!(await stat(workspace)).isDirectory()) throw new Error("restored workspace root is not a directory");
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
||||
try {
|
||||
const organization = await requireSiloOrganization(prisma, readSiloOrganizationId());
|
||||
const provider = await verifyStoredProviderEnvelopes(prisma, secrets);
|
||||
const feishu = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
|
||||
const [activeProviders, activeFeishu] = await Promise.all([
|
||||
prisma.organizationProviderConnection.count({
|
||||
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
|
||||
}),
|
||||
prisma.organizationFeishuApplicationConnection.count({
|
||||
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
|
||||
}),
|
||||
]);
|
||||
if (activeProviders < 1) throw new Error("restored Silo has no ACTIVE Provider Connection");
|
||||
if (activeFeishu !== 1) throw new Error(`restored Silo must have one ACTIVE Feishu Connection; found ${activeFeishu}`);
|
||||
console.log(JSON.stringify({ organizationId: organization.id, providerEnvelopes: provider, feishuEnvelopes: feishu }));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): string {
|
||||
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
|
||||
throw new Error("usage: restore-preflight --keyring-file /root/recovery/secret-keyring.json");
|
||||
}
|
||||
return argv[1];
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(`[restore-preflight] failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { PrismaClient } from "@prisma/client";
|
||||
import { rotateLocalKek } from "../security/localKekRotation.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SERVICE_UNIT = "cph-hub.service";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.getuid?.() !== 0) {
|
||||
@@ -30,22 +29,31 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
async function requireStoppedService(): Promise<void> {
|
||||
const serviceUnit = readServiceUnit();
|
||||
let activeState: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"systemctl",
|
||||
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
|
||||
["show", "--property=ActiveState", "--value", serviceUnit],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
activeState = stdout.trim();
|
||||
} catch {
|
||||
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`);
|
||||
throw new Error(`cannot verify that ${serviceUnit} is stopped`);
|
||||
}
|
||||
if (activeState !== "inactive" && activeState !== "failed") {
|
||||
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
|
||||
throw new Error(`${serviceUnit} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readServiceUnit(): string {
|
||||
const serviceUnit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
|
||||
if (serviceUnit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
|
||||
throw new Error("HUB_SYSTEMD_UNIT must name this Silo unit (cph-hub-<instance>.service)");
|
||||
}
|
||||
return serviceUnit;
|
||||
}
|
||||
|
||||
function parseKeyringFile(argv: readonly string[]): string {
|
||||
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
|
||||
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface SiloOrganization {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the deployment-pinned Organization identity. A Silo process must never
|
||||
* infer its tenant from request data or from whichever row happens to exist.
|
||||
*/
|
||||
export function readSiloOrganizationId(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): string {
|
||||
const organizationId = env["HUB_SILO_ORGANIZATION_ID"]?.trim();
|
||||
if (organizationId === undefined || organizationId === "") {
|
||||
throw new Error("HUB_SILO_ORGANIZATION_ID is required");
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the database is a single-tenant Silo before accepting traffic.
|
||||
* Archived rows count too: pointing a Silo process at a pooled or reused
|
||||
* database is a deployment error, not a condition to paper over.
|
||||
*/
|
||||
export async function requireSiloOrganization(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<SiloOrganization> {
|
||||
const [count, organization] = await Promise.all([
|
||||
prisma.organization.count(),
|
||||
prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { id: true, slug: true, name: true, status: true },
|
||||
}),
|
||||
]);
|
||||
if (count !== 1) {
|
||||
throw new Error(`Silo database must contain exactly one Organization; found ${count}`);
|
||||
}
|
||||
if (organization === null) {
|
||||
throw new Error(
|
||||
`Silo Organization mismatch: configured ${organizationId} is not the sole database Organization`,
|
||||
);
|
||||
}
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new Error(`Silo Organization ${organizationId} is ${organization.status}`);
|
||||
}
|
||||
return { id: organization.id, slug: organization.slug, name: organization.name };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class SiloFixedWindowRateLimiter {
|
||||
private windowStartedAt: number;
|
||||
private used = 0;
|
||||
|
||||
constructor(readonly limit: number, readonly windowMs: number, now = Date.now()) {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("rate limit must be a positive integer");
|
||||
if (!Number.isSafeInteger(windowMs) || windowMs <= 0) throw new Error("rate window must be a positive integer");
|
||||
this.windowStartedAt = now;
|
||||
}
|
||||
|
||||
consume(now = Date.now()): { readonly allowed: true } | { readonly allowed: false; readonly retryAfterSeconds: number } {
|
||||
if (now >= this.windowStartedAt + this.windowMs) {
|
||||
this.windowStartedAt = now;
|
||||
this.used = 0;
|
||||
}
|
||||
if (this.used >= this.limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((this.windowStartedAt + this.windowMs - now) / 1000)),
|
||||
};
|
||||
}
|
||||
this.used += 1;
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user