bopodev-api 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,38 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { mkdir } from "node:fs/promises";
3
+ import { TemplateManifestDefault, TemplateManifestSchema } from "bopodev-contracts";
3
4
  import { getAdapterModels } from "bopodev-agent-sdk";
4
5
  import {
5
6
  bootstrapDatabase,
7
+ createProjectWorkspace,
6
8
  createAgent,
9
+ getCurrentTemplateVersion,
10
+ getTemplate,
11
+ getTemplateBySlug,
7
12
  createCompany,
8
13
  createIssue,
9
14
  createProject,
10
15
  listAgents,
11
16
  listCompanies,
12
17
  listIssues,
18
+ listProjectWorkspaces,
13
19
  listProjects,
20
+ updateProjectWorkspace,
14
21
  updateIssue,
15
22
  updateAgent,
16
23
  updateCompany
17
24
  } from "bopodev-db";
18
25
  import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
26
+ import {
27
+ normalizeAbsolutePath,
28
+ normalizeCompanyWorkspacePath,
29
+ resolveAgentFallbackWorkspacePath,
30
+ resolveProjectWorkspacePath
31
+ } from "../lib/instance-paths";
19
32
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
20
33
  import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
34
+ import { applyTemplateManifest } from "../services/template-apply-service";
35
+ import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
21
36
 
