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.
- package/package.json +4 -4
- package/src/app.ts +57 -1
- package/src/context.ts +3 -0
- package/src/lib/agent-config.ts +10 -1
- package/src/lib/git-runtime.ts +447 -0
- package/src/lib/instance-paths.ts +75 -10
- package/src/lib/workspace-policy.ts +153 -10
- package/src/middleware/request-actor.ts +67 -2
- package/src/realtime/hub.ts +31 -2
- package/src/routes/agents.ts +146 -107
- package/src/routes/auth.ts +54 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +8 -0
- package/src/routes/issues.ts +23 -10
- package/src/routes/projects.ts +361 -63
- package/src/routes/templates.ts +439 -0
- package/src/scripts/backfill-project-workspaces.ts +61 -24
- package/src/scripts/db-init.ts +7 -1
- package/src/scripts/onboard-seed.ts +140 -12
- package/src/security/actor-token.ts +133 -0
- package/src/security/deployment-mode.ts +73 -0
- package/src/server.ts +72 -4
- package/src/services/governance-service.ts +122 -15
- package/src/services/heartbeat-service.ts +136 -36
- package/src/services/plugin-runtime.ts +2 -2
- package/src/services/template-apply-service.ts +138 -0
- package/src/services/template-catalog.ts +325 -0
- package/src/services/template-preview-service.ts +78 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
` -
|
|
191
|
-
` -
|
|
192
|
-
` -
|
|
193
|
-
` -
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|