@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.3
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 +141 -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/changes-62ZIEQSN.js +1 -0
- package/public/assets/chunk-2PZVOKID.js +1 -0
- package/public/assets/chunk-4D5IENEB.js +1 -0
- package/public/assets/chunk-5SARP5MU.js +1 -0
- package/public/assets/chunk-63YLZ3PR.js +1 -0
- package/public/assets/chunk-7FUHKF2M.js +1 -0
- package/public/assets/chunk-APQEVJ2K.js +1 -0
- package/public/assets/chunk-CGOZQMEZ.js +2 -0
- package/public/assets/chunk-J4VYS3KV.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-SIHDWM2Y.js +154 -0
- package/public/assets/chunk-TEJVYIKC.js +1 -0
- package/public/assets/code-browser-VB2HGZSG.js +4 -0
- package/public/assets/filtered-tasks-ES5CP4KV.js +1 -0
- package/public/assets/interactive-browser-7F5ZXRUV.js +7 -0
- package/public/assets/join-project-6RTFHNCA.js +1 -0
- package/public/assets/main-65EW73SB.js +1 -0
- package/public/assets/project-invitations-view-GMPWGWYM.js +1 -0
- package/public/assets/settings-UB5Q4AQM.js +9 -0
- package/public/assets/styles-18792BB4.css +32 -0
- package/public/assets/team-server-wizard-2GLDWS6D.js +8 -0
- package/public/assets/terminal-IWAIM4DE.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 +148 -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 +143 -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 +88 -0
- package/public/docs/installation.html +80 -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 +129 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +65 -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 +412 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +131 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/computer-use.swift +64 -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/agent-tools.js +28 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +242 -0
- package/src/adapters/agy-tools.js +45 -0
- package/src/adapters/agy.js +326 -0
- package/src/adapters/claude-stream.js +58 -0
- package/src/adapters/claude.js +282 -0
- package/src/adapters/codex-app-server.js +555 -0
- package/src/adapters/codex.js +311 -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 +433 -0
- package/src/adapters/team-tools.js +103 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-browser.js +688 -0
- package/src/agent-computer.js +88 -0
- package/src/agent-container.js +155 -0
- package/src/agent-team-definitions.js +131 -0
- package/src/agent-tool-client.mjs +79 -0
- package/src/agent-tools.js +296 -0
- package/src/approvals.js +134 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/browser-files.js +293 -0
- package/src/browser-ownership.js +150 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-family.js +25 -0
- package/src/chat-handoff.js +87 -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/documentation-changes.js +20 -0
- package/src/engine.js +5258 -0
- package/src/env.js +44 -0
- package/src/execution-budget.js +8 -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/frame-policy.js +66 -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/local-app-url.js +19 -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 +239 -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 +230 -0
- package/src/patterns/auto-team.js +816 -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 +21 -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 +46 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/task-review.js +45 -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 +392 -0
- package/src/preview.js +230 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-budget.js +22 -0
- package/src/project-team-model.js +225 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +234 -0
- package/src/project-team-usage.js +24 -0
- package/src/project-teams-api.js +43 -0
- package/src/project-teams.js +644 -0
- package/src/projects.js +290 -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 +725 -0
- package/src/repo-tree.js +106 -0
- package/src/repository-identity.js +13 -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 +111 -0
- package/src/run-projection.js +77 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +56 -0
- package/src/serve.js +138 -0
- package/src/server.js +2871 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +381 -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 +133 -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/task-team.js +164 -0
- package/src/team-evaluation.js +243 -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-steering.js +21 -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 +235 -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,381 @@
|
|
|
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", "invitations"]) || 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
|
+
if (raw.invitations !== void 0) {
|
|
72
|
+
if (!Array.isArray(raw.invitations) || raw.invitations.length > 256) throw new Error("Invalid invitations");
|
|
73
|
+
for (const invite of raw.invitations) {
|
|
74
|
+
if (!object(invite) || !keys(invite, ["id", "projectId", "role", "createdAt", "expiresAt", "acceptedAt", "revokedAt", "hash"]) || typeof invite.projectId !== "string" || typeof invite.id !== "string" || !/^i-[a-f0-9-]{36}$/.test(invite.id) || !hex(invite.hash, 64) || !integer(invite.createdAt) || !integer(invite.expiresAt) || invite.expiresAt !== invite.createdAt + 24 * 60 * 6e4 || invite.acceptedAt !== void 0 && !integer(invite.acceptedAt) || invite.revokedAt !== void 0 && !integer(invite.revokedAt)) throw new Error("Invalid invitation");
|
|
75
|
+
validateSharedRoles({ [String(invite.projectId)]: invite.role });
|
|
76
|
+
}
|
|
77
|
+
if (new Set(raw.invitations.map((invite) => invite.id)).size !== raw.invitations.length || new Set(raw.invitations.map((invite) => invite.hash)).size !== raw.invitations.length) throw new Error("Duplicate invitation");
|
|
78
|
+
}
|
|
79
|
+
for (const u of raw.users) {
|
|
80
|
+
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");
|
|
81
|
+
validateSharedRoles(u.roles);
|
|
82
|
+
}
|
|
83
|
+
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");
|
|
84
|
+
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");
|
|
85
|
+
if (new Set(raw.sessions.map((s) => s.hash)).size !== raw.sessions.length) throw new Error("Duplicate shared session");
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
class LoginLimiter {
|
|
89
|
+
buckets = /* @__PURE__ */ new Map();
|
|
90
|
+
check(address, name, now) {
|
|
91
|
+
for (const [key, value] of this.buckets) if (value.expires <= now) this.buckets.delete(key);
|
|
92
|
+
const keys2 = [["global", 100], [`ip:${address.slice(0, 128)}`, 20], [`user:${digest(name)}`, 8]];
|
|
93
|
+
if (keys2.some(([key, max]) => (this.buckets.get(key)?.count ?? 0) >= max)) throw new SharedAccountError("Too many sign-in attempts; try again later", 429);
|
|
94
|
+
for (const [key] of keys2) {
|
|
95
|
+
const bucket = this.buckets.get(key) ?? { count: 0, expires: now + 15 * 6e4 };
|
|
96
|
+
bucket.count++;
|
|
97
|
+
this.buckets.delete(key);
|
|
98
|
+
this.buckets.set(key, bucket);
|
|
99
|
+
}
|
|
100
|
+
while (this.buckets.size > 2048) this.buckets.delete(this.buckets.keys().next().value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
class SharedAccounts {
|
|
104
|
+
constructor(file = join(CODEFLOW_HOME, "shared-accounts.json")) {
|
|
105
|
+
this.file = file;
|
|
106
|
+
}
|
|
107
|
+
state = { version: 1, revision: 0, users: [], sessions: [] };
|
|
108
|
+
loaded = false;
|
|
109
|
+
loading;
|
|
110
|
+
readable = true;
|
|
111
|
+
writes = Promise.resolve();
|
|
112
|
+
listeners = /* @__PURE__ */ new Set();
|
|
113
|
+
limiter = new LoginLimiter();
|
|
114
|
+
get available() {
|
|
115
|
+
return this.loaded && this.readable;
|
|
116
|
+
}
|
|
117
|
+
get hasUsers() {
|
|
118
|
+
return this.available && this.state.users.length > 0;
|
|
119
|
+
}
|
|
120
|
+
get hasInvitations() {
|
|
121
|
+
return this.available && !!this.state.invitations?.length;
|
|
122
|
+
}
|
|
123
|
+
load() {
|
|
124
|
+
return this.loading ??= (async () => {
|
|
125
|
+
try {
|
|
126
|
+
const handle = await open(this.file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
127
|
+
try {
|
|
128
|
+
const stat = await handle.stat();
|
|
129
|
+
if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error("Shared account storage is not a bounded regular file");
|
|
130
|
+
this.state = validateState(JSON.parse(await handle.readFile("utf8")));
|
|
131
|
+
} finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error.code !== "ENOENT") {
|
|
136
|
+
this.readable = false;
|
|
137
|
+
throw new SharedAccountError("Shared accounts cannot be read; owner recovery remains available and the account file was left untouched", 503);
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
this.loaded = true;
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
}
|
|
144
|
+
snapshot() {
|
|
145
|
+
this.require();
|
|
146
|
+
return { revision: this.state.revision, users: this.state.users.map(publicUser) };
|
|
147
|
+
}
|
|
148
|
+
onChange(listener) {
|
|
149
|
+
this.listeners.add(listener);
|
|
150
|
+
return () => this.listeners.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
invitations() {
|
|
153
|
+
this.require();
|
|
154
|
+
return (this.state.invitations ?? []).map(({ hash: _hash, ...invite }) => ({ ...invite }));
|
|
155
|
+
}
|
|
156
|
+
async invite(input) {
|
|
157
|
+
const roles = validateSharedRoles({ [input.projectId]: input.role });
|
|
158
|
+
const token = randomBytes(32).toString("base64url"), now = Date.now();
|
|
159
|
+
const invitation = { id: `i-${randomUUID()}`, projectId: input.projectId, role: roles[input.projectId], createdAt: now, expiresAt: now + 24 * 60 * 6e4 };
|
|
160
|
+
await this.load();
|
|
161
|
+
await this.mutate((state) => {
|
|
162
|
+
state.invitations = (state.invitations ?? []).filter((entry) => entry.expiresAt > now);
|
|
163
|
+
if (state.invitations.length >= 256) throw new SharedAccountError("Too many invitations; wait for existing links to expire", 409);
|
|
164
|
+
state.invitations.push({ ...invitation, hash: digest(token) });
|
|
165
|
+
}, false);
|
|
166
|
+
return { invitation, token };
|
|
167
|
+
}
|
|
168
|
+
async revokeInvitation(id) {
|
|
169
|
+
await this.load();
|
|
170
|
+
await this.mutate((state) => {
|
|
171
|
+
const invite = state.invitations?.find((entry) => entry.id === id);
|
|
172
|
+
if (!invite) throw new SharedAccountError("Invitation not found", 404);
|
|
173
|
+
invite.revokedAt ??= Date.now();
|
|
174
|
+
}, false);
|
|
175
|
+
}
|
|
176
|
+
previewInvitation(token) {
|
|
177
|
+
this.require();
|
|
178
|
+
const { hash: _hash, ...invite } = this.findInvitation(this.state, token);
|
|
179
|
+
return { ...invite };
|
|
180
|
+
}
|
|
181
|
+
findInvitation(state, token) {
|
|
182
|
+
const invite = typeof token === "string" && /^[a-zA-Z0-9_-]{43}$/.test(token) ? state.invitations?.find((entry) => entry.hash === digest(token)) : void 0;
|
|
183
|
+
if (!invite || invite.expiresAt <= Date.now() || invite.revokedAt !== void 0 || invite.acceptedAt !== void 0) throw new SharedAccountError("This invitation expired, was used, or was revoked. Ask the project owner for a new link.", 410);
|
|
184
|
+
return invite;
|
|
185
|
+
}
|
|
186
|
+
/** Consume the invitation, membership and sign-in in one durable write; concurrent acceptance can succeed only once. */
|
|
187
|
+
async acceptInvitation(input, commit = (_projectId, save) => save()) {
|
|
188
|
+
await this.load();
|
|
189
|
+
this.require();
|
|
190
|
+
if (!input.secure && !input.loopback) throw new SharedAccountError("Joining a project requires HTTPS outside this computer", 403);
|
|
191
|
+
const invitation = this.previewInvitation(input.token);
|
|
192
|
+
const existing = input.existing ? await this.verify(input) : void 0;
|
|
193
|
+
if (!existing) this.limiter.check(input.address, String(input.username).slice(0, 128), Date.now());
|
|
194
|
+
const name = existing?.username ?? username(input.username), label = existing?.displayName ?? displayName(input.displayName);
|
|
195
|
+
const password = existing ? void 0 : await makePassword(input.password);
|
|
196
|
+
const token = randomBytes(32).toString("base64url");
|
|
197
|
+
let result;
|
|
198
|
+
await commit(invitation.projectId, () => this.change((state) => {
|
|
199
|
+
const invite = this.findInvitation(state, input.token), now = Date.now();
|
|
200
|
+
let user;
|
|
201
|
+
if (existing) {
|
|
202
|
+
user = this.find(state, existing.id);
|
|
203
|
+
if (!user.enabled || user.revision !== existing.revision) throw new SharedAccountError("Account changed; sign in again", 409);
|
|
204
|
+
if (!roleAtLeast(user.roles[invite.projectId], invite.role)) user.roles[invite.projectId] = invite.role;
|
|
205
|
+
user.revision++;
|
|
206
|
+
user.updatedAt = now;
|
|
207
|
+
state.sessions = state.sessions.filter((session) => session.userId !== user.id);
|
|
208
|
+
} else {
|
|
209
|
+
if (state.users.some((entry) => entry.username === name)) throw new SharedAccountError("That username is already in use. Choose Sign in with an existing account or another username.", 409);
|
|
210
|
+
if (state.users.length >= MAX_USERS) throw new SharedAccountError("This server has reached its account limit; contact the owner", 409);
|
|
211
|
+
user = { id: `u-${randomUUID()}`, username: name, displayName: label, enabled: true, revision: 1, roles: { [invite.projectId]: invite.role }, password, createdAt: now, updatedAt: now };
|
|
212
|
+
state.users.push(user);
|
|
213
|
+
}
|
|
214
|
+
invite.acceptedAt = now;
|
|
215
|
+
state.sessions = state.sessions.filter((session) => session.expiresAt > now && session.lastSeenAt + IDLE_MS > now).sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_SESSIONS - 1);
|
|
216
|
+
state.sessions.push({ hash: digest(token), userId: user.id, revision: user.revision, createdAt: now, lastSeenAt: now, expiresAt: now + SESSION_MS });
|
|
217
|
+
result = { token, user: publicUser(user), projectId: invite.projectId, expiresAt: now + SESSION_MS };
|
|
218
|
+
}));
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
create(input) {
|
|
222
|
+
return this.change(async (state) => {
|
|
223
|
+
this.cas(state, input.revision);
|
|
224
|
+
if (state.users.length >= MAX_USERS) throw new SharedAccountError("At most 128 shared accounts may be created");
|
|
225
|
+
const name = username(input.username), label = displayName(input.displayName), roles = validateSharedRoles(input.roles);
|
|
226
|
+
if (state.users.some((user) => user.username === name)) throw new SharedAccountError("That username is already in use", 409);
|
|
227
|
+
const password = await makePassword(input.password);
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
state.users.push({ id: `u-${randomUUID()}`, username: name, displayName: label, enabled: true, revision: 1, roles, password, createdAt: now, updatedAt: now });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
update(id, input) {
|
|
233
|
+
return this.change(async (state) => {
|
|
234
|
+
this.cas(state, input.revision);
|
|
235
|
+
const user = this.find(state, id);
|
|
236
|
+
if (input.displayName !== void 0) user.displayName = displayName(input.displayName);
|
|
237
|
+
if (input.enabled !== void 0) {
|
|
238
|
+
if (typeof input.enabled !== "boolean") throw new SharedAccountError("Enabled must be true or false");
|
|
239
|
+
user.enabled = input.enabled;
|
|
240
|
+
}
|
|
241
|
+
if (input.roles !== void 0) user.roles = validateSharedRoles(input.roles);
|
|
242
|
+
if (input.password !== void 0) user.password = await makePassword(input.password);
|
|
243
|
+
user.revision++;
|
|
244
|
+
user.updatedAt = Date.now();
|
|
245
|
+
state.sessions = state.sessions.filter((session) => session.userId !== id);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
revokeSessions(id, input) {
|
|
249
|
+
return this.change((state) => {
|
|
250
|
+
this.cas(state, input.revision);
|
|
251
|
+
const user = this.find(state, id);
|
|
252
|
+
user.revision++;
|
|
253
|
+
user.updatedAt = Date.now();
|
|
254
|
+
state.sessions = state.sessions.filter((session) => session.userId !== id);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** 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. */
|
|
258
|
+
async verifyCredentials(input) {
|
|
259
|
+
return publicUser(await this.verify(input));
|
|
260
|
+
}
|
|
261
|
+
async login(input) {
|
|
262
|
+
const user = await this.verify(input);
|
|
263
|
+
const token = randomBytes(32).toString("base64url"), now = Date.now();
|
|
264
|
+
const revision = user.revision;
|
|
265
|
+
await this.mutate((state) => {
|
|
266
|
+
const current = this.find(state, user.id);
|
|
267
|
+
if (!current.enabled || current.revision !== revision) throw new SharedAccountError("Account changed during sign-in; try again", 401);
|
|
268
|
+
state.sessions = state.sessions.filter((session) => session.expiresAt > now && session.lastSeenAt + IDLE_MS > now && session.userId !== user.id);
|
|
269
|
+
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);
|
|
270
|
+
state.sessions.push(...own, { hash: digest(token), userId: user.id, revision, createdAt: now, lastSeenAt: now, expiresAt: now + SESSION_MS });
|
|
271
|
+
state.sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
272
|
+
state.sessions = state.sessions.slice(0, MAX_SESSIONS);
|
|
273
|
+
}, false);
|
|
274
|
+
return { token, user: publicUser(user), expiresAt: now + SESSION_MS };
|
|
275
|
+
}
|
|
276
|
+
authenticate(token, options = Date.now()) {
|
|
277
|
+
const now = typeof options === "number" ? options : options.now ?? Date.now();
|
|
278
|
+
if (!this.available || !/^[a-zA-Z0-9_-]{43}$/.test(token)) return void 0;
|
|
279
|
+
const hash = digest(token);
|
|
280
|
+
const session = this.state.sessions.find((session2) => session2.hash === hash);
|
|
281
|
+
if (!session || session.expiresAt <= now || session.lastSeenAt + IDLE_MS <= now) return void 0;
|
|
282
|
+
const user = this.state.users.find((user2) => user2.id === session.userId);
|
|
283
|
+
if (!user?.enabled || user.revision !== session.revision) return void 0;
|
|
284
|
+
if (typeof options === "number" || options.touch !== false) session.lastSeenAt = Math.max(session.lastSeenAt, now);
|
|
285
|
+
return principal(user);
|
|
286
|
+
}
|
|
287
|
+
async logout(token) {
|
|
288
|
+
await this.load();
|
|
289
|
+
await this.mutate((state) => {
|
|
290
|
+
state.sessions = state.sessions.filter((session) => session.hash !== digest(token));
|
|
291
|
+
}, true);
|
|
292
|
+
}
|
|
293
|
+
/** The single password check: an unknown account still derives against a dummy salt, so timing never reveals which usernames exist. */
|
|
294
|
+
async verify(input) {
|
|
295
|
+
await this.load();
|
|
296
|
+
this.require();
|
|
297
|
+
if (!input.secure && !input.loopback) throw new SharedAccountError("Shared sign-in requires HTTPS outside this computer", 403);
|
|
298
|
+
const name = typeof input.username === "string" ? input.username.toLowerCase() : "";
|
|
299
|
+
this.limiter.check(input.address, name.slice(0, 128), Date.now());
|
|
300
|
+
const supplied = passwordText(input.password, false), user = this.state.users.find((entry) => entry.username === name);
|
|
301
|
+
const expected = user ? Buffer.from(user.password.hash, "hex") : dummyHash;
|
|
302
|
+
const result = await derive(supplied, user?.password.salt ?? dummySalt);
|
|
303
|
+
if (!timingSafeEqual(result, expected) || !user?.enabled) throw new SharedAccountError("Invalid username or password", 401);
|
|
304
|
+
return user;
|
|
305
|
+
}
|
|
306
|
+
require() {
|
|
307
|
+
if (!this.available) throw new SharedAccountError("Shared account storage is unavailable; use the owner recovery link", 503);
|
|
308
|
+
}
|
|
309
|
+
find(state, id) {
|
|
310
|
+
const user = state.users.find((user2) => user2.id === id);
|
|
311
|
+
if (!user) throw new SharedAccountError("Shared account not found", 404);
|
|
312
|
+
return user;
|
|
313
|
+
}
|
|
314
|
+
cas(state, revision) {
|
|
315
|
+
if (!integer(revision) || revision !== state.revision) throw new SharedAccountError("Accounts changed; reload before saving", 409);
|
|
316
|
+
}
|
|
317
|
+
async change(update) {
|
|
318
|
+
await this.load();
|
|
319
|
+
await this.mutate(async (state) => {
|
|
320
|
+
await update(state);
|
|
321
|
+
state.revision++;
|
|
322
|
+
}, true);
|
|
323
|
+
return this.snapshot();
|
|
324
|
+
}
|
|
325
|
+
mutate(update, notify) {
|
|
326
|
+
const write = this.writes.catch(() => void 0).then(async () => {
|
|
327
|
+
this.require();
|
|
328
|
+
const next = structuredClone(this.state);
|
|
329
|
+
await update(next);
|
|
330
|
+
await this.write(next);
|
|
331
|
+
this.state = next;
|
|
332
|
+
if (notify) for (const listener of this.listeners) {
|
|
333
|
+
try {
|
|
334
|
+
listener();
|
|
335
|
+
} catch {
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
this.writes = write;
|
|
340
|
+
return write;
|
|
341
|
+
}
|
|
342
|
+
async write(state) {
|
|
343
|
+
await mkdir(dirname(this.file), { recursive: true, mode: 448 });
|
|
344
|
+
try {
|
|
345
|
+
const info = await lstat(this.file);
|
|
346
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new SharedAccountError("Refusing to replace non-regular account storage", 503);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error.code !== "ENOENT") throw error;
|
|
349
|
+
}
|
|
350
|
+
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
351
|
+
try {
|
|
352
|
+
const handle = await open(temp, "wx", 384);
|
|
353
|
+
try {
|
|
354
|
+
await handle.writeFile(JSON.stringify(state));
|
|
355
|
+
await handle.sync();
|
|
356
|
+
} finally {
|
|
357
|
+
await handle.close();
|
|
358
|
+
}
|
|
359
|
+
await rename(temp, this.file);
|
|
360
|
+
await chmod(this.file, 384);
|
|
361
|
+
if (process.platform !== "win32") {
|
|
362
|
+
const dir = await open(dirname(this.file), "r");
|
|
363
|
+
try {
|
|
364
|
+
await dir.sync();
|
|
365
|
+
} finally {
|
|
366
|
+
await dir.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
await rm(temp, { force: true }).catch(() => {
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
export {
|
|
376
|
+
SHARED_ROLES,
|
|
377
|
+
SharedAccountError,
|
|
378
|
+
SharedAccounts,
|
|
379
|
+
roleAtLeast,
|
|
380
|
+
validateSharedRoles
|
|
381
|
+
};
|