22
37
  export interface OnboardSeedSummary {
23
38
  companyId: string;
@@ -25,12 +40,17 @@ export interface OnboardSeedSummary {
25
40
  companyCreated: boolean;
26
41
  ceoCreated: boolean;
27
42
  ceoProviderType: AgentProvider;
43
+ ceoRuntimeModel: string | null;
28
44
  ceoMigrated: boolean;
45
+ templateApplied: boolean;
46
+ templateId: string | null;
29
47
  }
30
48
 
31
49
  const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
32
50
  const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
33
51
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
52
+ const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
53
+ const DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
34
54
  type AgentProvider = "codex" | "claude_code" | "cursor" | "gemini_cli" | "opencode" | "openai_api" | "anthropic_api" | "shell";
35
55
  const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
36
56
  const STARTUP_PROJECT_NAME = "Leadership Setup";
@@ -42,12 +62,15 @@ export async function ensureOnboardingSeed(input: {
42
62
  companyName: string;
43
63
  companyId?: string;
44
64
  agentProvider?: AgentProvider;
65
+ agentModel?: string;
66
+ templateId?: string;
45
67
  }): Promise<OnboardSeedSummary> {
46
68
  const companyName = input.companyName.trim();
47
69
  if (companyName.length === 0) {
48
70
  throw new Error("BOPO_DEFAULT_COMPANY_NAME is required for onboarding seed.");
49
71
  }
50
72
  const agentProvider = parseAgentProvider(input.agentProvider) ?? "shell";
73
+ const requestedAgentModel = input.agentModel?.trim() || undefined;
51
74
 
52
75
  const { db, client } = await bootstrapDatabase(input.dbPath);
53
76
 
@@ -73,14 +96,16 @@ export async function ensureOnboardingSeed(input: {
73
96
 
74
97
  const companyId = companyRow.id;
75
98
  const resolvedCompanyName = companyRow.name;
99
+ await ensureCompanyBuiltinTemplateDefaults(db, companyId);
76
100
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
77
- await mkdir(defaultRuntimeCwd, { recursive: true });
101
+ await mkdir(normalizeCompanyWorkspacePath(companyId, defaultRuntimeCwd), { recursive: true });
78
102
  const seedRuntimeEnv = resolveSeedRuntimeEnv(agentProvider);
79
103
  const defaultRuntimeConfig = normalizeRuntimeConfig({
80
104
  defaultRuntimeCwd,
81
105
  runtimeConfig: {
82
106
  runtimeEnv: seedRuntimeEnv,
83
107
  runtimeModel: await resolveSeedRuntimeModel(agentProvider, {
108
+ requestedModel: requestedAgentModel,
84
109
  defaultRuntimeCwd,
85
110
  runtimeEnv: seedRuntimeEnv
86
111
  })
@@ -91,6 +116,7 @@ export async function ensureOnboardingSeed(input: {
91
116
  let ceoCreated = false;
92
117
  let ceoMigrated = false;
93
118
  let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
119
+ let ceoRuntimeModel = existingCeo?.runtimeModel ?? null;
94
120
 
95
121
  let ceoId = existingCeo?.id ?? null;
96
122
 
@@ -109,6 +135,7 @@ export async function ensureOnboardingSeed(input: {
109
135
  ceoId = ceo.id;
110
136
  ceoCreated = true;
111
137
  ceoProviderType = agentProvider;
138
+ ceoRuntimeModel = ceo.runtimeModel ?? defaultRuntimeConfig.runtimeModel ?? null;
112
139
  } else if (isBootstrapCeoRuntime(existingCeo.providerType, existingCeo.stateBlob)) {
113
140
  const nextState = {
114
141
  ...stripRuntimeFromState(existingCeo.stateBlob),
@@ -124,8 +151,10 @@ export async function ensureOnboardingSeed(input: {
124
151
  ceoMigrated = true;
125
152
  ceoProviderType = agentProvider;
126
153
  ceoId = existingCeo.id;
154
+ ceoRuntimeModel = defaultRuntimeConfig.runtimeModel ?? null;
127
155
  } else {
128
156
  ceoId = existingCeo.id;
157
+ ceoRuntimeModel = existingCeo.runtimeModel ?? null;
129
158
  }
130
159
 
131
160
  if (ceoId) {
@@ -136,6 +165,39 @@ export async function ensureOnboardingSeed(input: {
136
165
  ceoId
137
166
  });
138
167
  }
168
+ let templateApplied = false;
169
+ let appliedTemplateId: string | null = null;
170
+ if (input.templateId?.trim()) {
171
+ const requestedTemplateId = input.templateId.trim();
172
+ const template =
173
+ (await getTemplate(db, companyId, requestedTemplateId)) ??
174
+ (await getTemplateBySlug(db, companyId, requestedTemplateId));
175
+ if (template) {
176
+ const templateVersion = await getCurrentTemplateVersion(db, companyId, template.id);
177
+ if (templateVersion) {
178
+ let manifest: Record<string, unknown> = {};
179
+ try {
180
+ manifest = JSON.parse(templateVersion.manifestJson) as Record<string, unknown>;
181
+ } catch {
182
+ manifest = {};
183
+ }
184
+ const parsedManifest = TemplateManifestSchema.safeParse(manifest);
185
+ const normalizedManifest = parsedManifest.success
186
+ ? parsedManifest.data
187
+ : TemplateManifestSchema.parse(TemplateManifestDefault);
188
+ const applied = await applyTemplateManifest(db, {
189
+ companyId,
190
+ templateId: template.id,
191
+ templateVersion: templateVersion.version,
192
+ templateVersionId: templateVersion.id,
193
+ manifest: normalizedManifest,
194
+ variables: {}
195
+ });
196
+ templateApplied = applied.applied;
197
+ appliedTemplateId = template.id;
198
+ }
199
+ }
200
+ }
139
201
  await ensureCompanyModelPricingDefaults(db, companyId);
140
202
 
141
203
  return {
@@ -144,7 +206,10 @@ export async function ensureOnboardingSeed(input: {
144
206
  companyCreated,
145
207
  ceoCreated,
146
208
  ceoProviderType,
147
- ceoMigrated
209
+ ceoRuntimeModel,
210
+ ceoMigrated,
211
+ templateApplied,
212
+ templateId: appliedTemplateId
148
213
  };
149
214
  } finally {
150
215
  const maybeClose = (client as { close?: () => Promise<void> }).close;
@@ -158,6 +223,7 @@ async function ensureStartupProject(db: Awaited<ReturnType<typeof bootstrapDatab
158
223
  const projects = await listProjects(db, companyId);
159
224
  const existing = projects.find((project) => project.name === STARTUP_PROJECT_NAME);
160
225
  if (existing) {
226
+ await ensureProjectPrimaryWorkspace(db, companyId, existing.id, STARTUP_PROJECT_NAME);
161
227
  return existing.id;
162
228
  }
163
229
  const created = await createProject(db, {
@@ -165,9 +231,54 @@ async function ensureStartupProject(db: Awaited<ReturnType<typeof bootstrapDatab
165
231
  name: STARTUP_PROJECT_NAME,
166
232
  description: "Initial leadership onboarding and operating setup."
167
233
  });
234
+ if (!created) {
235
+ throw new Error("Failed to create startup project.");
236
+ }
237
+ await ensureProjectPrimaryWorkspace(db, companyId, created.id, STARTUP_PROJECT_NAME);
168
238
  return created.id;
169
239
  }
170
240
 
241
+ async function ensureProjectPrimaryWorkspace(
242
+ db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
243
+ companyId: string,
244
+ projectId: string,
245
+ projectName: string
246
+ ) {
247
+ const existingWorkspaces = await listProjectWorkspaces(db, companyId, projectId);
248
+ const existingPrimary = existingWorkspaces.find((workspace) => workspace.isPrimary);
249
+ if (existingPrimary) {
250
+ if (existingPrimary.cwd) {
251
+ await mkdir(normalizeCompanyWorkspacePath(companyId, existingPrimary.cwd), { recursive: true });
252
+ }
253
+ return existingPrimary;
254
+ }
255
+ const defaultWorkspaceCwd = resolveProjectWorkspacePath(companyId, projectId);
256
+ await mkdir(defaultWorkspaceCwd, { recursive: true });
257
+ const fallbackWorkspace = existingWorkspaces[0];
258
+ if (fallbackWorkspace) {
259
+ const normalizedCwd = fallbackWorkspace.cwd?.trim()
260
+ ? normalizeCompanyWorkspacePath(companyId, fallbackWorkspace.cwd)
261
+ : defaultWorkspaceCwd;
262
+ if (normalizedCwd) {
263
+ await mkdir(normalizedCwd, { recursive: true });
264
+ }
265
+ return updateProjectWorkspace(db, {
266
+ companyId,
267
+ projectId,
268
+ id: fallbackWorkspace.id,
269
+ cwd: normalizedCwd,
270
+ isPrimary: true
271
+ });
272
+ }
273
+ return createProjectWorkspace(db, {
274
+ companyId,
275
+ projectId,
276
+ name: projectName,
277
+ cwd: defaultWorkspaceCwd,
278
+ isPrimary: true
279
+ });
280
+ }
281
+
171
282
  async function ensureCeoStartupTask(
172
283
  db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
173
284
  input: { companyId: string; projectId: string; ceoId: string }
@@ -180,22 +291,25 @@ async function ensureCeoStartupTask(
180
291
  typeof issue.body === "string" &&
181
292
  issue.body.includes(CEO_STARTUP_TASK_MARKER)
182
293
  );
294
+ const ceoWorkspaceRoot = resolveAgentFallbackWorkspacePath(input.companyId, input.ceoId);
295
+ const ceoOperatingFolder = `${ceoWorkspaceRoot}/operating`;
296
+ const ceoTmpFolder = `${ceoWorkspaceRoot}/tmp`;
183
297
  const body = [
184
298
  CEO_STARTUP_TASK_MARKER,
185
299
  "",
186
300
  "Stand up your leadership operating baseline before taking on additional delivery work.",
187
301
  "",
188
- `1. Create the folder \`agents/${input.ceoId}/\` in the repository workspace.`,
302
+ `1. Create your operating folder at \`${ceoOperatingFolder}/\` (system path, outside project workspaces).`,
189
303
  "2. Author these files with your own voice and responsibilities:",
190
- ` - \`agents/${input.ceoId}/AGENTS.md\``,
191
- ` - \`agents/${input.ceoId}/HEARTBEAT.md\``,
192
- ` - \`agents/${input.ceoId}/SOUL.md\``,
193
- ` - \`agents/${input.ceoId}/TOOLS.md\``,
304
+ ` - \`${ceoOperatingFolder}/AGENTS.md\``,
305
+ ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
306
+ ` - \`${ceoOperatingFolder}/SOUL.md\``,
307
+ ` - \`${ceoOperatingFolder}/TOOLS.md\``,
194
308
  "3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
195
- ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: agents/${input.ceoId}/AGENTS.md ..." }\``,
309
+ ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: ${ceoOperatingFolder}/AGENTS.md ..." }\``,
196
310
  " - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
197
311
  " - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
198
- ` - If you must use payload files, store them in \`agents/${input.ceoId}/tmp/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
312
+ ` - If you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
199
313
  "4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
200
314
  " - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
201
315
  " - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role,bootstrapPrompt}'`",
@@ -207,6 +321,7 @@ async function ensureCeoStartupTask(
207
321
  "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
208
322
  "",
209
323
  "Safety checks before requesting hire:",
324
+ "- Do not write operating/system files under any project workspace folder.",
210
325
  "- Do not request duplicates if a Founding Engineer already exists.",
211
326
  "- Do not request duplicates if a pending approval for the same role is already open.",
212
327
  "- For control-plane calls, prefer direct header env vars (`BOPODEV_COMPANY_ID`, `BOPODEV_ACTOR_TYPE`, `BOPODEV_ACTOR_ID`, `BOPODEV_ACTOR_COMPANIES`, `BOPODEV_ACTOR_PERMISSIONS`) instead of parsing `BOPODEV_REQUEST_HEADERS_JSON`.",
@@ -242,11 +357,16 @@ async function main() {
242
357
  const companyName = process.env[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
243
358
  const companyId = process.env[DEFAULT_COMPANY_ID_ENV]?.trim() || undefined;
244
359
  const agentProvider = parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ?? undefined;
360
+ const agentModel = process.env[DEFAULT_AGENT_MODEL_ENV]?.trim() || undefined;
361
+ const templateId = process.env[DEFAULT_TEMPLATE_ENV]?.trim() || undefined;
362
+ const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
245
363
  const result = await ensureOnboardingSeed({
246
- dbPath: process.env.BOPO_DB_PATH,
364
+ dbPath,
247
365
  companyName,
248
366
  companyId,
249
- agentProvider
367
+ agentProvider,
368
+ agentModel,
369
+ templateId
250
370
  });
251
371
  // eslint-disable-next-line no-console
252
372
  console.log(JSON.stringify(result));
@@ -256,6 +376,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
256
376
  void main();
257
377
  }
258
378
 
379
+ function normalizeOptionalDbPath(value: string | undefined) {
380
+ const normalized = value?.trim();
381
+ return normalized && normalized.length > 0 ? normalizeAbsolutePath(normalized, { requireAbsoluteInput: true }) : undefined;
382
+ }
383
+
259
384
  function parseAgentProvider(value: unknown): AgentProvider | null {
260
385
  if (
261
386
  value === "codex" ||
@@ -296,8 +421,11 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
296
421
 
297
422
  async function resolveSeedRuntimeModel(
298
423
  agentProvider: AgentProvider,
299
- input: { defaultRuntimeCwd: string; runtimeEnv: Record<string, string> }
424
+ input: { requestedModel?: string; defaultRuntimeCwd: string; runtimeEnv: Record<string, string> }
300
425
  ): Promise<string | undefined> {
426
+ if (input.requestedModel) {
427
+ return input.requestedModel;
428
+ }
301
429
  if (agentProvider !== "opencode") {
302
430
  return undefined;
303
431
  }
@@ -0,0 +1,133 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ type ActorType = "board" | "member" | "agent";
4
+
5
+ export type ActorTokenPayload = {
6
+ v: 1;
7
+ iat: number;
8
+ exp: number;
9
+ actorType: ActorType;
10
+ actorId: string;
11
+ actorCompanies: string[] | null;
12
+ actorPermissions: string[];
13
+ };
14
+
15
+ export type ActorIdentity = {
16
+ type: ActorType;
17
+ id: string;
18
+ companyIds: string[] | null;
19
+ permissions: string[];
20
+ };
21
+
22
+ export function issueActorToken(
23
+ input: {
24
+ actorType: ActorType;
25
+ actorId: string;
26
+ actorCompanies: string[] | null;
27
+ actorPermissions: string[];
28
+ ttlSec?: number;
29
+ },
30
+ secret: string
31
+ ) {
32
+ const nowSec = Math.floor(Date.now() / 1000);
33
+ const ttlSec = Math.max(60, Math.min(86_400, input.ttlSec ?? 3_600));
34
+ const payload: ActorTokenPayload = {
35
+ v: 1,
36
+ iat: nowSec,
37
+ exp: nowSec + ttlSec,
38
+ actorType: input.actorType,
39
+ actorId: input.actorId.trim(),
40
+ actorCompanies: input.actorType === "board" ? null : input.actorCompanies ?? [],
41
+ actorPermissions: dedupe(input.actorPermissions)
42
+ };
43
+ const encodedPayload = encodeBase64Url(JSON.stringify(payload));
44
+ const signature = sign(encodedPayload, secret);
45
+ return `${encodedPayload}.${signature}`;
46
+ }
47
+
48
+ export function verifyActorToken(token: string | undefined | null, secret: string): ActorIdentity | null {
49
+ const trimmed = token?.trim();
50
+ if (!trimmed) {
51
+ return null;
52
+ }
53
+ const [encodedPayload, encodedSignature] = trimmed.split(".");
54
+ if (!encodedPayload || !encodedSignature) {
55
+ return null;
56
+ }
57
+ const expectedSignature = sign(encodedPayload, secret);
58
+ if (!safeStringEqual(expectedSignature, encodedSignature)) {
59
+ return null;
60
+ }
61
+ const decoded = decodeBase64Url(encodedPayload);
62
+ if (!decoded) {
63
+ return null;
64
+ }
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(decoded);
68
+ } catch {
69
+ return null;
70
+ }
71
+ if (!parsed || typeof parsed !== "object") {
72
+ return null;
73
+ }
74
+ const payload = parsed as Partial<ActorTokenPayload>;
75
+ if (payload.v !== 1 || typeof payload.exp !== "number" || typeof payload.iat !== "number") {
76
+ return null;
77
+ }
78
+ const nowSec = Math.floor(Date.now() / 1000);
79
+ if (payload.exp < nowSec) {
80
+ return null;
81
+ }
82
+ if (payload.actorType !== "board" && payload.actorType !== "member" && payload.actorType !== "agent") {
83
+ return null;
84
+ }
85
+ if (typeof payload.actorId !== "string" || payload.actorId.trim().length === 0) {
86
+ return null;
87
+ }
88
+ const companyIds =
89
+ payload.actorType === "board"
90
+ ? null
91
+ : Array.isArray(payload.actorCompanies)
92
+ ? payload.actorCompanies.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
93
+ : [];
94
+ const permissions = Array.isArray(payload.actorPermissions)
95
+ ? payload.actorPermissions.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
96
+ : [];
97
+ return {
98
+ type: payload.actorType,
99
+ id: payload.actorId.trim(),
100
+ companyIds,
101
+ permissions: dedupe(permissions)
102
+ };
103
+ }
104
+
105
+ function sign(encodedPayload: string, secret: string) {
106
+ const digest = createHmac("sha256", secret).update(encodedPayload).digest("base64url");
107
+ return digest;
108
+ }
109
+
110
+ function encodeBase64Url(value: string) {
111
+ return Buffer.from(value, "utf8").toString("base64url");
112
+ }
113
+
114
+ function decodeBase64Url(value: string) {
115
+ try {
116
+ return Buffer.from(value, "base64url").toString("utf8");
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function safeStringEqual(left: string, right: string) {
123
+ const leftBuffer = Buffer.from(left);
124
+ const rightBuffer = Buffer.from(right);
125
+ if (leftBuffer.length !== rightBuffer.length) {
126
+ return false;
127
+ }
128
+ return timingSafeEqual(leftBuffer, rightBuffer);
129
+ }
130
+
131
+ function dedupe(values: string[]) {
132
+ return Array.from(new Set(values.map((entry) => entry.trim()).filter(Boolean)));
133
+ }
@@ -0,0 +1,73 @@
1
+ import { URL } from "node:url";
2
+
3
+ export type DeploymentMode = "local" | "authenticated_private" | "authenticated_public";
4
+
5
+ const VALID_MODES = new Set<DeploymentMode>(["local", "authenticated_private", "authenticated_public"]);
6
+
7
+ export function resolveDeploymentMode(raw = process.env.BOPO_DEPLOYMENT_MODE): DeploymentMode {
8
+ const normalized = raw?.trim() ?? "";
9
+ if (!normalized) {
10
+ return "local";
11
+ }
12
+ if (VALID_MODES.has(normalized as DeploymentMode)) {
13
+ return normalized as DeploymentMode;
14
+ }
15
+ throw new Error(
16
+ `Invalid BOPO_DEPLOYMENT_MODE '${normalized}'. Expected one of: local, authenticated_private, authenticated_public.`
17
+ );
18
+ }
19
+
20
+ export function isAuthenticatedMode(mode: DeploymentMode) {
21
+ return mode === "authenticated_private" || mode === "authenticated_public";
22
+ }
23
+
24
+ export function resolveAllowedOrigins(mode: DeploymentMode, raw = process.env.BOPO_ALLOWED_ORIGINS) {
25
+ if (mode === "local") {
26
+ return ["http://localhost:4010", "http://127.0.0.1:4010"] as string[];
27
+ }
28
+ const parsed = parseCommaList(raw);
29
+ if (parsed.length === 0) {
30
+ throw new Error(
31
+ `BOPO_ALLOWED_ORIGINS is required in ${mode} mode. Example: https://bopo.example.com,https://admin.example.com`
32
+ );
33
+ }
34
+ return parsed;
35
+ }
36
+
37
+ export function resolvePublicBaseUrl(raw = process.env.BOPO_PUBLIC_BASE_URL) {
38
+ const value = raw?.trim() ?? "";
39
+ if (!value) {
40
+ return null;
41
+ }
42
+ try {
43
+ return new URL(value);
44
+ } catch {
45
+ throw new Error(`BOPO_PUBLIC_BASE_URL must be a valid URL. Received: '${value}'.`);
46
+ }
47
+ }
48
+
49
+ export function resolveAllowedHostnames(mode: DeploymentMode, raw = process.env.BOPO_ALLOWED_HOSTNAMES) {
50
+ const hostnames = new Set(parseCommaList(raw));
51
+ const publicUrl = resolvePublicBaseUrl();
52
+ if (publicUrl?.hostname) {
53
+ hostnames.add(publicUrl.hostname);
54
+ }
55
+ if (mode === "local" && hostnames.size === 0) {
56
+ hostnames.add("localhost");
57
+ hostnames.add("127.0.0.1");
58
+ }
59
+ if (isAuthenticatedMode(mode) && hostnames.size === 0) {
60
+ throw new Error(`BOPO_ALLOWED_HOSTNAMES is required in ${mode} mode.`);
61
+ }
62
+ return Array.from(hostnames);
63
+ }
64
+
65
+ export function parseCommaList(value: string | undefined | null) {
66
+ if (!value) {
67
+ return [];
68
+ }
69
+ return value
70
+ .split(",")
71
+ .map((entry) => entry.trim())
72
+ .filter((entry) => entry.length > 0);
73
+ }
package/src/server.ts CHANGED
@@ -11,13 +11,26 @@ import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
11
11
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
12
12
  import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
13
13
  import { attachRealtimeHub } from "./realtime/hub";
14
+ import {
15
+ isAuthenticatedMode,
16
+ resolveAllowedHostnames,
17
+ resolveAllowedOrigins,
18
+ resolveDeploymentMode,
19
+ resolvePublicBaseUrl
20
+ } from "./security/deployment-mode";
14
21
  import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
22
+ import { ensureBuiltinTemplatesRegistered } from "./services/template-catalog";
15
23
  import { createHeartbeatScheduler } from "./worker/scheduler";
16
24
 
17
25
  loadApiEnv();
18
26
 
19
27
  async function main() {
20
- const dbPath = process.env.BOPO_DB_PATH;
28
+ const deploymentMode = resolveDeploymentMode();
29
+ const allowedOrigins = resolveAllowedOrigins(deploymentMode);
30
+ const allowedHostnames = resolveAllowedHostnames(deploymentMode);
31
+ const publicBaseUrl = resolvePublicBaseUrl();
32
+ validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
33
+ const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
21
34
  const port = Number(process.env.PORT ?? 4020);
22
35
  const { db } = await bootstrapDatabase(dbPath);
23
36
  const existingCompanies = await listCompanies(db);
@@ -25,6 +38,10 @@ async function main() {
25
38
  db,
26
39
  existingCompanies.map((company) => company.id)
27
40
  );
41
+ await ensureBuiltinTemplatesRegistered(
42
+ db,
43
+ existingCompanies.map((company) => company.id)
44
+ );
28
45
  const codexCommand = process.env.BOPO_CODEX_COMMAND ?? "codex";
29
46
  const openCodeCommand = process.env.BOPO_OPENCODE_COMMAND ?? "opencode";
30
47
  const skipCodexPreflight = process.env.BOPO_SKIP_CODEX_PREFLIGHT === "1";
@@ -92,17 +109,20 @@ async function main() {
92
109
  "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
93
110
  }
94
111
  });
95
- const app = createApp({ db, getRuntimeHealth, realtimeHub });
112
+ const app = createApp({ db, deploymentMode, allowedOrigins, getRuntimeHealth, realtimeHub });
96
113
  server.on("request", app);
97
114
  server.listen(port, () => {
98
115
  // eslint-disable-next-line no-console
99
- console.log(`BopoDev API running on http://localhost:${port}`);
116
+ console.log(`BopoDev API running in ${deploymentMode} mode on port ${port}`);
100
117
  });
101
118
 
102
119
  const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
103
120
  const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
104
- if (schedulerCompanyId) {
121
+ if (schedulerCompanyId && shouldStartScheduler()) {
105
122
  createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
123
+ } else if (schedulerCompanyId) {
124
+ // eslint-disable-next-line no-console
125
+ console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
106
126
  }
107
127
  }
108
128
 
@@ -184,6 +204,49 @@ function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
184
204
  }
185
205
  }
186
206
 
207
+ function validateDeploymentConfiguration(
208
+ deploymentMode: ReturnType<typeof resolveDeploymentMode>,
209
+ allowedOrigins: string[],
210
+ allowedHostnames: string[],
211
+ publicBaseUrl: URL | null
212
+ ) {
213
+ if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
214
+ throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
215
+ }
216
+ if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
217
+ throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
218
+ }
219
+ if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
220
+ // eslint-disable-next-line no-console
221
+ console.warn(
222
+ "[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
223
+ );
224
+ }
225
+ if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
226
+ throw new Error(
227
+ "Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
228
+ );
229
+ }
230
+ if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
231
+ throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
232
+ }
233
+ // eslint-disable-next-line no-console
234
+ console.log(
235
+ `[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
236
+ );
237
+ }
238
+
239
+ function shouldStartScheduler() {
240
+ const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
241
+ if (rawRole === "off" || rawRole === "follower") {
242
+ return false;
243
+ }
244
+ if (rawRole === "leader" || rawRole === "auto") {
245
+ return true;
246
+ }
247
+ throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
248
+ }
249
+
187
250
  function loadApiEnv() {
188
251
  const sourceDir = dirname(fileURLToPath(import.meta.url));
189
252
  const repoRoot = resolve(sourceDir, "../../../");
@@ -192,3 +255,8 @@ function loadApiEnv() {
192
255
  loadDotenv({ path, override: false, quiet: true });
193
256
  }
194
257
  }
258
+
259
+ function normalizeOptionalDbPath(value: string | undefined) {
260
+ const normalized = value?.trim();
261
+ return normalized && normalized.length > 0 ? normalized : undefined;
262
+ }