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
|
@@ -18,6 +18,11 @@ function normalizePath(raw: string) {
|
|
|
18
18
|
return resolve(expandHomePrefix(raw.trim()));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type NormalizeAbsolutePathOptions = {
|
|
22
|
+
requireAbsoluteInput?: boolean;
|
|
23
|
+
baseDir?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
21
26
|
export function resolveBopoHomeDir() {
|
|
22
27
|
const configured = process.env.BOPO_HOME?.trim();
|
|
23
28
|
if (configured) {
|
|
@@ -46,20 +51,27 @@ export function resolveBopoInstanceRoot() {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export function resolveProjectWorkspacePath(companyId: string, projectId: string) {
|
|
49
|
-
assertPathSegment(companyId, "companyId");
|
|
50
|
-
assertPathSegment(projectId, "projectId");
|
|
51
|
-
return join(resolveBopoInstanceRoot(), "workspaces",
|
|
54
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
55
|
+
const safeProjectId = assertPathSegment(projectId, "projectId");
|
|
56
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "projects", safeProjectId);
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export function resolveCompanyProjectsWorkspacePath(companyId: string) {
|
|
55
|
-
assertPathSegment(companyId, "companyId");
|
|
56
|
-
return join(resolveBopoInstanceRoot(), "workspaces",
|
|
60
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
61
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: string) {
|
|
60
|
-
assertPathSegment(companyId, "companyId");
|
|
61
|
-
assertPathSegment(agentId, "agentId");
|
|
62
|
-
return join(resolveBopoInstanceRoot(), "workspaces",
|
|
65
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
66
|
+
const safeAgentId = assertPathSegment(agentId, "agentId");
|
|
67
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "agents", safeAgentId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveAgentProjectWorktreeRootPath(companyId: string, agentId: string, projectId: string) {
|
|
71
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
72
|
+
const safeAgentId = assertPathSegment(agentId, "agentId");
|
|
73
|
+
const safeProjectId = assertPathSegment(projectId, "projectId");
|
|
74
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "agents", safeAgentId, "worktrees", safeProjectId);
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
|
|
@@ -78,8 +90,19 @@ export function resolveStorageRoot() {
|
|
|
78
90
|
return join(resolveBopoInstanceRoot(), "data", "storage");
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
export function normalizeAbsolutePath(value: string) {
|
|
82
|
-
|
|
93
|
+
export function normalizeAbsolutePath(value: string, options?: NormalizeAbsolutePathOptions) {
|
|
94
|
+
const trimmed = value.trim();
|
|
95
|
+
const expanded = expandHomePrefix(trimmed);
|
|
96
|
+
if (options?.requireAbsoluteInput && !isAbsolute(expanded)) {
|
|
97
|
+
throw new Error(`Expected absolute path input, received '${value}'.`);
|
|
98
|
+
}
|
|
99
|
+
if (isAbsolute(expanded)) {
|
|
100
|
+
return resolve(expanded);
|
|
101
|
+
}
|
|
102
|
+
if (options?.baseDir) {
|
|
103
|
+
return resolve(options.baseDir, expanded);
|
|
104
|
+
}
|
|
105
|
+
return resolve(expanded);
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
export function isInsidePath(parent: string, child: string) {
|
|
@@ -92,9 +115,51 @@ export function isInsidePath(parent: string, child: string) {
|
|
|
92
115
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
93
116
|
}
|
|
94
117
|
|
|
118
|
+
export function resolveWorkspaceRootPath() {
|
|
119
|
+
return join(resolveBopoInstanceRoot(), "workspaces");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveCompanyWorkspaceRootPath(companyId: string) {
|
|
123
|
+
return resolveCompanyProjectsWorkspacePath(companyId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function assertPathInsidePath(parent: string, candidate: string, label = "path") {
|
|
127
|
+
const normalizedParent = normalizeAbsolutePath(parent, { requireAbsoluteInput: true });
|
|
128
|
+
const normalizedCandidate = normalizeAbsolutePath(candidate, { requireAbsoluteInput: true });
|
|
129
|
+
if (!isInsidePath(normalizedParent, normalizedCandidate)) {
|
|
130
|
+
throw new Error(`Invalid ${label} '${candidate}': must be inside '${normalizedParent}'.`);
|
|
131
|
+
}
|
|
132
|
+
return normalizedCandidate;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function assertPathInsideWorkspaceRoot(candidate: string, label = "path") {
|
|
136
|
+
return assertPathInsidePath(resolveWorkspaceRootPath(), candidate, label);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function assertPathInsideCompanyWorkspaceRoot(companyId: string, candidate: string, label = "path") {
|
|
140
|
+
return assertPathInsidePath(resolveCompanyWorkspaceRootPath(companyId), candidate, label);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function normalizeCompanyWorkspacePath(
|
|
144
|
+
companyId: string,
|
|
145
|
+
value: string,
|
|
146
|
+
options?: { requireAbsoluteInput?: boolean }
|
|
147
|
+
) {
|
|
148
|
+
const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(companyId);
|
|
149
|
+
const normalized = normalizeAbsolutePath(value, {
|
|
150
|
+
requireAbsoluteInput: options?.requireAbsoluteInput ?? false,
|
|
151
|
+
baseDir: companyWorkspaceRoot
|
|
152
|
+
});
|
|
153
|
+
return assertPathInsideCompanyWorkspaceRoot(companyId, normalized, "workspace path");
|
|
154
|
+
}
|
|
155
|
+
|
|
95
156
|
function assertPathSegment(value: string, label: string) {
|
|
96
157
|
const trimmed = value.trim();
|
|
158
|
+
if (trimmed !== value) {
|
|
159
|
+
throw new Error(`Invalid ${label} for workspace path '${value}'.`);
|
|
160
|
+
}
|
|
97
161
|
if (!SAFE_PATH_SEGMENT_RE.test(trimmed)) {
|
|
98
162
|
throw new Error(`Invalid ${label} for workspace path '${value}'.`);
|
|
99
163
|
}
|
|
164
|
+
return trimmed;
|
|
100
165
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { and, eq, inArray } from "drizzle-orm";
|
|
2
2
|
import type { BopoDb } from "bopodev-db";
|
|
3
|
-
import { projects } from "bopodev-db";
|
|
3
|
+
import { projectWorkspaces, projects } from "bopodev-db";
|
|
4
4
|
import {
|
|
5
|
+
assertPathInsideCompanyWorkspaceRoot,
|
|
5
6
|
isInsidePath,
|
|
7
|
+
normalizeCompanyWorkspacePath,
|
|
6
8
|
normalizeAbsolutePath,
|
|
7
9
|
resolveAgentFallbackWorkspacePath,
|
|
8
10
|
resolveAgentMemoryRootPath,
|
|
@@ -13,6 +15,24 @@ export function hasText(value: string | null | undefined) {
|
|
|
13
15
|
return Boolean(value && value.trim().length > 0);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
export type ExecutionWorkspaceMode = "project_primary" | "isolated" | "agent_default";
|
|
19
|
+
export type ExecutionWorkspaceStrategyType = "git_worktree";
|
|
20
|
+
export interface ProjectExecutionWorkspacePolicy {
|
|
21
|
+
mode?: ExecutionWorkspaceMode;
|
|
22
|
+
strategy?: {
|
|
23
|
+
type?: ExecutionWorkspaceStrategyType;
|
|
24
|
+
rootDir?: string | null;
|
|
25
|
+
branchPrefix?: string | null;
|
|
26
|
+
} | null;
|
|
27
|
+
credentials?: {
|
|
28
|
+
mode?: "host" | "env_token";
|
|
29
|
+
tokenEnvVar?: string | null;
|
|
30
|
+
username?: string | null;
|
|
31
|
+
} | null;
|
|
32
|
+
allowRemotes?: string[] | null;
|
|
33
|
+
allowBranchPrefixes?: string[] | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
export function parseRuntimeCwd(stateBlob: string | null | undefined) {
|
|
17
37
|
if (!stateBlob) {
|
|
18
38
|
return null;
|
|
@@ -28,18 +48,18 @@ export function parseRuntimeCwd(stateBlob: string | null | undefined) {
|
|
|
28
48
|
|
|
29
49
|
export async function inferSingleWorkspaceLocalPath(db: BopoDb, companyId: string) {
|
|
30
50
|
const rows = await db
|
|
31
|
-
.select({
|
|
32
|
-
.from(
|
|
33
|
-
.where(eq(
|
|
51
|
+
.select({ cwd: projectWorkspaces.cwd })
|
|
52
|
+
.from(projectWorkspaces)
|
|
53
|
+
.where(and(eq(projectWorkspaces.companyId, companyId), eq(projectWorkspaces.isPrimary, true)));
|
|
34
54
|
const paths = Array.from(
|
|
35
55
|
new Set(
|
|
36
56
|
rows
|
|
37
|
-
.map((row) => row.
|
|
57
|
+
.map((row) => row.cwd?.trim() ?? "")
|
|
38
58
|
.filter((value) => value.length > 0)
|
|
39
59
|
)
|
|
40
60
|
);
|
|
41
61
|
const singlePath = paths.length === 1 ? paths[0] : null;
|
|
42
|
-
return singlePath ?
|
|
62
|
+
return singlePath ? normalizeCompanyWorkspacePath(companyId, singlePath) : null;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
export async function resolveDefaultRuntimeCwdForCompany(db: BopoDb, companyId: string) {
|
|
@@ -51,14 +71,74 @@ export async function resolveDefaultRuntimeCwdForCompany(db: BopoDb, companyId:
|
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
export async function getProjectWorkspaceMap(db: BopoDb, companyId: string, projectIds: string[]) {
|
|
74
|
+
const context = await getProjectWorkspaceContextMap(db, companyId, projectIds);
|
|
75
|
+
return new Map(Array.from(context.entries()).map(([projectId, value]) => [projectId, value.cwd]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function getProjectWorkspaceContextMap(db: BopoDb, companyId: string, projectIds: string[]) {
|
|
54
79
|
if (projectIds.length === 0) {
|
|
55
|
-
return new Map<
|
|
80
|
+
return new Map<
|
|
81
|
+
string,
|
|
82
|
+
{
|
|
83
|
+
workspaceId: string | null;
|
|
84
|
+
workspaceName: string | null;
|
|
85
|
+
cwd: string | null;
|
|
86
|
+
repoUrl: string | null;
|
|
87
|
+
repoRef: string | null;
|
|
88
|
+
policy: ProjectExecutionWorkspacePolicy | null;
|
|
89
|
+
}
|
|
90
|
+
>();
|
|
56
91
|
}
|
|
57
|
-
const
|
|
58
|
-
.select({
|
|
92
|
+
const workspaceRows = await db
|
|
93
|
+
.select({
|
|
94
|
+
id: projectWorkspaces.id,
|
|
95
|
+
projectId: projectWorkspaces.projectId,
|
|
96
|
+
name: projectWorkspaces.name,
|
|
97
|
+
cwd: projectWorkspaces.cwd,
|
|
98
|
+
repoUrl: projectWorkspaces.repoUrl,
|
|
99
|
+
repoRef: projectWorkspaces.repoRef
|
|
100
|
+
})
|
|
101
|
+
.from(projectWorkspaces)
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(projectWorkspaces.companyId, companyId),
|
|
105
|
+
inArray(projectWorkspaces.projectId, projectIds),
|
|
106
|
+
eq(projectWorkspaces.isPrimary, true)
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
const projectRows = await db
|
|
110
|
+
.select({ id: projects.id, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
|
59
111
|
.from(projects)
|
|
60
112
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
|
|
61
|
-
|
|
113
|
+
|
|
114
|
+
const workspaceMap = new Map(
|
|
115
|
+
workspaceRows.map((row) => [row.projectId, row.cwd ? normalizeCompanyWorkspacePath(companyId, row.cwd) : null])
|
|
116
|
+
);
|
|
117
|
+
const workspaceByProject = new Map(workspaceRows.map((row) => [row.projectId, row]));
|
|
118
|
+
const policyMap = new Map(projectRows.map((row) => [row.id, parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy)]));
|
|
119
|
+
const result = new Map<
|
|
120
|
+
string,
|
|
121
|
+
{
|
|
122
|
+
workspaceId: string | null;
|
|
123
|
+
workspaceName: string | null;
|
|
124
|
+
cwd: string | null;
|
|
125
|
+
repoUrl: string | null;
|
|
126
|
+
repoRef: string | null;
|
|
127
|
+
policy: ProjectExecutionWorkspacePolicy | null;
|
|
128
|
+
}
|
|
129
|
+
>();
|
|
130
|
+
for (const projectId of projectIds) {
|
|
131
|
+
const workspace = workspaceByProject.get(projectId);
|
|
132
|
+
result.set(projectId, {
|
|
133
|
+
workspaceId: workspace?.id ?? null,
|
|
134
|
+
workspaceName: workspace?.name ?? null,
|
|
135
|
+
cwd: workspaceMap.get(projectId) ?? null,
|
|
136
|
+
repoUrl: workspace?.repoUrl?.trim() ? workspace.repoUrl.trim() : null,
|
|
137
|
+
repoRef: workspace?.repoRef?.trim() ? workspace.repoRef.trim() : null,
|
|
138
|
+
policy: policyMap.get(projectId) ?? null
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
62
142
|
}
|
|
63
143
|
|
|
64
144
|
export function ensureRuntimeInsideWorkspace(projectWorkspacePath: string, runtimeCwd: string) {
|
|
@@ -78,3 +158,66 @@ export function resolveAgentFallbackWorkspace(companyId: string, agentId: string
|
|
|
78
158
|
export function resolveAgentMemoryRoot(companyId: string, agentId: string) {
|
|
79
159
|
return resolveAgentMemoryRootPath(companyId, agentId);
|
|
80
160
|
}
|
|
161
|
+
|
|
162
|
+
export function parseProjectExecutionWorkspacePolicy(
|
|
163
|
+
value: string | Record<string, unknown> | null | undefined
|
|
164
|
+
): ProjectExecutionWorkspacePolicy | null {
|
|
165
|
+
if (!value) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const parsedValue =
|
|
169
|
+
typeof value === "string"
|
|
170
|
+
? (() => {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(value) as Record<string, unknown>;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
})()
|
|
177
|
+
: value;
|
|
178
|
+
if (!parsedValue || typeof parsedValue !== "object") {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const mode = parsedValue.mode;
|
|
182
|
+
const strategy = parsedValue.strategy as Record<string, unknown> | undefined;
|
|
183
|
+
const normalizedMode: ExecutionWorkspaceMode | undefined =
|
|
184
|
+
mode === "project_primary" || mode === "isolated" || mode === "agent_default" ? mode : undefined;
|
|
185
|
+
const normalizedStrategy =
|
|
186
|
+
strategy && typeof strategy === "object"
|
|
187
|
+
? {
|
|
188
|
+
type: strategy.type === "git_worktree" ? ("git_worktree" as const) : undefined,
|
|
189
|
+
rootDir: typeof strategy.rootDir === "string" ? strategy.rootDir : null,
|
|
190
|
+
branchPrefix: typeof strategy.branchPrefix === "string" ? strategy.branchPrefix : null
|
|
191
|
+
}
|
|
192
|
+
: null;
|
|
193
|
+
const credentials = parsedValue.credentials as Record<string, unknown> | undefined;
|
|
194
|
+
const normalizedCredentials =
|
|
195
|
+
credentials && typeof credentials === "object"
|
|
196
|
+
? {
|
|
197
|
+
mode:
|
|
198
|
+
credentials.mode === "host" || credentials.mode === "env_token"
|
|
199
|
+
? (credentials.mode as "host" | "env_token")
|
|
200
|
+
: undefined,
|
|
201
|
+
tokenEnvVar: typeof credentials.tokenEnvVar === "string" ? credentials.tokenEnvVar : null,
|
|
202
|
+
username: typeof credentials.username === "string" ? credentials.username : null
|
|
203
|
+
}
|
|
204
|
+
: null;
|
|
205
|
+
const allowRemotes = Array.isArray(parsedValue.allowRemotes)
|
|
206
|
+
? parsedValue.allowRemotes.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
|
207
|
+
: null;
|
|
208
|
+
const allowBranchPrefixes = Array.isArray(parsedValue.allowBranchPrefixes)
|
|
209
|
+
? parsedValue.allowBranchPrefixes.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
|
210
|
+
: null;
|
|
211
|
+
return {
|
|
212
|
+
mode: normalizedMode,
|
|
213
|
+
strategy: normalizedStrategy,
|
|
214
|
+
credentials: normalizedCredentials,
|
|
215
|
+
allowRemotes,
|
|
216
|
+
allowBranchPrefixes
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function assertRuntimeCwdForCompany(companyId: string, runtimeCwd: string, label = "runtimeCwd") {
|
|
221
|
+
const normalized = normalizeAbsolutePath(runtimeCwd, { requireAbsoluteInput: true });
|
|
222
|
+
return assertPathInsideCompanyWorkspaceRoot(companyId, normalized, label);
|
|
223
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { NextFunction, Request, Response } from "express";
|
|
2
2
|
import { RequestActorHeadersSchema } from "bopodev-contracts";
|
|
3
3
|
import { sendError } from "../http";
|
|
4
|
+
import { verifyActorToken } from "../security/actor-token";
|
|
5
|
+
import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
|
|
4
6
|
|
|
5
7
|
export type RequestActor = {
|
|
6
8
|
type: "board" | "member" | "agent";
|
|
@@ -20,6 +22,22 @@ declare global {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function attachRequestActor(req: Request, res: Response, next: NextFunction) {
|
|
25
|
+
const deploymentMode = resolveDeploymentMode();
|
|
26
|
+
const tokenSecret = process.env.BOPO_AUTH_TOKEN_SECRET?.trim() || "";
|
|
27
|
+
const tokenIdentity = tokenSecret
|
|
28
|
+
? verifyActorToken(readBearerToken(req.header("authorization")) ?? req.header("x-bopo-actor-token"), tokenSecret)
|
|
29
|
+
: null;
|
|
30
|
+
if (tokenIdentity) {
|
|
31
|
+
req.actor = {
|
|
32
|
+
type: tokenIdentity.type,
|
|
33
|
+
id: tokenIdentity.id,
|
|
34
|
+
companyIds: tokenIdentity.companyIds,
|
|
35
|
+
permissions: tokenIdentity.permissions
|
|
36
|
+
};
|
|
37
|
+
next();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
const actorHeadersResult = RequestActorHeadersSchema.safeParse({
|
|
24
42
|
"x-actor-type": req.header("x-actor-type")?.trim().toLowerCase(),
|
|
25
43
|
"x-actor-id": req.header("x-actor-id")?.trim(),
|
|
@@ -39,9 +57,44 @@ export function attachRequestActor(req: Request, res: Response, next: NextFuncti
|
|
|
39
57
|
const companyIds = parseCommaList(actorHeaders["x-actor-companies"]);
|
|
40
58
|
const permissions = parseCommaList(actorHeaders["x-actor-permissions"]) ?? [];
|
|
41
59
|
const hasActorHeaders = Boolean(
|
|
42
|
-
actorHeaders["x-actor-type"] ||
|
|
60
|
+
actorHeaders["x-actor-type"] ||
|
|
61
|
+
actorHeaders["x-actor-id"] ||
|
|
62
|
+
(companyIds && companyIds.length > 0) ||
|
|
63
|
+
permissions.length > 0
|
|
43
64
|
);
|
|
44
|
-
const allowLocalBoardFallback =
|
|
65
|
+
const allowLocalBoardFallback =
|
|
66
|
+
deploymentMode === "local" && process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
|
|
67
|
+
const trustActorHeaders = deploymentMode === "local" || process.env.BOPO_TRUST_ACTOR_HEADERS === "1";
|
|
68
|
+
const bootstrapSecret = process.env.BOPO_AUTH_BOOTSTRAP_SECRET?.trim();
|
|
69
|
+
const bootstrapHeader = req.header("x-bopo-bootstrap-secret")?.trim();
|
|
70
|
+
const allowBootstrapTokenIssue =
|
|
71
|
+
req.method === "POST" &&
|
|
72
|
+
req.path === "/auth/actor-token" &&
|
|
73
|
+
Boolean(bootstrapSecret && bootstrapHeader && bootstrapSecret === bootstrapHeader);
|
|
74
|
+
if (isAuthenticatedMode(deploymentMode) && !hasActorHeaders && !tokenIdentity) {
|
|
75
|
+
if (allowBootstrapTokenIssue) {
|
|
76
|
+
req.actor = {
|
|
77
|
+
type: "board",
|
|
78
|
+
id: "bootstrap-secret",
|
|
79
|
+
companyIds: null,
|
|
80
|
+
permissions: []
|
|
81
|
+
};
|
|
82
|
+
next();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
return sendError(
|
|
86
|
+
res,
|
|
87
|
+
"Missing actor identity. Provide Authorization Bearer actor token or trusted x-actor-* headers.",
|
|
88
|
+
401
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (isAuthenticatedMode(deploymentMode) && hasActorHeaders && !trustActorHeaders) {
|
|
92
|
+
return sendError(
|
|
93
|
+
res,
|
|
94
|
+
"x-actor-* headers are disabled in authenticated mode. Set BOPO_TRUST_ACTOR_HEADERS=1 only behind a trusted proxy.",
|
|
95
|
+
401
|
|
96
|
+
);
|
|
97
|
+
}
|
|
45
98
|
const actorType = hasActorHeaders
|
|
46
99
|
? actorHeaders["x-actor-type"] ?? "member"
|
|
47
100
|
: allowLocalBoardFallback
|
|
@@ -62,6 +115,18 @@ export function attachRequestActor(req: Request, res: Response, next: NextFuncti
|
|
|
62
115
|
next();
|
|
63
116
|
}
|
|
64
117
|
|
|
118
|
+
function readBearerToken(value: string | undefined) {
|
|
119
|
+
const trimmed = value?.trim();
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const lower = trimmed.toLowerCase();
|
|
124
|
+
if (!lower.startsWith("bearer ")) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return trimmed.slice("bearer ".length).trim() || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
65
130
|
export function requirePermission(permission: string) {
|
|
66
131
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
67
132
|
if (!hasPermission(req, permission)) {
|
package/src/realtime/hub.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
type RealtimeMessage
|
|
6
6
|
} from "bopodev-contracts";
|
|
7
7
|
import { WebSocket, WebSocketServer } from "ws";
|
|
8
|
+
import { verifyActorToken } from "../security/actor-token";
|
|
9
|
+
import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
|
|
8
10
|
|
|
9
11
|
type RealtimeEventMessage = Extract<RealtimeMessage, { kind: "event" }>;
|
|
10
12
|
type RealtimeBootstrapLoader = (companyId: string) => Promise<RealtimeEventMessage>;
|
|
@@ -25,7 +27,7 @@ export function attachRealtimeHub(
|
|
|
25
27
|
|
|
26
28
|
wss.on("connection", async (socket, request) => {
|
|
27
29
|
const subscription = getSubscription(request.url);
|
|
28
|
-
if (!subscription || !canSubscribeToCompany(request.headers, subscription.companyId)) {
|
|
30
|
+
if (!subscription || !canSubscribeToCompany(request.url, request.headers, subscription.companyId)) {
|
|
29
31
|
socket.close(1008, "Invalid realtime subscription");
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
@@ -132,13 +134,31 @@ function getSubscription(requestUrl: string | undefined) {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function canSubscribeToCompany(
|
|
137
|
+
requestUrl: string | undefined,
|
|
135
138
|
headers: Record<string, string | string[] | undefined>,
|
|
136
139
|
companyId: string
|
|
137
140
|
) {
|
|
141
|
+
const deploymentMode = resolveDeploymentMode();
|
|
142
|
+
const tokenSecret = process.env.BOPO_AUTH_TOKEN_SECRET?.trim() || "";
|
|
143
|
+
const tokenIdentity = tokenSecret ? verifyActorToken(readRealtimeTokenFromUrl(requestUrl), tokenSecret) : null;
|
|
144
|
+
if (tokenIdentity) {
|
|
145
|
+
if (tokenIdentity.type === "board") {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return tokenIdentity.companyIds?.includes(companyId) ?? false;
|
|
149
|
+
}
|
|
138
150
|
const actorType = readHeader(headers, "x-actor-type")?.toLowerCase();
|
|
139
151
|
const actorCompanies = parseCommaList(readHeader(headers, "x-actor-companies"));
|
|
140
152
|
const hasActorHeaders = Boolean(actorType || actorCompanies.length > 0);
|
|
141
|
-
const allowLocalBoardFallback =
|
|
153
|
+
const allowLocalBoardFallback =
|
|
154
|
+
deploymentMode === "local" && process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
|
|
155
|
+
const trustActorHeaders = deploymentMode === "local" || process.env.BOPO_TRUST_ACTOR_HEADERS === "1";
|
|
156
|
+
if (isAuthenticatedMode(deploymentMode) && !hasActorHeaders) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (isAuthenticatedMode(deploymentMode) && hasActorHeaders && !trustActorHeaders) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
142
162
|
|
|
143
163
|
if (!hasActorHeaders) {
|
|
144
164
|
return allowLocalBoardFallback;
|
|
@@ -149,6 +169,15 @@ function canSubscribeToCompany(
|
|
|
149
169
|
return actorCompanies.includes(companyId);
|
|
150
170
|
}
|
|
151
171
|
|
|
172
|
+
function readRealtimeTokenFromUrl(requestUrl: string | undefined) {
|
|
173
|
+
if (!requestUrl) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
177
|
+
const token = url.searchParams.get("authToken")?.trim();
|
|
178
|
+
return token && token.length > 0 ? token : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
|
|
153
182
|
const value = headers[name];
|
|
154
183
|
if (Array.isArray(value)) {
|