toolroll 0.9.45 → 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 +18 -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 +5 -0
- 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 +1 -1
- package/dist/export.js +4 -3
- 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 +1 -1
- package/dist/operate-remote.js +7 -1
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +52 -1
- 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/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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.46 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **Tokens from the terminal.** `toolroll tokens create|list|revoke|rotate`.
|
|
6
|
+
Creating or rotating asks for your password and shows the secret once.
|
|
7
|
+
Tokens can be limited to projects and always expire; rotating never
|
|
8
|
+
extends one. You're warned 7 days and 1 day before expiry.
|
|
9
|
+
- **Request limits per token.** Too many requests get a 429 with
|
|
10
|
+
Retry-After. Owners change the limits in the console.
|
|
11
|
+
- **`toolroll audit`** and the People page show who did what remotely, with
|
|
12
|
+
which token, and how it went. People see only their own.
|
|
13
|
+
- **A real domain, safely.** HSTS only on the https public host. Tokens are
|
|
14
|
+
refused over plain HTTP from outside this machine or your tailnet.
|
|
15
|
+
`toolroll serve check-public` checks the setup; see docs/team-server.md.
|
|
16
|
+
- **Agents sign in instead of pasting a token.** OAuth with PKCE and a
|
|
17
|
+
consent screen, short-lived tokens, and reuse detection. Pasted tokens
|
|
18
|
+
still work.
|
|
19
|
+
- Still to come: `brief` and `spend` remotely for limited access.
|
|
20
|
+
|
|
3
21
|
## 0.9.45 — 2026-10-07
|
|
4
22
|
|
|
5
23
|
- **Your projects, your lists.** People with access to some projects can now
|
package/dist/api-tokens.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export type TokenAccess = "read" | "act";
|
|
2
|
+
export type TokenPurpose = "api" | "mcp";
|
|
3
|
+
export declare const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
|
|
4
|
+
/** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
|
|
5
|
+
export declare const tokenPurpose: (value: unknown) => TokenPurpose;
|
|
6
|
+
export declare const tokenRotationProblem: (token: {
|
|
7
|
+
purpose: TokenPurpose;
|
|
8
|
+
}) => string | null;
|
|
2
9
|
export declare const TOKEN_DAYS: readonly [30, 90, 365];
|
|
3
10
|
export declare function mintApiToken(): {
|
|
4
11
|
id: string;
|
|
@@ -14,3 +21,29 @@ export declare function parseApiToken(presented: string): {
|
|
|
14
21
|
} | null;
|
|
15
22
|
/** Whether the secret is the one whose hash was kept, in constant time. */
|
|
16
23
|
export declare function secretMatches(secret: string, keptHash: string): boolean;
|
|
24
|
+
/** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
|
|
25
|
+
export declare const ROTATION_OVERLAP_MINUTES = 10;
|
|
26
|
+
export declare const ROTATION_OVERLAP_MAX_MINUTES = 60;
|
|
27
|
+
/** v111: the person is told this many days before a token expires, once each. */
|
|
28
|
+
export declare const EXPIRY_NOTICE_DAYS: readonly [7, 1];
|
|
29
|
+
type TokenState = {
|
|
30
|
+
revokedAt: string | null;
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
overlapUntil: string | null;
|
|
33
|
+
};
|
|
34
|
+
/** Whether a token signs anyone in at `now`: unrevoked, unexpired, and (once replaced) inside its overlap. */
|
|
35
|
+
export declare function tokenLive(token: TokenState, now: number): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The projects a token's person may use through it now: the person's current access, narrowed by the token's limit.
|
|
38
|
+
* Null is every project. A limit never widens access, and a project the person has lost stays lost.
|
|
39
|
+
*/
|
|
40
|
+
export declare function tokenProjects(account: readonly string[] | null, token: readonly string[] | null): string[] | null;
|
|
41
|
+
/** Whether `projects` (a principal's) stays inside the token's limit. A limited token never stands for every project. */
|
|
42
|
+
export declare function withinTokenLimit(projects: readonly string[] | null, limit: readonly string[] | null): boolean;
|
|
43
|
+
/** Whole days until a token expires, rounded up (0 once it has). */
|
|
44
|
+
export declare const daysLeft: (expiresAt: string, now: number) => number;
|
|
45
|
+
/** The expiry notice a token is due at `now` (7 or 1 days), or null. Replaced and revoked tokens get none. */
|
|
46
|
+
export declare function expiryNoticeDue(token: TokenState & {
|
|
47
|
+
replacedBy: string | null;
|
|
48
|
+
}, now: number): (typeof EXPIRY_NOTICE_DAYS)[number] | null;
|
|
49
|
+
export {};
|
package/dist/api-tokens.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* stay a person's act in the console.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
export const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
|
|
12
|
+
/** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
|
|
13
|
+
export const tokenPurpose = (value) => value === undefined || value === "api" ? "api" : "mcp";
|
|
14
|
+
export const tokenRotationProblem = (token) => token.purpose === "api" ? null : MCP_ROTATION_REFUSAL;
|
|
11
15
|
export const TOKEN_DAYS = [30, 90, 365];
|
|
12
16
|
const SHAPE = /^so_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
|
|
13
17
|
export function mintApiToken() {
|
|
@@ -25,3 +29,38 @@ export function secretMatches(secret, keptHash) {
|
|
|
25
29
|
const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
|
|
26
30
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
27
31
|
}
|
|
32
|
+
/** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
|
|
33
|
+
export const ROTATION_OVERLAP_MINUTES = 10;
|
|
34
|
+
export const ROTATION_OVERLAP_MAX_MINUTES = 60;
|
|
35
|
+
/** v111: the person is told this many days before a token expires, once each. */
|
|
36
|
+
export const EXPIRY_NOTICE_DAYS = [7, 1];
|
|
37
|
+
/** Whether a token signs anyone in at `now`: unrevoked, unexpired, and (once replaced) inside its overlap. */
|
|
38
|
+
export function tokenLive(token, now) {
|
|
39
|
+
if (token.revokedAt !== null || !(Date.parse(token.expiresAt) > now))
|
|
40
|
+
return false;
|
|
41
|
+
return token.overlapUntil === null || Date.parse(token.overlapUntil) > now;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The projects a token's person may use through it now: the person's current access, narrowed by the token's limit.
|
|
45
|
+
* Null is every project. A limit never widens access, and a project the person has lost stays lost.
|
|
46
|
+
*/
|
|
47
|
+
export function tokenProjects(account, token) {
|
|
48
|
+
if (token === null)
|
|
49
|
+
return account === null ? null : [...account];
|
|
50
|
+
return token.filter(repo => account === null || account.includes(repo));
|
|
51
|
+
}
|
|
52
|
+
/** Whether `projects` (a principal's) stays inside the token's limit. A limited token never stands for every project. */
|
|
53
|
+
export function withinTokenLimit(projects, limit) {
|
|
54
|
+
if (limit === null)
|
|
55
|
+
return true;
|
|
56
|
+
return projects !== null && projects.every(repo => limit.includes(repo));
|
|
57
|
+
}
|
|
58
|
+
/** Whole days until a token expires, rounded up (0 once it has). */
|
|
59
|
+
export const daysLeft = (expiresAt, now) => Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 86_400_000));
|
|
60
|
+
/** The expiry notice a token is due at `now` (7 or 1 days), or null. Replaced and revoked tokens get none. */
|
|
61
|
+
export function expiryNoticeDue(token, now) {
|
|
62
|
+
if (token.replacedBy !== null || !tokenLive(token, now))
|
|
63
|
+
return null;
|
|
64
|
+
const left = Date.parse(token.expiresAt) - now;
|
|
65
|
+
return EXPIRY_NOTICE_DAYS.filter(days => left <= days * 86_400_000).at(-1) ?? null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RemoteRun } from "./remote-run.js";
|
|
2
|
+
import { type AuditAction, type AuditReader } from "./remote-audit.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
export declare const AUDIT_USAGE = "Use audit [--person <name>] [--token <name>] [--source api|mcp] [--since <days>d] [--limit <n>] [--cursor <id>] [--json].";
|
|
5
|
+
type AuditContext = {
|
|
6
|
+
store: Store;
|
|
7
|
+
write: (s: string) => void;
|
|
8
|
+
json: boolean;
|
|
9
|
+
now: Date;
|
|
10
|
+
principal?: RemoteRun;
|
|
11
|
+
};
|
|
12
|
+
/** The reader a run is: the machine's owner locally, else the remote caller with their role and projects. */
|
|
13
|
+
export declare function readerOf(store: Store, remote: RemoteRun | undefined): AuditReader;
|
|
14
|
+
export declare function auditCommand(positional: readonly string[], flags: Map<string, string | true>, context: AuditContext): number;
|
|
15
|
+
/** One line per action: outcome first, then who, token, source, what and where. */
|
|
16
|
+
export declare function auditLine(action: AuditAction): string;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `toolroll audit`: who did what on this server with their API tokens (remote-audit.ts). On the server's own machine
|
|
3
|
+
* it lists everyone; run remotely it is the caller's own view — an approver sees everyone within their projects, a
|
|
4
|
+
* viewer only themselves. `--token` names a token locally; remotely, where `--token` is always a refused credential
|
|
5
|
+
* flag, the same filter is `--token-name`.
|
|
6
|
+
*/
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { checkedEnvelopeJson } from "./contracts/cli.js";
|
|
9
|
+
import { AUDIT_LIMIT, AUDIT_SOURCES, cursorOf, OWNER_READER, remoteAudit, sinceOf } from "./remote-audit.js";
|
|
10
|
+
const EXIT = { ok: 0, usage: 2, refused: 3 };
|
|
11
|
+
const COMMAND = "audit";
|
|
12
|
+
const FLAGS = new Set(["person", "token", "token-name", "source", "since", "limit", "cursor", "json", "db"]);
|
|
13
|
+
export const AUDIT_USAGE = "Use audit [--person <name>] [--token <name>] [--source api|mcp] [--since <days>d] [--limit <n>] [--cursor <id>] [--json].";
|
|
14
|
+
/** The reader a run is: the machine's owner locally, else the remote caller with their role and projects. */
|
|
15
|
+
export function readerOf(store, remote) {
|
|
16
|
+
if (remote === undefined)
|
|
17
|
+
return OWNER_READER;
|
|
18
|
+
const role = store.accountOf(remote.account)?.role ?? "viewer";
|
|
19
|
+
const all = remote.allows(null);
|
|
20
|
+
return { self: remote.account, everyone: role === "approver", repos: all ? null : store.knownRepos().filter(repo => remote.allows(repo)), unplaced: all };
|
|
21
|
+
}
|
|
22
|
+
export function auditCommand(positional, flags, context) {
|
|
23
|
+
const fail = (reason, message, code) => {
|
|
24
|
+
context.write(context.json ? checkedEnvelopeJson({ ok: false, command: COMMAND, reason, message }) : message);
|
|
25
|
+
return code;
|
|
26
|
+
};
|
|
27
|
+
const usage = (message) => fail("usage", `${message} ${AUDIT_USAGE}`, EXIT.usage);
|
|
28
|
+
for (const name of flags.keys())
|
|
29
|
+
if (!FLAGS.has(name))
|
|
30
|
+
return usage(`--${name} is not an audit option.`);
|
|
31
|
+
if (positional.length > 0)
|
|
32
|
+
return usage("audit takes no arguments.");
|
|
33
|
+
const value = (name) => {
|
|
34
|
+
const one = flags.get(name);
|
|
35
|
+
return one === undefined ? null : typeof one === "string" && one !== "" ? one : undefined;
|
|
36
|
+
};
|
|
37
|
+
const person = value("person"), source = value("source"), since = value("since"), limit = value("limit"), cursor = value("cursor");
|
|
38
|
+
const tokenFlag = value("token"), tokenName = value("token-name");
|
|
39
|
+
for (const [name, one] of [["person", person], ["token", tokenFlag], ["token-name", tokenName], ["source", source], ["since", since], ["limit", limit], ["cursor", cursor]]) {
|
|
40
|
+
if (one === undefined)
|
|
41
|
+
return usage(`--${name} needs a value.`);
|
|
42
|
+
}
|
|
43
|
+
if (tokenFlag !== null && tokenName !== null && tokenFlag !== tokenName)
|
|
44
|
+
return usage("Name one token.");
|
|
45
|
+
const token = tokenFlag ?? tokenName ?? null;
|
|
46
|
+
if (token !== null && /^so_[a-f0-9]{12}_/.test(token))
|
|
47
|
+
return usage("Name the token (as shown in Settings), never its secret.");
|
|
48
|
+
if (source !== null && !AUDIT_SOURCES.includes(source))
|
|
49
|
+
return usage("--source is api or mcp.");
|
|
50
|
+
const from = since === null ? null : sinceOf(since, context.now);
|
|
51
|
+
if (since !== null && from === null)
|
|
52
|
+
return usage("--since is a number of days, like 7d.");
|
|
53
|
+
const size = limit === null ? AUDIT_LIMIT.default : /^[1-9][0-9]{0,2}$/.test(limit) && Number(limit) <= AUDIT_LIMIT.max ? Number(limit) : null;
|
|
54
|
+
if (size === null)
|
|
55
|
+
return usage(`--limit is 1 to ${AUDIT_LIMIT.max}.`);
|
|
56
|
+
const before = cursor === null ? null : cursorOf(cursor);
|
|
57
|
+
if (cursor !== null && before === null)
|
|
58
|
+
return usage("--cursor is the nextCursor of an earlier page.");
|
|
59
|
+
const filters = { person: person ?? null, token, source: (source ?? null), since: from };
|
|
60
|
+
const page = remoteAudit(context.store, readerOf(context.store, context.principal), filters, { limit: size, before });
|
|
61
|
+
if (!page.ok)
|
|
62
|
+
return fail(page.reason, page.message, EXIT.refused);
|
|
63
|
+
if (context.json) {
|
|
64
|
+
context.write(checkedEnvelopeJson({ ok: true, command: COMMAND, filters, actions: page.actions, limit: page.limit, nextCursor: page.nextCursor }));
|
|
65
|
+
return EXIT.ok;
|
|
66
|
+
}
|
|
67
|
+
const lines = page.actions.length === 0 ? ["No remote actions."] : page.actions.map(auditLine);
|
|
68
|
+
if (page.nextCursor !== null)
|
|
69
|
+
lines.push(`Older: toolroll audit --cursor ${page.nextCursor}${flagsAgain(flags)}`);
|
|
70
|
+
context.write(lines.join("\n"));
|
|
71
|
+
return EXIT.ok;
|
|
72
|
+
}
|
|
73
|
+
/** One line per action: outcome first, then who, token, source, what and where. */
|
|
74
|
+
export function auditLine(action) {
|
|
75
|
+
const outcome = action.outcome === "ok" ? "ok" : `${action.outcome}${action.reason === null ? "" : ` (${action.reason})`}`;
|
|
76
|
+
const where = action.repo === null ? "" : ` ${basename(action.repo)}`;
|
|
77
|
+
return `${action.at.slice(0, 16).replace("T", " ")} ${outcome} ${action.person} ${action.token ?? "unknown token"} ${action.source} ${action.name}${where}`;
|
|
78
|
+
}
|
|
79
|
+
/** The filters again for the next page, quoted when they need it. */
|
|
80
|
+
function flagsAgain(flags) {
|
|
81
|
+
const quote = (one) => /^[A-Za-z0-9_./:@-]+$/.test(one) ? one : `'${one.replaceAll("'", "'\\''")}'`;
|
|
82
|
+
return ["person", "token", "token-name", "source", "since", "limit"].flatMap(name => {
|
|
83
|
+
const one = flags.get(name);
|
|
84
|
+
return typeof one === "string" ? [` --${name} ${quote(one)}`] : [];
|
|
85
|
+
}).join("");
|
|
86
|
+
}
|
package/dist/cli-http.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import type { Store } from './store.js';
|
|
3
3
|
import type { Principal } from './operate.js';
|
|
4
4
|
import { type RemoteCommandLookup } from './remote-command.js';
|
|
5
|
+
import { type Admission, type BudgetLimit } from './request-budget.js';
|
|
5
6
|
/**
|
|
6
7
|
* `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
|
|
7
8
|
* server runs that same command as that person. There is no per-resource API here and no second policy: the transport
|
|
@@ -28,6 +29,8 @@ export type CliFailure = {
|
|
|
28
29
|
ok: false;
|
|
29
30
|
code: string;
|
|
30
31
|
message: string;
|
|
32
|
+
limit?: BudgetLimit;
|
|
33
|
+
retryAfter?: number;
|
|
31
34
|
};
|
|
32
35
|
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
33
36
|
export declare const CLI_FILES_BYTES: number;
|
|
@@ -40,6 +43,8 @@ export type CliHttpOptions = {
|
|
|
40
43
|
run: () => Promise<RunOperateAs | null>;
|
|
41
44
|
/** Tests: command policy while the parallel command contract is not yet available. Defaults to COMMAND_GUIDE. */
|
|
42
45
|
modeOf?: RemoteCommandLookup | undefined;
|
|
46
|
+
/** The token's request budget (request-budget.ts), charged once per request before its body is read. */
|
|
47
|
+
admit?: ((principal: Principal) => Admission) | undefined;
|
|
43
48
|
store: Store;
|
|
44
49
|
};
|
|
45
50
|
/** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
|
package/dist/cli-http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
|
|
2
|
+
import { limitWords } from './request-budget.js';
|
|
2
3
|
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
3
4
|
export const CLI_FILES_BYTES = 256 * 1024;
|
|
4
5
|
/** The whole JSON body: the files plus argv and JSON escaping headroom. */
|
|
@@ -6,10 +7,10 @@ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
|
|
|
6
7
|
const ARGV_MAX = 256;
|
|
7
8
|
const ARG_BYTES = 16 * 1024;
|
|
8
9
|
const FILES_MAX = 16;
|
|
9
|
-
function send(response, status, value) {
|
|
10
|
+
function send(response, status, value, headers = {}) {
|
|
10
11
|
if (response.destroyed || response.writableEnded)
|
|
11
12
|
return;
|
|
12
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
13
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
13
14
|
response.end(JSON.stringify(value));
|
|
14
15
|
}
|
|
15
16
|
async function body(request) {
|
|
@@ -95,6 +96,15 @@ export async function handleCliHttp(request, response, options) {
|
|
|
95
96
|
}
|
|
96
97
|
if (!principal)
|
|
97
98
|
return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
|
|
99
|
+
// Counted once here, however long the command then holds the request (a task wait is one request).
|
|
100
|
+
const admitted = options.admit === undefined ? { ok: true } : options.admit(principal);
|
|
101
|
+
if (!admitted.ok && admitted.status === 503)
|
|
102
|
+
return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
|
|
103
|
+
if (!admitted.ok) {
|
|
104
|
+
send(response, 429, { ok: false, code: 'rate-limited', message: limitWords(admitted.limit, admitted.retryAfter), limit: admitted.limit, retryAfter: admitted.retryAfter }, { 'retry-after': String(admitted.retryAfter) });
|
|
105
|
+
request.resume();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
98
108
|
let value;
|
|
99
109
|
try {
|
|
100
110
|
value = await body(request);
|
package/dist/cli.js
CHANGED
|
@@ -208,6 +208,7 @@ export const OPERATE_COMMANDS = new Set([
|
|
|
208
208
|
"task",
|
|
209
209
|
"project",
|
|
210
210
|
"ledger",
|
|
211
|
+
"audit",
|
|
211
212
|
"storage",
|
|
212
213
|
"monitoring",
|
|
213
214
|
"integrations",
|
|
@@ -254,7 +255,7 @@ export const OPERATE_COMMANDS = new Set([
|
|
|
254
255
|
"run",
|
|
255
256
|
"routine",
|
|
256
257
|
"flows",
|
|
257
|
-
"config", "chat", "proposals", "mode", "chat-approval", "people", "keys",
|
|
258
|
+
"config", "chat", "proposals", "mode", "chat-approval", "people", "tokens", "keys",
|
|
258
259
|
"setup",
|
|
259
260
|
"verify",
|
|
260
261
|
"intake",
|
|
@@ -304,6 +305,11 @@ export async function main(argv, write = line => console.log(line), mainOptions
|
|
|
304
305
|
write("-o requires --json — the output file receives the machine envelope");
|
|
305
306
|
return USAGE_EXIT;
|
|
306
307
|
}
|
|
308
|
+
// A new API token is shown once, on screen: never saved to a file by the entry point.
|
|
309
|
+
if (outputFile !== undefined && args.find(one => !one.startsWith("-")) === "tokens") {
|
|
310
|
+
write("-o isn't used with tokens: a new token is shown once, never saved to a file");
|
|
311
|
+
return USAGE_EXIT;
|
|
312
|
+
}
|
|
307
313
|
resetCapturedEnvelope();
|
|
308
314
|
// Long-running commands (serve, up) emit their ONE envelope at startup,
|
|
309
315
|
// when its URL is useful — so -o is preflighted BEFORE dispatch (an
|
package/dist/contracts/cli.d.ts
CHANGED
|
@@ -714,6 +714,53 @@ export declare const COMMAND_OUTPUTS: {
|
|
|
714
714
|
reason: z.ZodString;
|
|
715
715
|
message: z.ZodOptional<z.ZodString>;
|
|
716
716
|
}, z.core.$loose>], "ok">;
|
|
717
|
+
readonly audit: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
718
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
719
|
+
ok: z.ZodLiteral<true>;
|
|
720
|
+
command: z.ZodLiteral<"audit">;
|
|
721
|
+
filters: z.ZodObject<{
|
|
722
|
+
person: z.ZodNullable<z.ZodString>;
|
|
723
|
+
token: z.ZodNullable<z.ZodString>;
|
|
724
|
+
source: z.ZodNullable<z.ZodEnum<{
|
|
725
|
+
api: "api";
|
|
726
|
+
mcp: "mcp";
|
|
727
|
+
}>>;
|
|
728
|
+
since: z.ZodNullable<z.ZodString>;
|
|
729
|
+
}, z.core.$loose>;
|
|
730
|
+
actions: z.ZodArray<z.ZodObject<{
|
|
731
|
+
id: z.ZodNumber;
|
|
732
|
+
at: z.ZodString;
|
|
733
|
+
person: z.ZodString;
|
|
734
|
+
token: z.ZodNullable<z.ZodString>;
|
|
735
|
+
source: z.ZodEnum<{
|
|
736
|
+
api: "api";
|
|
737
|
+
mcp: "mcp";
|
|
738
|
+
}>;
|
|
739
|
+
kind: z.ZodEnum<{
|
|
740
|
+
command: "command";
|
|
741
|
+
tool: "tool";
|
|
742
|
+
}>;
|
|
743
|
+
name: z.ZodString;
|
|
744
|
+
command: z.ZodString;
|
|
745
|
+
tool: z.ZodNullable<z.ZodString>;
|
|
746
|
+
repo: z.ZodNullable<z.ZodString>;
|
|
747
|
+
taskId: z.ZodNullable<z.ZodString>;
|
|
748
|
+
outcome: z.ZodEnum<{
|
|
749
|
+
error: "error";
|
|
750
|
+
ok: "ok";
|
|
751
|
+
refused: "refused";
|
|
752
|
+
}>;
|
|
753
|
+
reason: z.ZodNullable<z.ZodString>;
|
|
754
|
+
}, z.core.$loose>>;
|
|
755
|
+
limit: z.ZodNumber;
|
|
756
|
+
nextCursor: z.ZodNullable<z.ZodString>;
|
|
757
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
758
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
759
|
+
ok: z.ZodLiteral<false>;
|
|
760
|
+
command: z.ZodLiteral<"audit">;
|
|
761
|
+
reason: z.ZodString;
|
|
762
|
+
message: z.ZodOptional<z.ZodString>;
|
|
763
|
+
}, z.core.$loose>], "ok">;
|
|
717
764
|
readonly "skills list": z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
718
765
|
envelopeVersion: z.ZodLiteral<1>;
|
|
719
766
|
ok: z.ZodLiteral<true>;
|
package/dist/contracts/cli.js
CHANGED
|
@@ -148,6 +148,16 @@ const taskShowOutput = answers("task show", {
|
|
|
148
148
|
proofAccepted: z.boolean(),
|
|
149
149
|
stops: list,
|
|
150
150
|
});
|
|
151
|
+
/** `audit` (remote-audit.ts): one page of remote actions, newest first, and the filters it applied. */
|
|
152
|
+
const auditOutput = answers("audit", {
|
|
153
|
+
filters: z.looseObject({ person: nullable(str), token: nullable(str), source: nullable(z.enum(["api", "mcp"])), since: nullable(str) }),
|
|
154
|
+
actions: rows({
|
|
155
|
+
id: int, at: str, person: str, token: nullable(str), source: z.enum(["api", "mcp"]), kind: z.enum(["command", "tool"]), name: str, command: str,
|
|
156
|
+
tool: nullable(str), repo: nullable(str), taskId: nullable(str), outcome: z.enum(["ok", "refused", "error"]), reason: nullable(str),
|
|
157
|
+
}),
|
|
158
|
+
limit: int,
|
|
159
|
+
nextCursor: nullable(str),
|
|
160
|
+
});
|
|
151
161
|
const flowListed = z.looseObject({ id: int, name: str, repo: str, project: str, zones: z.array(str), triggers: int, cardsWaiting: int, needDecision: int });
|
|
152
162
|
/**
|
|
153
163
|
* The commands whose answers name their fields: the most-used ones (contract, the no-verb scan, status,
|
|
@@ -189,6 +199,7 @@ export const COMMAND_OUTPUTS = {
|
|
|
189
199
|
"cap list": answers("cap list", { repo: str, capabilities: list }),
|
|
190
200
|
gaps: answers("gaps", { repo: str, gaps: list }),
|
|
191
201
|
reap: answers("reap", { count: int, released: list }),
|
|
202
|
+
audit: auditOutput,
|
|
192
203
|
"skills list": answers("skills list", { guides: rows({ name: str, title: str, oneLiner: str }) }),
|
|
193
204
|
};
|
|
194
205
|
const declared = new Map(Object.entries(COMMAND_OUTPUTS));
|
|
@@ -80,6 +80,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
80
80
|
readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
|
|
81
81
|
readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
|
|
82
82
|
readonly sessions: FormContract<"access" | "action" | "days" | "everyone" | "name" | "password" | "session" | "token">;
|
|
83
|
+
readonly requestLimits: FormContract<"act-per-minute" | "action" | "password" | "per-day" | "per-minute" | "read-per-minute" | "target">;
|
|
83
84
|
readonly signIn: FormContract<"action" | "client-id" | "client-secret" | "groups-claim" | "issuer" | "label" | "password" | "passwords" | "scopes" | `group-${string}` | `projects-${string}` | `role-${string}`>;
|
|
84
85
|
readonly updates: FormContract<"password" | "version" | "when">;
|
|
85
86
|
readonly retention: FormContract<"chat" | "checkouts" | "evidence" | "notifications" | "password">;
|
|
@@ -89,6 +89,7 @@ export const CONSOLE_FORMS = {
|
|
|
89
89
|
policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
|
|
90
90
|
approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
|
|
91
91
|
sessions: formContract("POST /settings/sessions", ["everyone", "action", "session", "token", "name", "access", "days", "password"]),
|
|
92
|
+
requestLimits: formContract("POST /settings/request-limits", ["target", "action", "read-per-minute", "act-per-minute", "per-minute", "per-day", "password"]),
|
|
92
93
|
signIn: formContract("POST /settings/sign-in", ["action", "password", "issuer", "client-id", "client-secret", "label", "scopes", "groups-claim", "passwords"], { prefixes: ["group-", "role-", "projects-"] }),
|
|
93
94
|
updates: formContract("POST /settings/updates, /settings/updates/seen, /settings/updates/cancel", ["password", "version", "when"]),
|
|
94
95
|
retention: formContract("POST /settings/retention", ["password", "evidence", "checkouts", "chat", "notifications"]),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP sign-in (mcp-oauth.ts): what an MCP client may send when it registers itself (RFC 7591) and when it trades a
|
|
3
|
+
* code or a refresh secret for tokens (RFC 6749 §4.1.3, §6, with PKCE, RFC 7636).
|
|
4
|
+
*
|
|
5
|
+
* A registration is read strictly for the fields Toolroll uses and ignores the rest (RFC 7591 §2: unknown metadata
|
|
6
|
+
* is ignored). Only public clients register: no secret is issued, so `token_endpoint_auth_method` is `none` when
|
|
7
|
+
* given. Whether a redirect is https, or http on 127.0.0.1 / [::1], is checked by mcp-oauth.ts after reading.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { type ContractResult } from "./contract.js";
|
|
11
|
+
/** The most redirects one client registers, and how long each and the client's name may be. */
|
|
12
|
+
export declare const OAUTH_LIMITS: {
|
|
13
|
+
readonly redirects: 5;
|
|
14
|
+
readonly redirect: 512;
|
|
15
|
+
readonly name: 100;
|
|
16
|
+
};
|
|
17
|
+
export declare const OAUTH_GRANTS: readonly ["authorization_code", "refresh_token"];
|
|
18
|
+
export declare const oauthRegistrationSchema: z.ZodObject<{
|
|
19
|
+
redirect_uris: z.ZodArray<z.ZodString>;
|
|
20
|
+
client_name: z.ZodOptional<z.ZodString>;
|
|
21
|
+
token_endpoint_auth_method: z.ZodOptional<z.ZodLiteral<"none">>;
|
|
22
|
+
grant_types: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
23
|
+
authorization_code: "authorization_code";
|
|
24
|
+
refresh_token: "refresh_token";
|
|
25
|
+
}>>>;
|
|
26
|
+
response_types: z.ZodOptional<z.ZodArray<z.ZodLiteral<"code">>>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
/** A PKCE verifier (RFC 7636 §4.1) and a S256 challenge (43 base64url characters). */
|
|
29
|
+
export declare const PKCE_VERIFIER: RegExp;
|
|
30
|
+
export declare const PKCE_CHALLENGE: RegExp;
|
|
31
|
+
/** A token request, by grant type. Every value is one form field. */
|
|
32
|
+
export declare const oauthTokenRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
33
|
+
grant_type: z.ZodLiteral<"authorization_code">;
|
|
34
|
+
code: z.ZodString;
|
|
35
|
+
redirect_uri: z.ZodString;
|
|
36
|
+
client_id: z.ZodString;
|
|
37
|
+
code_verifier: z.ZodString;
|
|
38
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
40
|
+
grant_type: z.ZodLiteral<"refresh_token">;
|
|
41
|
+
refresh_token: z.ZodString;
|
|
42
|
+
client_id: z.ZodString;
|
|
43
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
44
|
+
scope: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strip>], "grant_type">;
|
|
46
|
+
export type OAuthRegistration = z.infer<typeof oauthRegistrationSchema>;
|
|
47
|
+
export type OAuthTokenRequest = z.infer<typeof oauthTokenRequestSchema>;
|
|
48
|
+
export declare const readOAuthRegistration: (input: unknown) => ContractResult<OAuthRegistration>;
|
|
49
|
+
/** A token request's form: a field given twice is refused (RFC 6749 §3.2); fields this grant doesn't take are ignored. */
|
|
50
|
+
export declare function readOAuthTokenRequest(form: URLSearchParams): ContractResult<OAuthTokenRequest> | {
|
|
51
|
+
ok: false;
|
|
52
|
+
repeated: true;
|
|
53
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP sign-in (mcp-oauth.ts): what an MCP client may send when it registers itself (RFC 7591) and when it trades a
|
|
3
|
+
* code or a refresh secret for tokens (RFC 6749 §4.1.3, §6, with PKCE, RFC 7636).
|
|
4
|
+
*
|
|
5
|
+
* A registration is read strictly for the fields Toolroll uses and ignores the rest (RFC 7591 §2: unknown metadata
|
|
6
|
+
* is ignored). Only public clients register: no secret is issued, so `token_endpoint_auth_method` is `none` when
|
|
7
|
+
* given. Whether a redirect is https, or http on 127.0.0.1 / [::1], is checked by mcp-oauth.ts after reading.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { parseContract } from "./contract.js";
|
|
11
|
+
/** The most redirects one client registers, and how long each and the client's name may be. */
|
|
12
|
+
export const OAUTH_LIMITS = { redirects: 5, redirect: 512, name: 100 };
|
|
13
|
+
export const OAUTH_GRANTS = ["authorization_code", "refresh_token"];
|
|
14
|
+
export const oauthRegistrationSchema = z.object({
|
|
15
|
+
redirect_uris: z.array(z.string().min(1).max(OAUTH_LIMITS.redirect)).min(1).max(OAUTH_LIMITS.redirects),
|
|
16
|
+
client_name: z.string().max(OAUTH_LIMITS.name).optional(),
|
|
17
|
+
token_endpoint_auth_method: z.literal("none").optional(),
|
|
18
|
+
grant_types: z.array(z.enum(OAUTH_GRANTS)).min(1).max(2).optional(),
|
|
19
|
+
response_types: z.array(z.literal("code")).min(1).max(1).optional(),
|
|
20
|
+
});
|
|
21
|
+
/** A PKCE verifier (RFC 7636 §4.1) and a S256 challenge (43 base64url characters). */
|
|
22
|
+
export const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
23
|
+
export const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
const bounded = z.string().min(1).max(2048);
|
|
25
|
+
/** A token request, by grant type. Every value is one form field. */
|
|
26
|
+
export const oauthTokenRequestSchema = z.discriminatedUnion("grant_type", [
|
|
27
|
+
z.object({ grant_type: z.literal("authorization_code"), code: bounded, redirect_uri: bounded, client_id: bounded, code_verifier: z.string().regex(PKCE_VERIFIER), resource: bounded.optional() }),
|
|
28
|
+
z.object({ grant_type: z.literal("refresh_token"), refresh_token: bounded, client_id: bounded, resource: bounded.optional(), scope: bounded.optional() }),
|
|
29
|
+
]);
|
|
30
|
+
export const readOAuthRegistration = (input) => parseContract(oauthRegistrationSchema, input);
|
|
31
|
+
/** A token request's form: a field given twice is refused (RFC 6749 §3.2); fields this grant doesn't take are ignored. */
|
|
32
|
+
export function readOAuthTokenRequest(form) {
|
|
33
|
+
const fields = {};
|
|
34
|
+
for (const [key, value] of form) {
|
|
35
|
+
if (Object.hasOwn(fields, key))
|
|
36
|
+
return { ok: false, repeated: true };
|
|
37
|
+
fields[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return parseContract(oauthTokenRequestSchema, fields);
|
|
40
|
+
}
|
package/dist/credentials-ui.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export type CredentialsView = {
|
|
|
16
16
|
})[];
|
|
17
17
|
tokens: ApiTokenRow[];
|
|
18
18
|
now: number;
|
|
19
|
+
/** Request limits (request-budget-ui.ts): words for each token's line, and the operator's disclosure. */
|
|
20
|
+
limits?: {
|
|
21
|
+
note: (token: ApiTokenRow) => string;
|
|
22
|
+
section: string;
|
|
23
|
+
};
|
|
19
24
|
};
|
|
20
25
|
export declare function credentialsHtml(view: CredentialsView, csrf: string, notice: {
|
|
21
26
|
said?: string | null;
|
package/dist/credentials-ui.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { TOKEN_DAYS } from "./api-tokens.js";
|
|
1
|
+
import { daysLeft, expiryNoticeDue, TOKEN_DAYS, tokenLive } from "./api-tokens.js";
|
|
2
|
+
import { projectName } from "./project.js";
|
|
2
3
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3
4
|
export const CREDENTIALS_CSS = `.credentials{max-width:820px;min-width:0}.credentials .card{padding:14px 16px;margin:12px 0}.credentials h2{font-size:1.05rem;margin:0 0 8px}` +
|
|
4
5
|
`.credentials .rows{display:grid;gap:0;border:1px solid var(--so-line);border-radius:10px;overflow:hidden}.credentials .row-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--so-line)}` +
|
|
5
6
|
`.credentials .row-item:last-child{border-bottom:0}.credentials .row-item p{margin:0}.credentials .row-item .meta{font-size:.85rem}.credentials form{margin:0}.credentials button{min-height:40px;white-space:nowrap}` +
|
|
6
7
|
`.credentials .create{display:grid;gap:10px}.credentials .create label{display:grid;gap:6px}.credentials .create input,.credentials .create select{box-sizing:border-box;width:100%}.credentials .badge-here{font-size:.75rem;padding:1px 6px;border-radius:999px;background:var(--so-success-soft);color:var(--so-success);margin-left:6px}` +
|
|
8
|
+
`.credentials .row-item>div{min-width:0}.credentials .row-item p{overflow-wrap:anywhere}.credentials .soon{color:var(--so-warning,#9a6700);font-weight:600}` +
|
|
7
9
|
`@media(max-width:600px){.credentials .row-item{flex-wrap:wrap}.credentials .create input,.credentials .create select{font-size:16px}}`;
|
|
8
10
|
/** "Chrome on macOS", from a user agent, for a person to recognise a session. */
|
|
9
11
|
export function deviceWords(agent) {
|
|
@@ -30,16 +32,26 @@ export function credentialsHtml(view, csrf, notice) {
|
|
|
30
32
|
`${one.here ? "" : post({ action: "end-session", session: one.idHash }, "Sign out")}</div>`).join("")}</div>`;
|
|
31
33
|
const others = view.sessions.filter(one => !one.here && one.account === view.who).length;
|
|
32
34
|
const endOthers = others > 0 && !view.everyone ? post({ action: "end-others" }, `Sign out everywhere else (${others})`) : "";
|
|
33
|
-
const live = view.tokens.filter(one =>
|
|
35
|
+
const live = view.tokens.filter(one => tokenLive(one, view.now));
|
|
36
|
+
// v111: its projects, and one state: when it stops (soon, said once and marked) or, once replaced, when it ends.
|
|
37
|
+
const ends = (one) => {
|
|
38
|
+
if (one.replacedBy !== null && one.overlapUntil !== null)
|
|
39
|
+
return `<span class="soon">Replaced · stops ${e(one.overlapUntil.slice(11, 16))} UTC</span>`;
|
|
40
|
+
if (expiryNoticeDue(one, view.now) === null)
|
|
41
|
+
return `Expires ${e(one.expiresAt.slice(0, 10))}`;
|
|
42
|
+
const days = daysLeft(one.expiresAt, view.now);
|
|
43
|
+
return `<span class="soon">Expires ${days <= 1 ? "within a day" : `in ${days} days`}</span>`;
|
|
44
|
+
};
|
|
45
|
+
const scope = (one) => one.projects === null ? "All projects" : one.projects.length === 0 ? "No projects" : one.projects.map(repo => e(projectName(repo))).join(", ");
|
|
34
46
|
const tokens = live.length === 0 ? `<p class="meta">No API tokens.</p>` : `<div class="rows">${live.map(one => `<div class="row-item" data-token="${e(one.id)}"><div><p>${view.everyone ? `<strong>${e(one.account)}</strong> · ` : ""}${e(one.name)} · ${one.access === "act" ? "Can act" : "Read only"}</p>` +
|
|
35
|
-
`<p class="meta"
|
|
47
|
+
`<p class="meta">${scope(one)} · ${ends(one)} · last used ${e(when(one.lastUsedAt, view.now))}${view.limits === undefined ? "" : ` · ${e(view.limits.note(one))}`}</p></div>${post({ action: "revoke-token", token: one.id }, "Revoke")}</div>`).join("")}</div>`;
|
|
36
48
|
const create = `<details class="card"><summary>New API token</summary><form method="post" action="/settings/sessions" class="create">${hidden({ csrf, action: "create-token" })}` +
|
|
37
49
|
`<label>Name<input type="text" name="name" maxlength="60" required placeholder="for example: CI"></label>` +
|
|
38
50
|
`<label>It can<select name="access"><option value="read">Read (tasks, results, the ledger)</option><option value="act">Act as you (file and manage work; never approve)</option></select></label>` +
|
|
39
51
|
`<label>Expires in<select name="days">${TOKEN_DAYS.map(days => `<option value="${days}"${days === 90 ? " selected" : ""}>${days} days</option>`).join("")}</select></label>` +
|
|
40
52
|
`<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button>Make the token</button></form></details>`;
|
|
41
53
|
return `<section class="credentials">${note}${toggle}<h2>Signed in</h2>${sessions}${endOthers}<h2 style="margin-top:1.5rem">API tokens</h2>` +
|
|
42
|
-
`<p class="meta">For scripts and CI: send <code>Authorization: Bearer <token></code>. A token can't approve anything.</p>${tokens}${view.everyone ? "" : create}</section>`;
|
|
54
|
+
`<p class="meta">For scripts and CI: send <code>Authorization: Bearer <token></code>. A token can't approve anything.</p>${tokens}${view.everyone ? "" : create}${view.limits?.section ?? ""}</section>`;
|
|
43
55
|
}
|
|
44
56
|
/** The token, once: a focused page with no script. */
|
|
45
57
|
export function tokenShownHtml(name, token, access, expiresAt) {
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
94
94
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
95
95
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
96
96
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
97
|
+
version?: never;
|
|
97
98
|
active: boolean;
|
|
98
99
|
phase: string;
|
|
99
100
|
detail: string;
|
|
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
107
108
|
wasRunning?: never;
|
|
108
109
|
backupPath?: never;
|
|
109
110
|
updateId?: never;
|
|
110
|
-
version?: never;
|
|
111
111
|
buildId?: never;
|
|
112
112
|
workDir?: never;
|
|
113
113
|
} | {
|
package/dist/export.js
CHANGED
|
@@ -29,14 +29,14 @@ import { readEmailSettings } from "./email-settings.js";
|
|
|
29
29
|
import { SUBSCRIPTION_CAPABLE, readAuthMode } from "./keys.js";
|
|
30
30
|
export const EXPORT_FORMAT = "standing-orders/export/v1";
|
|
31
31
|
/** Tables that hold nothing but secrets or short-lived sign-in material, and derived search indexes. */
|
|
32
|
-
const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair"]);
|
|
32
|
+
const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair", "oauth_code", "oauth_refresh"]);
|
|
33
33
|
const DERIVED_TABLE = /^(sqlite_|memory_search)/;
|
|
34
34
|
/** Columns that hold a secret (or what stands in for one): never exported. */
|
|
35
35
|
const SECRET_COLUMNS = new Set([
|
|
36
36
|
"api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
|
|
37
37
|
"flow_trigger.hook_hash", "held_session.cookie", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
|
|
38
38
|
"chat_turn.credential_key", "mate_session.credential_key", "mate_turn.credential_key", "bridge_lease.push_url", "workflow_preview.token",
|
|
39
|
-
"quota.credential_fp",
|
|
39
|
+
"quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
|
|
40
40
|
]);
|
|
41
41
|
/** A column named like this holds a secret unless it is reviewed below; an unreviewed one is dropped. */
|
|
42
42
|
const SECRET_NAME = /hash|secret|token|passw|cookie|csrf|credential|p256dh|^auth$|key|nonce|endpoint|url/i;
|
|
@@ -56,7 +56,7 @@ export function categoryOf(table) {
|
|
|
56
56
|
[/^teammate/, "teammates"],
|
|
57
57
|
[/^(flow|routine|workflow_)/, "flows"],
|
|
58
58
|
[/^(chat_|mate_|team_|telegram_|slack_|discord_|teams_|notification|push_|bridge_lease)/, "chats"],
|
|
59
|
-
[/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_)/, "people"],
|
|
59
|
+
[/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
|
|
60
60
|
[/^(run|artifact|claim|worktree$|execution_slot|contest|tournament_terms|fallback_cycle|fallback_transition|held_session|session_turn|attended_authorization|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
|
|
61
61
|
[/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
|
|
62
62
|
[/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
|
|
@@ -67,6 +67,7 @@ export function categoryOf(table) {
|
|
|
67
67
|
/** Extra shapes beside the evidence scanner's: this installation's own tokens and password hashes, and bearer headers. */
|
|
68
68
|
const EXTRA_SHAPES = [
|
|
69
69
|
/\bso_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
70
|
+
/\bsor_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
70
71
|
/\blt_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
71
72
|
/\bscrypt\$[^\s"',;]+/g,
|
|
72
73
|
/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
|