@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/bin/codeflow.mjs +8 -0
- package/bin/launcher.mjs +127 -0
- package/dist/remote-host.json +1 -0
- package/licenses/inter.txt +92 -0
- package/licenses/preact.txt +21 -0
- package/licenses/signals-core.txt +21 -0
- package/licenses/signals.txt +21 -0
- package/licenses/typescript-notices.txt +193 -0
- package/licenses/typescript.txt +55 -0
- package/licenses/uqr.txt +22 -0
- package/licenses/xterm-fit.txt +19 -0
- package/licenses/xterm.txt +21 -0
- package/package.json +34 -4
- package/public/assets/browser-7XI52A6V.js +8 -0
- package/public/assets/changes-EYFPASEI.js +1 -0
- package/public/assets/chunk-7H3ZLTKA.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-LLMQG23J.js +163 -0
- package/public/assets/main-KIRM2OEK.js +1 -0
- package/public/assets/settings-QJZVW22Y.js +9 -0
- package/public/assets/styles-615EBB20.css +32 -0
- package/public/assets/terminal-A2FI2NAX.js +62 -0
- package/public/assets.json +1 -0
- package/public/bundle-meta.json +1 -0
- package/public/docs/.nojekyll +0 -0
- package/public/docs/accessibility.html +19 -0
- package/public/docs/acp-agents.html +117 -0
- package/public/docs/agent-containers.html +79 -0
- package/public/docs/api.html +1130 -0
- package/public/docs/app.js +342 -0
- package/public/docs/application-improvements.html +275 -0
- package/public/docs/architecture-explorer.html +19 -0
- package/public/docs/architecture.html +149 -0
- package/public/docs/auto-team.html +142 -0
- package/public/docs/automations.html +109 -0
- package/public/docs/checkpoint-review.html +37 -0
- package/public/docs/code-hosts.html +35 -0
- package/public/docs/code-intelligence.html +35 -0
- package/public/docs/configuration.html +346 -0
- package/public/docs/context-inspection.html +23 -0
- package/public/docs/coordinated-changes.html +28 -0
- package/public/docs/deferred-runs.html +25 -0
- package/public/docs/dependency-discovery.html +19 -0
- package/public/docs/documentation.html +84 -0
- package/public/docs/dynamic-teams-evaluation.html +174 -0
- package/public/docs/dynamic-teams-implementation-plan.html +882 -0
- package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
- package/public/docs/dynamic-teams-plan-review-0.html +204 -0
- package/public/docs/extensions.html +42 -0
- package/public/docs/external-sources.html +96 -0
- package/public/docs/filesystem-policy.html +26 -0
- package/public/docs/filtered-tasks.html +44 -0
- package/public/docs/font-license.txt +92 -0
- package/public/docs/getting-started.html +118 -0
- package/public/docs/github-review-threads.html +26 -0
- package/public/docs/github.html +157 -0
- package/public/docs/help.html +69 -0
- package/public/docs/index.html +77 -0
- package/public/docs/installation.html +74 -0
- package/public/docs/launch-profiles.html +48 -0
- package/public/docs/maintenance.html +60 -0
- package/public/docs/memory-claims.html +31 -0
- package/public/docs/memory-storage.html +58 -0
- package/public/docs/notifications.html +39 -0
- package/public/docs/patterns.html +195 -0
- package/public/docs/performance-results.html +210 -0
- package/public/docs/performance.html +166 -0
- package/public/docs/project-conventions.html +22 -0
- package/public/docs/project-first-navigation-plan.html +587 -0
- package/public/docs/project-teams-implementation-plan.html +772 -0
- package/public/docs/project-teams.html +99 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +61 -0
- package/public/docs/remote-workers.html +64 -0
- package/public/docs/review-inbox.html +26 -0
- package/public/docs/run-storage.html +23 -0
- package/public/docs/runs.html +141 -0
- package/public/docs/search.json +1 -0
- package/public/docs/security.html +133 -0
- package/public/docs/shared-projects.html +31 -0
- package/public/docs/ssh-projects.html +60 -0
- package/public/docs/style.css +768 -0
- package/public/docs/task-reruns.html +30 -0
- package/public/docs/team-collaboration.html +379 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +122 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/pty.py +273 -0
- package/src/acp-connections.js +110 -0
- package/src/adapters/acp-presets.js +37 -0
- package/src/adapters/acp-types.js +0 -0
- package/src/adapters/acp.js +328 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +223 -0
- package/src/adapters/agy.js +297 -0
- package/src/adapters/claude.js +220 -0
- package/src/adapters/codex-app-server.js +518 -0
- package/src/adapters/codex.js +299 -0
- package/src/adapters/errors.js +57 -0
- package/src/adapters/index.js +91 -0
- package/src/adapters/managed-permissions.js +37 -0
- package/src/adapters/model-discovery.js +100 -0
- package/src/adapters/process.js +418 -0
- package/src/adapters/team-tools.js +102 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-container.js +155 -0
- package/src/approvals.js +121 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-handoff.js +55 -0
- package/src/checkpoint-review.js +72 -0
- package/src/checks.js +303 -0
- package/src/code-host-git.js +102 -0
- package/src/code-hosts-api.js +99 -0
- package/src/code-hosts.js +482 -0
- package/src/code-intelligence-worker.mjs +452 -0
- package/src/code-intelligence.js +141 -0
- package/src/collab/api.js +365 -0
- package/src/collab/checkpoints.js +198 -0
- package/src/collab/connections.js +139 -0
- package/src/collab/host-execution.js +347 -0
- package/src/collab/hub-client.js +210 -0
- package/src/collab/link-api.js +229 -0
- package/src/collab/link.js +595 -0
- package/src/collab/model.js +496 -0
- package/src/collab/runner.js +1014 -0
- package/src/collab/store.js +1452 -0
- package/src/collab/terminals.js +361 -0
- package/src/collaboration-reviews.js +116 -0
- package/src/config.js +48 -0
- package/src/context-budget.js +14 -0
- package/src/context-inspection.js +204 -0
- package/src/context.js +154 -0
- package/src/conventions.js +283 -0
- package/src/coordinated-api.js +247 -0
- package/src/deferred-launch.js +82 -0
- package/src/deferred-runs.js +515 -0
- package/src/dependency-health.js +35 -0
- package/src/diff.js +43 -0
- package/src/docs-help.js +316 -0
- package/src/doctor.js +232 -0
- package/src/engine.js +4366 -0
- package/src/env.js +44 -0
- package/src/experience.js +50 -0
- package/src/extensions.js +144 -0
- package/src/filesystem-access.js +107 -0
- package/src/filesystem-policy.js +238 -0
- package/src/filtered-task-limits.js +93 -0
- package/src/filtered-tasks-api.js +53 -0
- package/src/filtered-tasks.js +576 -0
- package/src/fs.js +57 -0
- package/src/git.js +626 -0
- package/src/github-review-threads.js +181 -0
- package/src/github.js +538 -0
- package/src/http.js +69 -0
- package/src/improve-settings.js +38 -0
- package/src/improve.js +187 -0
- package/src/integrations.js +373 -0
- package/src/integrity.js +259 -0
- package/src/journey-api.js +77 -0
- package/src/journey-replay.js +65 -0
- package/src/journey-store.js +97 -0
- package/src/journeys.js +60 -0
- package/src/json.js +81 -0
- package/src/launch-profiles.js +292 -0
- package/src/learnings.js +80 -0
- package/src/log.js +39 -0
- package/src/maintenance.js +335 -0
- package/src/memory-analysis.js +182 -0
- package/src/memory-claims.js +153 -0
- package/src/memory-sqlite.js +236 -0
- package/src/memory.js +552 -0
- package/src/merge-validation.js +58 -0
- package/src/model-catalog.js +238 -0
- package/src/model-research.js +91 -0
- package/src/notes.js +176 -0
- package/src/notifications.js +413 -0
- package/src/patterns/auto-team-model.js +212 -0
- package/src/patterns/auto-team.js +659 -0
- package/src/patterns/council.js +199 -0
- package/src/patterns/debate.js +188 -0
- package/src/patterns/findings.js +141 -0
- package/src/patterns/index.js +19 -0
- package/src/patterns/loop.js +158 -0
- package/src/patterns/pipeline.js +125 -0
- package/src/patterns/prompts.js +181 -0
- package/src/patterns/race.js +147 -0
- package/src/patterns/schemas.js +40 -0
- package/src/patterns/solo.js +49 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/tdd.js +231 -0
- package/src/patterns/team-strategies.js +67 -0
- package/src/performance.js +43 -0
- package/src/persistence-status.js +35 -0
- package/src/phone-sessions.js +96 -0
- package/src/preview-overlay.js +383 -0
- package/src/preview.js +219 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-model.js +178 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +213 -0
- package/src/project-teams-api.js +36 -0
- package/src/project-teams.js +551 -0
- package/src/projects.js +286 -0
- package/src/provider-eligibility.js +9 -0
- package/src/readiness.js +98 -0
- package/src/remote-tls.js +66 -0
- package/src/remote-workers/bundles.js +198 -0
- package/src/remote-workers/client.js +332 -0
- package/src/remote-workers/durability.js +37 -0
- package/src/remote-workers/protocol.js +68 -0
- package/src/remote-workers/stdio.js +138 -0
- package/src/remote-workers/worker.js +304 -0
- package/src/remote.js +204 -0
- package/src/repo-config.js +201 -0
- package/src/repo-coordination.js +732 -0
- package/src/repo-tree.js +106 -0
- package/src/retention-policy.js +466 -0
- package/src/review-inbox.js +38 -0
- package/src/routing.js +49 -0
- package/src/run-analysis.js +111 -0
- package/src/run-cold-store.js +231 -0
- package/src/run-environment.js +15 -0
- package/src/run-history.js +91 -0
- package/src/run-log.js +160 -0
- package/src/run-permissions.js +110 -0
- package/src/run-projection.js +75 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +54 -0
- package/src/serve.js +138 -0
- package/src/server.js +2738 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +301 -0
- package/src/shared-api.js +239 -0
- package/src/shell.js +346 -0
- package/src/ship.js +344 -0
- package/src/slots.js +74 -0
- package/src/snapshot.js +388 -0
- package/src/source-context.js +17 -0
- package/src/sse-writer.js +70 -0
- package/src/ssh-projects/bootstrap.mjs +95 -0
- package/src/ssh-projects/config.js +45 -0
- package/src/ssh-projects/gateway.js +143 -0
- package/src/ssh-projects/manager.js +320 -0
- package/src/ssh-projects/team-autostart.mjs +93 -0
- package/src/ssh-projects/team-remove.mjs +69 -0
- package/src/ssh-projects/team-status.mjs +39 -0
- package/src/ssh-projects/transport.js +116 -0
- package/src/stats.js +139 -0
- package/src/store.js +544 -0
- package/src/stream.js +91 -0
- package/src/sweeps.js +241 -0
- package/src/tailscale.js +101 -0
- package/src/task-draft.js +23 -0
- package/src/team-evaluation.js +242 -0
- package/src/team-mailbox.js +160 -0
- package/src/team-memory.js +84 -0
- package/src/team-rerun-graph.js +15 -0
- package/src/team-reruns.js +130 -0
- package/src/team-scheduler.js +121 -0
- package/src/team-template-store.js +98 -0
- package/src/team-templates.js +117 -0
- package/src/team-tool-client.mjs +79 -0
- package/src/team-tools.js +176 -0
- package/src/teams.js +0 -0
- package/src/templates.js +62 -0
- package/src/transcript.js +134 -0
- package/src/verification-integrity.js +27 -0
- package/src/verification.js +183 -0
- package/src/watch.js +195 -0
- package/src/work-queue.js +47 -0
- package/src/workflow-control.js +83 -0
- package/src/workspace-api.js +207 -0
- package/src/workspace-changes.js +61 -0
- package/src/workspace.js +146 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { redactExport } from "./privacy.js";
|
|
2
|
+
import { sensitiveContextPath } from "./context-inspection.js";
|
|
3
|
+
class SharedPathError extends Error {
|
|
4
|
+
status = 400;
|
|
5
|
+
}
|
|
6
|
+
function canonicalSharedPath(target) {
|
|
7
|
+
const raw = target.split("?")[0];
|
|
8
|
+
if (!raw.startsWith("/") || raw.length > 4e3 || /[\\#\x00-\x20\x7f]/.test(raw) || /%(?:2f|5c|00|25)/i.test(raw)) throw new SharedPathError("Invalid request path");
|
|
9
|
+
let path;
|
|
10
|
+
try {
|
|
11
|
+
path = decodeURIComponent(raw);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new SharedPathError("Invalid request path encoding");
|
|
14
|
+
}
|
|
15
|
+
if (/[\\#\x00-\x20\x7f]/.test(path)) throw new SharedPathError("Invalid request path");
|
|
16
|
+
const parts = path === "/" ? [] : path.slice(1).split("/");
|
|
17
|
+
if (parts.some((part) => !part || part === "." || part === "..")) throw new SharedPathError("Ambiguous request path");
|
|
18
|
+
if (parts[0] === "api" && parts[1] === "v1" && parts[2] !== "capabilities") parts.splice(1, 1);
|
|
19
|
+
return { pathname: `/${parts.join("/")}`, parts };
|
|
20
|
+
}
|
|
21
|
+
function sharedRoutePolicy(method, parts) {
|
|
22
|
+
const route = parts.join("/");
|
|
23
|
+
if (method === "GET" && route === "api/auth/self" || method === "POST" && route === "api/auth/logout") return "identity";
|
|
24
|
+
if (method === "GET" && ["api/patterns", "api/templates"].includes(route)) return "catalog";
|
|
25
|
+
if (method === "GET" && route === "api/projects") return "project-list";
|
|
26
|
+
if (method === "GET" && ["api/runs", "api/events"].includes(route)) return "run-list";
|
|
27
|
+
if (parts[0] === "api" && parts[1] === "runs" && /^[a-zA-Z0-9_-]{1,100}$/.test(parts[2] ?? "")) {
|
|
28
|
+
if (method === "GET" && (parts.length === 3 || parts.length === 4 && ["events", "export", "diff", "review-coverage", "collaboration"].includes(parts[3]))) return "run-read";
|
|
29
|
+
if (method === "POST" && parts.length === 4 && ["review-coverage", "collaboration"].includes(parts[3])) return "run-review";
|
|
30
|
+
}
|
|
31
|
+
return "owner";
|
|
32
|
+
}
|
|
33
|
+
function sharedProjectRole(identity, projectId) {
|
|
34
|
+
return projectId && Object.hasOwn(identity.roles, projectId) ? identity.roles[projectId] : void 0;
|
|
35
|
+
}
|
|
36
|
+
function sharedCanReadRun(identity, run, projects) {
|
|
37
|
+
if (run.projectTeam || run.pattern === "team-task-draft") return false;
|
|
38
|
+
const candidates = run.project ? projects.filter((project) => project.id === run.project) : projects.filter((project) => project.repos.includes(run.repo));
|
|
39
|
+
return candidates.length === 1 && candidates[0].repos.includes(run.repo) && !!sharedProjectRole(identity, candidates[0].id);
|
|
40
|
+
}
|
|
41
|
+
function sharedProjectProjection(project) {
|
|
42
|
+
return { id: project.id, name: project.name, createdAt: project.createdAt };
|
|
43
|
+
}
|
|
44
|
+
function sharedDiffProjection(files) {
|
|
45
|
+
return redactExport(files.filter((file) => !sensitiveContextPath(file.path) && (!file.oldPath || !sensitiveContextPath(file.oldPath))));
|
|
46
|
+
}
|
|
47
|
+
function sharedCoverageProjection(value) {
|
|
48
|
+
const files = value.files.filter((path) => !sensitiveContextPath(path));
|
|
49
|
+
const reviewed = value.reviewed.filter((path) => files.includes(path));
|
|
50
|
+
return { scope: "shared-reviewable-files", omittedCount: value.files.length - files.length, head: value.head, files, reviewed, stale: !!value.stale, coverage: { changed: files.length, reviewed: reviewed.length, percent: files.length ? Math.round(reviewed.length / files.length * 100) : 100 } };
|
|
51
|
+
}
|
|
52
|
+
function pick(value, keys) {
|
|
53
|
+
return Object.fromEntries(keys.filter((key) => value[key] !== void 0).map((key) => [key, value[key]]));
|
|
54
|
+
}
|
|
55
|
+
function boundedText(value, ceiling) {
|
|
56
|
+
if (value === void 0) return { text: void 0, truncated: false };
|
|
57
|
+
const data = Buffer.from(value);
|
|
58
|
+
if (data.length <= ceiling) return { text: value, truncated: false };
|
|
59
|
+
let end = ceiling;
|
|
60
|
+
while (end > 0 && (data[end] & 192) === 128) end--;
|
|
61
|
+
return { text: data.subarray(0, end).toString("utf8"), truncated: true };
|
|
62
|
+
}
|
|
63
|
+
function stepProjection(step) {
|
|
64
|
+
const finalText = boundedText(step.finalText, 8 * 1024), error = boundedText(step.error, 4 * 1024);
|
|
65
|
+
return redactExport({ ...pick(step, ["id", "kind", "title", "role", "agent", "memberId", "taskId", "coordination", "invocations", "status", "startedAt", "endedAt", "retries"]), ...finalText.text !== void 0 ? { finalText: finalText.text } : {}, ...error.text !== void 0 ? { error: error.text } : {}, finalTextTruncated: finalText.truncated, errorTruncated: error.truncated, ...step.contextInput ? { contextInput: pick(step.contextInput, ["scope", "measuredAt", "promptBytes", "schemaBytes", "bytes", "approximateTokens", "resumed", "excluded", "policy", "exceeded", "violations"]) } : {} });
|
|
66
|
+
}
|
|
67
|
+
function sharedRunProjection(run, canReadRun = () => false) {
|
|
68
|
+
const verification = run.verification, task = boundedText(run.task, 24 * 1024), summary = boundedText(run.summary, 32 * 1024), error = boundedText(run.error, 4 * 1024);
|
|
69
|
+
const cold = run.cold ? { cold: true, detailAvailable: true } : {};
|
|
70
|
+
const steps = "steps" in run ? run.steps.slice(-200).map(stepProjection) : void 0;
|
|
71
|
+
return redactExport({
|
|
72
|
+
...pick(run, ["id", "project", "pattern", "status", "createdAt", "endedAt", "baseRef", "baseLabel", "stopReason", "verdict", "facts"]),
|
|
73
|
+
...cold,
|
|
74
|
+
task: task.text,
|
|
75
|
+
...summary.text !== void 0 ? { summary: summary.text } : {},
|
|
76
|
+
...error.text !== void 0 ? { error: error.text } : {},
|
|
77
|
+
truncation: { task: task.truncated, summary: summary.truncated, error: error.truncated, omittedSteps: "steps" in run ? Math.max(0, run.steps.length - 200) : 0 },
|
|
78
|
+
worktrees: run.worktrees.map((worktree) => pick(worktree, ["name", "branch"])),
|
|
79
|
+
...steps ? { steps } : {},
|
|
80
|
+
...run.parentRunId && canReadRun(run.parentRunId) ? { parentRunId: run.parentRunId } : {},
|
|
81
|
+
...run.supersededBy && canReadRun(run.supersededBy) ? { supersededBy: run.supersededBy } : {},
|
|
82
|
+
...verification ? { verification: { ...pick(verification, ["version", "profile", "state", "startedAt", "endedAt", "head", "branch"]), checks: verification.checks.map((evidence) => ({ check: pick(evidence.check, ["id", "title", "kind", "required"]), diagnosis: evidence.diagnosis })), ...verification.requirements ? { requirements: verification.requirements.map((requirement) => pick(requirement, ["id", "description", "covered", "missing"])) } : {} } } : {}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function sharedEventProjection(event, canReadRun = () => false) {
|
|
86
|
+
if (!canReadRun(event.runId)) return void 0;
|
|
87
|
+
const common = { seq: event.seq, ts: event.ts, runId: event.runId };
|
|
88
|
+
if (event.type === "run") return { ...common, type: "run", run: sharedRunProjection(event.run, canReadRun) };
|
|
89
|
+
if (event.type === "step") return { ...common, type: "step", step: stepProjection(event.step) };
|
|
90
|
+
return void 0;
|
|
91
|
+
}
|
|
92
|
+
export {
|
|
93
|
+
SharedPathError,
|
|
94
|
+
canonicalSharedPath,
|
|
95
|
+
sharedCanReadRun,
|
|
96
|
+
sharedCoverageProjection,
|
|
97
|
+
sharedDiffProjection,
|
|
98
|
+
sharedEventProjection,
|
|
99
|
+
sharedProjectProjection,
|
|
100
|
+
sharedProjectRole,
|
|
101
|
+
sharedRoutePolicy,
|
|
102
|
+
sharedRunProjection
|
|
103
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { CODEFLOW_HOME } from "./config.js";
|
|
6
|
+
const SHARED_ROLES = ["viewer", "reviewer", "contributor", "maintainer"];
|
|
7
|
+
function roleAtLeast(role, needed) {
|
|
8
|
+
const rank = role === void 0 ? -1 : SHARED_ROLES.indexOf(role), floor = SHARED_ROLES.indexOf(needed);
|
|
9
|
+
return rank >= 0 && floor >= 0 && rank >= floor;
|
|
10
|
+
}
|
|
11
|
+
class SharedAccountError extends Error {
|
|
12
|
+
constructor(message, status = 400) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const MAX_USERS = 128, MAX_SESSIONS = 1024, SESSION_MS = 12 * 60 * 6e4, IDLE_MS = 30 * 6e4;
|
|
18
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
19
|
+
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
const keys = (v, allowed) => Object.keys(v).every((key) => allowed.includes(key));
|
|
21
|
+
const integer = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
22
|
+
const hex = (v, length) => typeof v === "string" && new RegExp(`^[a-f0-9]{${length}}$`).test(v);
|
|
23
|
+
const userId = (v) => typeof v === "string" && /^u-[a-f0-9-]{36}$/.test(v);
|
|
24
|
+
function username(value, reserved = true) {
|
|
25
|
+
if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,63}$/.test(value)) throw new SharedAccountError("Use 3\u201364 letters, digits, dots, underscores or dashes for the username");
|
|
26
|
+
const name = value.toLowerCase();
|
|
27
|
+
if (reserved && name === "owner") throw new SharedAccountError("The username owner is reserved for the server owner");
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
function displayName(value) {
|
|
31
|
+
if (typeof value !== "string" || !value.trim() || value.length > 120 || /[\x00-\x1f\x7f]/.test(value)) throw new SharedAccountError("Enter a display name of up to 120 characters");
|
|
32
|
+
return value.trim();
|
|
33
|
+
}
|
|
34
|
+
function passwordText(value, creating = true) {
|
|
35
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > 1024 || creating && [...value].length < 15) throw new SharedAccountError("Passwords need at least 15 characters and at most 1024 UTF-8 bytes");
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function validateSharedRoles(value) {
|
|
39
|
+
if (!object(value) || Object.keys(value).length > 200) throw new SharedAccountError("Project roles must be a map with at most 200 assigned projects");
|
|
40
|
+
const roles = {};
|
|
41
|
+
for (const [id, role] of Object.entries(value)) {
|
|
42
|
+
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id) || ["__proto__", "constructor", "prototype"].includes(id) || !SHARED_ROLES.includes(role)) throw new SharedAccountError("Only project viewer, reviewer, contributor and maintainer roles may be assigned");
|
|
43
|
+
roles[id] = role;
|
|
44
|
+
}
|
|
45
|
+
return roles;
|
|
46
|
+
}
|
|
47
|
+
let activeDerivations = 0;
|
|
48
|
+
async function derive(value, salt) {
|
|
49
|
+
if (activeDerivations >= 2) throw new SharedAccountError("Sign-in is busy; try again shortly", 429);
|
|
50
|
+
activeDerivations++;
|
|
51
|
+
try {
|
|
52
|
+
return await new Promise((resolve, reject) => scrypt(value, Buffer.from(salt, "hex"), 32, { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }, (error, result) => error ? reject(error) : resolve(result)));
|
|
53
|
+
} finally {
|
|
54
|
+
activeDerivations--;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function makePassword(value) {
|
|
58
|
+
const text = passwordText(value);
|
|
59
|
+
const salt = randomBytes(16).toString("hex");
|
|
60
|
+
return { algorithm: "scrypt", N: 131072, r: 8, p: 1, salt, hash: (await derive(text, salt)).toString("hex") };
|
|
61
|
+
}
|
|
62
|
+
const dummySalt = randomBytes(16).toString("hex"), dummyHash = randomBytes(32);
|
|
63
|
+
function publicUser(user) {
|
|
64
|
+
return { id: user.id, username: user.username, displayName: user.displayName, enabled: user.enabled, revision: user.revision, roles: { ...user.roles }, createdAt: user.createdAt, updatedAt: user.updatedAt };
|
|
65
|
+
}
|
|
66
|
+
function principal(user) {
|
|
67
|
+
return { kind: "shared", userId: user.id, username: user.username, displayName: user.displayName, revision: user.revision, roles: { ...user.roles } };
|
|
68
|
+
}
|
|
69
|
+
function validateState(raw) {
|
|
70
|
+
if (!object(raw) || !keys(raw, ["version", "revision", "users", "sessions"]) || raw.version !== 1 || !integer(raw.revision) || !Array.isArray(raw.users) || raw.users.length > MAX_USERS || !Array.isArray(raw.sessions) || raw.sessions.length > MAX_SESSIONS) throw new Error("Invalid shared account storage");
|
|
71
|
+
for (const u of raw.users) {
|
|
72
|
+
if (!object(u) || !keys(u, ["id", "username", "displayName", "enabled", "revision", "roles", "createdAt", "updatedAt", "password"]) || !userId(u.id) || username(u.username, false) !== u.username || displayName(u.displayName) !== u.displayName || typeof u.enabled !== "boolean" || !integer(u.revision) || u.revision < 1 || !integer(u.createdAt) || !integer(u.updatedAt) || u.updatedAt < u.createdAt || !object(u.password) || !keys(u.password, ["algorithm", "N", "r", "p", "salt", "hash"]) || u.password.algorithm !== "scrypt" || u.password.N !== 131072 || u.password.r !== 8 || u.password.p !== 1 || !hex(u.password.salt, 32) || !hex(u.password.hash, 64)) throw new Error("Invalid shared account record");
|
|
73
|
+
validateSharedRoles(u.roles);
|
|
74
|
+
}
|
|
75
|
+
if (new Set(raw.users.map((u) => u.id)).size !== raw.users.length || new Set(raw.users.map((u) => u.username)).size !== raw.users.length) throw new Error("Duplicate shared account identity");
|
|
76
|
+
for (const s of raw.sessions) if (!object(s) || !keys(s, ["hash", "userId", "revision", "createdAt", "lastSeenAt", "expiresAt"]) || !hex(s.hash, 64) || !userId(s.userId) || !integer(s.revision) || !integer(s.createdAt) || !integer(s.lastSeenAt) || !integer(s.expiresAt) || s.lastSeenAt < s.createdAt || s.expiresAt !== s.createdAt + SESSION_MS || s.lastSeenAt > s.expiresAt || !raw.users.some((u) => u.id === s.userId)) throw new Error("Invalid shared account session");
|
|
77
|
+
if (new Set(raw.sessions.map((s) => s.hash)).size !== raw.sessions.length) throw new Error("Duplicate shared session");
|
|
78
|
+
return raw;
|
|
79
|
+
}
|
|
80
|
+
class LoginLimiter {
|
|
81
|
+
buckets = /* @__PURE__ */ new Map();
|
|
82
|
+
check(address, name, now) {
|
|
83
|
+
for (const [key, value] of this.buckets) if (value.expires <= now) this.buckets.delete(key);
|
|
84
|
+
const keys2 = [["global", 100], [`ip:${address.slice(0, 128)}`, 20], [`user:${digest(name)}`, 8]];
|
|
85
|
+
if (keys2.some(([key, max]) => (this.buckets.get(key)?.count ?? 0) >= max)) throw new SharedAccountError("Too many sign-in attempts; try again later", 429);
|
|
86
|
+
for (const [key] of keys2) {
|
|
87
|
+
const bucket = this.buckets.get(key) ?? { count: 0, expires: now + 15 * 6e4 };
|
|
88
|
+
bucket.count++;
|
|
89
|
+
this.buckets.delete(key);
|
|
90
|
+
this.buckets.set(key, bucket);
|
|
91
|
+
}
|
|
92
|
+
while (this.buckets.size > 2048) this.buckets.delete(this.buckets.keys().next().value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
class SharedAccounts {
|
|
96
|
+
constructor(file = join(CODEFLOW_HOME, "shared-accounts.json")) {
|
|
97
|
+
this.file = file;
|
|
98
|
+
}
|
|
99
|
+
state = { version: 1, revision: 0, users: [], sessions: [] };
|
|
100
|
+
loaded = false;
|
|
101
|
+
loading;
|
|
102
|
+
readable = true;
|
|
103
|
+
writes = Promise.resolve();
|
|
104
|
+
listeners = /* @__PURE__ */ new Set();
|
|
105
|
+
limiter = new LoginLimiter();
|
|
106
|
+
get available() {
|
|
107
|
+
return this.loaded && this.readable;
|
|
108
|
+
}
|
|
109
|
+
get hasUsers() {
|
|
110
|
+
return this.available && this.state.users.length > 0;
|
|
111
|
+
}
|
|
112
|
+
load() {
|
|
113
|
+
return this.loading ??= (async () => {
|
|
114
|
+
try {
|
|
115
|
+
const handle = await open(this.file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
116
|
+
try {
|
|
117
|
+
const stat = await handle.stat();
|
|
118
|
+
if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error("Shared account storage is not a bounded regular file");
|
|
119
|
+
this.state = validateState(JSON.parse(await handle.readFile("utf8")));
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error.code !== "ENOENT") {
|
|
125
|
+
this.readable = false;
|
|
126
|
+
throw new SharedAccountError("Shared accounts cannot be read; owner recovery remains available and the account file was left untouched", 503);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
this.loaded = true;
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
}
|
|
133
|
+
snapshot() {
|
|
134
|
+
this.require();
|
|
135
|
+
return { revision: this.state.revision, users: this.state.users.map(publicUser) };
|
|
136
|
+
}
|
|
137
|
+
onChange(listener) {
|
|
138
|
+
this.listeners.add(listener);
|
|
139
|
+
return () => this.listeners.delete(listener);
|
|
140
|
+
}
|
|
141
|
+
create(input) {
|
|
142
|
+
return this.change(async (state) => {
|
|
143
|
+
this.cas(state, input.revision);
|
|
144
|
+
if (state.users.length >= MAX_USERS) throw new SharedAccountError("At most 128 shared accounts may be created");
|
|
145
|
+
const name = username(input.username), label = displayName(input.displayName), roles = validateSharedRoles(input.roles);
|
|
146
|
+
if (state.users.some((user) => user.username === name)) throw new SharedAccountError("That username is already in use", 409);
|
|
147
|
+
const password = await makePassword(input.password);
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
state.users.push({ id: `u-${randomUUID()}`, username: name, displayName: label, enabled: true, revision: 1, roles, password, createdAt: now, updatedAt: now });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
update(id, input) {
|
|
153
|
+
return this.change(async (state) => {
|
|
154
|
+
this.cas(state, input.revision);
|
|
155
|
+
const user = this.find(state, id);
|
|
156
|
+
if (input.displayName !== void 0) user.displayName = displayName(input.displayName);
|
|
157
|
+
if (input.enabled !== void 0) {
|
|
158
|
+
if (typeof input.enabled !== "boolean") throw new SharedAccountError("Enabled must be true or false");
|
|
159
|
+
user.enabled = input.enabled;
|
|
160
|
+
}
|
|
161
|
+
if (input.roles !== void 0) user.roles = validateSharedRoles(input.roles);
|
|
162
|
+
if (input.password !== void 0) user.password = await makePassword(input.password);
|
|
163
|
+
user.revision++;
|
|
164
|
+
user.updatedAt = Date.now();
|
|
165
|
+
state.sessions = state.sessions.filter((session) => session.userId !== id);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
revokeSessions(id, input) {
|
|
169
|
+
return this.change((state) => {
|
|
170
|
+
this.cas(state, input.revision);
|
|
171
|
+
const user = this.find(state, id);
|
|
172
|
+
user.revision++;
|
|
173
|
+
user.updatedAt = Date.now();
|
|
174
|
+
state.sessions = state.sessions.filter((session) => session.userId !== id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** Runner enrolment (POST /api/team/runners/connect) proves a password exactly like sign-in, with the same transport rule, limiter and constant-time compare, but must not mint a cookie session. */
|
|
178
|
+
async verifyCredentials(input) {
|
|
179
|
+
return publicUser(await this.verify(input));
|
|
180
|
+
}
|
|
181
|
+
async login(input) {
|
|
182
|
+
const user = await this.verify(input);
|
|
183
|
+
const token = randomBytes(32).toString("base64url"), now = Date.now();
|
|
184
|
+
const revision = user.revision;
|
|
185
|
+
await this.mutate((state) => {
|
|
186
|
+
const current = this.find(state, user.id);
|
|
187
|
+
if (!current.enabled || current.revision !== revision) throw new SharedAccountError("Account changed during sign-in; try again", 401);
|
|
188
|
+
state.sessions = state.sessions.filter((session) => session.expiresAt > now && session.lastSeenAt + IDLE_MS > now && session.userId !== user.id);
|
|
189
|
+
const own = this.state.sessions.filter((session) => session.userId === user.id && session.expiresAt > now && session.lastSeenAt + IDLE_MS > now).sort((a, b) => b.createdAt - a.createdAt).slice(0, 7);
|
|
190
|
+
state.sessions.push(...own, { hash: digest(token), userId: user.id, revision, createdAt: now, lastSeenAt: now, expiresAt: now + SESSION_MS });
|
|
191
|
+
state.sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
192
|
+
state.sessions = state.sessions.slice(0, MAX_SESSIONS);
|
|
193
|
+
}, false);
|
|
194
|
+
return { token, user: publicUser(user), expiresAt: now + SESSION_MS };
|
|
195
|
+
}
|
|
196
|
+
authenticate(token, options = Date.now()) {
|
|
197
|
+
const now = typeof options === "number" ? options : options.now ?? Date.now();
|
|
198
|
+
if (!this.available || !/^[a-zA-Z0-9_-]{43}$/.test(token)) return void 0;
|
|
199
|
+
const hash = digest(token);
|
|
200
|
+
const session = this.state.sessions.find((session2) => session2.hash === hash);
|
|
201
|
+
if (!session || session.expiresAt <= now || session.lastSeenAt + IDLE_MS <= now) return void 0;
|
|
202
|
+
const user = this.state.users.find((user2) => user2.id === session.userId);
|
|
203
|
+
if (!user?.enabled || user.revision !== session.revision) return void 0;
|
|
204
|
+
if (typeof options === "number" || options.touch !== false) session.lastSeenAt = Math.max(session.lastSeenAt, now);
|
|
205
|
+
return principal(user);
|
|
206
|
+
}
|
|
207
|
+
async logout(token) {
|
|
208
|
+
await this.load();
|
|
209
|
+
await this.mutate((state) => {
|
|
210
|
+
state.sessions = state.sessions.filter((session) => session.hash !== digest(token));
|
|
211
|
+
}, true);
|
|
212
|
+
}
|
|
213
|
+
/** The single password check: an unknown account still derives against a dummy salt, so timing never reveals which usernames exist. */
|
|
214
|
+
async verify(input) {
|
|
215
|
+
await this.load();
|
|
216
|
+
this.require();
|
|
217
|
+
if (!input.secure && !input.loopback) throw new SharedAccountError("Shared sign-in requires HTTPS outside this computer", 403);
|
|
218
|
+
const name = typeof input.username === "string" ? input.username.toLowerCase() : "";
|
|
219
|
+
this.limiter.check(input.address, name.slice(0, 128), Date.now());
|
|
220
|
+
const supplied = passwordText(input.password, false), user = this.state.users.find((entry) => entry.username === name);
|
|
221
|
+
const expected = user ? Buffer.from(user.password.hash, "hex") : dummyHash;
|
|
222
|
+
const result = await derive(supplied, user?.password.salt ?? dummySalt);
|
|
223
|
+
if (!timingSafeEqual(result, expected) || !user?.enabled) throw new SharedAccountError("Invalid username or password", 401);
|
|
224
|
+
return user;
|
|
225
|
+
}
|
|
226
|
+
require() {
|
|
227
|
+
if (!this.available) throw new SharedAccountError("Shared account storage is unavailable; use the owner recovery link", 503);
|
|
228
|
+
}
|
|
229
|
+
find(state, id) {
|
|
230
|
+
const user = state.users.find((user2) => user2.id === id);
|
|
231
|
+
if (!user) throw new SharedAccountError("Shared account not found", 404);
|
|
232
|
+
return user;
|
|
233
|
+
}
|
|
234
|
+
cas(state, revision) {
|
|
235
|
+
if (!integer(revision) || revision !== state.revision) throw new SharedAccountError("Accounts changed; reload before saving", 409);
|
|
236
|
+
}
|
|
237
|
+
async change(update) {
|
|
238
|
+
await this.load();
|
|
239
|
+
await this.mutate(async (state) => {
|
|
240
|
+
await update(state);
|
|
241
|
+
state.revision++;
|
|
242
|
+
}, true);
|
|
243
|
+
return this.snapshot();
|
|
244
|
+
}
|
|
245
|
+
mutate(update, notify) {
|
|
246
|
+
const write = this.writes.catch(() => void 0).then(async () => {
|
|
247
|
+
this.require();
|
|
248
|
+
const next = structuredClone(this.state);
|
|
249
|
+
await update(next);
|
|
250
|
+
await this.write(next);
|
|
251
|
+
this.state = next;
|
|
252
|
+
if (notify) for (const listener of this.listeners) {
|
|
253
|
+
try {
|
|
254
|
+
listener();
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
this.writes = write;
|
|
260
|
+
return write;
|
|
261
|
+
}
|
|
262
|
+
async write(state) {
|
|
263
|
+
await mkdir(dirname(this.file), { recursive: true, mode: 448 });
|
|
264
|
+
try {
|
|
265
|
+
const info = await lstat(this.file);
|
|
266
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new SharedAccountError("Refusing to replace non-regular account storage", 503);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error.code !== "ENOENT") throw error;
|
|
269
|
+
}
|
|
270
|
+
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
271
|
+
try {
|
|
272
|
+
const handle = await open(temp, "wx", 384);
|
|
273
|
+
try {
|
|
274
|
+
await handle.writeFile(JSON.stringify(state));
|
|
275
|
+
await handle.sync();
|
|
276
|
+
} finally {
|
|
277
|
+
await handle.close();
|
|
278
|
+
}
|
|
279
|
+
await rename(temp, this.file);
|
|
280
|
+
await chmod(this.file, 384);
|
|
281
|
+
if (process.platform !== "win32") {
|
|
282
|
+
const dir = await open(dirname(this.file), "r");
|
|
283
|
+
try {
|
|
284
|
+
await dir.sync();
|
|
285
|
+
} finally {
|
|
286
|
+
await dir.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(temp, { force: true }).catch(() => {
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export {
|
|
296
|
+
SHARED_ROLES,
|
|
297
|
+
SharedAccountError,
|
|
298
|
+
SharedAccounts,
|
|
299
|
+
roleAtLeast,
|
|
300
|
+
validateSharedRoles
|
|
301
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { HttpError, readJson, send } from "./http.js";
|
|
3
|
+
import { roleAtLeast } from "./shared-accounts.js";
|
|
4
|
+
import { sharedCanReadRun, sharedCoverageProjection, sharedDiffProjection, sharedEventProjection, sharedProjectProjection, sharedProjectRole, sharedRoutePolicy, sharedRunProjection } from "./shared-access.js";
|
|
5
|
+
import { afterRunCursor, compareRuns, decodeRunCursor, encodeRunCursor } from "./run-history.js";
|
|
6
|
+
import { sensitiveContextPath } from "./context-inspection.js";
|
|
7
|
+
import { redactExport } from "./privacy.js";
|
|
8
|
+
const sharedStreams = /* @__PURE__ */ new Map();
|
|
9
|
+
let totalSharedStreams = 0;
|
|
10
|
+
const STREAM_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
function sharedStream(res, userId) {
|
|
12
|
+
const count = sharedStreams.get(userId) ?? 0;
|
|
13
|
+
if (count >= 4 || totalSharedStreams >= 128) throw new HttpError(429, "Too many shared event streams; close another tab and retry");
|
|
14
|
+
sharedStreams.set(userId, count + 1);
|
|
15
|
+
totalSharedStreams++;
|
|
16
|
+
let closed = false, blocked = false;
|
|
17
|
+
const disposers = [];
|
|
18
|
+
const close = () => {
|
|
19
|
+
if (closed) return;
|
|
20
|
+
closed = true;
|
|
21
|
+
const remaining = (sharedStreams.get(userId) ?? 1) - 1;
|
|
22
|
+
if (remaining) sharedStreams.set(userId, remaining);
|
|
23
|
+
else sharedStreams.delete(userId);
|
|
24
|
+
totalSharedStreams--;
|
|
25
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
26
|
+
res.destroy();
|
|
27
|
+
};
|
|
28
|
+
const write = (frame) => {
|
|
29
|
+
if (closed) return;
|
|
30
|
+
if (blocked || Buffer.byteLength(frame) > STREAM_BYTES || res.writableLength + Buffer.byteLength(frame) > STREAM_BYTES) return close();
|
|
31
|
+
blocked = !res.write(frame);
|
|
32
|
+
};
|
|
33
|
+
res.on("drain", () => {
|
|
34
|
+
blocked = false;
|
|
35
|
+
});
|
|
36
|
+
res.on("close", close);
|
|
37
|
+
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" });
|
|
38
|
+
return { close, add: (dispose) => disposers.push(dispose), emit: (value) => write(`data: ${JSON.stringify(value)}
|
|
39
|
+
|
|
40
|
+
`), ping: () => write(": connected\n\n") };
|
|
41
|
+
}
|
|
42
|
+
async function handleSharedApi(req, res, url, parts, host) {
|
|
43
|
+
const policy = sharedRoutePolicy(req.method ?? "GET", parts);
|
|
44
|
+
if (policy === "owner" || policy === "identity") throw new HttpError(403, "This operation requires the Code Flow owner");
|
|
45
|
+
const current = () => {
|
|
46
|
+
const identity = host.accounts.authenticate(host.token, { touch: false });
|
|
47
|
+
if (!identity || identity.userId !== host.identity.userId || identity.revision !== host.identity.revision) throw new HttpError(401, "Shared session expired or was revoked");
|
|
48
|
+
return identity;
|
|
49
|
+
};
|
|
50
|
+
const projectId = (run2) => run2.project ?? (() => {
|
|
51
|
+
const matches = host.projects.list().filter((project) => project.repos.includes(run2.repo));
|
|
52
|
+
return matches.length === 1 ? matches[0].id : void 0;
|
|
53
|
+
})();
|
|
54
|
+
const canRead = (run2) => (!host.canReadFilePolicy || host.canReadFilePolicy(run2)) && sharedCanReadRun(current(), run2, host.projects.list());
|
|
55
|
+
const canReadId = (id) => {
|
|
56
|
+
const run2 = host.manager.get(id);
|
|
57
|
+
return !!run2 && canRead(run2);
|
|
58
|
+
};
|
|
59
|
+
const projection = (run2) => sharedRunProjection({ ...run2, project: projectId(run2) }, canReadId);
|
|
60
|
+
const authorized = () => host.manager.list().filter(canRead).sort(compareRuns);
|
|
61
|
+
const requestedRun = () => {
|
|
62
|
+
const run2 = host.manager.get(parts[2] ?? "");
|
|
63
|
+
if (!run2 || !canRead(run2)) throw new HttpError(404, "Run not found");
|
|
64
|
+
return run2;
|
|
65
|
+
};
|
|
66
|
+
const assertBranch = (run2, branch) => {
|
|
67
|
+
if (typeof branch !== "string" || !run2.worktrees.some((tree) => tree.branch === branch)) throw new HttpError(404, "Result branch not found");
|
|
68
|
+
return branch;
|
|
69
|
+
};
|
|
70
|
+
const coverage = sharedCoverageProjection;
|
|
71
|
+
current();
|
|
72
|
+
if (policy === "catalog") return send(res, 200, redactExport(parts[1] === "patterns" ? host.patterns : host.templates));
|
|
73
|
+
if (policy === "project-list") return send(res, 200, host.projects.list().filter((project) => sharedProjectRole(current(), project.id)).map(sharedProjectProjection));
|
|
74
|
+
if (policy === "run-list" && parts[1] === "runs") {
|
|
75
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 50;
|
|
76
|
+
const query = url.searchParams.get("q") ?? "", selectedProject = url.searchParams.get("project");
|
|
77
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100 || query.length > 300) throw new HttpError(400, "Choose a history limit of 1\u2013100 and a bounded search");
|
|
78
|
+
if (selectedProject && !sharedProjectRole(current(), selectedProject)) throw new HttpError(404, "Project not found");
|
|
79
|
+
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
80
|
+
const filtered = authorized().filter((run2) => {
|
|
81
|
+
if (selectedProject && projectId(run2) !== selectedProject) return false;
|
|
82
|
+
if (!words.length) return true;
|
|
83
|
+
const full = run2.cold ? host.manager.get(run2.id) : run2;
|
|
84
|
+
if (!full || !canRead(full) || selectedProject && projectId(full) !== selectedProject) return false;
|
|
85
|
+
const text = [full.id, full.task, full.pattern].join("\n").toLowerCase();
|
|
86
|
+
return words.every((word) => text.includes(word));
|
|
87
|
+
});
|
|
88
|
+
const cursor = url.searchParams.get("cursor");
|
|
89
|
+
let after;
|
|
90
|
+
try {
|
|
91
|
+
after = cursor ? decodeRunCursor(cursor) : void 0;
|
|
92
|
+
} catch {
|
|
93
|
+
throw new HttpError(400, "Invalid shared history cursor");
|
|
94
|
+
}
|
|
95
|
+
const remaining = after ? filtered.filter((run2) => afterRunCursor(run2, after)) : filtered;
|
|
96
|
+
const page = remaining.slice(0, limit);
|
|
97
|
+
return send(res, 200, { runs: page.map(projection), total: filtered.length, ...remaining.length > limit ? { nextCursor: encodeRunCursor(page.at(-1)) } : {} });
|
|
98
|
+
}
|
|
99
|
+
if (policy === "run-list") {
|
|
100
|
+
const { emit, close, add, ping } = sharedStream(res, host.identity.userId);
|
|
101
|
+
let visible = /* @__PURE__ */ new Set();
|
|
102
|
+
const notification = (run2) => ({ id: run2.id, project: projectId(run2), status: run2.status, createdAt: run2.createdAt, endedAt: run2.endedAt });
|
|
103
|
+
const reset = () => {
|
|
104
|
+
const list = authorized();
|
|
105
|
+
visible = new Set(list.map((run2) => run2.id));
|
|
106
|
+
emit({ type: "hello", epoch: randomUUID(), runs: list.slice(0, 100).map(notification), total: list.length });
|
|
107
|
+
};
|
|
108
|
+
add(host.manager.subscribeAll((run2) => {
|
|
109
|
+
if (run2.deleted) {
|
|
110
|
+
if (visible.delete(run2.id)) reset();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
if (canRead(run2)) {
|
|
115
|
+
visible.add(run2.id);
|
|
116
|
+
emit({ type: "run", run: notification(run2) });
|
|
117
|
+
} else if (visible.delete(run2.id)) reset();
|
|
118
|
+
} catch {
|
|
119
|
+
close();
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
if (host.onFilePolicyChange) add(host.onFilePolicyChange(() => close()));
|
|
123
|
+
add(host.accounts.onChange(() => {
|
|
124
|
+
try {
|
|
125
|
+
current();
|
|
126
|
+
} catch {
|
|
127
|
+
close();
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
add(host.projects.onChange(() => {
|
|
131
|
+
try {
|
|
132
|
+
current();
|
|
133
|
+
reset();
|
|
134
|
+
} catch {
|
|
135
|
+
close();
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
const timer = setInterval(() => {
|
|
139
|
+
try {
|
|
140
|
+
current();
|
|
141
|
+
ping();
|
|
142
|
+
} catch {
|
|
143
|
+
close();
|
|
144
|
+
}
|
|
145
|
+
}, 15e3);
|
|
146
|
+
timer.unref();
|
|
147
|
+
add(() => clearInterval(timer));
|
|
148
|
+
reset();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const run = requestedRun();
|
|
152
|
+
if (policy === "run-review") {
|
|
153
|
+
const reviewer = (identity2) => roleAtLeast(sharedProjectRole(identity2, projectId(run)), "reviewer");
|
|
154
|
+
if (!reviewer(current())) throw new HttpError(403, "A project reviewer role is required");
|
|
155
|
+
const body = await readJson(req, 16 * 1024, { timeoutMs: 1e4 });
|
|
156
|
+
const branch = assertBranch(run, body.branch);
|
|
157
|
+
const identity = current();
|
|
158
|
+
if (!canRead(run) || !reviewer(identity)) throw new HttpError(404, "Run not found");
|
|
159
|
+
if (parts[3] === "review-coverage") {
|
|
160
|
+
if (typeof body.path !== "string" || sensitiveContextPath(body.path)) throw new HttpError(403, "This path requires owner review");
|
|
161
|
+
const result2 = await host.manager.recordFileReview(run.id, { branch, expectedHead: body.expectedHead, path: body.path, reviewed: body.reviewed }, `shared:${identity.userId}:${identity.username}`, () => {
|
|
162
|
+
if (!canRead(run) || !reviewer(current())) throw new HttpError(403, "Project reviewer access changed");
|
|
163
|
+
});
|
|
164
|
+
current();
|
|
165
|
+
if (!canRead(run)) throw new HttpError(404, "Run not found");
|
|
166
|
+
return send(res, 200, coverage(result2));
|
|
167
|
+
}
|
|
168
|
+
const result = await host.comments.add(run.id, { branch, expectedHead: body.expectedHead, text: body.text }, { id: `shared:${identity.userId}`, name: identity.displayName }, async () => {
|
|
169
|
+
current();
|
|
170
|
+
const latest = requestedRun();
|
|
171
|
+
assertBranch(latest, branch);
|
|
172
|
+
return (await host.manager.reviewCoverage(run.id, branch)).head;
|
|
173
|
+
}, () => {
|
|
174
|
+
if (!canRead(run) || !reviewer(current())) throw new HttpError(403, "Project reviewer access changed");
|
|
175
|
+
});
|
|
176
|
+
current();
|
|
177
|
+
if (!canRead(run)) throw new HttpError(404, "Run not found");
|
|
178
|
+
return send(res, 201, result);
|
|
179
|
+
}
|
|
180
|
+
if (parts.length === 3) return send(res, 200, projection(run));
|
|
181
|
+
if (parts[3] === "export") return send(res, 200, { version: 1, exportedAt: Date.now(), run: projection(run) });
|
|
182
|
+
if (parts[3] === "collaboration") {
|
|
183
|
+
const result = await host.comments.list(run.id);
|
|
184
|
+
current();
|
|
185
|
+
if (!canRead(run)) throw new HttpError(404, "Run not found");
|
|
186
|
+
return send(res, 200, result);
|
|
187
|
+
}
|
|
188
|
+
if (parts[3] === "diff" || parts[3] === "review-coverage") {
|
|
189
|
+
const branch = assertBranch(run, url.searchParams.get("branch"));
|
|
190
|
+
const result = parts[3] === "diff" ? sharedDiffProjection(await host.manager.diff(run.id, branch, false)) : coverage(await host.manager.reviewCoverage(run.id, branch));
|
|
191
|
+
current();
|
|
192
|
+
if (!canRead(run)) throw new HttpError(404, "Run not found");
|
|
193
|
+
return send(res, 200, result);
|
|
194
|
+
}
|
|
195
|
+
if (parts[3] === "events") {
|
|
196
|
+
const { emit, close, add, ping } = sharedStream(res, host.identity.userId);
|
|
197
|
+
add(host.manager.subscribe(run.id, (event) => {
|
|
198
|
+
try {
|
|
199
|
+
requestedRun();
|
|
200
|
+
const value = sharedEventProjection(event, canReadId);
|
|
201
|
+
if (value) emit(value);
|
|
202
|
+
} catch {
|
|
203
|
+
close();
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
206
|
+
if (host.onFilePolicyChange) add(host.onFilePolicyChange(() => close()));
|
|
207
|
+
add(host.accounts.onChange(() => {
|
|
208
|
+
try {
|
|
209
|
+
requestedRun();
|
|
210
|
+
} catch {
|
|
211
|
+
close();
|
|
212
|
+
}
|
|
213
|
+
}));
|
|
214
|
+
add(host.projects.onChange(() => {
|
|
215
|
+
try {
|
|
216
|
+
requestedRun();
|
|
217
|
+
} catch {
|
|
218
|
+
close();
|
|
219
|
+
}
|
|
220
|
+
}));
|
|
221
|
+
const timer = setInterval(() => {
|
|
222
|
+
try {
|
|
223
|
+
requestedRun();
|
|
224
|
+
ping();
|
|
225
|
+
} catch {
|
|
226
|
+
close();
|
|
227
|
+
}
|
|
228
|
+
}, 15e3);
|
|
229
|
+
timer.unref();
|
|
230
|
+
add(() => clearInterval(timer));
|
|
231
|
+
const snapshot = projection(run);
|
|
232
|
+
emit({ type: "hello", snapshot: { run: { ...snapshot, steps: void 0 }, steps: snapshot.steps ?? [] } });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
throw new HttpError(403, "This operation requires the Code Flow owner");
|
|
236
|
+
}
|
|
237
|
+
export {
|
|
238
|
+
handleSharedApi
|
|
239
|
+
};
|