toolroll 0.9.49 → 0.9.50

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli-http.js +3 -0
  3. package/dist/flow-triggers.js +7 -0
  4. package/dist/mcp-http.js +6 -11
  5. package/dist/mcp-oauth.js +5 -21
  6. package/dist/mcp-person.d.ts +0 -9
  7. package/dist/mcp-person.js +2 -1
  8. package/dist/operate.js +4 -1
  9. package/dist/serve.d.ts +2 -605
  10. package/dist/serve.js +423 -23783
  11. package/dist/server/chat.d.ts +41 -0
  12. package/dist/server/chat.js +1458 -0
  13. package/dist/server/edge-refusal.d.ts +5 -0
  14. package/dist/server/edge-refusal.js +56 -0
  15. package/dist/server/flows.d.ts +12 -0
  16. package/dist/server/flows.js +832 -0
  17. package/dist/server/guards.d.ts +72 -0
  18. package/dist/server/guards.js +357 -0
  19. package/dist/server/handler-context.d.ts +22 -0
  20. package/dist/server/handler-context.js +1 -0
  21. package/dist/server/handler-registry.d.ts +21 -0
  22. package/dist/server/handler-registry.js +34 -0
  23. package/dist/server/pages-assets.d.ts +10 -0
  24. package/dist/server/pages-assets.js +103 -0
  25. package/dist/server/pages.d.ts +11 -0
  26. package/dist/server/pages.js +910 -0
  27. package/dist/server/people-tokens.d.ts +20 -0
  28. package/dist/server/people-tokens.js +694 -0
  29. package/dist/server/remote-hooks.d.ts +29 -0
  30. package/dist/server/remote-hooks.js +130 -0
  31. package/dist/server/remote.d.ts +59 -0
  32. package/dist/server/remote.js +185 -0
  33. package/dist/server/route-policy.d.ts +18 -0
  34. package/dist/server/route-policy.js +30 -0
  35. package/dist/server/route-table.d.ts +74 -0
  36. package/dist/server/route-table.js +350 -0
  37. package/dist/server/runtime.d.ts +280 -0
  38. package/dist/server/runtime.js +25 -0
  39. package/dist/server/settings.d.ts +22 -0
  40. package/dist/server/settings.js +2388 -0
  41. package/dist/server/shared.d.ts +3334 -0
  42. package/dist/server/shared.js +12708 -0
  43. package/dist/server/task-views.d.ts +82 -0
  44. package/dist/server/task-views.js +1099 -0
  45. package/dist/server/tasks.d.ts +87 -0
  46. package/dist/server/tasks.js +3579 -0
  47. package/dist/session-http.js +3 -0
  48. package/dist/team-http.d.ts +5 -1
  49. package/dist/team-http.js +6 -2
  50. package/dist/teams.js +3 -0
  51. package/package.json +1 -1
