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.
- package/CHANGELOG.md +8 -0
- package/dist/cli-http.js +3 -0
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.js +6 -11
- package/dist/mcp-oauth.js +5 -21
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.js +4 -1
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +423 -23783
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +694 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +185 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.js +3 -0
- package/dist/team-http.d.ts +5 -1
- package/dist/team-http.js +6 -2
- package/dist/teams.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type FormFieldOf, type FormView } from "../contracts/console-api.js";
|
|
2
|
+
import type { TaskFamily } from "../store.js";
|
|
3
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
import { type Principal } from "../operate-remote.js";
|
|
5
|
+
import { authenticateApprover as checkApproverPassword } from "../scope.js";
|
|
6
|
+
import { SourceBudget } from "../sign-in-guard.js";
|
|
7
|
+
import { type Run, type Store } from "../store.js";
|
|
8
|
+
import type { RouteDeclaration } from './route-table.js';
|
|
9
|
+
import { PersistentSessions, type Session, type Who } from "./shared.js";
|
|
10
|
+
/** Shared browser, credential and project guards. Runtime access is lazy to preserve server initialization order. */
|
|
11
|
+
export interface GuardsRuntime {
|
|
12
|
+
store: Store;
|
|
13
|
+
liveCeiling: () => {
|
|
14
|
+
repos: string[];
|
|
15
|
+
roots: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
codingProjects: () => string[];
|
|
18
|
+
unscopedMode: boolean;
|
|
19
|
+
managedRepos: () => string[];
|
|
20
|
+
ceiling: import("../project.js").ProjectCeiling;
|
|
21
|
+
allowedHost: (host: string | undefined) => boolean;
|
|
22
|
+
defaultProject: string | null;
|
|
23
|
+
actionTarget: (url: URL, who: Who, request: IncomingMessage, body?: FormView<FormFieldOf<"ledgerTarget">> | null) => {
|
|
24
|
+
repo: string | null;
|
|
25
|
+
taskId: string | null;
|
|
26
|
+
runId: number | null;
|
|
27
|
+
action: string;
|
|
28
|
+
};
|
|
29
|
+
team: {
|
|
30
|
+
execute: import("../team-contract.js").TeamExecute;
|
|
31
|
+
start: () => void;
|
|
32
|
+
close: () => Promise<void>;
|
|
33
|
+
pass: () => Promise<void>;
|
|
34
|
+
cursor: (actor: import("../team-contract.js").TeamActor, conversationId?: string) => number | null;
|
|
35
|
+
domain: import("../team-leads.js").TeamLeads;
|
|
36
|
+
};
|
|
37
|
+
joinSourceOf: (request: IncomingMessage) => string;
|
|
38
|
+
signInBudget: SourceBudget;
|
|
39
|
+
passwordAllowed: (name: string) => boolean;
|
|
40
|
+
sessions: PersistentSessions;
|
|
41
|
+
}
|
|
42
|
+
export declare function createSharedGuards(runtime: GuardsRuntime): {
|
|
43
|
+
authenticateApprover: (who: Who, token: string, repo?: string | null) => ReturnType<typeof checkApproverPassword>;
|
|
44
|
+
authorizeMutation: (request: IncomingMessage, who: Who, body: FormView<FormFieldOf<"mutationGuard">>) => {
|
|
45
|
+
status: number;
|
|
46
|
+
message: string;
|
|
47
|
+
} | null;
|
|
48
|
+
projectOf: (who: Who, request: IncomingMessage) => string | null | undefined;
|
|
49
|
+
resolveRouteProject: (source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, body: URLSearchParams | null) => string | null;
|
|
50
|
+
projectRequestAllowed: (route: RouteDeclaration, source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, response: ServerResponse, body: URLSearchParams | null) => boolean;
|
|
51
|
+
identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
|
|
52
|
+
principal?: Principal;
|
|
53
|
+
}) => Who | null;
|
|
54
|
+
lookupSession: (candidate: string, touch?: boolean) => Session | null;
|
|
55
|
+
restricted: () => boolean;
|
|
56
|
+
visible: (repo: string | null) => boolean;
|
|
57
|
+
admissionList: () => string[] | null;
|
|
58
|
+
codingActorAllowed: (actor: {
|
|
59
|
+
name: string;
|
|
60
|
+
generation: number;
|
|
61
|
+
}) => boolean;
|
|
62
|
+
codingProjectAllowed: (repo: string) => boolean;
|
|
63
|
+
workAccess: () => {
|
|
64
|
+
principal: 'operator';
|
|
65
|
+
repos: string[] | null;
|
|
66
|
+
includeUnplaced: boolean;
|
|
67
|
+
viewer: string | null;
|
|
68
|
+
};
|
|
69
|
+
familyOf: (taskId: string) => TaskFamily | null;
|
|
70
|
+
taskRepoOf: (taskRef: number) => string | null;
|
|
71
|
+
runVisible: (run: Run) => boolean;
|
|
72
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { OWNER_ACTIONS, sharedActionPayload } from '../chat-actions.js';
|
|
2
|
+
import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import {} from "node:http";
|
|
5
|
+
import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "../api-tokens.js";
|
|
6
|
+
import { oauthProjects, oauthTokenAllowed } from "../mcp-oauth.js";
|
|
7
|
+
import { REMOTE_MESSAGES, reproveRemote } from "../operate-remote.js";
|
|
8
|
+
import { canonicalProject, rowVisible } from "../project.js";
|
|
9
|
+
import { authenticateAccount, authenticateApprover as checkApproverPassword } from "../scope.js";
|
|
10
|
+
import { SourceBudget } from "../sign-in-guard.js";
|
|
11
|
+
import {} from "../store.js";
|
|
12
|
+
import { matchTaskPath, PersistentSessions, refuse, requestContext, SESSION_ABSOLUTE_MS, SESSION_COOKIE, SESSION_IDLE_MS } from "./shared.js";
|
|
13
|
+
export function createSharedGuards(runtime) {
|
|
14
|
+
// v99: wrong passwords. One address guessing across names runs out of tries here; each name
|
|
15
|
+
// locks on its own (sign-in-guard.ts), and every lock, whatever road it came by, is kept.
|
|
16
|
+
/**
|
|
17
|
+
* Every step-up in the console (v100): a person the identity provider
|
|
18
|
+
* checked in the last ten minutes confirms with that (an empty password
|
|
19
|
+
* field); anyone else types their password. Standing (an approver, with
|
|
20
|
+
* access to the project) is checked either way.
|
|
21
|
+
*/
|
|
22
|
+
// v100: the shared check honours a fresh identity-provider sign-in itself (freshIdentitySignIn, set per request below).
|
|
23
|
+
function authenticateApprover(who, token, repo) {
|
|
24
|
+
// Re-entering a password never upgrades a machine credential into a browser ceremony.
|
|
25
|
+
if (who.via !== "cookie")
|
|
26
|
+
return { ok: false, reason: "not-an-approver" };
|
|
27
|
+
return checkApproverPassword(runtime.store, who.name, token, repo);
|
|
28
|
+
}
|
|
29
|
+
// Native tools share the installation's login and can read beyond a project.
|
|
30
|
+
// Account project membership cannot provide an OS read boundary.
|
|
31
|
+
function codingActorAllowed(actor) {
|
|
32
|
+
return runtime.store.isInstanceOperator(actor.name) && runtime.store.accountOf(actor.name)?.generation === actor.generation;
|
|
33
|
+
}
|
|
34
|
+
const restricted = () => {
|
|
35
|
+
const actor = requestContext.getStore()?.actor;
|
|
36
|
+
return actor !== undefined && runtime.store.accountOf(actor)?.projects !== null;
|
|
37
|
+
};
|
|
38
|
+
const visible = (repo) => {
|
|
39
|
+
const actor = requestContext.getStore()?.actor;
|
|
40
|
+
return rowVisible(runtime.liveCeiling(), repo) && (actor === undefined || runtime.store.accountCanAccess(actor, repo));
|
|
41
|
+
};
|
|
42
|
+
function codingProjectAllowed(repo) { return runtime.codingProjects().includes(repo); }
|
|
43
|
+
/** The enumerable admission list for roll-up SQL: repos-only ceilings
|
|
44
|
+
* enumerate themselves; root ceilings enumerate the STORED repos that
|
|
45
|
+
* pass the ceiling (Codex roll-up review, finding 11); unscoped = null. */
|
|
46
|
+
const admissionList = () => restricted() ? [...(runtime.store.accountOf(requestContext.getStore().actor)?.projects ?? [])].filter(visible) : runtime.unscopedMode
|
|
47
|
+
? null
|
|
48
|
+
: [...new Set([...runtime.managedRepos(), ...(runtime.ceiling.roots.length === 0 ? [] : runtime.store.knownRepos().filter(visible))])];
|
|
49
|
+
/** The task behind a resource, for the ceiling check; null = no ref (visible). */
|
|
50
|
+
const taskRepoOf = (taskRef) => runtime.store.refForId(taskRef)?.repo ?? null;
|
|
51
|
+
/**
|
|
52
|
+
* The one gate every POST passes — parse elsewhere, authorize here. A
|
|
53
|
+
* refusal names its status; null means proceed. Duplicated security fields
|
|
54
|
+
* are refused outright: two `csrf` values in one body is not a preference,
|
|
55
|
+
* it is a smuggling attempt.
|
|
56
|
+
*/
|
|
57
|
+
function authorizeMutation(request, who, body) {
|
|
58
|
+
// A read token never mutates, whatever its person's role (operate-remote.ts says the same to the remote CLI).
|
|
59
|
+
if (who.via === "bearer") {
|
|
60
|
+
if (who.principal !== undefined) {
|
|
61
|
+
const live = reproveRemote(runtime.store, who.principal, new Date());
|
|
62
|
+
if (!live.ok)
|
|
63
|
+
return { status: 403, message: REMOTE_MESSAGES.stale };
|
|
64
|
+
if (live.scope !== "act")
|
|
65
|
+
return { status: 403, message: REMOTE_MESSAGES.read };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const current = runtime.store.accountOf(who.name);
|
|
69
|
+
if (current === null || current.revokedAt !== null || current.role !== who.role || current.generation !== (who.via === "cookie" ? who.session.generation : who.generation)) {
|
|
70
|
+
return { status: 403, message: "Your access changed. Sign in again." };
|
|
71
|
+
}
|
|
72
|
+
const type = request.headers["content-type"] ?? "";
|
|
73
|
+
if (!type.startsWith("application/x-www-form-urlencoded")) {
|
|
74
|
+
return { status: 415, message: "forms only" };
|
|
75
|
+
}
|
|
76
|
+
for (const field of ["csrf", "token", "digest", "nonce", "confirm"]) {
|
|
77
|
+
if (body.getAll(field).length > 1) {
|
|
78
|
+
return { status: 400, message: `duplicated ${field} field` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (who.via === "cookie") {
|
|
82
|
+
// A PRESENT Origin must name this server. An ABSENT one is not a
|
|
83
|
+
// forgery: iOS Safari and some in-app browsers omit Origin on
|
|
84
|
+
// same-origin form posts, and refusing them locked the console on
|
|
85
|
+
// the operator's own phone. The per-session CSRF token below is the
|
|
86
|
+
// primary proof either way — a cross-site attacker can post, but
|
|
87
|
+
// cannot read the token to include it.
|
|
88
|
+
const origin = request.headers.origin;
|
|
89
|
+
const referer = request.headers.referer;
|
|
90
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
91
|
+
if (named !== null && !runtime.allowedHost(named.replace(/^https?:\/\//, "").split("/")[0])) {
|
|
92
|
+
return { status: 403, message: "origin not allowed" };
|
|
93
|
+
}
|
|
94
|
+
if (body.get("csrf") !== who.session.csrf) {
|
|
95
|
+
return { status: 403, message: "stale form — reload and try again" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The project a request views through — cookie sessions carry their open
|
|
102
|
+
* project; bearer callers may name one per request, constrained by the
|
|
103
|
+
* ceiling. Returns undefined when a named project is outside the ceiling:
|
|
104
|
+
* that is a refusal, not a fallback.
|
|
105
|
+
*/
|
|
106
|
+
function projectOf(who, request) {
|
|
107
|
+
const fallback = () => restricted() ? admissionList()?.[0] ?? null : runtime.defaultProject;
|
|
108
|
+
if (who.via === "cookie") {
|
|
109
|
+
const selected = who.session.project;
|
|
110
|
+
if (!restricted()) {
|
|
111
|
+
if (selected === null || visible(selected))
|
|
112
|
+
return selected;
|
|
113
|
+
// A saved selection can outlive the live ceiling, including its startup default.
|
|
114
|
+
const project = fallback();
|
|
115
|
+
return visible(project) ? project : null;
|
|
116
|
+
}
|
|
117
|
+
return selected !== null && visible(selected) ? selected : fallback();
|
|
118
|
+
}
|
|
119
|
+
const header = request.headers["x-standing-orders-project"];
|
|
120
|
+
if (header === undefined)
|
|
121
|
+
return fallback();
|
|
122
|
+
if (Array.isArray(header))
|
|
123
|
+
return undefined;
|
|
124
|
+
const canonical = canonicalProject(header);
|
|
125
|
+
if (canonical === null || !visible(canonical))
|
|
126
|
+
return undefined;
|
|
127
|
+
return canonical;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* What a project-limited account may do at this address: a projection of the route table's `limited` column
|
|
131
|
+
* (server/route-table.ts), never a hand-kept list. An address the table does not declare is instance-only.
|
|
132
|
+
*/
|
|
133
|
+
function resolveRouteProject(source, url, who, request, body) {
|
|
134
|
+
const id = Number(url.pathname.split('/')[2]);
|
|
135
|
+
const runRepo = (runId) => {
|
|
136
|
+
const run = runId == null ? null : runtime.store.getRun(runId);
|
|
137
|
+
return run === null ? null : runtime.store.refForId(run.taskRef)?.repo ?? null;
|
|
138
|
+
};
|
|
139
|
+
switch (source) {
|
|
140
|
+
case 'none': return null;
|
|
141
|
+
case 'session': return projectOf(who, request) ?? null;
|
|
142
|
+
case 'form': return body?.get('repo')?.trim() || projectOf(who, request) || null;
|
|
143
|
+
case 'form-path': {
|
|
144
|
+
const path = body?.get('path')?.trim() ?? '';
|
|
145
|
+
// Invalid/missing paths keep the opener's existing 400 response and recoverable form.
|
|
146
|
+
return path === '' ? null : canonicalProject(path);
|
|
147
|
+
}
|
|
148
|
+
case 'task': {
|
|
149
|
+
const task = matchTaskPath(url.pathname, '(?:/[a-z-]+)?$');
|
|
150
|
+
return task === null ? null : runtime.store.lookupRef(task.taskId)?.repo ?? null;
|
|
151
|
+
}
|
|
152
|
+
case 'run': return runRepo(id);
|
|
153
|
+
case 'decision': return runRepo(runtime.store.getDecision(id)?.run);
|
|
154
|
+
case 'incident': return runRepo(runtime.store.openIncidents().find(one => one.id === id)?.run);
|
|
155
|
+
case 'routine': return runtime.store.getRoutine(id)?.repo ?? null;
|
|
156
|
+
case 'flow': return runtime.store.getFlow(id)?.repo ?? null;
|
|
157
|
+
case 'proposal':
|
|
158
|
+
case 'coding': return runtime.actionTarget(url, who, request, body === null ? null : readForm(body, CONSOLE_FORMS.ledgerTarget)).repo;
|
|
159
|
+
case 'conversation': return projectOf(who, request) ?? null;
|
|
160
|
+
case 'adapter': throw new Error('A console route cannot delegate project admission to a protocol adapter');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function projectRequestAllowed(route, source, url, who, request, response, body) {
|
|
164
|
+
const path = url.pathname;
|
|
165
|
+
const project = resolveRouteProject(source, url, who, request, body);
|
|
166
|
+
if (restricted() && body !== null) {
|
|
167
|
+
const repos = body.getAll("repo");
|
|
168
|
+
if (repos.length > 1 || repos.some(repo => repo.trim() !== "" && !visible(repo.trim()))) {
|
|
169
|
+
refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!restricted()) {
|
|
174
|
+
if (project !== null && !visible(project)) {
|
|
175
|
+
if (source === 'form-path')
|
|
176
|
+
refuse(response, who, 403, "that project is outside this console's reach", "/projects");
|
|
177
|
+
else if (source === 'form')
|
|
178
|
+
refuse(response, who, 403, "that project is outside what this server was configured to show", "/projects");
|
|
179
|
+
else
|
|
180
|
+
refuse(response, who, 404, "No such resource in your projects.", "/projects");
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (source === 'form-path' && project !== null && !visible(project)) {
|
|
186
|
+
refuse(response, who, 403, "that project is outside this console's reach", "/projects");
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// Shared coordination has its own complete audience/project admission.
|
|
190
|
+
// A person's open-project filter does not deny an explicitly scoped room.
|
|
191
|
+
if (route?.limited === 'conversation' && request.method === 'GET' && who.via === 'cookie' && url.searchParams.get('private') !== '1' && ((!url.searchParams.has('task') && !url.searchParams.has('result')) || url.searchParams.has('conversation'))) {
|
|
192
|
+
try {
|
|
193
|
+
const snapshot = runtime.team.domain.snapshot({ name: who.name, generation: who.session.generation }, url.searchParams.get('conversation') ?? undefined, url.searchParams.get('lead') ?? undefined);
|
|
194
|
+
if ((snapshot.leads.length > 0 || url.searchParams.get('team') === '1') && (!snapshot.selected || snapshot.selected.projects.every(visible)))
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
refuse(response, who, 404, 'This conversation is unavailable.', '/projects');
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// The person's own settings and saved objects (personal pairing, their chat-approval setting, their coding
|
|
203
|
+
// sessions): each route separately proves cookie, standing, password and every project it names.
|
|
204
|
+
if (route?.limited === "self")
|
|
205
|
+
return true;
|
|
206
|
+
if (route?.limited === "proposal") {
|
|
207
|
+
const proposal = runtime.store.getMateProposal(Number(path.split('/')[3])), action = proposal?.kind === 'action' ? sharedActionPayload(proposal.payload) : null;
|
|
208
|
+
const shared = proposal ? runtime.store.handle.prepare('SELECT id FROM team_conversation WHERE thread=?').get(proposal.thread) : null;
|
|
209
|
+
if (shared && who.via === 'cookie') {
|
|
210
|
+
try {
|
|
211
|
+
const access = runtime.team.domain.access({ name: who.name, generation: who.session.generation }, String(shared['id']), 'contributor');
|
|
212
|
+
if (access.conversation.projects.every(visible))
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch { /* The same scoped refusal below also covers revoked membership. */ }
|
|
216
|
+
}
|
|
217
|
+
if (action && proposal && runtime.store.getMateThread(proposal.thread)?.approver === who.name && visible(action.repo) && runtime.store.accountCanAccess(who.name, action.repo))
|
|
218
|
+
return true;
|
|
219
|
+
// A card about the person themselves (what their lead knows about them) is in no project.
|
|
220
|
+
if (action && proposal && action.repo === '' && OWNER_ACTIONS.has(action.operation) && runtime.store.getMateThread(proposal.thread)?.approver === who.name)
|
|
221
|
+
return true;
|
|
222
|
+
refuse(response, who, 404, 'No such action in your projects.', '/projects');
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
// A task address whose id does not decode names no task.
|
|
226
|
+
const undecodable = route?.project === "task" && matchTaskPath(path, "(?:/[a-z-]+)?$") === null;
|
|
227
|
+
if (route === null || route.limited === "deny" || route.limited === "conversation" || undecodable) {
|
|
228
|
+
refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
if (route.limited === "resource") {
|
|
232
|
+
if (!visible(project)) {
|
|
233
|
+
refuse(response, who, 404, "No such resource in your projects.", "/projects");
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Every collection except the unscoped ones must have a concrete project;
|
|
238
|
+
// NULL otherwise means all rows in legacy store APIs.
|
|
239
|
+
if (route.limited !== "unscoped" && !visible(projectOf(who, request) ?? null)) {
|
|
240
|
+
refuse(response, who, 403, "No assigned project is available. Ask an instance operator for access.", "/projects");
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (route.id === "board") {
|
|
244
|
+
url.searchParams.delete("scope");
|
|
245
|
+
if (url.searchParams.get("view") === "order")
|
|
246
|
+
url.searchParams.delete("view");
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
function workAccess() {
|
|
251
|
+
// The reader's own lead's claims read "<name> is on it" (lead-voice.ts); nobody else's.
|
|
252
|
+
return { principal: 'operator', repos: admissionList(), includeUnplaced: visible(null), viewer: requestContext.getStore()?.actor ?? null };
|
|
253
|
+
}
|
|
254
|
+
function familyOf(taskId) {
|
|
255
|
+
return runtime.store.taskFamilyOf(taskId, admissionList(), visible(null));
|
|
256
|
+
}
|
|
257
|
+
// ---- identity ------------------------------------------------------------
|
|
258
|
+
function identify(request, touch = true, limited = false, refusedToken) {
|
|
259
|
+
// v101: an API token. Wrong ones spend the address's tries like a wrong password; expired or revoked ones, and a removed account's, name no one.
|
|
260
|
+
const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
261
|
+
if (presented !== undefined) {
|
|
262
|
+
const source = runtime.joinSourceOf(request), at = Date.now();
|
|
263
|
+
if (runtime.signInBudget.waitFor(source, at) > 0)
|
|
264
|
+
return null;
|
|
265
|
+
const parsed = parseApiToken(presented);
|
|
266
|
+
const kept = parsed === null ? null : runtime.store.apiTokenSecret(parsed.id);
|
|
267
|
+
if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash)) {
|
|
268
|
+
runtime.signInBudget.failed(source, at);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (!tokenLive(kept.row, at))
|
|
272
|
+
return null;
|
|
273
|
+
// v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
|
|
274
|
+
// through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
|
|
275
|
+
const account = runtime.store.accountOf(kept.row.account);
|
|
276
|
+
if (account === null || account.revokedAt !== null)
|
|
277
|
+
return null;
|
|
278
|
+
const principal = { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id,
|
|
279
|
+
projects: tokenProjects(tokenProjects(account.projects, kept.row.projects), oauthProjects(runtime.store, kept.row.id)) };
|
|
280
|
+
// Preserve proof only for a refusal and its admission charge. This never makes an OAuth token a console login.
|
|
281
|
+
if (!oauthTokenAllowed(runtime.store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)) ||
|
|
282
|
+
(kept.row.projects !== null && !limited)) {
|
|
283
|
+
if (refusedToken !== undefined)
|
|
284
|
+
refusedToken.principal = principal;
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
runtime.store.touchApiToken(kept.row.id, new Date(at));
|
|
288
|
+
return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name, generation: account.generation, principal };
|
|
289
|
+
}
|
|
290
|
+
const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? "");
|
|
291
|
+
if (bearer !== null) {
|
|
292
|
+
// A password on a request is a sign-in too: the same per-address tries and per-name lock.
|
|
293
|
+
const source = runtime.joinSourceOf(request), at = Date.now();
|
|
294
|
+
if (runtime.signInBudget.waitFor(source, at) > 0)
|
|
295
|
+
return null;
|
|
296
|
+
const authenticated = authenticateAccount(runtime.store, bearer[1], bearer[2]);
|
|
297
|
+
if (!authenticated.ok && authenticated.reason === "unknown")
|
|
298
|
+
runtime.signInBudget.failed(source, at);
|
|
299
|
+
return authenticated.ok && runtime.passwordAllowed(bearer[1]) ? { name: bearer[1], via: "bearer", role: authenticated.role, generation: authenticated.generation } : null;
|
|
300
|
+
}
|
|
301
|
+
const cookies = request.headers.cookie ?? "";
|
|
302
|
+
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
|
|
303
|
+
if (match === null)
|
|
304
|
+
return null;
|
|
305
|
+
const session = lookupSession(match[1], touch);
|
|
306
|
+
return session === null ? null : { name: session.name, via: "cookie", session, role: session.role };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Constant-time against the session table, small as it is — and every hit
|
|
310
|
+
* is re-proved against time and the approver's credential generation. A
|
|
311
|
+
* rotated credential kills its cookies the same way it kills its Telegram
|
|
312
|
+
* bindings: authority derived from the old secret does not outlive it.
|
|
313
|
+
*/
|
|
314
|
+
function lookupSession(candidate, touch = true) {
|
|
315
|
+
const bytes = Buffer.from(candidate, "utf8");
|
|
316
|
+
let found = null;
|
|
317
|
+
for (const [id, session] of runtime.sessions) {
|
|
318
|
+
const stored = Buffer.from(id, "utf8");
|
|
319
|
+
if (stored.length === bytes.length && timingSafeEqual(stored, bytes)) {
|
|
320
|
+
found = [id, session];
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// v101: not in memory, maybe from before a restart.
|
|
325
|
+
if (found === null) {
|
|
326
|
+
const loaded = runtime.sessions.load(candidate);
|
|
327
|
+
if (loaded !== null)
|
|
328
|
+
found = [candidate, loaded];
|
|
329
|
+
}
|
|
330
|
+
for (const [id, session] of found === null ? [] : [found]) {
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
if (now - session.lastSeen > SESSION_IDLE_MS || now - session.createdAt > SESSION_ABSOLUTE_MS) {
|
|
333
|
+
runtime.sessions.delete(id);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (runtime.store.approverGeneration(session.name) !== session.generation) {
|
|
337
|
+
runtime.sessions.delete(id);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (touch) {
|
|
341
|
+
session.lastSeen = now;
|
|
342
|
+
runtime.sessions.persist(session, true);
|
|
343
|
+
}
|
|
344
|
+
return session;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* When this server is scoped to a repo, a run whose task belongs to a
|
|
350
|
+
* different repo does not exist here — its evidence may hold that other
|
|
351
|
+
* repo's diffs, and one console instance is one trust domain.
|
|
352
|
+
*/
|
|
353
|
+
function runVisible(run) {
|
|
354
|
+
return visible(taskRepoOf(run.taskRef));
|
|
355
|
+
}
|
|
356
|
+
return { authenticateApprover, authorizeMutation, projectOf, resolveRouteProject, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible };
|
|
357
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { RouteDeclaration } from './route-table.js';
|
|
3
|
+
import type { Who } from './shared.js';
|
|
4
|
+
export interface HandlerContext {
|
|
5
|
+
url: URL;
|
|
6
|
+
who: Who;
|
|
7
|
+
request: IncomingMessage;
|
|
8
|
+
response: ServerResponse;
|
|
9
|
+
route: RouteDeclaration;
|
|
10
|
+
now: Date;
|
|
11
|
+
project: string | null;
|
|
12
|
+
chosenProject: string | null;
|
|
13
|
+
posted: URLSearchParams;
|
|
14
|
+
}
|
|
15
|
+
export interface EdgeContext {
|
|
16
|
+
route: RouteDeclaration;
|
|
17
|
+
url: URL;
|
|
18
|
+
who: Who | null;
|
|
19
|
+
request: IncomingMessage;
|
|
20
|
+
response: ServerResponse;
|
|
21
|
+
method: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EdgeContext, HandlerContext } from './handler-context.js';
|
|
2
|
+
import { type Domain, type RouteDeclaration, type RouteMethod } from './route-table.js';
|
|
3
|
+
export type Registration = {
|
|
4
|
+
id: string;
|
|
5
|
+
domain: Domain;
|
|
6
|
+
stage: 'console';
|
|
7
|
+
method: RouteMethod;
|
|
8
|
+
handle: (ctx: HandlerContext) => Promise<void>;
|
|
9
|
+
} | {
|
|
10
|
+
id: string;
|
|
11
|
+
domain: Domain;
|
|
12
|
+
stage: 'edge';
|
|
13
|
+
method: RouteMethod;
|
|
14
|
+
handle: (ctx: EdgeContext) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
/** Registrations are independent of policy. Missing, extra or incompatible entries fail at startup. */
|
|
17
|
+
export declare function assertHandlerRegistry(registrations: readonly Registration[], routes?: readonly RouteDeclaration[]): void;
|
|
18
|
+
export declare function createHandlerRegistry(registrations: readonly Registration[]): {
|
|
19
|
+
console(route: RouteDeclaration, ctx: HandlerContext): Promise<void>;
|
|
20
|
+
edge(route: RouteDeclaration, ctx: EdgeContext): Promise<void>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ROUTES } from './route-table.js';
|
|
2
|
+
/** Registrations are independent of policy. Missing, extra or incompatible entries fail at startup. */
|
|
3
|
+
export function assertHandlerRegistry(registrations, routes = ROUTES) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
for (const handler of registrations) {
|
|
6
|
+
if (seen.has(handler.id))
|
|
7
|
+
throw new Error(`Duplicate handler: ${handler.id}`);
|
|
8
|
+
seen.add(handler.id);
|
|
9
|
+
const route = routes.find(row => row.id === handler.id);
|
|
10
|
+
if (!route || route.domain !== handler.domain || route.method !== handler.method || route.stage !== handler.stage)
|
|
11
|
+
throw new Error(`Handler has no compatible route: ${handler.id}`);
|
|
12
|
+
}
|
|
13
|
+
for (const route of routes)
|
|
14
|
+
if (!seen.has(route.id))
|
|
15
|
+
throw new Error(`Route has no handler: ${route.id}`);
|
|
16
|
+
}
|
|
17
|
+
export function createHandlerRegistry(registrations) {
|
|
18
|
+
assertHandlerRegistry(registrations);
|
|
19
|
+
const byId = new Map(registrations.map(handler => [handler.id, handler]));
|
|
20
|
+
return {
|
|
21
|
+
async console(route, ctx) {
|
|
22
|
+
const handler = byId.get(route.id);
|
|
23
|
+
if (!handler || handler.stage !== 'console')
|
|
24
|
+
throw new Error(`No console handler: ${route.id}`);
|
|
25
|
+
return handler.handle(ctx);
|
|
26
|
+
},
|
|
27
|
+
async edge(route, ctx) {
|
|
28
|
+
const handler = byId.get(route.id);
|
|
29
|
+
if (!handler || handler.stage !== 'edge')
|
|
30
|
+
throw new Error(`No edge handler: ${route.id}`);
|
|
31
|
+
return handler.handle(ctx);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pages domain's install assets (moved from serve.ts unchanged): the web app manifest, icons, service worker and
|
|
3
|
+
* typefaces, declared in the route table's edge stage and answered before authentication.
|
|
4
|
+
*/
|
|
5
|
+
import type { ServerResponse } from "node:http";
|
|
6
|
+
/**
|
|
7
|
+
* Answers one install asset or typeface (GET only; the caller checks the method), with nosniff and its own
|
|
8
|
+
* conservative caching/CSP. False for any other path: unknown names fall through to the router's refusal.
|
|
9
|
+
*/
|
|
10
|
+
export declare function serveInstallAsset(response: ServerResponse, pathname: string, respond: (response: ServerResponse, status: number, type: string, body: string) => void): boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { GEIST_SANS_400, GEIST_SANS_500, GEIST_SANS_600, GEIST_MONO_400, GEIST_MONO_500, GEIST_MONO_600 } from "../fonts.js";
|
|
2
|
+
// ---- the phone (arc 3): install assets, served BEFORE authentication — they
|
|
3
|
+
// contain nothing secret, and a background service-worker update that met a
|
|
4
|
+
// login redirect would fail MIME validation and unregister itself.
|
|
5
|
+
const PWA_ICON_192 = "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAB1klEQVR42u3bMQ0AIAxFwepgQgD+TSECPJSBQO/nKSA30lhmBwtPYAAZQAaQAWQGkAFkABlAZgAZQPY4oNaHagaQABJAAkgAASSABJAAEkAACSABJIAEEEACSAAJIAEEkAASQAJIAHlHgAASQAJIAAkggASQABJAAgggASSABJAAAkgACSABJIAAAgggASSAss3aAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggORLK0AAASSABJAAEkAACSABJIAEEEACSAAJIAEEkAASQAJIAAEkgASQAHKV4SoDIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAJIfiR6R4AAEkACSAAJIIAEkAASQAIIIAEkgASQAAJIAAkgASSAAAIIIAEkgFxluMoACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggACSH4kCCCABJIAEkAACSAAJIAEkgAASQAJIAAkggASQABJAAggggAASQAJIAAkggASQABJAAgggASSABJAAAkgACSB9BMgMIAPIADKAzAAygAwgA8gAMgPI7mwDbzYVUJcW7UcAAAAASUVORK5CYII=";
|
|
6
|
+
const PWA_ICON_512 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAJF0lEQVR42u3VwQ0AEBBFQXU4KUD/TW0R3JzcRLJhfqYCwivDzMy+XHEEZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmZgJgZmYCYGZmAmBmJgBmZiYAZmYmAGZmJgBmZiYAZmYmAGZmJgDb1dYBOCEAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACAIAAACAAAAgAAAIAgAAAIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACAIAAACAAAAgAAAIAgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIAAACAIAAACAAAAgAAAIAgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIAAACAIAAACAAAAgAAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACcF+Y5Z5HKgACIAAmAAgAAmACgAAgACYACAACYAKAACAAJgAIAAJgAoAAIAAmAAgAAmACIAACgP/FBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQABMABAAARAAEwAEAAEwAUAAEAATAAQAATABQAAQABMABAABMAFAABAAEwABEAAEwARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEQADMBEAABEAAzARAAARAAMwEQAAEAEAABABAAAQAQAAAEAAABAAAAQBAAAAQAAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAANwcgAAAIAAACAAAAgCAAAAgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIAgAAAIAAACAAAAgCAAAAgAAACIAC/C7Pc80gFQAAEwAQAAUAATAAQAATABAABQABMABAABMAEAAFAAEwAEAAEwAQAAUAATAAEQADwv5gACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgAkAAiAAAmACgAAgACYACAACYAKAACAAJgAIAAJgAoAAIAAmAAgAAmACIAACgACYAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAIgJkACIAACICZAAiAAAiAmQAIgAAACIAAAAiAAAAIAAACAIAAACAAAAgAAAIAgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAmwMQAAAEAAABAEAAABAAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAQAQAAEAEAABABAAAAQAAAEAAABAEAAABAAAASAJcxyzyMVAAEQABMABAABMAFAABAAEwAEAAEwAUAAEAATAAQAATABQAAQABMABAABMAEQAAHA/2ICIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgACYACIAACIAJAAKAAJgAIAAIgAkAAoAAmAAgAAiACQACgACYACAACIAJgAAIAAJgAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIgAGYCIAACIABmAiAAAiAAZgIgAAIAIAACACAAAgAgAAAIAAACAIAAACAAAAgAAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAAgAgAAIAIAACACAAbg5AAAAQAAAEAAABAEAAABAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAAQBAAAAQAAAEAAABAEAAABAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABcHkAAgCAAAAgAAAIAAACAIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgAAACIAAAAiAAAAIgJmZPT4BMDMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMxMAMzMTADMzEwAzMwEwMzMBMDMzATAzMwEwMzMBMDOzJzYBVJhD+Nnu218AAAAASUVORK5CYII=";
|
|
7
|
+
const PWA_ICON_APPLE = "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABqklEQVR42u3aMQ0AIAxFwepgQgD+TSECFHQiYWjv5ylobmwcs2ThBAaHwWFwGBwGh8FhcBgcBofBkW7MparBITgEh+AQHIJDcAgOwQEHHHDAAYfgEByCQ3AIDsEhOASHI8IBBxxwCA7BITgEh+AQHIJDcAgOOOCAQ3AIDsEhOASH4BAcguNPu9PggAMOOOCAAw444IADDjjggAMOOOCAAw444IADDjjggAMOOOCAAw44PPsIDsEhOASH4BAcgkNwCA444IADDjgEh+AQHIJDcAgOwSE44IDDg7EHYzjggAMOOOCAAw444IADDjjggAMOOOCAAw444IADDjjggAMOOOCAAw559hEcgkNwwAEHHHDAITgEh+AQHIJDcAgOwQEHHHDAAYfgEByCQ3B4MIYDDjjggAMOOOCAAw444IADDjjggAMOOOCAAw444IADDjjggAMOOODw7OPZBw7BITgEh+AQHIJDcAgOOOCAAw44BIfgEByCQ3AIDsEhOAQHHHDAITgEh+AQHIJDcAgOwSE44IADDjgecVjbwWFwGBwGh8FhcBgcBofBYeV3AaohX51oqNRKAAAAAElFTkSuQmCC";
|
|
8
|
+
const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="6" y="6" width="88" height="88" rx="14" fill="#1a202c"/><rect x="28" y="28" width="44" height="8" rx="4" fill="#ebebeb"/><rect x="28" y="47" width="44" height="8" rx="4" fill="#ebebeb"/><rect x="28" y="66" width="44" height="8" rx="4" fill="#ebebeb"/></svg>`;
|
|
9
|
+
/** The typefaces by route: exact names only, served pre-auth like the icons. */
|
|
10
|
+
const FONT_FILES = {
|
|
11
|
+
"/fonts/geist-sans-400.woff2": GEIST_SANS_400,
|
|
12
|
+
"/fonts/geist-sans-500.woff2": GEIST_SANS_500,
|
|
13
|
+
"/fonts/geist-sans-600.woff2": GEIST_SANS_600,
|
|
14
|
+
"/fonts/geist-mono-400.woff2": GEIST_MONO_400,
|
|
15
|
+
"/fonts/geist-mono-500.woff2": GEIST_MONO_500,
|
|
16
|
+
"/fonts/geist-mono-600.woff2": GEIST_MONO_600,
|
|
17
|
+
};
|
|
18
|
+
const PWA_MANIFEST = JSON.stringify({
|
|
19
|
+
name: "Toolroll",
|
|
20
|
+
short_name: "Toolroll",
|
|
21
|
+
id: "/",
|
|
22
|
+
scope: "/",
|
|
23
|
+
start_url: "/",
|
|
24
|
+
display: "standalone",
|
|
25
|
+
background_color: "#0b0b0b",
|
|
26
|
+
theme_color: "#0b0b0b",
|
|
27
|
+
icons: [
|
|
28
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
|
29
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
|
30
|
+
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" },
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
// The service worker, DELIBERATELY MINIMAL: no fetch handler — no cache, no
|
|
34
|
+
// offline copy of authenticated pages, nothing intercepting requests. Push
|
|
35
|
+
// and the tap, only. notificationclick resolves ONLY allow-listed relative
|
|
36
|
+
// paths — the payload URL is data, revalidated, never handed raw to the
|
|
37
|
+
// browser (arc 3 finding 5).
|
|
38
|
+
const PWA_WORKER = `// Toolroll — push only; deliberately NO fetch handler (no offline cache of an authenticated console).
|
|
39
|
+
const SHAPES = [/^\\/next$/, /^\\/review$/, /^\\/system$/, /^\\/routines$/, /^\\/routines\\/[0-9]+$/, /^\\/d\\/[0-9]+$/, /^\\/contest\\/[0-9]+$/, /^\\/r\\/[0-9]+$/];
|
|
40
|
+
self.addEventListener("push", function (event) {
|
|
41
|
+
var data = {};
|
|
42
|
+
try { data = event.data ? event.data.json() : {}; } catch (e) {}
|
|
43
|
+
var body = typeof data.body === "string" ? data.body : "the console needs you";
|
|
44
|
+
var tag = typeof data.tag === "string" ? data.tag : "toolroll";
|
|
45
|
+
var url = typeof data.url === "string" && SHAPES.some(function (s) { return s.test(data.url); }) ? data.url : "/next";
|
|
46
|
+
if (typeof data.waiting === "number" && data.waiting >= 0 && self.registration.setAppBadge) {
|
|
47
|
+
// A count, never content. Honest no-op where unsupported.
|
|
48
|
+
event.waitUntil(self.registration.setAppBadge(Math.floor(data.waiting)).catch(function () {}));
|
|
49
|
+
}
|
|
50
|
+
event.waitUntil(self.registration.showNotification("Toolroll", { body: body, tag: tag, data: { url: url } }));
|
|
51
|
+
});
|
|
52
|
+
self.addEventListener("message", function (event) {
|
|
53
|
+
// The page recomputes on load/focus and is authoritative over any stale
|
|
54
|
+
// push: {badge: N} sets, {badge: 0} clears.
|
|
55
|
+
var badge = event.data && typeof event.data.badge === "number" ? event.data.badge : null;
|
|
56
|
+
if (badge === null || !self.registration.setAppBadge) return;
|
|
57
|
+
if (badge <= 0 && self.registration.clearAppBadge) { self.registration.clearAppBadge().catch(function () {}); return; }
|
|
58
|
+
self.registration.setAppBadge(Math.floor(badge)).catch(function () {});
|
|
59
|
+
});
|
|
60
|
+
self.addEventListener("notificationclick", function (event) {
|
|
61
|
+
event.notification.close();
|
|
62
|
+
if (self.registration.clearAppBadge) self.registration.clearAppBadge().catch(function () {});
|
|
63
|
+
var url = event.notification.data && event.notification.data.url;
|
|
64
|
+
var target = new URL(SHAPES.some(function (s) { return s.test(url); }) ? url : "/next", self.location.origin).href;
|
|
65
|
+
event.waitUntil(clients.matchAll({ type: "window" }).then(function (open) {
|
|
66
|
+
for (var i = 0; i < open.length; i++) { if (open[i].url === target && open[i].focus) return open[i].focus(); }
|
|
67
|
+
return clients.openWindow(target);
|
|
68
|
+
}));
|
|
69
|
+
});
|
|
70
|
+
`;
|
|
71
|
+
/**
|
|
72
|
+
* Answers one install asset or typeface (GET only; the caller checks the method), with nosniff and its own
|
|
73
|
+
* conservative caching/CSP. False for any other path: unknown names fall through to the router's refusal.
|
|
74
|
+
*/
|
|
75
|
+
export function serveInstallAsset(response, pathname, respond) {
|
|
76
|
+
const asset = (type, body, csp) => {
|
|
77
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
78
|
+
response.setHeader("cache-control", pathname === "/sw.js" ? "no-store" : "public, max-age=3600");
|
|
79
|
+
if (csp !== undefined)
|
|
80
|
+
response.setHeader("content-security-policy", csp);
|
|
81
|
+
respond(response, 200, type, body);
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
if (pathname === "/manifest.webmanifest")
|
|
85
|
+
return asset("application/manifest+json", PWA_MANIFEST);
|
|
86
|
+
if (pathname === "/favicon.ico")
|
|
87
|
+
return asset("image/png", Buffer.from(PWA_ICON_APPLE, "base64"));
|
|
88
|
+
if (pathname === "/icon.svg")
|
|
89
|
+
return asset("image/svg+xml", PWA_ICON_SVG);
|
|
90
|
+
if (pathname === "/icon-192.png")
|
|
91
|
+
return asset("image/png", Buffer.from(PWA_ICON_192, "base64"));
|
|
92
|
+
if (pathname === "/icon-512.png")
|
|
93
|
+
return asset("image/png", Buffer.from(PWA_ICON_512, "base64"));
|
|
94
|
+
if (pathname === "/apple-touch-icon.png")
|
|
95
|
+
return asset("image/png", Buffer.from(PWA_ICON_APPLE, "base64"));
|
|
96
|
+
if (pathname === "/sw.js")
|
|
97
|
+
return asset("text/javascript; charset=utf-8", PWA_WORKER, "default-src 'none'");
|
|
98
|
+
// The typefaces: exact-allowlisted like the icons — nothing dynamic rides the path.
|
|
99
|
+
const font = FONT_FILES[pathname];
|
|
100
|
+
if (font !== undefined)
|
|
101
|
+
return asset("font/woff2", Buffer.from(font, "base64"));
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Registration } from './handler-registry.js';
|
|
2
|
+
import { type ServerResponse } from "node:http";
|
|
3
|
+
import type { HandlerContext } from './handler-context.js';
|
|
4
|
+
import type { ServerRuntime } from './runtime.js';
|
|
5
|
+
import { type Who } from "./shared.js";
|
|
6
|
+
export declare function createPagesHandlers(runtime: ServerRuntime): {
|
|
7
|
+
registrations: Registration[];
|
|
8
|
+
get: (ctx: HandlerContext) => Promise<void>;
|
|
9
|
+
post: (ctx: HandlerContext) => Promise<void>;
|
|
10
|
+
projectsScreen: (response: ServerResponse, who: Who, problem: string | null, status: number, returnTo?: string) => Promise<void>;
|
|
11
|
+
};
|