toolroll 0.9.44 → 0.9.46
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 +29 -0
- package/dist/api-tokens.d.ts +33 -0
- package/dist/api-tokens.js +39 -0
- package/dist/audit-cli.d.ts +17 -0
- package/dist/audit-cli.js +86 -0
- package/dist/cli-http.d.ts +10 -16
- package/dist/cli-http.js +12 -2
- package/dist/cli.js +7 -1
- package/dist/contracts/cli.d.ts +47 -0
- package/dist/contracts/cli.js +11 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/mcp-oauth.d.ts +53 -0
- package/dist/contracts/mcp-oauth.js +40 -0
- package/dist/credentials-ui.d.ts +5 -0
- package/dist/credentials-ui.js +16 -4
- package/dist/desktop-update.d.ts +2 -2
- package/dist/dispatch.d.ts +2 -2
- package/dist/dispatch.js +8 -4
- package/dist/export.js +4 -3
- package/dist/lead-status.d.ts +7 -3
- package/dist/lead-status.js +21 -12
- package/dist/mcp-http.d.ts +6 -1
- package/dist/mcp-http.js +22 -4
- package/dist/mcp-oauth.d.ts +82 -0
- package/dist/mcp-oauth.js +479 -0
- package/dist/mcp-person.js +1 -1
- package/dist/operate-remote-arguments.js +2 -0
- package/dist/operate-remote.d.ts +13 -1
- package/dist/operate-remote.js +30 -4
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +86 -12
- package/dist/people-audit-ui.d.ts +14 -0
- package/dist/people-audit-ui.js +38 -0
- package/dist/public-access.d.ts +50 -0
- package/dist/public-access.js +87 -0
- package/dist/public-check.d.ts +37 -0
- package/dist/public-check.js +170 -0
- package/dist/remote-audit.d.ts +79 -0
- package/dist/remote-audit.js +161 -0
- package/dist/remote-run.d.ts +4 -0
- package/dist/request-budget-ui.d.ts +11 -0
- package/dist/request-budget-ui.js +31 -0
- package/dist/request-budget.d.ts +96 -0
- package/dist/request-budget.js +227 -0
- package/dist/serve.d.ts +4 -1
- package/dist/serve.js +120 -13
- package/dist/store.d.ts +118 -2
- package/dist/store.js +236 -7
- package/dist/surface.js +19 -2
- package/dist/token-notices.d.ts +22 -0
- package/dist/token-notices.js +52 -0
- package/dist/tokens-cli.d.ts +21 -0
- package/dist/tokens-cli.js +184 -0
- package/dist/work-summary.d.ts +4 -1
- package/dist/work-summary.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
export const REQUEST_BUDGET_SCHEMA = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS request_budget_limit (
|
|
3
|
+
target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),
|
|
4
|
+
read_per_minute INTEGER CHECK (read_per_minute IS NULL OR read_per_minute BETWEEN 1 AND 600),
|
|
5
|
+
act_per_minute INTEGER CHECK (act_per_minute IS NULL OR act_per_minute BETWEEN 1 AND 600),
|
|
6
|
+
per_day INTEGER CHECK (per_day IS NULL OR per_day BETWEEN 1 AND 100000),
|
|
7
|
+
set_by TEXT NOT NULL,
|
|
8
|
+
set_at TEXT NOT NULL
|
|
9
|
+
);
|
|
10
|
+
CREATE TABLE IF NOT EXISTS request_budget_usage (
|
|
11
|
+
token_id TEXT PRIMARY KEY,
|
|
12
|
+
minute TEXT NOT NULL,
|
|
13
|
+
day TEXT NOT NULL,
|
|
14
|
+
noted TEXT NOT NULL,
|
|
15
|
+
saved_at INTEGER NOT NULL
|
|
16
|
+
);
|
|
17
|
+
`;
|
|
18
|
+
export const REQUEST_BUDGET_DEFAULTS = Object.freeze({ read: 120, act: 30, day: 10_000 });
|
|
19
|
+
export const PER_MINUTE_MAX = 600;
|
|
20
|
+
export const PER_DAY_MAX = 100_000;
|
|
21
|
+
export const TRACKED_TOKENS = 1024;
|
|
22
|
+
export const FLUSH_MS = 5_000;
|
|
23
|
+
const MINUTE = 60_000, DAY = 86_400_000;
|
|
24
|
+
/** The words for a 429, in one line. */
|
|
25
|
+
export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : "act requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
|
|
26
|
+
/** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
|
|
27
|
+
export function parseLimit(raw, max) {
|
|
28
|
+
const text = raw.trim();
|
|
29
|
+
if (text === "")
|
|
30
|
+
return null;
|
|
31
|
+
if (!/^[0-9]{1,6}$/.test(text))
|
|
32
|
+
return { problem: `Limits are whole numbers from 1 to ${max.toLocaleString("en-US")}.` };
|
|
33
|
+
const value = Number(text);
|
|
34
|
+
return value >= 1 && value <= max ? value : { problem: `Limits are whole numbers from 1 to ${max.toLocaleString("en-US")}.` };
|
|
35
|
+
}
|
|
36
|
+
const within = (value, max) => {
|
|
37
|
+
if (value === null || value === undefined)
|
|
38
|
+
return null;
|
|
39
|
+
const n = Number(value);
|
|
40
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > max)
|
|
41
|
+
throw new Error("request budget: a saved limit is out of bounds");
|
|
42
|
+
return n;
|
|
43
|
+
};
|
|
44
|
+
function readOverride(db, target) {
|
|
45
|
+
const row = db.prepare("SELECT read_per_minute, act_per_minute, per_day FROM request_budget_limit WHERE target = ?").get(target);
|
|
46
|
+
if (row === undefined)
|
|
47
|
+
return null;
|
|
48
|
+
return { readPerMinute: within(row["read_per_minute"], PER_MINUTE_MAX), actPerMinute: within(row["act_per_minute"], PER_MINUTE_MAX), perDay: within(row["per_day"], PER_DAY_MAX) };
|
|
49
|
+
}
|
|
50
|
+
/** Every override: the installation's under "*", each token's under its id. */
|
|
51
|
+
export function limitOverrides(db) {
|
|
52
|
+
const out = new Map();
|
|
53
|
+
for (const row of db.prepare("SELECT target FROM request_budget_limit ORDER BY target").all()) {
|
|
54
|
+
const target = String(row["target"]);
|
|
55
|
+
out.set(target, readOverride(db, target));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Token override, then the installation's, then the default — per limit. */
|
|
60
|
+
export function effectiveLimits(db, tokenId, access) {
|
|
61
|
+
const token = readOverride(db, tokenId), installation = readOverride(db, "*");
|
|
62
|
+
const pick = (of, fallback) => (token === null ? null : of(token)) ?? (installation === null ? null : of(installation)) ?? fallback;
|
|
63
|
+
const minute = (one) => access === "read" ? one.readPerMinute : one.actPerMinute;
|
|
64
|
+
const perMinute = pick(minute, REQUEST_BUDGET_DEFAULTS[access]), perDay = pick(one => one.perDay, REQUEST_BUDGET_DEFAULTS.day);
|
|
65
|
+
return { perMinute, perDay, overridden: perMinute !== REQUEST_BUDGET_DEFAULTS[access] || perDay !== REQUEST_BUDGET_DEFAULTS.day };
|
|
66
|
+
}
|
|
67
|
+
/** Set (or, all null, clear) an override, with the change in the policy ledger. The caller checks who may. */
|
|
68
|
+
export function setLimitOverride(store, target, next, by, now) {
|
|
69
|
+
const db = store.handle;
|
|
70
|
+
if (target !== "*" && !/^[0-9a-f]{12}$/.test(target))
|
|
71
|
+
throw new Error("request budget: unknown target");
|
|
72
|
+
within(next.readPerMinute, PER_MINUTE_MAX);
|
|
73
|
+
within(next.actPerMinute, PER_MINUTE_MAX);
|
|
74
|
+
within(next.perDay, PER_DAY_MAX);
|
|
75
|
+
const before = readOverride(db, target);
|
|
76
|
+
const cleared = next.readPerMinute === null && next.actPerMinute === null && next.perDay === null;
|
|
77
|
+
if (cleared)
|
|
78
|
+
db.prepare("DELETE FROM request_budget_limit WHERE target = ?").run(target);
|
|
79
|
+
else
|
|
80
|
+
db.prepare(`INSERT INTO request_budget_limit (target, read_per_minute, act_per_minute, per_day, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
81
|
+
ON CONFLICT(target) DO UPDATE SET read_per_minute = excluded.read_per_minute, act_per_minute = excluded.act_per_minute, per_day = excluded.per_day, set_by = excluded.set_by, set_at = excluded.set_at`)
|
|
82
|
+
.run(target, next.readPerMinute, next.actPerMinute, next.perDay, by, now.toISOString());
|
|
83
|
+
const after = cleared ? null : next;
|
|
84
|
+
const words = (one) => one === null ? "defaults" : [one.readPerMinute === null ? null : `read ${one.readPerMinute}/min`, one.actPerMinute === null ? null : `act ${one.actPerMinute}/min`, one.perDay === null ? null : `${one.perDay}/day`].filter(Boolean).join(", ");
|
|
85
|
+
const name = target === "*" ? "installation" : `token ${store.apiTokenSecret(target)?.row.name ?? target}`;
|
|
86
|
+
store.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "request limits changed", outcome: "changed", source: "policy", detail: `${name}: ${words(before)} → ${words(after)}` });
|
|
87
|
+
return { before, after };
|
|
88
|
+
}
|
|
89
|
+
const finiteInts = (value, max) => Array.isArray(value) && value.length <= max && value.every(one => Number.isSafeInteger(one) && one >= 0);
|
|
90
|
+
function prune(usage, now) {
|
|
91
|
+
let drop = 0;
|
|
92
|
+
while (drop < usage.minute.length && usage.minute[drop] <= now - MINUTE)
|
|
93
|
+
drop++;
|
|
94
|
+
if (drop > 0) {
|
|
95
|
+
usage.minute.splice(0, drop);
|
|
96
|
+
usage.dirty = true;
|
|
97
|
+
}
|
|
98
|
+
for (const at of usage.day.keys())
|
|
99
|
+
if ((at + 1) * MINUTE + DAY <= now) {
|
|
100
|
+
usage.day.delete(at);
|
|
101
|
+
usage.dirty = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** One per server: both routes share it, so a token's allowance is never doubled. */
|
|
105
|
+
export class RequestBudget {
|
|
106
|
+
store;
|
|
107
|
+
clock;
|
|
108
|
+
tracked;
|
|
109
|
+
flushMs;
|
|
110
|
+
usage = new Map();
|
|
111
|
+
lastFlush;
|
|
112
|
+
constructor(options) {
|
|
113
|
+
this.store = options.store;
|
|
114
|
+
this.clock = options.clock ?? Date.now;
|
|
115
|
+
this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
|
|
116
|
+
this.flushMs = options.flushMs ?? FLUSH_MS;
|
|
117
|
+
this.lastFlush = this.clock();
|
|
118
|
+
}
|
|
119
|
+
/** How many tokens are tracked in memory now (never more than `tracked`). */
|
|
120
|
+
get size() { return this.usage.size; }
|
|
121
|
+
/**
|
|
122
|
+
* Admit one request by an authenticated token, or say why not. Called once per HTTP request, before its body is
|
|
123
|
+
* read. Anything unreadable refuses with 503 and nothing runs.
|
|
124
|
+
*/
|
|
125
|
+
admit(tokenId, route) {
|
|
126
|
+
try {
|
|
127
|
+
const now = this.clock();
|
|
128
|
+
const token = this.store.apiTokenSecret(tokenId)?.row;
|
|
129
|
+
if (token === undefined)
|
|
130
|
+
return { ok: false, status: 503 };
|
|
131
|
+
const limits = effectiveLimits(this.store.handle, token.id, token.access);
|
|
132
|
+
const usage = this.load(token.id, now);
|
|
133
|
+
prune(usage, now);
|
|
134
|
+
let wait = 0, limit = null;
|
|
135
|
+
if (usage.minute.length >= limits.perMinute) {
|
|
136
|
+
wait = usage.minute[usage.minute.length - limits.perMinute] + MINUTE - now;
|
|
137
|
+
limit = token.access === "read" ? "read-per-minute" : "act-per-minute";
|
|
138
|
+
}
|
|
139
|
+
let total = 0;
|
|
140
|
+
for (const count of usage.day.values())
|
|
141
|
+
total += count;
|
|
142
|
+
if (total >= limits.perDay) {
|
|
143
|
+
let freed = 0;
|
|
144
|
+
for (const at of [...usage.day.keys()].sort((a, b) => a - b)) {
|
|
145
|
+
freed += usage.day.get(at);
|
|
146
|
+
if (total - freed < limits.perDay) {
|
|
147
|
+
const until = (at + 1) * MINUTE + DAY - now;
|
|
148
|
+
if (until > wait || limit === null) {
|
|
149
|
+
wait = until;
|
|
150
|
+
limit = "per-day";
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (limit !== null) {
|
|
157
|
+
const retryAfter = Math.max(1, Math.ceil(wait / 1000));
|
|
158
|
+
const window = limit === "per-day" ? "day" : "minute";
|
|
159
|
+
// Once per exhausted window: the marker lasts until the window has room again, and is saved with the entry.
|
|
160
|
+
if (usage.noted[window] <= now) {
|
|
161
|
+
usage.noted[window] = now + retryAfter * 1000;
|
|
162
|
+
usage.dirty = true;
|
|
163
|
+
this.save(token.id, usage, now);
|
|
164
|
+
this.store.recordAction({ at: new Date(now).toISOString(), actor: token.account, repo: null, taskId: null, runId: null, action: "remote request limit reached", outcome: "refused", source: route, detail: `token ${token.name}: ${limit}` });
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, status: 429, limit, retryAfter };
|
|
167
|
+
}
|
|
168
|
+
usage.minute.push(now);
|
|
169
|
+
if (usage.minute.length > PER_MINUTE_MAX)
|
|
170
|
+
usage.minute.splice(0, usage.minute.length - PER_MINUTE_MAX);
|
|
171
|
+
const at = Math.floor(now / MINUTE);
|
|
172
|
+
usage.day.set(at, (usage.day.get(at) ?? 0) + 1);
|
|
173
|
+
usage.dirty = true;
|
|
174
|
+
if (now - this.lastFlush >= this.flushMs)
|
|
175
|
+
this.flush();
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { ok: false, status: 503 };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Save every changed entry. A server calls this on a timer and when it closes. */
|
|
183
|
+
flush() {
|
|
184
|
+
const now = this.clock();
|
|
185
|
+
this.lastFlush = now;
|
|
186
|
+
for (const [id, usage] of this.usage)
|
|
187
|
+
if (usage.dirty)
|
|
188
|
+
this.save(id, usage, now);
|
|
189
|
+
// Saved rows whose every count has expired are no use to anyone.
|
|
190
|
+
this.store.handle.prepare("DELETE FROM request_budget_usage WHERE saved_at <= ?").run(now - DAY - MINUTE);
|
|
191
|
+
}
|
|
192
|
+
save(id, usage, now) {
|
|
193
|
+
this.store.handle.prepare(`INSERT INTO request_budget_usage (token_id, minute, day, noted, saved_at) VALUES (?, ?, ?, ?, ?)
|
|
194
|
+
ON CONFLICT(token_id) DO UPDATE SET minute = excluded.minute, day = excluded.day, noted = excluded.noted, saved_at = excluded.saved_at`)
|
|
195
|
+
.run(id, JSON.stringify(usage.minute), JSON.stringify([...usage.day]), JSON.stringify(usage.noted), now);
|
|
196
|
+
usage.dirty = false;
|
|
197
|
+
}
|
|
198
|
+
/** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
|
|
199
|
+
load(id, now) {
|
|
200
|
+
let usage = this.usage.get(id);
|
|
201
|
+
if (usage !== undefined) {
|
|
202
|
+
this.usage.delete(id);
|
|
203
|
+
this.usage.set(id, usage);
|
|
204
|
+
return usage;
|
|
205
|
+
}
|
|
206
|
+
const row = this.store.handle.prepare("SELECT minute, day, noted FROM request_budget_usage WHERE token_id = ?").get(id);
|
|
207
|
+
if (row === undefined)
|
|
208
|
+
usage = { minute: [], day: new Map(), noted: { minute: 0, day: 0 }, dirty: false };
|
|
209
|
+
else {
|
|
210
|
+
const minute = JSON.parse(String(row["minute"])), day = JSON.parse(String(row["day"])), noted = JSON.parse(String(row["noted"]));
|
|
211
|
+
if (!finiteInts(minute, PER_MINUTE_MAX) || !Array.isArray(day) || day.length > 2 * DAY / MINUTE || !day.every(pair => finiteInts(pair, 2) && pair.length === 2)
|
|
212
|
+
|| noted === null || typeof noted !== "object" || !finiteInts([noted["minute"], noted["day"]], 2)) {
|
|
213
|
+
throw new Error("request budget: saved usage is unreadable");
|
|
214
|
+
}
|
|
215
|
+
const kept = noted;
|
|
216
|
+
usage = { minute: [...minute].sort((a, b) => a - b), day: new Map(day), noted: { minute: kept.minute, day: kept.day }, dirty: false };
|
|
217
|
+
}
|
|
218
|
+
this.usage.set(id, usage);
|
|
219
|
+
while (this.usage.size > this.tracked) {
|
|
220
|
+
const [oldest, entry] = this.usage.entries().next().value;
|
|
221
|
+
if (entry.dirty)
|
|
222
|
+
this.save(oldest, entry, now);
|
|
223
|
+
this.usage.delete(oldest);
|
|
224
|
+
}
|
|
225
|
+
return usage;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -41,13 +41,16 @@ export type ServeOptions = {
|
|
|
41
41
|
* (arc 3 finding 2/16): EXACTLY an origin — no path, query, credentials.
|
|
42
42
|
* The one trust anchor for secure-context features: it joins the allowed
|
|
43
43
|
* hosts, its origin authorizes POSTs, cookies turn Secure, and the
|
|
44
|
-
* install/push cards light up. X-Forwarded-* is
|
|
44
|
+
* install/push cards light up. X-Forwarded-* is consulted only from a
|
|
45
|
+
* loopback peer, the same-host proxy (public-access.ts).
|
|
45
46
|
*/
|
|
46
47
|
publicUrl?: string;
|
|
47
48
|
/** Tests: the shared command boundary `POST /api/cli` runs (default: operate.ts's runOperateAs). */
|
|
48
49
|
cliRunner?: RunOperateAs;
|
|
49
50
|
/** Tests: remote command metadata until the shared contract declares it. */
|
|
50
51
|
cliModeOf?: CliHttpOptions['modeOf'];
|
|
52
|
+
/** Tests: the clock request budgets for API tokens count by (request-budget.ts). */
|
|
53
|
+
requestBudgetClock?: () => number;
|
|
51
54
|
/** Where repos.json lives — every enrollment locks exactly this file. */
|
|
52
55
|
registryPath?: string;
|
|
53
56
|
/** This console fronts an `up` process: onboarding copy says how to watch. */
|
package/dist/serve.js
CHANGED
|
@@ -262,9 +262,15 @@ import { LIMITS_CSS, limitsHtml, limitsView } from "./limits-ui.js";
|
|
|
262
262
|
import { budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames as teammateNamesOf, usd as spendUsd } from "./spend.js";
|
|
263
263
|
import { targetOf } from "./monitoring.js";
|
|
264
264
|
import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
|
|
265
|
-
import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS } from "./api-tokens.js";
|
|
265
|
+
import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
266
266
|
import { createMcpHttp } from "./mcp-http.js";
|
|
267
|
+
import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride } from "./request-budget.js";
|
|
268
|
+
import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
|
|
269
|
+
import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
|
|
267
270
|
import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
|
|
271
|
+
import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
|
|
272
|
+
import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
|
|
273
|
+
import { cursorOf as auditCursorOf, remoteAudit } from "./remote-audit.js";
|
|
268
274
|
import { logEvent } from "./log.js";
|
|
269
275
|
import { modeTermsFromJson, modeWords, presetTerms, modeTermsJson, modeDigestOf, MODE_MAX_DAYS } from "./modes.js";
|
|
270
276
|
import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, readProviderKey, keyStatus, plausibleKey, readAuthMode, readAuthModeStrict, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
|
|
@@ -535,7 +541,7 @@ export function createDecisionServer(options) {
|
|
|
535
541
|
* the trusted proxy itself appended; earlier entries are client-typed). */
|
|
536
542
|
function joinSourceOf(request) {
|
|
537
543
|
const peer = request.socket.remoteAddress ?? "unknown";
|
|
538
|
-
const loopback =
|
|
544
|
+
const loopback = LOOPBACK_PEERS.has(peer);
|
|
539
545
|
if (!loopback)
|
|
540
546
|
return peer;
|
|
541
547
|
const forwarded = request.headers["x-forwarded-for"];
|
|
@@ -581,7 +587,7 @@ export function createDecisionServer(options) {
|
|
|
581
587
|
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
|
|
582
588
|
const fromThisComputer = (request) => {
|
|
583
589
|
const peer = request.socket.remoteAddress ?? "";
|
|
584
|
-
const loopback =
|
|
590
|
+
const loopback = LOOPBACK_PEERS.has(peer);
|
|
585
591
|
return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
|
|
586
592
|
&& /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
587
593
|
};
|
|
@@ -797,16 +803,19 @@ export function createDecisionServer(options) {
|
|
|
797
803
|
return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
|
|
798
804
|
}, cursor: team.cursor, streams: teamStreams,
|
|
799
805
|
});
|
|
806
|
+
// v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
|
|
807
|
+
const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
|
|
800
808
|
// Remote CLI: one live API token names the person; the shared command boundary decides everything else.
|
|
801
809
|
const cliEndpoint = (request, response) => handleCliHttp(request, response, {
|
|
810
|
+
admit: principal => requestBudget.admit(principal.tokenId, "api"),
|
|
802
811
|
authenticate: request => {
|
|
803
|
-
const who = identify(request, false);
|
|
812
|
+
const who = identify(request, false, true);
|
|
804
813
|
const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
|
|
805
814
|
const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
|
|
806
815
|
const account = who === null ? null : store.accountOf(who.name);
|
|
807
816
|
if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
|
|
808
817
|
return null;
|
|
809
|
-
return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: account.projects
|
|
818
|
+
return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
|
|
810
819
|
},
|
|
811
820
|
run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
|
|
812
821
|
modeOf: options.cliModeOf,
|
|
@@ -876,6 +885,15 @@ export function createDecisionServer(options) {
|
|
|
876
885
|
timer.unref?.();
|
|
877
886
|
server.on("close", () => clearInterval(timer));
|
|
878
887
|
}
|
|
888
|
+
{
|
|
889
|
+
const flushBudget = () => { try {
|
|
890
|
+
requestBudget.flush();
|
|
891
|
+
}
|
|
892
|
+
catch { /* the next admit refuses if the database is unwell */ } };
|
|
893
|
+
const timer = setInterval(flushBudget, REQUEST_BUDGET_FLUSH_MS);
|
|
894
|
+
timer.unref?.();
|
|
895
|
+
server.on("close", () => { clearInterval(timer); flushBudget(); });
|
|
896
|
+
}
|
|
879
897
|
const servedPort = () => {
|
|
880
898
|
const address = server.address();
|
|
881
899
|
return typeof address === "object" && address !== null ? address.port : null;
|
|
@@ -1237,6 +1255,17 @@ export function createDecisionServer(options) {
|
|
|
1237
1255
|
if (!allowedHost(request.headers.host)) {
|
|
1238
1256
|
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
1239
1257
|
}
|
|
1258
|
+
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
1259
|
+
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
1260
|
+
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
1261
|
+
const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
|
|
1262
|
+
if (hsts !== null)
|
|
1263
|
+
response.setHeader("Strict-Transport-Security", hsts);
|
|
1264
|
+
const insecure = plainHttpRefusal(transport, hook.pathname, request.headers.authorization);
|
|
1265
|
+
if (insecure !== null) {
|
|
1266
|
+
request.resume();
|
|
1267
|
+
return respond(response, 403, "text/plain; charset=utf-8", insecure);
|
|
1268
|
+
}
|
|
1240
1269
|
const url = new URL(request.url ?? "/", "http://placeholder");
|
|
1241
1270
|
// A token in a URL is a token in history, logs, and referers. Refused
|
|
1242
1271
|
// outright rather than ignored, so nobody learns the habit works.
|
|
@@ -1246,6 +1275,9 @@ export function createDecisionServer(options) {
|
|
|
1246
1275
|
// The MCP gateway over HTTP (mcp-http.ts): coordinators and people's own API tokens, after the Host check.
|
|
1247
1276
|
if (url.pathname === "/mcp")
|
|
1248
1277
|
return mcpHttp(request, response);
|
|
1278
|
+
// MCP sign-in (mcp-oauth.ts): its discovery documents, client registration, consent and tokens.
|
|
1279
|
+
if (await oauthHttp(request, response, url))
|
|
1280
|
+
return;
|
|
1249
1281
|
// Back from Google's consent screen (v89). The session cookie is SameSite=Strict and stays behind on
|
|
1250
1282
|
// a return from another site: the visit's one-time state (made by a signed-in approver) is the proof.
|
|
1251
1283
|
if (url.pathname === GOOGLE_CALLBACK && request.method === "GET")
|
|
@@ -2752,11 +2784,26 @@ export function createDecisionServer(options) {
|
|
|
2752
2784
|
return sendScreen(response, 200, screen("mode", [`<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm].join("\n"), { chrome: chromeFor(project, "mode") }));
|
|
2753
2785
|
}
|
|
2754
2786
|
if (url.pathname === "/people") {
|
|
2787
|
+
// People → a person: their API tokens and remote activity (people-audit-ui.ts). An approver may open anyone, within
|
|
2788
|
+
// their projects; anyone else only themselves. Another person reads exactly like nobody.
|
|
2789
|
+
const person = url.searchParams.get("person");
|
|
2790
|
+
if (person !== null) {
|
|
2791
|
+
const token = (url.searchParams.get("token-name") ?? "").slice(0, 60) || null, before = url.searchParams.get("before");
|
|
2792
|
+
const cursor = before === null ? null : auditCursorOf(before);
|
|
2793
|
+
if (before !== null && cursor === null)
|
|
2794
|
+
return refuse(response, who, 400, "Invalid activity cursor.", "/people");
|
|
2795
|
+
const page = who.role === "approver" || person === who.name
|
|
2796
|
+
? remoteAudit(store, { self: who.name, everyone: who.role === "approver", repos: admissionList(), unplaced: store.isInstanceOperator(who.name) }, { person, token, source: null, since: null }, { before: cursor })
|
|
2797
|
+
: null;
|
|
2798
|
+
if (page === null || !page.ok)
|
|
2799
|
+
return refuse(response, who, 404, "No such person.", "/people");
|
|
2800
|
+
return sendScreen(response, 200, screen("people", personAuditHtml({ name: person, tokens: store.apiTokens(person), actions: page.actions, token, nextCursor: page.nextCursor, now }), { chrome: chromeFor(project, "people") }));
|
|
2801
|
+
}
|
|
2755
2802
|
// Approvers see everyone (D7's ceiling: every fact already passed
|
|
2756
2803
|
// the process admission); a viewer sees exactly themselves (U3).
|
|
2757
2804
|
if (restricted()) {
|
|
2758
2805
|
const projects = admissionList() ?? [];
|
|
2759
|
-
return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p>`, { chrome: chromeFor(project, "people") }));
|
|
2806
|
+
return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p><p><a href="${escape(personHref(who.name))}">Your remote activity</a></p>`, { chrome: chromeFor(project, "people") }));
|
|
2760
2807
|
}
|
|
2761
2808
|
const approverView = store.isInstanceOperator(who.name);
|
|
2762
2809
|
const accounts = store.accountFacts().filter(one => approverView || one.name === who.name);
|
|
@@ -2787,6 +2834,7 @@ export function createDecisionServer(options) {
|
|
|
2787
2834
|
`<p class="meta">${one.projects === null ? "All projects" : one.projects.length === 0 ? "No project access" : one.projects.map(repo => escape(projectName(repo))).join(", ")}</p>`,
|
|
2788
2835
|
approverView && one.revokedAt === null ? `<details><summary>Edit project access</summary><form method="post" action="/people/projects">${hiddenFields({ csrf, name: one.name })}${accessFields(one.projects)}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><p class="meta">Changing access signs this person out and ends their derived sessions and modes. Previously approved work stays recorded.</p><button type="submit">Save project access</button></form></details>` : "",
|
|
2789
2836
|
`<p class="meta">${seen === null ? "not signed in right now" : `signed in \u2014 active ${escape(new Date(seen).toISOString().slice(11, 16))} UTC`} \u00b7 joined ${escape(one.addedAt.slice(0, 10))}</p>`,
|
|
2837
|
+
`<p><a href="${escape(personHref(one.name))}">Tokens and remote activity</a></p>`,
|
|
2790
2838
|
attended.length === 0
|
|
2791
2839
|
? ""
|
|
2792
2840
|
: `<p class="row">watching now: ${attended.map(session => `<a href="/t/${escape(session.taskId)}">${escape(session.taskId)}</a>`).join(", ")}</p>`,
|
|
@@ -3065,7 +3113,7 @@ export function createDecisionServer(options) {
|
|
|
3065
3113
|
if (tokenId === null)
|
|
3066
3114
|
return true;
|
|
3067
3115
|
const token = store.apiTokenSecret(tokenId)?.row;
|
|
3068
|
-
return token !== undefined &&
|
|
3116
|
+
return token !== undefined && tokenLive(token, Date.now());
|
|
3069
3117
|
};
|
|
3070
3118
|
// The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
|
|
3071
3119
|
const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
|
|
@@ -4034,7 +4082,8 @@ export function createDecisionServer(options) {
|
|
|
4034
4082
|
const everyone = url.searchParams.get("everyone") === "1" && store.isInstanceOperator(who.name);
|
|
4035
4083
|
const here = sessions.hashOf(who.session);
|
|
4036
4084
|
const view = { who: who.name, everyone, canSeeEveryone: store.isInstanceOperator(who.name), now: Date.now(),
|
|
4037
|
-
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name)
|
|
4085
|
+
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name),
|
|
4086
|
+
limits: { note: tokenLimitWords.bind(null, store.handle), section: store.isInstanceOperator(who.name) ? requestLimitsHtml(store.handle, store.apiTokens(null), who.session.csrf, Date.now()) : "" } };
|
|
4038
4087
|
return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions & tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
4039
4088
|
}
|
|
4040
4089
|
// Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
|
|
@@ -7472,6 +7521,31 @@ export function createDecisionServer(options) {
|
|
|
7472
7521
|
store.setApprovalRules(repo, next, who.name, now);
|
|
7473
7522
|
return back("said", `Saved. ${rulesSummary(next)}`);
|
|
7474
7523
|
}
|
|
7524
|
+
// v112: request limits for API tokens (request-budget.ts). An instance operator, in a browser, with their password;
|
|
7525
|
+
// never over /api/cli or /mcp (a token never reaches a console form).
|
|
7526
|
+
if (url.pathname === "/settings/request-limits") {
|
|
7527
|
+
const body = readForm(posted, CONSOLE_FORMS.requestLimits);
|
|
7528
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7529
|
+
return refuse(response, who, 403, "An instance operator sets request limits.", "/settings/sessions");
|
|
7530
|
+
const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
|
|
7531
|
+
const target = body.get("target") ?? "";
|
|
7532
|
+
const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
|
|
7533
|
+
if (target !== "*" && token === null)
|
|
7534
|
+
return back("problem", "That token is no longer live. Nothing changed.");
|
|
7535
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
|
|
7536
|
+
return back("problem", "Enter your Toolroll password to change request limits.");
|
|
7537
|
+
const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
|
|
7538
|
+
const clear = body.get("action") === "clear";
|
|
7539
|
+
const fields = clear ? { readPerMinute: null, actPerMinute: null, perDay: null }
|
|
7540
|
+
: token === null ? { readPerMinute: read("read-per-minute"), actPerMinute: read("act-per-minute"), perDay: read("per-day") }
|
|
7541
|
+
: { readPerMinute: token.access === "read" ? read("per-minute") : null, actPerMinute: token.access === "act" ? read("per-minute") : null, perDay: read("per-day") };
|
|
7542
|
+
const problem = Object.values(fields).find((value) => value !== null && typeof value === "object");
|
|
7543
|
+
if (problem !== undefined)
|
|
7544
|
+
return back("problem", `${problem.problem} Nothing changed.`);
|
|
7545
|
+
setLimitOverride(store, target, fields, who.name, now);
|
|
7546
|
+
const label = token === null ? "Everyone's tokens" : token.name;
|
|
7547
|
+
return back("said", clear || Object.values(fields).every(value => value === null) ? `${label}: default limits.` : `${label}: limits saved.`);
|
|
7548
|
+
}
|
|
7475
7549
|
if (url.pathname === "/settings/sessions") {
|
|
7476
7550
|
const body = readForm(posted, CONSOLE_FORMS.sessions);
|
|
7477
7551
|
if (who.via !== "cookie")
|
|
@@ -11780,10 +11854,32 @@ export function createDecisionServer(options) {
|
|
|
11780
11854
|
approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
|
|
11781
11855
|
}));
|
|
11782
11856
|
}
|
|
11783
|
-
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request) !== null,
|
|
11857
|
+
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
|
|
11858
|
+
resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
|
|
11784
11859
|
enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
|
|
11860
|
+
// MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
|
|
11861
|
+
const oauthHttp = createOAuthHttp({ store, clock,
|
|
11862
|
+
originOf: request => consoleOrigin(request.headers.host),
|
|
11863
|
+
requesterKey: joinSourceOf,
|
|
11864
|
+
session: request => {
|
|
11865
|
+
const who = identify(request, false);
|
|
11866
|
+
if (who === null || who.via !== "cookie")
|
|
11867
|
+
return null;
|
|
11868
|
+
return { name: who.name, role: who.role, csrf: who.session.csrf,
|
|
11869
|
+
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
|
|
11870
|
+
},
|
|
11871
|
+
sameSite: request => {
|
|
11872
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
11873
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
11874
|
+
return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
|
|
11875
|
+
},
|
|
11876
|
+
// The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
|
|
11877
|
+
// grant (their role, their projects) is checked beside it.
|
|
11878
|
+
confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
|
|
11879
|
+
projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
|
|
11880
|
+
});
|
|
11785
11881
|
// ---- identity ------------------------------------------------------------
|
|
11786
|
-
function identify(request, touch = true) {
|
|
11882
|
+
function identify(request, touch = true, limited = false) {
|
|
11787
11883
|
// 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.
|
|
11788
11884
|
const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
11789
11885
|
if (presented !== undefined) {
|
|
@@ -11796,11 +11892,18 @@ export function createDecisionServer(options) {
|
|
|
11796
11892
|
signInBudget.failed(source, at);
|
|
11797
11893
|
return null;
|
|
11798
11894
|
}
|
|
11799
|
-
if (
|
|
11895
|
+
if (!tokenLive(kept.row, at))
|
|
11896
|
+
return null;
|
|
11897
|
+
// v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
|
|
11898
|
+
// through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
|
|
11899
|
+
if (kept.row.projects !== null && !limited)
|
|
11800
11900
|
return null;
|
|
11801
11901
|
const account = store.accountOf(kept.row.account);
|
|
11802
11902
|
if (account === null || account.revokedAt !== null)
|
|
11803
11903
|
return null;
|
|
11904
|
+
// v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
|
|
11905
|
+
if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)))
|
|
11906
|
+
return null;
|
|
11804
11907
|
store.touchApiToken(kept.row.id, new Date(at));
|
|
11805
11908
|
return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
|
|
11806
11909
|
}
|
|
@@ -12395,6 +12498,10 @@ export function createDecisionServer(options) {
|
|
|
12395
12498
|
const closeServer = server.close.bind(server);
|
|
12396
12499
|
server.close = ((callback) => {
|
|
12397
12500
|
leadClosing = true;
|
|
12501
|
+
try {
|
|
12502
|
+
requestBudget.flush();
|
|
12503
|
+
}
|
|
12504
|
+
catch { /* saved again on the close event when it can be */ }
|
|
12398
12505
|
for (const stream of teamStreams)
|
|
12399
12506
|
stream.end();
|
|
12400
12507
|
teamStreams.clear();
|
|
@@ -16304,7 +16411,7 @@ const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;o
|
|
|
16304
16411
|
'.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
|
|
16305
16412
|
'.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
|
|
16306
16413
|
'@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
|
|
16307
|
-
const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
16414
|
+
const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
16308
16415
|
/**
|
|
16309
16416
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
16310
16417
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -20132,7 +20239,7 @@ function safeReturn(raw) {
|
|
|
20132
20239
|
* button can name. Nothing here is a second redirect framework: one
|
|
20133
20240
|
* same-site path, or "/".
|
|
20134
20241
|
*/
|
|
20135
|
-
const LOGIN_RETURN_PAGES = /^\/(t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
|
|
20242
|
+
const LOGIN_RETURN_PAGES = /^\/(oauth\/authorize|t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
|
|
20136
20243
|
function loginReturn(raw) {
|
|
20137
20244
|
const safe = safeReturn(raw);
|
|
20138
20245
|
if (safe === "/")
|