@@ -0,0 +1,350 @@
1
+ /**
2
+ * THE ROUTE TABLE: every address this server answers, in one ordered list.
3
+ *
4
+ * Each row declares its method, its exact pattern, which callers may reach it,
5
+ * the scope it needs, how its project is found, and what a project-limited
6
+ * account may do there. The server matches a request against this table before
7
+ * any handler runs; a request no row declares is refused (fail closed). The
8
+ * scattered allowlists that used to repeat these facts by hand (the limited
9
+ * account's read/write sets, the viewer's POST set, the selected-project
10
+ * exceptions) are projections of this table.
11
+ *
12
+ * Handlers still re-prove what can change after admission: passwords, nonces,
13
+ * webhook signatures, object membership, stale sessions and revoked tokens.
14
+ */
15
+ const CONSOLE = ["cookie", "bearer"];
16
+ const exact = (path) => `^${path.replace(/[.*+?^${}()|[\]\\/]/g, m => (m === "/" ? "\\/" : `\\${m}`))}$`;
17
+ const sampleOf = (pattern) => {
18
+ // Literal patterns sample themselves; others must name a sample.
19
+ if (!/^\^[^()[\]?*+{}|]*\$$/.test(pattern.replace(/\\[./-]/g, "")))
20
+ throw new Error(`route ${pattern} needs a sample`);
21
+ return pattern.slice(1, -1).replace(/\\(.)/g, "$1");
22
+ };
23
+ const rows = [];
24
+ function row(stage, domain, id, method, pattern, options = {}) {
25
+ const console_ = stage === "console";
26
+ rows.push({
27
+ id, domain, stage, method, pattern,
28
+ callers: options.callers ?? (options.scope === "step-up" ? ["cookie"] : console_ ? CONSOLE : ["anonymous"]),
29
+ scope: options.scope ?? (console_ ? (method === "POST" ? "act" : "read") : "none"),
30
+ project: options.project ?? (console_ ? "session" : "none"),
31
+ limited: options.limited ?? "deny",
32
+ needsProject: options.needsProject ?? (console_ && method === "GET"),
33
+ viewer: options.viewer ?? false,
34
+ host: options.host ?? "after",
35
+ proof: options.proof ?? (console_ ? "console" : "public"),
36
+ sample: options.sample ?? sampleOf(pattern),
37
+ ...(options.scopeRefusal === undefined ? {} : { scopeRefusal: options.scopeRefusal }),
38
+ ...(options.callerRefusal === undefined ? {} : { callerRefusal: options.callerRefusal }),
39
+ });
40
+ }
41
+ const edge = (domain, id, method, path, options = {}) => row("edge", domain, id, method, path.startsWith("^") ? path : exact(path), options);
42
+ const get = (domain, id, path, options = {}) => row("console", domain, id, "GET", path.startsWith("^") ? path : exact(path), options);
43
+ const post = (domain, id, path, options = {}) => row("console", domain, id, "POST", path.startsWith("^") ? path : exact(path), options);
44
+ const ID = "[0-9]{1,15}";
45
+ const FLOW = "[1-9][0-9]{0,9}";
46
+ const TASK = "[^/]+";
47
+ /** Free of a project: no opener redirect for a session with nothing open. */
48
+ const free = { needsProject: false };
49
+ // ---- edge: answered before the console's sign-in ---------------------------------------------------------------
50
+ // Ordered exactly as the server checks them: health and webhooks before the Host check, the rest after it.
51
+ edge("pages", "edge.healthz", "GET,HEAD", "/healthz", { host: "before" });
52
+ edge("remote", "edge.telegram-hook", "POST", "/hooks/telegram", { callers: ["service"], host: "before", proof: "adapter" });
53
+ edge("remote", "edge.flow-form", "GET,HEAD", "^\\/hooks\\/form\\/[^/]+$", { project: "flow", sample: "/hooks/form/abc", host: "before", proof: "adapter" });
54
+ edge("remote", "edge.flow-form-send", "POST", "^\\/hooks\\/form\\/[^/]+$", { project: "flow", sample: "/hooks/form/abc", host: "before", proof: "adapter" });
55
+ edge("remote", "edge.flow-hook", "POST", "^\\/hooks\\/flow\\/[^/]+$", { callers: ["service"], project: "flow", sample: "/hooks/flow/abc", host: "before", proof: "adapter" });
56
+ // MCP and CLI multiplex read/act operations in POST bodies. Their minimum is read;
57
+ // the same evaluator receives the operation capability after protocol decoding.
58
+ edge("remote", "edge.mcp", "POST", "/mcp", { callers: ["bearer", "oauth", "coordinator"], scope: "read", project: "adapter", proof: "adapter" });
59
+ edge("remote", "edge.oauth-discovery", "GET,HEAD", "^\\/\\.well-known\\/(oauth-protected-resource(\\/mcp)?|oauth-authorization-server)$", { sample: "/.well-known/oauth-authorization-server" });
60
+ edge("remote", "edge.oauth-register", "POST", "/oauth/register");
61
+ edge("remote", "edge.oauth-token", "POST", "/oauth/token", { proof: "adapter", callers: ["oauth"] });
62
+ edge("remote", "edge.oauth-authorize", "GET,HEAD", "/oauth/authorize");
63
+ edge("remote", "edge.oauth-consent", "POST", "/oauth/authorize", { callers: ["cookie"], scope: "step-up", viewer: true, proof: "adapter" });
64
+ edge("settings", "edge.google-callback", "GET", "/settings/google/callback", { callers: ["service"], proof: "adapter" });
65
+ edge("settings", "edge.connect-callback", "GET", "/settings/tools/connected", { callers: ["service"], proof: "adapter" });
66
+ for (const operation of ["list", "show", "changes", "start", "send", "stop", "resume", "recover"]) {
67
+ edge("remote", `edge.sessions-${operation}`, "POST", `/api/sessions/${operation}`, { callers: ["bearer"], scope: ["list", "show", "changes"].includes(operation) ? "read" : "act", project: "adapter", proof: "adapter" });
68
+ }
69
+ edge("remote", "edge.team-read", "GET", "/api/team", { callers: ["cookie", "bearer"], scope: "read", project: "adapter", proof: "adapter" });
70
+ edge("remote", "edge.team-send", "POST", "/api/team", { callers: ["cookie", "bearer"], scope: "read", project: "adapter", proof: "adapter" });
71
+ edge("remote", "edge.team-events", "GET", "/api/team/events", { callers: ["cookie", "bearer"], scope: "read", project: "adapter", proof: "adapter" });
72
+ edge("remote", "edge.cli", "POST", "/api/cli", { callers: ["bearer"], scope: "read", project: "adapter", proof: "adapter" });
73
+ edge("remote", "edge.teams", "POST", "/teams/messages", { callers: ["service"], proof: "adapter" });
74
+ edge("pages", "edge.browser-asset", "GET,HEAD", "^\\/assets\\/(workspace\\.js|workspace\\.css|THIRD_PARTY_NOTICES\\.txt)$", { sample: "/assets/workspace.js" });
75
+ edge("pages", "edge.workspace-style", "GET,HEAD", "^\\/assets\\/workspace-[0-9a-f]{64}\\.css$", { sample: `/assets/workspace-${"0".repeat(64)}.css` });
76
+ edge("pages", "edge.install-asset", "GET", "^\\/(manifest\\.webmanifest|favicon\\.ico|icon\\.svg|icon-192\\.png|icon-512\\.png|apple-touch-icon\\.png|sw\\.js)$", { sample: "/sw.js" });
77
+ edge("pages", "edge.font", "GET", "^\\/fonts\\/geist-(sans|mono)-(400|500|600)\\.woff2$", { sample: "/fonts/geist-sans-400.woff2" });
78
+ edge("pages", "edge.desktop-health", "GET", "/desktop/health");
79
+ edge("people", "edge.login-page", "GET", "/login");
80
+ edge("people", "edge.login-link", "GET", "^\\/login\\/once\\/[^/]+$", { sample: "/login/once/abc" });
81
+ edge("people", "edge.sso-start", "GET", "/login/sso");
82
+ edge("people", "edge.sso-callback", "GET", "/login/sso/callback", { callers: ["service"], proof: "adapter" });
83
+ edge("people", "edge.sso-finish", "GET", "/login/sso/finish");
84
+ edge("people", "edge.signup", "POST", "/signup");
85
+ edge("people", "edge.login", "POST", "/login");
86
+ edge("people", "edge.logout", "POST", "/logout", { callers: ["anonymous", "cookie"] });
87
+ edge("people", "edge.join", "GET", "^\\/join\\/[A-Za-z0-9_-]{16,64}$", { sample: "/join/abcdefghijklmnop" });
88
+ edge("people", "edge.join-send", "POST", "^\\/join\\/[A-Za-z0-9_-]{16,64}$", { sample: "/join/abcdefghijklmnop" });
89
+ // ---- console reads ---------------------------------------------------------------------------------------------
90
+ // Coding: the handler answers any other caller in its own format (JSON for the workspace's fetches).
91
+ get("tasks", "code.page", "^\\/code(\\/.*)?$", { project: "coding", limited: "self", ...free, sample: "/code" });
92
+ get("pages", "projects.browse", "/projects/browse", free);
93
+ get("pages", "projects.github", "/projects/github", free);
94
+ get("pages", "projects.page", "/projects", { limited: "unscoped", ...free });
95
+ get("pages", "home", "/", { limited: "collection", ...free });
96
+ get("tasks", "inbox", "/inbox", { limited: "collection", ...free });
97
+ get("tasks", "work", "/work", { limited: "collection", ...free });
98
+ get("tasks", "next", "/next", { limited: "collection" });
99
+ get("pages", "morning", "/morning");
100
+ get("pages", "workbench", "/workbench", free);
101
+ get("tasks", "board", "/board", { limited: "collection" });
102
+ get("tasks", "review", "/review", { limited: "collection" });
103
+ get("settings", "spend", "/spend", free);
104
+ get("pages", "ledger", "/ledger", { limited: "unscoped", ...free });
105
+ get("pages", "activity", "/activity");
106
+ get("tasks", "done", "/done", { limited: "collection" });
107
+ get("settings", "mode", "/mode");
108
+ get("people", "people.page", "/people", { limited: "unscoped", ...free });
109
+ get("pages", "system", "/system");
110
+ get("tasks", "tasks", "/tasks", { limited: "collection" });
111
+ get("tasks", "queue", "/queue");
112
+ get("pages", "peek", "/peek");
113
+ get("pages", "fleet", "/fleet", free);
114
+ get("tasks", "tasks.new", "/tasks/new", { limited: "collection", ...free });
115
+ get("tasks", "task.live", `^\\/t\\/${TASK}\\/live$`, { project: "task", limited: "resource", ...free, sample: "/t/one/live" });
116
+ get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
117
+ get("tasks", "task.evidence", `^\\/t\\/${TASK}\\/evidence$`, { project: "task", limited: "resource", ...free, sample: "/t/one/evidence" });
118
+ get("pages", "ledger.export", "/ledger/export", { limited: "unscoped", ...free });
119
+ get("tasks", "runs", "/runs", { limited: "collection" });
120
+ get("pages", "menu", "/menu", { limited: "collection", ...free });
121
+ get("tasks", "run.page", `^\\/r\\/${ID}$`, { project: "run", limited: "resource", ...free, sample: "/r/1" });
122
+ get("tasks", "run.evidence", `^\\/r\\/${ID}\\/evidence\\/${ID}$`, { project: "run", limited: "resource", ...free, sample: "/r/1/evidence/2" });
123
+ get("tasks", "contest.page", `^\\/contest\\/${ID}$`, { ...free, sample: "/contest/1" });
124
+ get("pages", "caps", "/caps");
125
+ get("flows", "flows.new", "/flows/new", { limited: "resource", ...free });
126
+ get("flows", "flows.gallery", "^\\/flows\\/new\\/[a-z-]{1,40}$", { limited: "resource", ...free, sample: "/flows/new/triage" });
127
+ get("flows", "flows.page", "/flows", { limited: "resource", ...free });
128
+ get("flows", "kits", "/kits");
129
+ get("flows", "kit.page", "^\\/kits\\/[a-z-]{1,40}$", { sample: "/kits/support" });
130
+ get("flows", "teammates", "/teammates", free);
131
+ get("flows", "teammate.page", `^\\/teammates\\/[1-9][0-9]{0,9}(\\/soul\\.md)?$`, { ...free, sample: "/teammates/1/soul.md" });
132
+ get("flows", "flow.read", `^\\/flows\\/${FLOW}\\/(insights|runs\\/${FLOW}\\/${FLOW})$`, { project: "flow", limited: "resource", ...free, sample: "/flows/1/runs/2/3" });
133
+ get("flows", "flow.export", `^\\/flows\\/${FLOW}\\/export$`, { project: "flow", limited: "resource", ...free, sample: "/flows/1/export" });
134
+ get("flows", "flow.live", `^\\/flows\\/${FLOW}\\/live$`, { callers: ["cookie"], project: "flow", limited: "resource", ...free, sample: "/flows/1/live",
135
+ callerRefusal: { status: 403, type: "application/json", body: JSON.stringify({ error: "session" }) } });
136
+ get("flows", "flow.page", `^\\/flows\\/${FLOW}$`, { project: "flow", limited: "resource", ...free, sample: "/flows/1" });
137
+ get("flows", "recipes", "/recipes", { limited: "collection", ...free });
138
+ for (const one of ["run", "start", "new", "edit", "from-task", "preview", "export"])
139
+ get("flows", `recipes.${one}`, `/recipes/${one}`, { limited: "collection" });
140
+ // Any other recipe screen answers the recipe area's own refusal.
141
+ get("flows", "recipes.other", "^\\/recipes\\/.*$", { sample: "/recipes/unknown" });
142
+ get("tasks", "routines", "/routines", { limited: "collection" });
143
+ get("chat", "chat.stream", "/chat/stream", free);
144
+ get("chat", "chat.mate-status", "/chat/mate/status", free);
145
+ get("chat", "chat.demo-live", "/chat/demo/live", free);
146
+ get("chat", "chat.page", "/chat", { project: "conversation", limited: "conversation", ...free });
147
+ get("chat", "chat.ack", `^\\/chat\\/ack\\/${ID}$`, { sample: "/chat/ack/1" });
148
+ get("tasks", "routine.page", `^\\/routines\\/${ID}$`, { project: "routine", limited: "resource", sample: "/routines/1" });
149
+ get("settings", "control", "/control");
150
+ get("settings", "control.connection", "/control/connection");
151
+ get("chat", "chat.action", `^\\/chat\\/action\\/${ID}$`, { project: "proposal", limited: "proposal", ...free, sample: "/chat/action/1" });
152
+ for (const one of ["models", "tools", "project", "approval", "policy", "sessions", "integrations", "monitoring", "updates", "retention", "storage", "pull-requests", "checks", "backups", "data", "sign-in", "lead", "teams", "discord", "slack", "slack/manifest"]) {
153
+ get(one === "sessions" ? "people" : "settings", `settings.${one}`, `/settings/${one}`, free);
154
+ }
155
+ get("settings", "settings.skills", "/settings/skills", { limited: "unscoped", ...free });
156
+ get("settings", "settings.flows", "/settings/flows", { limited: "collection", ...free });
157
+ get("settings", "settings.knowledge", "/settings/knowledge", { limited: "unscoped", ...free });
158
+ get("settings", "settings.learning", "/settings/learning", { limited: "unscoped", ...free });
159
+ get("settings", "settings.telegram", "/settings/telegram", { limited: "self", ...free });
160
+ get("settings", "settings.page", "/settings", { limited: "unscoped", ...free });
161
+ get("pages", "health", "/health", free);
162
+ get("pages", "metrics", "/metrics", free);
163
+ get("chat", "push.key", "/push/key");
164
+ get("tasks", "decision.page", `^\\/d\\/${ID}$`, { project: "decision", limited: "resource", ...free, sample: "/d/1" });
165
+ get("chat", "lead.status", "/lead/status", free);
166
+ get("chat", "chat.task-status", "/chat/task-status", free);
167
+ get("tasks", "decision.evidence", `^\\/d\\/${ID}\\/evidence\\/${ID}$`, { project: "decision", limited: "resource", ...free, sample: "/d/1/evidence/2" });
168
+ // ---- console actions -------------------------------------------------------------------------------------------
169
+ // The attended beat guards itself (cookie, form type, Sec-Fetch-Site) before the shared mutation guard.
170
+ post("tasks", "session.attended-beats", "/session/attended-beats", { callers: ["cookie"], scopeRefusal: "your login can watch, not keep sessions alive", callerRefusal: { status: 403, type: "text/plain; charset=utf-8", body: "watching is a browser session's act" } });
171
+ // The scripted demo: the handler answers a non-browser caller "no page here".
172
+ post("chat", "chat.demo", "^\\/chat\\/demo\\/(ask|[0-9]{1,9}\\/(approve|change|revise|complete))$", { sample: "/chat/demo/ask" });
173
+ post("settings", "provider.resume", "^\\/providers\\/[a-z]+\\/resume$", { sample: "/providers/codex/resume" });
174
+ post("settings", "settings.updates-dismiss", "/settings/updates/dismiss");
175
+ post("settings", "settings.updates-checks", "/settings/updates/checks");
176
+ post("tasks", "code.act", "^\\/code(\\/.*)?$", { scope: "step-up", project: "coding", limited: "self", sample: "/code/start" });
177
+ post("settings", "settings.skills-revise", "/settings/skills/revise", { project: "form", limited: "unscoped" });
178
+ post("settings", "settings.skills-import", "/settings/skills/import", { project: "form", limited: "unscoped" });
179
+ post("settings", "settings.skills-change", "/settings/skills/change", { project: "form", limited: "unscoped" });
180
+ post("flows", "kit.act", "^\\/kits\\/[a-z-]{1,40}\\/(setup|sample|github)$", { project: "form", sample: "/kits/support/setup" });
181
+ post("flows", "teammate.new", "/teammates/new", { project: "form" });
182
+ post("flows", "teammate.act", "^\\/teammates\\/[1-9][0-9]{0,9}\\/(soul|state|note|settings|summary|tools|memory|routines|week)$", { sample: "/teammates/1/soul" });
183
+ post("flows", "teammate.answer", "^\\/teammates\\/questions\\/[1-9][0-9]{0,9}\\/answer$", { sample: "/teammates/questions/1/answer" });
184
+ post("flows", "flows.gallery-create", "^\\/flows\\/new\\/[a-z-]{1,40}$", { project: "form", limited: "resource", sample: "/flows/new/triage" });
185
+ post("flows", "flows.create", "/flows/new", { project: "form", limited: "resource" });
186
+ post("flows", "flows.example", "/flows/example", { project: "form" });
187
+ post("flows", "flows.import", "/flows/import", { project: "form", limited: "resource" });
188
+ post("flows", "flow.act", `^\\/flows\\/${FLOW}\\/(save|cards|archive|scripts)$`, { project: "flow", limited: "resource", sample: "/flows/1/save" });
189
+ post("flows", "flow.instance-act", `^\\/flows\\/${FLOW}\\/(linear-key|hooks-address|secrets)$`, { scope: "step-up", project: "flow", sample: "/flows/1/secrets" });
190
+ post("flows", "flow.triggers", `^\\/flows\\/${FLOW}\\/triggers$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers" });
191
+ post("flows", "flow.card", `^\\/flows\\/${FLOW}\\/cards\\/${FLOW}\\/(move|decide|cancel|comment|assign|watch)$`, { project: "flow", limited: "resource", sample: "/flows/1/cards/2/move" });
192
+ post("flows", "flow.card-choose", `^\\/flows\\/${FLOW}\\/cards\\/${FLOW}\\/choose$`, { project: "flow", sample: "/flows/1/cards/2/choose" });
193
+ post("flows", "flow.trigger.pause", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/pause$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/pause" });
194
+ post("flows", "flow.trigger.resume", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/resume$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/resume" });
195
+ post("flows", "flow.trigger.remove", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/remove$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/remove" });
196
+ post("flows", "flow.trigger.check", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/check$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/check" });
197
+ post("flows", "flow.trigger.press", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/press$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/press" });
198
+ post("flows", "flow.trigger.renew", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/renew$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/renew" });
199
+ post("flows", "flow.trigger.secret", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/secret$`, { scope: "step-up", project: "flow", limited: "resource", sample: "/flows/1/triggers/2/secret" });
200
+ post("flows", "flow.trigger.share", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/share$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/share" });
201
+ post("flows", "flow.trigger.unshare", `^\\/flows\\/${FLOW}\\/triggers\\/${FLOW}\\/unshare$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers/2/unshare" });
202
+ post("settings", "spend.budget", "/spend/budget", { scope: "step-up" });
203
+ post("settings", "settings.project-concurrency", "/settings/project/concurrency");
204
+ post("settings", "settings.project-delete", "/settings/project/delete", { scope: "step-up" });
205
+ post("pages", "ledger.checkpoint", "/ledger/checkpoint");
206
+ for (const one of ["policy", "approval", "request-limits", "sessions", "sign-in", "updates", "updates/seen", "updates/cancel", "retention", "storage", "storage/clean", "storage/discard", "pull-requests", "checks", "backups/now", "backups", "data", "integrations/test", "monitoring", "tools/connect", "tools/change", "appearance"]) {
207
+ post(one === "sessions" ? "people" : "settings", `settings.${one.replaceAll("/", "-")}-send`, `/settings/${one}`, { ...(["policy", "approval", "request-limits", "sessions", "sign-in", "updates", "updates/seen", "updates/cancel", "retention", "storage", "storage/clean", "storage/discard", "pull-requests", "checks", "backups", "data", "monitoring", "tools/connect", "tools/change"].includes(one) ? { scope: "step-up" } : {}) });
208
+ }
209
+ post("settings", "settings.flows-on", "/settings/flows/on", { limited: "collection" });
210
+ // Any other models action answers the models area's own refusal.
211
+ post("settings", "settings.models-act", "^\\/settings\\/models\\/.*$", { sample: "/settings/models/check" });
212
+ post("settings", "settings.knowledge-refresh", "/settings/knowledge/refresh", { project: "form", limited: "unscoped" });
213
+ post("settings", "settings.knowledge-proposal", "/settings/knowledge/proposal", { project: "form" });
214
+ post("settings", "settings.knowledge-decision", "/settings/knowledge/decision", { project: "form" });
215
+ post("settings", "settings.knowledge-change", "/settings/knowledge/change", { project: "form", limited: "unscoped" });
216
+ post("settings", "settings.learning-change", "/settings/learning/change", { project: "form", limited: "unscoped" });
217
+ for (const one of ["setup-preview", "setup-approve", "instructions-preview", "instructions-approve"])
218
+ post("settings", `control.${one}`, `/control/${one}`, { ...(one.endsWith("approve") ? { scope: "step-up" } : {}) });
219
+ for (const service of ["slack", "teams", "discord"])
220
+ post("settings", `settings.${service}-send`, `^\\/settings\\/${service}\\/(connect|pair|unpair|disconnect|alerts)$`, { sample: `/settings/${service}/pair`, scope: "step-up" });
221
+ post("settings", "settings.telegram-retry", "/settings/telegram/retry", { limited: "self", scope: "step-up" });
222
+ for (const one of ["confirm", "save", "off"])
223
+ post("settings", `settings.chat-approval-${one}`, `/settings/chat-approval/${one}`, { limited: "self", callers: ["cookie"], ...(one === "save" ? { scope: "step-up" } : {}) });
224
+ post("settings", "settings.telegram-pair", "/settings/telegram/pair", { limited: "self", scope: "step-up" });
225
+ post("settings", "settings.telegram-unpair", "/settings/telegram/unpair", { limited: "self", scope: "step-up" });
226
+ for (const one of ["messaging", "permission-default", "quality-default", "notifications", "notifications/mute", "telegram-digest", "provider-key", "provider-key-clear", "telegram-token", "email", "email-test", "email-read-test", "google", "google/disconnect"]) {
227
+ post("settings", `settings.${one.replaceAll("/", "-")}-send`, `/settings/${one}`);
228
+ }
229
+ post("pages", "projects.select", "/projects/select", { project: "form-path", limited: "unscoped", viewer: true });
230
+ post("pages", "projects.remove", "/projects/remove", { project: "form" });
231
+ post("pages", "projects.open", "/projects/open", { project: "form-path" });
232
+ post("tasks", "tasks.add", "/tasks/add", { project: "form", limited: "collection" });
233
+ post("tasks", "queue.move", "/queue/move");
234
+ post("tasks", "queue.note", "/queue/note");
235
+ post("pages", "fleet.register", "/fleet/runner/register", { scope: "step-up" });
236
+ post("settings", "mode.confirm", "/mode/confirm", { scope: "step-up" });
237
+ post("settings", "mode.sign", "/mode/sign", { scope: "step-up" });
238
+ post("settings", "mode.revoke", "/mode/revoke");
239
+ post("people", "people.projects", "/people/projects", { scope: "step-up" });
240
+ post("people", "people.invite", "/people/invite", { scope: "step-up" });
241
+ post("people", "people.invite-revoke", "/people/invite-revoke", { scope: "step-up" });
242
+ post("people", "people.revoke", "/people/revoke", { scope: "step-up" });
243
+ post("pages", "fleet.retire", "/fleet/runner/retire", { scope: "step-up" });
244
+ // Answering a parked decision needs act access and irreversible-choice confirmation, not password step-up.
245
+ post("tasks", "decision.answer", `^\\/d\\/${ID}\\/answer$`, { project: "decision", limited: "resource", scope: "act", sample: "/d/1/answer" });
246
+ post("tasks", "contest.act.arm", `^\\/contest\\/${ID}\\/arm$`, { sample: "/contest/1/arm" });
247
+ post("tasks", "contest.act.pick", `^\\/contest\\/${ID}\\/pick$`, { scope: "step-up", sample: "/contest/1/pick" });
248
+ post("tasks", "contest.act.abandon", `^\\/contest\\/${ID}\\/abandon$`, { scope: "step-up", sample: "/contest/1/abandon" });
249
+ post("tasks", "task.attend.attend-preview", `^\\/t\\/${TASK}\\/attend-preview$`, { callers: ["cookie"], project: "task", sample: "/t/one/attend-preview" });
250
+ post("tasks", "task.attend.attend", `^\\/t\\/${TASK}\\/attend$`, { scope: "step-up", project: "task", sample: "/t/one/attend" });
251
+ post("tasks", "task.attend.attend-revoke", `^\\/t\\/${TASK}\\/attend-revoke$`, { callers: ["cookie"], project: "task", sample: "/t/one/attend-revoke" });
252
+ post("tasks", "task.act.hold", `^\\/t\\/${TASK}\\/hold$`, { project: "task", limited: "resource", sample: "/t/one/hold" });
253
+ post("tasks", "task.act.unhold", `^\\/t\\/${TASK}\\/unhold$`, { project: "task", limited: "resource", sample: "/t/one/unhold" });
254
+ post("tasks", "task.act.requeue", `^\\/t\\/${TASK}\\/requeue$`, { project: "task", limited: "resource", sample: "/t/one/requeue" });
255
+ post("tasks", "task.act.cancel", `^\\/t\\/${TASK}\\/cancel$`, { project: "task", limited: "resource", sample: "/t/one/cancel" });
256
+ post("tasks", "task.act.scope", `^\\/t\\/${TASK}\\/scope$`, { project: "task", limited: "resource", sample: "/t/one/scope" });
257
+ post("tasks", "task.act.approve", `^\\/t\\/${TASK}\\/approve$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/approve" });
258
+ post("tasks", "task.act.plan", `^\\/t\\/${TASK}\\/plan$`, { project: "task", limited: "resource", sample: "/t/one/plan" });
259
+ post("tasks", "task.act.plan-edit", `^\\/t\\/${TASK}\\/plan-edit$`, { project: "task", limited: "resource", sample: "/t/one/plan-edit" });
260
+ post("tasks", "task.act.next", `^\\/t\\/${TASK}\\/next$`, { project: "task", limited: "resource", sample: "/t/one/next" });
261
+ post("tasks", "task.act.reopen", `^\\/t\\/${TASK}\\/reopen$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/reopen" });
262
+ post("tasks", "task.act.steer", `^\\/t\\/${TASK}\\/steer$`, { project: "task", limited: "resource", sample: "/t/one/steer" });
263
+ post("tasks", "task.act.accept-proof", `^\\/t\\/${TASK}\\/accept-proof$`, { project: "task", limited: "resource", sample: "/t/one/accept-proof" });
264
+ post("tasks", "task.act.accept-revision", `^\\/t\\/${TASK}\\/accept-revision$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/accept-revision" });
265
+ post("tasks", "task.act.reject-revision", `^\\/t\\/${TASK}\\/reject-revision$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/reject-revision" });
266
+ post("tasks", "task.act.route", `^\\/t\\/${TASK}\\/route$`, { project: "task", limited: "resource", sample: "/t/one/route" });
267
+ post("tasks", "task.act.retry-review", `^\\/t\\/${TASK}\\/retry-review$`, { project: "task", limited: "resource", sample: "/t/one/retry-review" });
268
+ post("tasks", "task.act.complete", `^\\/t\\/${TASK}\\/complete$`, { project: "task", limited: "resource", sample: "/t/one/complete" });
269
+ post("tasks", "task.act.merge", `^\\/t\\/${TASK}\\/merge$`, { project: "task", limited: "resource", sample: "/t/one/merge" });
270
+ post("tasks", "task.act.confirm-stopped", `^\\/t\\/${TASK}\\/confirm-stopped$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/confirm-stopped" });
271
+ post("tasks", "task.act.stop", `^\\/t\\/${TASK}\\/stop$`, { project: "task", limited: "resource", sample: "/t/one/stop" });
272
+ post("tasks", "task.act.resume-arm", `^\\/t\\/${TASK}\\/resume-arm$`, { project: "task", limited: "resource", sample: "/t/one/resume-arm" });
273
+ post("tasks", "task.act.resume", `^\\/t\\/${TASK}\\/resume$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/resume" });
274
+ post("tasks", "task.instance-act", `^\\/t\\/${TASK}\\/(block|unblock|repair-dependency|follow-up)$`, { project: "task", sample: "/t/one/block" });
275
+ // Onboarding names a GitHub repository, not a local project. Its root-index/nonce ceremony proves the future destination.
276
+ post("pages", "projects.onboard-preview", "/projects/onboard-preview", { project: "none" });
277
+ post("pages", "projects.onboard-confirm", "/projects/onboard-confirm", { scope: "step-up", project: "none" });
278
+ post("chat", "push.subscribe", "/push/subscribe", { scope: "step-up" });
279
+ post("chat", "push.remove", "/push/remove");
280
+ post("chat", "onboarding.phone-dismiss", "/onboarding/phone/dismiss");
281
+ for (const one of ["identity", "about", "promise/cancel", "on"])
282
+ post("settings", `settings.lead-${one.replaceAll("/", "-")}`, `/settings/lead/${one}`);
283
+ post("chat", "chat.config", "/chat/config", { scope: "step-up" });
284
+ for (const one of ["mint", "follow", "end", "stop"])
285
+ post("chat", `chat.mate-${one}`, `/chat/mate/${one}`, { callers: ["cookie"], ...(one === "mint" ? { scope: "step-up" } : {}) });
286
+ post("chat", "chat.proposal", `^\\/chat\\/proposal\\/${ID}\\/(confirm|dismiss)$`, { project: "proposal", limited: "proposal", sample: "/chat/proposal/1/confirm" });
287
+ post("chat", "coordinator.proposal", `^\\/proposals\\/${ID}\\/(confirm|dismiss)$`, { sample: "/proposals/1/confirm" });
288
+ post("chat", "chat.send", "/chat", { scope: "step-up", project: "conversation" });
289
+ post("chat", "chat.file", "^\\/chat\\/file\\/[0-9a-f]{32}$", { scope: "step-up", sample: `/chat/file/${"a".repeat(32)}` });
290
+ post("chat", "chat.ack-send", `^\\/chat\\/ack\\/${ID}$`, { scope: "step-up", sample: "/chat/ack/1" });
291
+ for (const one of ["prepare", "preview", "import", "save"])
292
+ post("flows", `recipes.${one}-send`, `/recipes/${one}`, { limited: "collection" });
293
+ post("flows", "recipes.launch-send", "/recipes/launch", { limited: "collection" });
294
+ post("tasks", "routines.add", "/routines/add", { project: "form", limited: "collection" });
295
+ post("tasks", "routine.act.approve", `^\\/routines\\/${ID}\\/approve$`, { scope: "step-up", project: "routine", limited: "resource", sample: "/routines/1/approve" });
296
+ post("tasks", "routine.act.refresh", `^\\/routines\\/${ID}\\/refresh$`, { project: "routine", limited: "resource", sample: "/routines/1/refresh" });
297
+ post("tasks", "routine.act.pause", `^\\/routines\\/${ID}\\/pause$`, { project: "routine", limited: "resource", sample: "/routines/1/pause" });
298
+ post("tasks", "routine.act.resume", `^\\/routines\\/${ID}\\/resume$`, { project: "routine", limited: "resource", sample: "/routines/1/resume" });
299
+ post("tasks", "routine.act.run-now", `^\\/routines\\/${ID}\\/run-now$`, { scope: "step-up", project: "routine", limited: "resource", sample: "/routines/1/run-now" });
300
+ post("tasks", "run.act", `^\\/r\\/${ID}\\/(note|comment|revise|draft-repair|checks|add-tests)$`, { project: "run", limited: "resource", sample: "/r/1/note" });
301
+ post("tasks", "session.editor-links", "/session/editor-links", { viewer: true });
302
+ post("tasks", "run.turn", `^\\/r\\/${ID}\\/turn$`, { project: "run", sample: "/r/1/turn" });
303
+ post("tasks", "incident.resolve", `^\\/i\\/${ID}\\/resolve$`, { project: "incident", sample: "/i/1/resolve" });
304
+ /** The sole route table, in match order. */
305
+ export const ROUTES = Object.freeze(rows.map(one => Object.freeze(one)));
306
+ const compiled = ROUTES.map(route => ({ route, test: new RegExp(route.pattern) }));
307
+ const methodMatches = (declared, method) => declared === method || (declared === "GET,HEAD" && (method === "GET" || method === "HEAD"));
308
+ /** The first row that declares this method and path, or null: an undeclared request never reaches a handler. */
309
+ export function matchRoute(method, pathname, stage) {
310
+ for (const { route, test } of compiled) {
311
+ if (stage !== undefined && route.stage !== stage)
312
+ continue;
313
+ if (methodMatches(route.method, method) && test.test(pathname))
314
+ return route;
315
+ }
316
+ return null;
317
+ }
318
+ /** True when some row declares this path for another method (a wrong-method request). */
319
+ export function declaredForAnotherMethod(method, pathname, stage) {
320
+ return compiled.some(({ route, test }) => (stage === undefined || route.stage === stage) && !methodMatches(route.method, method) && test.test(pathname));
321
+ }
322
+ /** Refuses a malformed table at startup: duplicate ids, duplicate method+pattern rows, or a row shadowed by an earlier one for its own sample. */
323
+ export function assertRouteTable(routes = ROUTES) {
324
+ const ids = new Set(), keys = new Set();
325
+ for (const route of routes) {
326
+ if (ids.has(route.id))
327
+ throw new Error(`route table: duplicate id ${route.id}`);
328
+ ids.add(route.id);
329
+ const key = `${route.stage} ${route.method} ${route.pattern}`;
330
+ if (keys.has(key))
331
+ throw new Error(`route table: duplicate route ${key}`);
332
+ keys.add(key);
333
+ if (route.scope === "step-up" && (route.callers.length !== 1 || route.callers[0] !== "cookie"))
334
+ throw new Error(`route table: ${route.id} step-up must be cookie-only`);
335
+ if (route.stage === "console" && (route.proof !== "console" || route.host !== "after"))
336
+ throw new Error(`route table: ${route.id} cannot bypass console admission`);
337
+ if (route.callers.length === 0)
338
+ throw new Error(`route table: ${route.id} admits no caller`);
339
+ const test = new RegExp(route.pattern);
340
+ if (!test.test(route.sample))
341
+ throw new Error(`route table: ${route.id} does not match its sample ${route.sample}`);
342
+ const method = route.method === "GET,HEAD" ? "GET" : route.method;
343
+ const winner = routes.find(one => one.stage === route.stage && methodMatches(one.method, method) && new RegExp(one.pattern).test(route.sample));
344
+ if (winner !== route)
345
+ throw new Error(`route table: ${route.id} is shadowed by ${winner?.id} for ${route.sample}`);
346
+ }
347
+ }
348
+ assertRouteTable();
349
+ /** The effective policy of every row, as plain data — what the snapshot test compares. */
350
+ export const routePolicySnapshot = () => ROUTES.map(({ sample: _sample, ...rest }) => ({ ...rest }));