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,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote domain's public roads (moved from serve.ts unchanged): the liveness probe and the webhooks a public
|
|
3
|
+
* relay may expose. Each is declared in the route table's edge stage and answers before the console's sign-in.
|
|
4
|
+
*/
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
+
import type { Store } from "../store.js";
|
|
7
|
+
/** What the hooks read from the server: its store, clock and the two configuration paths. */
|
|
8
|
+
export interface RemoteHookContext {
|
|
9
|
+
readonly store: Store;
|
|
10
|
+
readonly clock: () => Date;
|
|
11
|
+
readonly options: {
|
|
12
|
+
readonly configDir?: string | undefined;
|
|
13
|
+
readonly telegramTokenFile?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Flow webhooks (v82): the one road a public relay may expose. The secret
|
|
17
|
+
* address proves nothing about the sender on its own for GitHub and Linear:
|
|
18
|
+
* their signatures are checked too. Nothing runs here; cards wait for a pass. */
|
|
19
|
+
/**
|
|
20
|
+
* v98: Telegram pushes this bot's updates here. Only a request carrying our
|
|
21
|
+
* secret header is Telegram's; each update is kept for the bridge (which
|
|
22
|
+
* applies it through the same door as a polled one), and answered at once so
|
|
23
|
+
* Telegram doesn't send it again. An update already kept or applied is fine.
|
|
24
|
+
*/
|
|
25
|
+
export declare function telegramHook(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
26
|
+
export declare function flowHook(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
|
|
27
|
+
/** A shared button as a public form (v84): its questions, and one card per submission. No sign-in, no session, nothing about the flow shown. */
|
|
28
|
+
export declare function flowForm(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
|
|
29
|
+
export declare function healthz(store: Store, response: ServerResponse, head: boolean): void;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { adapterPolicy } from "./route-policy.js";
|
|
2
|
+
import { flowFormPage, FORM_PATH, HOOK_PATH, receiveFlowForm, receiveFlowHook } from "../flow-triggers.js";
|
|
3
|
+
import { keepPushedUpdate, loadBotToken, pushedByTelegram, telegramHookSecret } from "../telegram.js";
|
|
4
|
+
/** Flow webhooks (v82): the one road a public relay may expose. The secret
|
|
5
|
+
* address proves nothing about the sender on its own for GitHub and Linear:
|
|
6
|
+
* their signatures are checked too. Nothing runs here; cards wait for a pass. */
|
|
7
|
+
/**
|
|
8
|
+
* v98: Telegram pushes this bot's updates here. Only a request carrying our
|
|
9
|
+
* secret header is Telegram's; each update is kept for the bridge (which
|
|
10
|
+
* applies it through the same door as a polled one), and answered at once so
|
|
11
|
+
* Telegram doesn't send it again. An update already kept or applied is fine.
|
|
12
|
+
*/
|
|
13
|
+
export async function telegramHook(ctx, request, response) {
|
|
14
|
+
const { store, options, clock } = ctx;
|
|
15
|
+
const reply = (status) => { response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); response.end(status === 200 ? "ok" : "no"); request.resume(); };
|
|
16
|
+
const source = options.configDir === undefined || options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile);
|
|
17
|
+
if (source === null || options.configDir === undefined)
|
|
18
|
+
return reply(404);
|
|
19
|
+
if (request.method !== "POST")
|
|
20
|
+
return reply(405);
|
|
21
|
+
if (!pushedByTelegram(request.headers["x-telegram-bot-api-secret-token"], telegramHookSecret(options.configDir)))
|
|
22
|
+
return reply(401);
|
|
23
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
|
|
24
|
+
return reply(403);
|
|
25
|
+
if (Number(request.headers["content-length"] ?? 0) > 1_000_000)
|
|
26
|
+
return reply(413);
|
|
27
|
+
const chunks = [];
|
|
28
|
+
try {
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
let size = 0;
|
|
31
|
+
request.on("data", (chunk) => { size += chunk.length; if (size > 1_000_000)
|
|
32
|
+
reject(new Error("too-large"));
|
|
33
|
+
else
|
|
34
|
+
chunks.push(chunk); });
|
|
35
|
+
request.on("end", resolve);
|
|
36
|
+
request.on("error", reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return reply(413);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return reply(keepPushedUpdate(store, source.botId, Buffer.concat(chunks), clock()).ok ? 200 : 400);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return reply(500);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function flowHook(ctx, request, response, url) {
|
|
50
|
+
const { store, options, clock } = ctx;
|
|
51
|
+
const reply = (status, said) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify({ said })); request.resume(); };
|
|
52
|
+
if (url.pathname.startsWith(FORM_PATH))
|
|
53
|
+
return flowForm(ctx, request, response, url);
|
|
54
|
+
if (options.configDir === undefined || !url.pathname.startsWith(HOOK_PATH))
|
|
55
|
+
return reply(404, "No such address.");
|
|
56
|
+
if (request.method !== "POST")
|
|
57
|
+
return reply(405, "Send a POST.");
|
|
58
|
+
if (Number(request.headers["content-length"] ?? 0) > 1_000_000)
|
|
59
|
+
return reply(413, "Too large.");
|
|
60
|
+
const chunks = [];
|
|
61
|
+
try {
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
let size = 0;
|
|
64
|
+
request.on("data", (chunk) => { size += chunk.length; if (size > 1_000_000)
|
|
65
|
+
reject(new Error("too-large"));
|
|
66
|
+
else
|
|
67
|
+
chunks.push(chunk); });
|
|
68
|
+
request.on("end", resolve);
|
|
69
|
+
request.on("error", reject);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return reply(error instanceof Error && error.message === "too-large" ? 413 : 400, "Couldn't read that.");
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const answer = receiveFlowHook(store, url.pathname.slice(HOOK_PATH.length), { headers: request.headers, body: Buffer.concat(chunks) }, options.configDir, clock());
|
|
77
|
+
return reply(answer.status, answer.said);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return reply(500, "Not saved.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A shared button as a public form (v84): its questions, and one card per submission. No sign-in, no session, nothing about the flow shown. */
|
|
84
|
+
export async function flowForm(ctx, request, response, url) {
|
|
85
|
+
const { store, clock } = ctx;
|
|
86
|
+
const page = (answer) => {
|
|
87
|
+
response.writeHead(answer.status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff",
|
|
88
|
+
"x-frame-options": "DENY", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" });
|
|
89
|
+
response.end(request.method === "HEAD" ? undefined : answer.html);
|
|
90
|
+
request.resume();
|
|
91
|
+
};
|
|
92
|
+
const token = url.pathname.slice(FORM_PATH.length);
|
|
93
|
+
if (request.method === "GET" || request.method === "HEAD")
|
|
94
|
+
return page(flowFormPage(store, token, clock()));
|
|
95
|
+
if (request.method !== "POST")
|
|
96
|
+
return page({ status: 405, html: "" });
|
|
97
|
+
if (Number(request.headers["content-length"] ?? 0) > 65_536)
|
|
98
|
+
return page({ status: 413, html: "" });
|
|
99
|
+
let raw = "";
|
|
100
|
+
try {
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
request.setEncoding("utf8");
|
|
103
|
+
request.on("data", (chunk) => { raw += chunk; if (raw.length > 65_536)
|
|
104
|
+
reject(new Error("too-large")); });
|
|
105
|
+
request.on("end", resolve);
|
|
106
|
+
request.on("error", reject);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return page({ status: 413, html: "" });
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return page(receiveFlowForm(store, token, new URLSearchParams(raw), clock()));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return page({ status: 500, html: "" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function healthz(store, response, head) {
|
|
120
|
+
let healthy = true;
|
|
121
|
+
try {
|
|
122
|
+
store.handle.prepare("SELECT 1 FROM schema_version").get();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
healthy = false;
|
|
126
|
+
}
|
|
127
|
+
const body = JSON.stringify({ status: healthy ? "ok" : "unavailable" });
|
|
128
|
+
response.writeHead(healthy ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
129
|
+
response.end(head ? undefined : body);
|
|
130
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Registration } from './handler-registry.js';
|
|
2
|
+
/** tasks handlers, moved without changing their route bodies. */
|
|
3
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
import { CodingWorkspace } from '../coding-workspace.js';
|
|
5
|
+
import { type Principal } from "../operate-remote.js";
|
|
6
|
+
import { RequestBudget, SourceAdmission, type Admission } from "../request-budget.js";
|
|
7
|
+
import { type Store } from "../store.js";
|
|
8
|
+
import type { TeamResponse } from '../team-contract.js';
|
|
9
|
+
import type { EdgeContext } from './handler-context.js';
|
|
10
|
+
import { type RemoteHookContext } from "./remote-hooks.js";
|
|
11
|
+
import { type ServeOptions, type Who } from "./shared.js";
|
|
12
|
+
export interface RemoteRuntime {
|
|
13
|
+
identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
|
|
14
|
+
principal?: Principal;
|
|
15
|
+
}) => Who | null;
|
|
16
|
+
store: Store;
|
|
17
|
+
admitPasswordSource: (request: IncomingMessage) => Admission;
|
|
18
|
+
admitBearer: (request: IncomingMessage, actor: {
|
|
19
|
+
name: string;
|
|
20
|
+
principal?: Principal;
|
|
21
|
+
}) => Admission;
|
|
22
|
+
allowedHost: (host: string | undefined) => boolean;
|
|
23
|
+
team: {
|
|
24
|
+
execute: import("../team-contract.js").TeamExecute;
|
|
25
|
+
start: () => void;
|
|
26
|
+
close: () => Promise<void>;
|
|
27
|
+
pass: () => Promise<void>;
|
|
28
|
+
cursor: (actor: import("../team-contract.js").TeamActor, conversationId?: string) => number | null;
|
|
29
|
+
domain: import("../team-leads.js").TeamLeads;
|
|
30
|
+
};
|
|
31
|
+
teamBrowserReply: (reply: TeamResponse, actor: {
|
|
32
|
+
name: string;
|
|
33
|
+
generation: number;
|
|
34
|
+
}, csrf: string) => TeamResponse;
|
|
35
|
+
teamStreams: Set<ServerResponse<IncomingMessage>>;
|
|
36
|
+
requestBudget: RequestBudget;
|
|
37
|
+
options: ServeOptions;
|
|
38
|
+
coding: CodingWorkspace | null;
|
|
39
|
+
codingProjects: () => string[];
|
|
40
|
+
liveCeiling: () => {
|
|
41
|
+
repos: string[];
|
|
42
|
+
roots: readonly string[];
|
|
43
|
+
};
|
|
44
|
+
clock: () => Date;
|
|
45
|
+
evidenceRoot: string;
|
|
46
|
+
consoleOrigin: (host: string | undefined) => string | null;
|
|
47
|
+
managedRepos: () => string[];
|
|
48
|
+
joinSourceOf: (request: IncomingMessage) => string;
|
|
49
|
+
ssoSettings: () => import("../oidc.js").OidcSettings | null;
|
|
50
|
+
SSO_FRESH_MS: number;
|
|
51
|
+
oauthTokenBudget: SourceAdmission;
|
|
52
|
+
hookContext: RemoteHookContext;
|
|
53
|
+
teamsSourceBudget: SourceAdmission;
|
|
54
|
+
teamsTenantBudget: SourceAdmission;
|
|
55
|
+
}
|
|
56
|
+
export declare function createRemoteHandlers(runtime: RemoteRuntime): {
|
|
57
|
+
registrations: Registration[];
|
|
58
|
+
edge: (ctx: EdgeContext) => Promise<void>;
|
|
59
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** tasks handlers, moved without changing their route bodies. */
|
|
2
|
+
import {} from "node:http";
|
|
3
|
+
import { handleCliHttp } from '../cli-http.js';
|
|
4
|
+
import { CodingWorkspace } from '../coding-workspace.js';
|
|
5
|
+
import { createMcpHttp } from "../mcp-http.js";
|
|
6
|
+
import { createOAuthHttp, resourceMetadataUrl } from "../mcp-oauth.js";
|
|
7
|
+
import { reproveRemote } from "../operate-remote.js";
|
|
8
|
+
import { rowVisible } from "../project.js";
|
|
9
|
+
import { RequestBudget, SourceAdmission } from "../request-budget.js";
|
|
10
|
+
import { authenticateAccount } from "../scope.js";
|
|
11
|
+
import { createSessionEndpoint } from '../session-server.js';
|
|
12
|
+
import {} from "../store.js";
|
|
13
|
+
import { handleTeamHttp } from '../team-http.js';
|
|
14
|
+
import { handleTeamsHttp } from "../teams.js";
|
|
15
|
+
import { flowHook, telegramHook } from "./remote-hooks.js";
|
|
16
|
+
import { respond } from "./shared.js";
|
|
17
|
+
export function createRemoteHandlers(runtime) {
|
|
18
|
+
const { identify, store, admitPasswordSource, admitBearer, allowedHost, team, teamBrowserReply, teamStreams, requestBudget, options, coding, codingProjects, liveCeiling, clock, evidenceRoot, consoleOrigin, managedRepos, joinSourceOf, ssoSettings, SSO_FRESH_MS, oauthTokenBudget, hookContext, teamsSourceBudget, teamsTenantBudget } = runtime;
|
|
19
|
+
/**
|
|
20
|
+
* The full principal a live `so_` API token stands for: its person and their generation, the token's own read/act
|
|
21
|
+
* scope, its project limit narrowed by the person's current access, and its id. Every route that accepts an API token
|
|
22
|
+
* builds its identity here and carries it whole, so no route can drop the token's narrower scope or projects.
|
|
23
|
+
*/
|
|
24
|
+
const tokenPrincipalOf = (request) => {
|
|
25
|
+
const who = identify(request, false, true);
|
|
26
|
+
return who?.via === "bearer" ? who.principal ?? null : null;
|
|
27
|
+
};
|
|
28
|
+
const presentsToken = (request) => /^Bearer so_\S+$/.test(request.headers.authorization ?? "");
|
|
29
|
+
const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
|
|
30
|
+
authenticate: request => {
|
|
31
|
+
if (presentsToken(request)) {
|
|
32
|
+
const principal = tokenPrincipalOf(request);
|
|
33
|
+
return principal === null ? null : { name: principal.account, generation: principal.generation, principal };
|
|
34
|
+
}
|
|
35
|
+
const who = identify(request, request.method === 'POST');
|
|
36
|
+
const account = who === null ? null : store.accountOf(who.name);
|
|
37
|
+
return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation, role: account.role } : null;
|
|
38
|
+
},
|
|
39
|
+
admit: admitPasswordSource,
|
|
40
|
+
admitAuthenticated: admitBearer,
|
|
41
|
+
revalidate: (request, actor) => {
|
|
42
|
+
const account = store.accountOf(actor.name);
|
|
43
|
+
if (!account || account.revokedAt !== null || account.generation !== actor.generation)
|
|
44
|
+
return false;
|
|
45
|
+
// An API token is re-proved against the store every time: unrevoked, unexpired, inside its project limit.
|
|
46
|
+
if (actor.principal !== undefined) {
|
|
47
|
+
const live = reproveRemote(store, actor.principal, new Date());
|
|
48
|
+
if (!live.ok)
|
|
49
|
+
return false;
|
|
50
|
+
// The post-body mutation check must see a scope lowered since authentication, too.
|
|
51
|
+
actor.principal = { ...actor.principal, scope: live.scope };
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
// A password bearer was proved when this connection opened. Cookie expiry/revocation
|
|
55
|
+
// is rechecked without allowing a passive stream to extend its lifetime.
|
|
56
|
+
if (request.headers.authorization)
|
|
57
|
+
return true;
|
|
58
|
+
const who = identify(request, false);
|
|
59
|
+
return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
|
|
60
|
+
},
|
|
61
|
+
authorizeMutation: (request, actor) => {
|
|
62
|
+
if (actor.principal !== undefined)
|
|
63
|
+
return request.headers.origin === undefined;
|
|
64
|
+
const who = identify(request, false);
|
|
65
|
+
if (!who || who.name !== actor.name)
|
|
66
|
+
return false;
|
|
67
|
+
if (who.via === 'bearer')
|
|
68
|
+
return request.headers.origin === undefined;
|
|
69
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
70
|
+
const named = typeof origin === 'string' && origin !== 'null' ? origin : typeof referer === 'string' ? referer : null;
|
|
71
|
+
if (named !== null && !allowedHost(named.replace(/^https?:\/\//, '').split('/')[0]))
|
|
72
|
+
return false;
|
|
73
|
+
return request.headers['x-csrf-token'] === who.session.csrf;
|
|
74
|
+
},
|
|
75
|
+
execute: async (actor, input) => {
|
|
76
|
+
const reply = await team.execute(actor, input);
|
|
77
|
+
if (actor.principal !== undefined)
|
|
78
|
+
return reply;
|
|
79
|
+
const who = identify(request, false);
|
|
80
|
+
return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
|
|
81
|
+
}, cursor: team.cursor, streams: teamStreams,
|
|
82
|
+
});
|
|
83
|
+
// Remote CLI: one live API token names the person; the shared command boundary decides everything else.
|
|
84
|
+
const cliEndpoint = (request, response) => handleCliHttp(request, response, {
|
|
85
|
+
admit: principal => requestBudget.admit(principal.tokenId, "api"),
|
|
86
|
+
authenticate: tokenPrincipalOf,
|
|
87
|
+
run: async () => options.cliRunner ?? (await import("../operate.js")).runOperateAs ?? null,
|
|
88
|
+
modeOf: options.cliModeOf,
|
|
89
|
+
store,
|
|
90
|
+
});
|
|
91
|
+
const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo),
|
|
92
|
+
admit: admitPasswordSource, admitAuthenticated: admitBearer });
|
|
93
|
+
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, ...(options.requestBudgetClock === undefined ? {} : { requestBudgetClock: options.requestBudgetClock }), signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
|
|
94
|
+
resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
|
|
95
|
+
enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
|
|
96
|
+
// MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
|
|
97
|
+
const oauthHttp = createOAuthHttp({ store, clock,
|
|
98
|
+
originOf: request => consoleOrigin(request.headers.host),
|
|
99
|
+
requesterKey: joinSourceOf,
|
|
100
|
+
session: request => {
|
|
101
|
+
const who = identify(request, false);
|
|
102
|
+
if (who === null || who.via !== "cookie")
|
|
103
|
+
return null;
|
|
104
|
+
return { name: who.name, role: who.role, csrf: who.session.csrf,
|
|
105
|
+
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
|
|
106
|
+
},
|
|
107
|
+
sameSite: request => {
|
|
108
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
109
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
110
|
+
return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
|
|
111
|
+
},
|
|
112
|
+
// The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
|
|
113
|
+
// grant (their role, their projects) is checked beside it.
|
|
114
|
+
confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
|
|
115
|
+
projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
|
|
116
|
+
admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
|
|
117
|
+
});
|
|
118
|
+
async function edge(ctx) {
|
|
119
|
+
const { request, response, url, route } = ctx;
|
|
120
|
+
switch (route.id) {
|
|
121
|
+
case 'edge.telegram-hook': return telegramHook(hookContext, request, response);
|
|
122
|
+
case 'edge.flow-hook':
|
|
123
|
+
case 'edge.flow-form':
|
|
124
|
+
case 'edge.flow-form-send': return flowHook(hookContext, request, response, url);
|
|
125
|
+
case 'edge.mcp': return mcpHttp(request, response);
|
|
126
|
+
case 'edge.oauth-discovery':
|
|
127
|
+
case 'edge.oauth-register':
|
|
128
|
+
case 'edge.oauth-token':
|
|
129
|
+
case 'edge.oauth-authorize':
|
|
130
|
+
case 'edge.oauth-consent':
|
|
131
|
+
await oauthHttp(request, response, url);
|
|
132
|
+
return;
|
|
133
|
+
case 'edge.sessions-list':
|
|
134
|
+
case 'edge.sessions-show':
|
|
135
|
+
case 'edge.sessions-changes':
|
|
136
|
+
case 'edge.sessions-start':
|
|
137
|
+
case 'edge.sessions-send':
|
|
138
|
+
case 'edge.sessions-stop':
|
|
139
|
+
case 'edge.sessions-resume':
|
|
140
|
+
case 'edge.sessions-recover':
|
|
141
|
+
await sessionEndpoint(request, response);
|
|
142
|
+
return;
|
|
143
|
+
case 'edge.team-read':
|
|
144
|
+
case 'edge.team-send':
|
|
145
|
+
case 'edge.team-events':
|
|
146
|
+
await teamEndpoint(request, response);
|
|
147
|
+
return;
|
|
148
|
+
case 'edge.cli':
|
|
149
|
+
await cliEndpoint(request, response);
|
|
150
|
+
return;
|
|
151
|
+
case 'edge.teams':
|
|
152
|
+
if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock,
|
|
153
|
+
admitSource: request => teamsSourceBudget.admit(joinSourceOf(request)), admitTenant: tenant => teamsTenantBudget.admit(tenant) }))
|
|
154
|
+
return;
|
|
155
|
+
return respond(response, 404, 'text/plain; charset=utf-8', 'No such address.');
|
|
156
|
+
}
|
|
157
|
+
throw new Error('Unregistered remote route');
|
|
158
|
+
}
|
|
159
|
+
const registrations = [
|
|
160
|
+
{ id: "edge.telegram-hook", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
161
|
+
{ id: "edge.flow-form", domain: "remote", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
162
|
+
{ id: "edge.flow-form-send", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
163
|
+
{ id: "edge.flow-hook", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
164
|
+
{ id: "edge.mcp", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
165
|
+
{ id: "edge.oauth-discovery", domain: "remote", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
166
|
+
{ id: "edge.oauth-register", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
167
|
+
{ id: "edge.oauth-token", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
168
|
+
{ id: "edge.oauth-authorize", domain: "remote", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
169
|
+
{ id: "edge.oauth-consent", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
170
|
+
{ id: "edge.sessions-list", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
171
|
+
{ id: "edge.sessions-show", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
172
|
+
{ id: "edge.sessions-changes", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
173
|
+
{ id: "edge.sessions-start", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
174
|
+
{ id: "edge.sessions-send", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
175
|
+
{ id: "edge.sessions-stop", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
176
|
+
{ id: "edge.sessions-resume", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
177
|
+
{ id: "edge.sessions-recover", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
178
|
+
{ id: "edge.team-read", domain: "remote", stage: "edge", method: "GET", handle: edge },
|
|
179
|
+
{ id: "edge.team-send", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
180
|
+
{ id: "edge.team-events", domain: "remote", stage: "edge", method: "GET", handle: edge },
|
|
181
|
+
{ id: "edge.cli", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
182
|
+
{ id: "edge.teams", domain: "remote", stage: "edge", method: "POST", handle: edge },
|
|
183
|
+
];
|
|
184
|
+
return { registrations, edge };
|
|
185
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Caller, ProjectResolver, RouteDeclaration, RouteScope } from './route-table.js';
|
|
2
|
+
export interface RoutePrincipal {
|
|
3
|
+
caller: Caller;
|
|
4
|
+
capability: 'none' | 'read' | 'act';
|
|
5
|
+
viewer?: boolean;
|
|
6
|
+
token?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type PolicyResult = {
|
|
9
|
+
ok: true;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
status: 403;
|
|
13
|
+
reason: 'caller' | 'scope' | 'project';
|
|
14
|
+
};
|
|
15
|
+
/** The row supplies the minimum capability and the project resolver. Passwords/nonces are re-proved by the handler. */
|
|
16
|
+
export declare function evaluateRoutePolicy(route: RouteDeclaration, principal: RoutePrincipal, resolve: (source: ProjectResolver) => boolean, operationScope?: RouteScope): PolicyResult;
|
|
17
|
+
export declare function withEdgePolicy<T>(route: RouteDeclaration, run: () => T): T;
|
|
18
|
+
export declare function adapterPolicy(principal: RoutePrincipal, scope?: RouteScope, resolve?: (source: ProjectResolver) => boolean): PolicyResult;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
/** The row supplies the minimum capability and the project resolver. Passwords/nonces are re-proved by the handler. */
|
|
3
|
+
export function evaluateRoutePolicy(route, principal, resolve, operationScope) {
|
|
4
|
+
if (!route.callers.includes(principal.caller))
|
|
5
|
+
return { ok: false, status: 403, reason: 'caller' };
|
|
6
|
+
const required = route.scope === 'step-up' || operationScope === 'step-up' ? 'step-up'
|
|
7
|
+
: route.scope === 'act' || operationScope === 'act' ? 'act' : route.scope === 'read' || operationScope === 'read' ? 'read' : 'none';
|
|
8
|
+
if (required === 'step-up' && principal.caller !== 'cookie')
|
|
9
|
+
return { ok: false, status: 403, reason: 'caller' };
|
|
10
|
+
const needsAct = required === 'act' || required === 'step-up';
|
|
11
|
+
const viewerPreference = principal.caller === 'cookie' && route.viewer && principal.viewer === true && principal.token !== true;
|
|
12
|
+
if ((needsAct && principal.capability !== 'act' && !viewerPreference) || (required === 'read' && principal.capability === 'none'))
|
|
13
|
+
return { ok: false, status: 403, reason: 'scope' };
|
|
14
|
+
if (!resolve(route.project))
|
|
15
|
+
return { ok: false, status: 403, reason: 'project' };
|
|
16
|
+
return { ok: true };
|
|
17
|
+
}
|
|
18
|
+
/** The dispatcher matches once; protocol authentication then checks the same row before operation dispatch. */
|
|
19
|
+
const edgePolicy = new AsyncLocalStorage();
|
|
20
|
+
export function withEdgePolicy(route, run) { return edgePolicy.run({ route }, run); }
|
|
21
|
+
export function adapterPolicy(principal, scope, resolve = source => source === 'none' || source === 'adapter') {
|
|
22
|
+
const context = edgePolicy.getStore();
|
|
23
|
+
// Standalone protocol adapters retain their own authoritative admission checks.
|
|
24
|
+
if (context === undefined)
|
|
25
|
+
return { ok: true };
|
|
26
|
+
const checked = evaluateRoutePolicy(context.route, { ...principal, caller: context.caller ?? principal.caller }, resolve, scope);
|
|
27
|
+
if (checked.ok && context.caller === undefined)
|
|
28
|
+
context.caller = principal.caller;
|
|
29
|
+
return checked;
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
/** Who presents the request. `service` is a verified external sender (a signed webhook, an OAuth return carrying its one-time state). */
|
|
16
|
+
export type Caller = "anonymous" | "service" | "cookie" | "bearer" | "oauth" | "coordinator" | "lead";
|
|
17
|
+
/** What the caller must hold: nothing, read, act (an approver or an `act` token), or a fresh password/provider check on top. */
|
|
18
|
+
export type RouteScope = "none" | "read" | "act" | "step-up";
|
|
19
|
+
/** Where the request's project comes from. */
|
|
20
|
+
export type ProjectResolver = "none" | "session" | "form" | "form-path" | "task" | "run" | "decision" | "incident" | "routine" | "flow" | "proposal" | "conversation" | "coding" | "adapter";
|
|
21
|
+
/**
|
|
22
|
+
* What a project-limited account may do here:
|
|
23
|
+
* - `deny`: instance access only.
|
|
24
|
+
* - `collection`: a listing within a concrete visible project.
|
|
25
|
+
* - `unscoped`: allowed without a concrete project (the handler filters to the account's projects).
|
|
26
|
+
* - `resource`: the named object's project must be visible, and so must the request's project.
|
|
27
|
+
* - `self`: the caller's own settings or saved objects; the handler proves ownership and each project named.
|
|
28
|
+
* - `proposal`: a chat proposal: the saved owner/room and project must be visible.
|
|
29
|
+
* - `conversation`: a shared room's own audience admission, else instance access.
|
|
30
|
+
*/
|
|
31
|
+
export type LimitedAccess = "deny" | "collection" | "unscoped" | "resource" | "self" | "proposal" | "conversation";
|
|
32
|
+
export type Domain = "tasks" | "flows" | "chat" | "settings" | "people" | "remote" | "pages";
|
|
33
|
+
/** `edge`: answered before the console's sign-in (its own protocol proves the caller). `console`: after identify(). */
|
|
34
|
+
export type Stage = "edge" | "console";
|
|
35
|
+
export type RouteMethod = "GET" | "POST" | "GET,HEAD";
|
|
36
|
+
export interface RouteDeclaration {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly domain: Domain;
|
|
39
|
+
readonly stage: Stage;
|
|
40
|
+
readonly method: RouteMethod;
|
|
41
|
+
/** An anchored regular expression source matched against the decoded-free pathname. */
|
|
42
|
+
readonly pattern: string;
|
|
43
|
+
readonly callers: readonly Caller[];
|
|
44
|
+
readonly scope: RouteScope;
|
|
45
|
+
readonly project: ProjectResolver;
|
|
46
|
+
readonly limited: LimitedAccess;
|
|
47
|
+
/** A cookie session with no open project is sent to the project opener first (console GET only). */
|
|
48
|
+
readonly needsProject: boolean;
|
|
49
|
+
/** A viewer (watch-only) account may POST here. */
|
|
50
|
+
readonly viewer: boolean;
|
|
51
|
+
/** Signed hooks and health are the only roads exposed before Host validation. */
|
|
52
|
+
readonly host: "before" | "after";
|
|
53
|
+
/** Protocol authentication stays in its adapter, before its operation handler. */
|
|
54
|
+
readonly proof: "console" | "public" | "adapter";
|
|
55
|
+
/** A concrete path this row must win, for the precedence test. */
|
|
56
|
+
readonly sample: string;
|
|
57
|
+
/** The exact answer to a caller the row does not admit, when the route has its own protocol (else a plain 403). */
|
|
58
|
+
readonly scopeRefusal?: string;
|
|
59
|
+
readonly callerRefusal?: {
|
|
60
|
+
readonly status: number;
|
|
61
|
+
readonly type: string;
|
|
62
|
+
readonly body: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** The sole route table, in match order. */
|
|
66
|
+
export declare const ROUTES: readonly RouteDeclaration[];
|
|
67
|
+
/** The first row that declares this method and path, or null: an undeclared request never reaches a handler. */
|
|
68
|
+
export declare function matchRoute(method: string, pathname: string, stage?: Stage): RouteDeclaration | null;
|
|
69
|
+
/** True when some row declares this path for another method (a wrong-method request). */
|
|
70
|
+
export declare function declaredForAnotherMethod(method: string, pathname: string, stage?: Stage): boolean;
|
|
71
|
+
/** Refuses a malformed table at startup: duplicate ids, duplicate method+pattern rows, or a row shadowed by an earlier one for its own sample. */
|
|
72
|
+
export declare function assertRouteTable(routes?: readonly RouteDeclaration[]): void;
|
|
73
|
+
/** The effective policy of every row, as plain data — what the snapshot test compares. */
|
|
74
|
+
export declare const routePolicySnapshot: () => Array<Omit<RouteDeclaration, "sample">>;
|