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
package/dist/store.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { DecisionOption } from "./contracts/decision.js";
|
|
|
2
2
|
import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json.js";
|
|
3
3
|
import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
|
|
4
4
|
import { type Actor } from "./actor.js";
|
|
5
|
+
import { type TokenPurpose } from "./api-tokens.js";
|
|
5
6
|
import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
|
|
6
7
|
import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
|
|
7
8
|
import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RiskLevel, type RouteOverride } from "./phase-routing.js";
|
|
@@ -145,7 +146,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
145
146
|
hash: string;
|
|
146
147
|
} | null;
|
|
147
148
|
};
|
|
148
|
-
export declare const SCHEMA_VERSION =
|
|
149
|
+
export declare const SCHEMA_VERSION = 113;
|
|
149
150
|
/**
|
|
150
151
|
* The migrations `toolroll update` may carry a database through in place: each
|
|
151
152
|
* one only adds (a table, a column, an accepted value) and leaves every saved row
|
|
@@ -155,11 +156,43 @@ export declare const SCHEMA_VERSION = 110;
|
|
|
155
156
|
* migrates and does not read this list.
|
|
156
157
|
*
|
|
157
158
|
* v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
|
|
158
|
-
* every row, id and hash-chain link is copied unchanged.
|
|
159
|
+
* every row, id and hash-chain link is copied unchanged. v111 adds nullable
|
|
160
|
+
* api_token columns (project limit, rotation); existing tokens read as before.
|
|
161
|
+
* v112 only adds the two request-budget tables.
|
|
162
|
+
* v113 adds MCP sign-in tables and API-token purpose metadata.
|
|
159
163
|
*/
|
|
160
164
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
161
165
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
162
166
|
export declare function updateSafeSchema(version: number | null): boolean;
|
|
167
|
+
export type OAuthClientRow = {
|
|
168
|
+
id: string;
|
|
169
|
+
name: string;
|
|
170
|
+
redirectUris: string[];
|
|
171
|
+
createdAt: string;
|
|
172
|
+
};
|
|
173
|
+
export type OAuthCodeRow = {
|
|
174
|
+
client: string;
|
|
175
|
+
account: string;
|
|
176
|
+
generation: number;
|
|
177
|
+
access: "read" | "act";
|
|
178
|
+
projects: string[];
|
|
179
|
+
redirectUri: string;
|
|
180
|
+
resource: string;
|
|
181
|
+
challenge: string;
|
|
182
|
+
expiresAt: string;
|
|
183
|
+
usedAt: string | null;
|
|
184
|
+
token: string | null;
|
|
185
|
+
};
|
|
186
|
+
export type OAuthGrantRow = {
|
|
187
|
+
token: string;
|
|
188
|
+
client: string;
|
|
189
|
+
account: string;
|
|
190
|
+
generation: number;
|
|
191
|
+
projects: string[];
|
|
192
|
+
resource: string;
|
|
193
|
+
accessExpiresAt: string;
|
|
194
|
+
refreshHash: string;
|
|
195
|
+
};
|
|
163
196
|
export type ApiTokenRow = {
|
|
164
197
|
id: string;
|
|
165
198
|
account: string;
|
|
@@ -171,6 +204,14 @@ export type ApiTokenRow = {
|
|
|
171
204
|
lastUsedAt: string | null;
|
|
172
205
|
revokedAt: string | null;
|
|
173
206
|
revokedBy: string | null;
|
|
207
|
+
/** v113: MCP manages its own access-secret rotation. */
|
|
208
|
+
purpose: TokenPurpose;
|
|
209
|
+
/** v111: the projects it is limited to (null: every project its person may use). */
|
|
210
|
+
projects: string[] | null;
|
|
211
|
+
/** v111: the token it replaced, the one that replaced it, and when this one stops after being replaced. */
|
|
212
|
+
replaces: string | null;
|
|
213
|
+
replacedBy: string | null;
|
|
214
|
+
overlapUntil: string | null;
|
|
174
215
|
};
|
|
175
216
|
export type WebSessionRow = {
|
|
176
217
|
idHash: string;
|
|
@@ -2591,7 +2632,26 @@ export declare class Store {
|
|
|
2591
2632
|
access: "read" | "act";
|
|
2592
2633
|
expiresAt: string;
|
|
2593
2634
|
by: string;
|
|
2635
|
+
projects?: readonly string[] | null;
|
|
2636
|
+
purpose?: TokenPurpose;
|
|
2594
2637
|
}, now: Date): void;
|
|
2638
|
+
/**
|
|
2639
|
+
* v111: replace a live, not-yet-replaced token with a new secret on the same terms (access, projects, end date, so
|
|
2640
|
+
* rotating never extends it). The old one keeps working until `overlapUntil` (never past its own end), then stops;
|
|
2641
|
+
* every check reads that time, and endRotatedApiTokens records the end afterwards. Only the token's own person.
|
|
2642
|
+
*/
|
|
2643
|
+
rotateApiToken(oldId: string, replacement: {
|
|
2644
|
+
id: string;
|
|
2645
|
+
secretHash: string;
|
|
2646
|
+
}, by: string, now: Date, overlapMs: number): {
|
|
2647
|
+
ok: true;
|
|
2648
|
+
row: ApiTokenRow;
|
|
2649
|
+
} | {
|
|
2650
|
+
ok: false;
|
|
2651
|
+
reason: "unknown" | "replaced" | "mcp-managed";
|
|
2652
|
+
};
|
|
2653
|
+
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
2654
|
+
endRotatedApiTokens(now: Date): number;
|
|
2595
2655
|
/** A token by id with its kept hash (for the check), or null. */
|
|
2596
2656
|
apiTokenSecret(id: string): {
|
|
2597
2657
|
row: ApiTokenRow;
|
|
@@ -2602,6 +2662,62 @@ export declare class Store {
|
|
|
2602
2662
|
/** Kept at most once a minute: a busy script doesn't write on every request. */
|
|
2603
2663
|
touchApiToken(id: string, now: Date): void;
|
|
2604
2664
|
revokeApiToken(id: string, by: string, now: Date, why?: string): boolean;
|
|
2665
|
+
/** A registered client, or null. */
|
|
2666
|
+
oauthClient(id: string): OAuthClientRow | null;
|
|
2667
|
+
/** Expire unused registrations, retaining clients with a live code, grant or in-process consent. */
|
|
2668
|
+
pruneOAuthClients(now: Date, pending?: readonly string[]): void;
|
|
2669
|
+
/**
|
|
2670
|
+
* Unused registrations expire after a day. Admission has a global and per-source bound; it never evicts a live
|
|
2671
|
+
* registration. Repeating metadata from the same source returns its existing registration without spending a slot.
|
|
2672
|
+
*/
|
|
2673
|
+
registerOAuthClient(client: {
|
|
2674
|
+
id: string;
|
|
2675
|
+
name: string;
|
|
2676
|
+
redirectUris: string[];
|
|
2677
|
+
source: string;
|
|
2678
|
+
}, now: Date, cap: number, perSourceCap: number, pending?: readonly string[]): OAuthClientRow | null;
|
|
2679
|
+
/** Keep a one-time code by its hash. */
|
|
2680
|
+
saveOAuthCode(hash: string, code: Omit<OAuthCodeRow, "usedAt" | "token">): void;
|
|
2681
|
+
/**
|
|
2682
|
+
* Spend a code: the first caller gets it (`taken`), once, before it expires. A code spent before answers `used`
|
|
2683
|
+
* with the token it made, so that token can be ended; anything else is null.
|
|
2684
|
+
*/
|
|
2685
|
+
takeOAuthCode(hash: string, now: Date): {
|
|
2686
|
+
taken: OAuthCodeRow;
|
|
2687
|
+
} | {
|
|
2688
|
+
used: string | null;
|
|
2689
|
+
} | null;
|
|
2690
|
+
/** Make a grant: its API token (created and recorded as any other) and its binding, together; the code notes the token. */
|
|
2691
|
+
createOAuthGrant(code: string, token: {
|
|
2692
|
+
id: string;
|
|
2693
|
+
account: string;
|
|
2694
|
+
name: string;
|
|
2695
|
+
secretHash: string;
|
|
2696
|
+
access: "read" | "act";
|
|
2697
|
+
expiresAt: string;
|
|
2698
|
+
}, grant: Omit<OAuthGrantRow, "token">, now: Date): void;
|
|
2699
|
+
/** The grant an API token signs in for, or null when the token is an ordinary one. */
|
|
2700
|
+
oauthGrant(token: string): OAuthGrantRow | null;
|
|
2701
|
+
/**
|
|
2702
|
+
* Classify, validate and rotate under one write transaction. Every issued hash remains in the family, so any
|
|
2703
|
+
* older secret revokes even a concurrent winner. An unknown guess or the wrong client cannot end the family.
|
|
2704
|
+
*/
|
|
2705
|
+
rotateOAuthGrant(token: string, ask: {
|
|
2706
|
+
client: string;
|
|
2707
|
+
resource: string;
|
|
2708
|
+
refreshHash: string;
|
|
2709
|
+
scope?: string;
|
|
2710
|
+
}, next: {
|
|
2711
|
+
refreshHash: string;
|
|
2712
|
+
accessHash: string;
|
|
2713
|
+
accessExpiresAt: string;
|
|
2714
|
+
}, now: Date, projectsFor: (account: string) => readonly string[]): {
|
|
2715
|
+
ok: true;
|
|
2716
|
+
access: "read" | "act";
|
|
2717
|
+
} | {
|
|
2718
|
+
ok: false;
|
|
2719
|
+
reason: "invalid_grant" | "invalid_scope";
|
|
2720
|
+
};
|
|
2605
2721
|
saveWebSession(session: WebSessionRow): void;
|
|
2606
2722
|
webSession(idHash: string): WebSessionRow | null;
|
|
2607
2723
|
webSessions(account: string | null): WebSessionRow[];
|
package/dist/store.js
CHANGED
|
@@ -12,6 +12,7 @@ import { cardOutputsForStore, cardOutputsFromStore } from "./contracts/stage-out
|
|
|
12
12
|
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
13
13
|
import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
|
|
14
14
|
import { scanForSecrets } from "./evidence.js";
|
|
15
|
+
import { tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
|
|
15
16
|
/**
|
|
16
17
|
* The database: a small task store, and the operational overlay beside it.
|
|
17
18
|
*
|
|
@@ -69,6 +70,7 @@ import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
|
69
70
|
import { RECIPE_SCHEMA } from "./recipes.js";
|
|
70
71
|
import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
|
|
71
72
|
import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
|
|
73
|
+
import { REQUEST_BUDGET_SCHEMA } from "./request-budget.js";
|
|
72
74
|
import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
|
|
73
75
|
import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
|
|
74
76
|
import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
|
|
@@ -564,7 +566,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
564
566
|
// v108 keeps sign-in pauses: one incident per provider whose sign-in stopped working (provider_auth_pause).
|
|
565
567
|
// v109 remembers which sign-in pause parked a task (task_ref.auth_wait_pause), so reviews and other runs outside the tick wait for it too.
|
|
566
568
|
// v110 lets the action ledger record commands a person ran on this server with their API token (source 'api', or 'mcp' from their agent).
|
|
567
|
-
|
|
569
|
+
// v111 keeps an API token's project limit and its rotation: the token that replaced it, and when the old one stops.
|
|
570
|
+
// v112 keeps request budgets for API tokens: owner overrides (request_budget_limit) and saved usage (request_budget_usage).
|
|
571
|
+
// v113 keeps MCP sign-ins (OAuth): registered clients, one-time codes, and each grant's binding to its API token.
|
|
572
|
+
export const SCHEMA_VERSION = 113;
|
|
568
573
|
/**
|
|
569
574
|
* The migrations `toolroll update` may carry a database through in place: each
|
|
570
575
|
* one only adds (a table, a column, an accepted value) and leaves every saved row
|
|
@@ -574,9 +579,12 @@ export const SCHEMA_VERSION = 110;
|
|
|
574
579
|
* migrates and does not read this list.
|
|
575
580
|
*
|
|
576
581
|
* v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
|
|
577
|
-
* every row, id and hash-chain link is copied unchanged.
|
|
582
|
+
* every row, id and hash-chain link is copied unchanged. v111 adds nullable
|
|
583
|
+
* api_token columns (project limit, rotation); existing tokens read as before.
|
|
584
|
+
* v112 only adds the two request-budget tables.
|
|
585
|
+
* v113 adds MCP sign-in tables and API-token purpose metadata.
|
|
578
586
|
*/
|
|
579
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110]);
|
|
587
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110, 111, 112, 113]);
|
|
580
588
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
581
589
|
export function updateSafeSchema(version) {
|
|
582
590
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -643,6 +651,58 @@ CREATE TABLE IF NOT EXISTS web_session (
|
|
|
643
651
|
);
|
|
644
652
|
CREATE INDEX IF NOT EXISTS web_session_account ON web_session (account);
|
|
645
653
|
`;
|
|
654
|
+
/**
|
|
655
|
+
* v113: MCP sign-in (OAuth 2.1, mcp-oauth.ts). A registered client, a one-time code (its hash, a minute at most), and
|
|
656
|
+
* each grant: the ordinary API token it signs in with (revoked like any other), the account generation and exact
|
|
657
|
+
* projects the person chose, and every issued refresh-secret hash (any replay ends the whole family).
|
|
658
|
+
*/
|
|
659
|
+
const OAUTH_SCHEMA = `
|
|
660
|
+
CREATE TABLE IF NOT EXISTS oauth_client (
|
|
661
|
+
id TEXT PRIMARY KEY,
|
|
662
|
+
name TEXT NOT NULL,
|
|
663
|
+
redirect_json TEXT NOT NULL,
|
|
664
|
+
created_at TEXT NOT NULL,
|
|
665
|
+
source_hash TEXT NOT NULL
|
|
666
|
+
);
|
|
667
|
+
CREATE INDEX IF NOT EXISTS oauth_client_source ON oauth_client (source_hash);
|
|
668
|
+
CREATE TABLE IF NOT EXISTS oauth_code (
|
|
669
|
+
hash TEXT PRIMARY KEY,
|
|
670
|
+
client TEXT NOT NULL REFERENCES oauth_client(id),
|
|
671
|
+
account TEXT NOT NULL,
|
|
672
|
+
generation INTEGER NOT NULL,
|
|
673
|
+
access TEXT NOT NULL CHECK (access IN ('read','act')),
|
|
674
|
+
projects_json TEXT NOT NULL,
|
|
675
|
+
redirect_uri TEXT NOT NULL,
|
|
676
|
+
resource TEXT NOT NULL,
|
|
677
|
+
challenge TEXT NOT NULL,
|
|
678
|
+
expires_at TEXT NOT NULL,
|
|
679
|
+
used_at TEXT,
|
|
680
|
+
token TEXT
|
|
681
|
+
);
|
|
682
|
+
CREATE TABLE IF NOT EXISTS oauth_grant (
|
|
683
|
+
token TEXT PRIMARY KEY REFERENCES api_token(id),
|
|
684
|
+
client TEXT NOT NULL REFERENCES oauth_client(id),
|
|
685
|
+
account TEXT NOT NULL,
|
|
686
|
+
generation INTEGER NOT NULL,
|
|
687
|
+
projects_json TEXT NOT NULL,
|
|
688
|
+
resource TEXT NOT NULL,
|
|
689
|
+
access_expires_at TEXT NOT NULL,
|
|
690
|
+
renew_hash TEXT NOT NULL
|
|
691
|
+
);
|
|
692
|
+
CREATE INDEX IF NOT EXISTS oauth_grant_client ON oauth_grant (client);
|
|
693
|
+
CREATE TABLE IF NOT EXISTS oauth_refresh (
|
|
694
|
+
token TEXT NOT NULL REFERENCES oauth_grant(token),
|
|
695
|
+
hash TEXT NOT NULL,
|
|
696
|
+
PRIMARY KEY (token, hash)
|
|
697
|
+
);
|
|
698
|
+
`;
|
|
699
|
+
const stringList = (raw) => { try {
|
|
700
|
+
const value = JSON.parse(String(raw));
|
|
701
|
+
return Array.isArray(value) && value.every((one) => typeof one === "string") ? value : [];
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return [];
|
|
705
|
+
} };
|
|
646
706
|
/** v100: which account each identity-provider identity (issuer + subject) signs in as. */
|
|
647
707
|
const SSO_SCHEMA = `
|
|
648
708
|
CREATE TABLE IF NOT EXISTS sso_identity (
|
|
@@ -4013,6 +4073,14 @@ function initializeStore(db, file) {
|
|
|
4013
4073
|
db.exec(TEAM_SCHEMA);
|
|
4014
4074
|
db.exec(SSO_SCHEMA);
|
|
4015
4075
|
db.exec(CREDENTIALS_SCHEMA);
|
|
4076
|
+
// v111: a token's project limit (null: every project its person may use) and its rotation.
|
|
4077
|
+
addColumn(db, "api_token", "projects_json", "TEXT");
|
|
4078
|
+
addColumn(db, "api_token", "replaces", "TEXT");
|
|
4079
|
+
addColumn(db, "api_token", "replaced_by", "TEXT");
|
|
4080
|
+
addColumn(db, "api_token", "overlap_until", "TEXT");
|
|
4081
|
+
// v113: MCP sign-in (OAuth), and whether a token is an ordinary one or an MCP sign-in's.
|
|
4082
|
+
db.exec(OAUTH_SCHEMA);
|
|
4083
|
+
addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
|
|
4016
4084
|
db.exec(APPROVAL_SCHEMA);
|
|
4017
4085
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
4018
4086
|
db.exec(MONITORING_SCHEMA);
|
|
@@ -4028,6 +4096,8 @@ function initializeStore(db, file) {
|
|
|
4028
4096
|
db.exec(SPEND_SCHEMA);
|
|
4029
4097
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
4030
4098
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
4099
|
+
// v112: request budgets for API tokens (request-budget.ts).
|
|
4100
|
+
db.exec(REQUEST_BUDGET_SCHEMA);
|
|
4031
4101
|
// The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
|
|
4032
4102
|
addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
|
|
4033
4103
|
// Sized routing (no version bump: additive, and a wider tier check whose rows carry over): how big each task is,
|
|
@@ -5547,7 +5617,20 @@ function rebuildExact(db, table, oldDdl, targetDdl, columns) {
|
|
|
5547
5617
|
function readApiToken(row) {
|
|
5548
5618
|
const text = (key) => row[key] == null ? null : String(row[key]);
|
|
5549
5619
|
return { id: String(row["id"]), account: String(row["account"]), name: String(row["name"]), access: row["access"] === "act" ? "act" : "read", createdAt: String(row["created_at"]), createdBy: String(row["created_by"]),
|
|
5550
|
-
expiresAt: String(row["expires_at"]), lastUsedAt: text("last_used_at"), revokedAt: text("revoked_at"), revokedBy: text("revoked_by")
|
|
5620
|
+
expiresAt: String(row["expires_at"]), lastUsedAt: text("last_used_at"), revokedAt: text("revoked_at"), revokedBy: text("revoked_by"),
|
|
5621
|
+
purpose: tokenPurpose(row["purpose"]), projects: tokenProjectsOf(row["projects_json"]), replaces: text("replaces"), replacedBy: text("replaced_by"), overlapUntil: text("overlap_until") };
|
|
5622
|
+
}
|
|
5623
|
+
/** A token's stored project limit. Anything unreadable limits it to no project at all: never wider. */
|
|
5624
|
+
function tokenProjectsOf(value) {
|
|
5625
|
+
if (value == null)
|
|
5626
|
+
return null;
|
|
5627
|
+
try {
|
|
5628
|
+
const parsed = JSON.parse(String(value));
|
|
5629
|
+
return Array.isArray(parsed) && parsed.every(one => typeof one === "string") ? parsed : [];
|
|
5630
|
+
}
|
|
5631
|
+
catch {
|
|
5632
|
+
return [];
|
|
5633
|
+
}
|
|
5551
5634
|
}
|
|
5552
5635
|
function readWebSession(row) {
|
|
5553
5636
|
return { idHash: String(row["id_hash"]), account: String(row["account"]), csrf: String(row["csrf"]), role: row["role"] === "viewer" ? "viewer" : "approver", generation: Number(row["generation"]),
|
|
@@ -9808,12 +9891,45 @@ export class Store {
|
|
|
9808
9891
|
// ---- API tokens and browser sessions (v101) ------------------------------
|
|
9809
9892
|
createApiToken(token, now) {
|
|
9810
9893
|
this.transact(() => {
|
|
9811
|
-
|
|
9812
|
-
|
|
9894
|
+
const projects = token.projects ?? null;
|
|
9895
|
+
this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
9896
|
+
.run(token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
|
|
9813
9897
|
this.recordAction({ at: now.toISOString(), actor: token.by, repo: null, taskId: null, runId: null, action: `API token created: ${token.name}`, outcome: token.access, source: "access",
|
|
9814
|
-
detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}` });
|
|
9898
|
+
detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}${projects === null ? "" : ` · ${projects.length === 0 ? "no projects" : projects.join(", ")}`} · id ${token.id}` });
|
|
9899
|
+
});
|
|
9900
|
+
}
|
|
9901
|
+
/**
|
|
9902
|
+
* v111: replace a live, not-yet-replaced token with a new secret on the same terms (access, projects, end date, so
|
|
9903
|
+
* rotating never extends it). The old one keeps working until `overlapUntil` (never past its own end), then stops;
|
|
9904
|
+
* every check reads that time, and endRotatedApiTokens records the end afterwards. Only the token's own person.
|
|
9905
|
+
*/
|
|
9906
|
+
rotateApiToken(oldId, replacement, by, now, overlapMs) {
|
|
9907
|
+
return this.transact(() => {
|
|
9908
|
+
const old = this.apiTokenSecret(oldId)?.row ?? null;
|
|
9909
|
+
const at = now.getTime();
|
|
9910
|
+
if (old === null || old.account !== by || old.revokedAt !== null || !(Date.parse(old.expiresAt) > at) || old.overlapUntil !== null && !(Date.parse(old.overlapUntil) > at))
|
|
9911
|
+
return { ok: false, reason: "unknown" };
|
|
9912
|
+
if (tokenRotationProblem(old) !== null || this.oauthGrant(oldId) !== null)
|
|
9913
|
+
return { ok: false, reason: "mcp-managed" };
|
|
9914
|
+
if (old.replacedBy !== null)
|
|
9915
|
+
return { ok: false, reason: "replaced" };
|
|
9916
|
+
const stamp = now.toISOString();
|
|
9917
|
+
const until = new Date(Math.min(at + Math.max(0, overlapMs), Date.parse(old.expiresAt))).toISOString();
|
|
9918
|
+
this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
9919
|
+
.run(replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id);
|
|
9920
|
+
this.db.prepare("UPDATE api_token SET replaced_by = ?, overlap_until = ? WHERE id = ? AND replaced_by IS NULL").run(replacement.id, until, old.id);
|
|
9921
|
+
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `API token rotated: ${old.name}`, outcome: old.access, source: "access",
|
|
9922
|
+
detail: `${old.account}'s · id ${old.id} → ${replacement.id}, the old one stops ${until.slice(0, 16).replace("T", " ")} UTC` });
|
|
9923
|
+
return { ok: true, row: this.apiTokenSecret(replacement.id).row };
|
|
9815
9924
|
});
|
|
9816
9925
|
}
|
|
9926
|
+
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
9927
|
+
endRotatedApiTokens(now) {
|
|
9928
|
+
const due = this.db.prepare("SELECT id, overlap_until FROM api_token WHERE revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
|
|
9929
|
+
for (const row of due)
|
|
9930
|
+
this.revokeApiToken(String(row["id"]), "system", new Date(String(row["overlap_until"])), "replaced; its overlap ended");
|
|
9931
|
+
return due.length;
|
|
9932
|
+
}
|
|
9817
9933
|
/** A token by id with its kept hash (for the check), or null. */
|
|
9818
9934
|
apiTokenSecret(id) {
|
|
9819
9935
|
const row = this.db.prepare("SELECT * FROM api_token WHERE id = ?").get(id);
|
|
@@ -9837,6 +9953,119 @@ export class Store {
|
|
|
9837
9953
|
return true;
|
|
9838
9954
|
});
|
|
9839
9955
|
}
|
|
9956
|
+
// ---- MCP sign-in (v113, mcp-oauth.ts) ---------------------------------------------------------------------------
|
|
9957
|
+
/** A registered client, or null. */
|
|
9958
|
+
oauthClient(id) {
|
|
9959
|
+
const row = this.db.prepare("SELECT * FROM oauth_client WHERE id = ?").get(id);
|
|
9960
|
+
return row === undefined ? null : { id: String(row["id"]), name: String(row["name"]), redirectUris: stringList(row["redirect_json"]), createdAt: String(row["created_at"]) };
|
|
9961
|
+
}
|
|
9962
|
+
/** Expire unused registrations, retaining clients with a live code, grant or in-process consent. */
|
|
9963
|
+
pruneOAuthClients(now, pending = []) {
|
|
9964
|
+
this.transact(() => {
|
|
9965
|
+
const stale = new Date(now.getTime() - 86_400_000).toISOString();
|
|
9966
|
+
this.db.prepare("DELETE FROM oauth_code WHERE expires_at <= ?").run(now.toISOString());
|
|
9967
|
+
this.db.prepare(`DELETE FROM oauth_client WHERE created_at <= ? AND id NOT IN (SELECT client FROM oauth_grant)
|
|
9968
|
+
AND id NOT IN (SELECT client FROM oauth_code) AND id NOT IN (SELECT value FROM json_each(?))`).run(stale, JSON.stringify(pending));
|
|
9969
|
+
});
|
|
9970
|
+
}
|
|
9971
|
+
/**
|
|
9972
|
+
* Unused registrations expire after a day. Admission has a global and per-source bound; it never evicts a live
|
|
9973
|
+
* registration. Repeating metadata from the same source returns its existing registration without spending a slot.
|
|
9974
|
+
*/
|
|
9975
|
+
registerOAuthClient(client, now, cap, perSourceCap, pending = []) {
|
|
9976
|
+
return this.transact(() => {
|
|
9977
|
+
const redirectJson = JSON.stringify(client.redirectUris);
|
|
9978
|
+
this.pruneOAuthClients(now, pending);
|
|
9979
|
+
const same = this.db.prepare("SELECT id FROM oauth_client WHERE name = ? AND redirect_json = ? AND source_hash = ?").get(client.name, redirectJson, client.source);
|
|
9980
|
+
if (same !== undefined)
|
|
9981
|
+
return this.oauthClient(String(same["id"]));
|
|
9982
|
+
const unused = "FROM oauth_client WHERE id NOT IN (SELECT client FROM oauth_grant)";
|
|
9983
|
+
if (Number(this.db.prepare(`SELECT COUNT(*) AS n ${unused}`).get()?.["n"] ?? 0) >= cap
|
|
9984
|
+
|| Number(this.db.prepare(`SELECT COUNT(*) AS n ${unused} AND source_hash = ?`).get(client.source)?.["n"] ?? 0) >= perSourceCap)
|
|
9985
|
+
return null;
|
|
9986
|
+
this.db.prepare("INSERT INTO oauth_client (id, name, redirect_json, created_at, source_hash) VALUES (?, ?, ?, ?, ?)").run(client.id, client.name, redirectJson, now.toISOString(), client.source);
|
|
9987
|
+
return this.oauthClient(client.id);
|
|
9988
|
+
});
|
|
9989
|
+
}
|
|
9990
|
+
/** Keep a one-time code by its hash. */
|
|
9991
|
+
saveOAuthCode(hash, code) {
|
|
9992
|
+
this.db.prepare("INSERT INTO oauth_code (hash, client, account, generation, access, projects_json, redirect_uri, resource, challenge, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
9993
|
+
.run(hash, code.client, code.account, code.generation, code.access, JSON.stringify(code.projects), code.redirectUri, code.resource, code.challenge, code.expiresAt);
|
|
9994
|
+
}
|
|
9995
|
+
/**
|
|
9996
|
+
* Spend a code: the first caller gets it (`taken`), once, before it expires. A code spent before answers `used`
|
|
9997
|
+
* with the token it made, so that token can be ended; anything else is null.
|
|
9998
|
+
*/
|
|
9999
|
+
takeOAuthCode(hash, now) {
|
|
10000
|
+
return this.transact(() => {
|
|
10001
|
+
const row = this.db.prepare("SELECT * FROM oauth_code WHERE hash = ?").get(hash);
|
|
10002
|
+
if (row === undefined)
|
|
10003
|
+
return null;
|
|
10004
|
+
if (row["used_at"] !== null)
|
|
10005
|
+
return { used: row["token"] === null ? null : String(row["token"]) };
|
|
10006
|
+
this.db.prepare("UPDATE oauth_code SET used_at = ? WHERE hash = ? AND used_at IS NULL").run(now.toISOString(), hash);
|
|
10007
|
+
if (String(row["expires_at"]) <= now.toISOString())
|
|
10008
|
+
return null;
|
|
10009
|
+
return { taken: { client: String(row["client"]), account: String(row["account"]), generation: Number(row["generation"]), access: row["access"] === "act" ? "act" : "read", projects: stringList(row["projects_json"]),
|
|
10010
|
+
redirectUri: String(row["redirect_uri"]), resource: String(row["resource"]), challenge: String(row["challenge"]), expiresAt: String(row["expires_at"]), usedAt: null, token: null } };
|
|
10011
|
+
});
|
|
10012
|
+
}
|
|
10013
|
+
/** Make a grant: its API token (created and recorded as any other) and its binding, together; the code notes the token. */
|
|
10014
|
+
createOAuthGrant(code, token, grant, now) {
|
|
10015
|
+
this.transact(() => {
|
|
10016
|
+
this.createApiToken({ ...token, by: token.account, projects: grant.projects, purpose: "mcp" }, now);
|
|
10017
|
+
this.db.prepare("INSERT INTO oauth_grant (token, client, account, generation, projects_json, resource, access_expires_at, renew_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
10018
|
+
.run(token.id, grant.client, grant.account, grant.generation, JSON.stringify(grant.projects), grant.resource, grant.accessExpiresAt, grant.refreshHash);
|
|
10019
|
+
this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token.id, grant.refreshHash);
|
|
10020
|
+
this.db.prepare("UPDATE oauth_code SET token = ? WHERE hash = ?").run(token.id, code);
|
|
10021
|
+
});
|
|
10022
|
+
}
|
|
10023
|
+
/** The grant an API token signs in for, or null when the token is an ordinary one. */
|
|
10024
|
+
oauthGrant(token) {
|
|
10025
|
+
const row = this.db.prepare("SELECT * FROM oauth_grant WHERE token = ?").get(token);
|
|
10026
|
+
return row === undefined ? null : { token: String(row["token"]), client: String(row["client"]), account: String(row["account"]), generation: Number(row["generation"]), projects: stringList(row["projects_json"]),
|
|
10027
|
+
resource: String(row["resource"]), accessExpiresAt: String(row["access_expires_at"]), refreshHash: String(row["renew_hash"]) };
|
|
10028
|
+
}
|
|
10029
|
+
/**
|
|
10030
|
+
* Classify, validate and rotate under one write transaction. Every issued hash remains in the family, so any
|
|
10031
|
+
* older secret revokes even a concurrent winner. An unknown guess or the wrong client cannot end the family.
|
|
10032
|
+
*/
|
|
10033
|
+
rotateOAuthGrant(token, ask, next, now, projectsFor) {
|
|
10034
|
+
return this.transact(() => {
|
|
10035
|
+
const invalid = { ok: false, reason: "invalid_grant" };
|
|
10036
|
+
const grant = this.oauthGrant(token), kept = this.apiTokenSecret(token)?.row;
|
|
10037
|
+
if (grant === null || kept === undefined || grant.client !== ask.client || grant.resource !== ask.resource)
|
|
10038
|
+
return invalid;
|
|
10039
|
+
const issued = this.db.prepare("SELECT 1 FROM oauth_refresh WHERE token = ? AND hash = ?").get(token, ask.refreshHash);
|
|
10040
|
+
if (issued === undefined)
|
|
10041
|
+
return invalid;
|
|
10042
|
+
if (grant.refreshHash !== ask.refreshHash) {
|
|
10043
|
+
this.revokeApiToken(token, "toolroll", now, "its refresh secret was used twice");
|
|
10044
|
+
return invalid;
|
|
10045
|
+
}
|
|
10046
|
+
if (kept.revokedAt !== null || !(Date.parse(kept.expiresAt) > now.getTime()))
|
|
10047
|
+
return invalid;
|
|
10048
|
+
const account = this.accountOf(grant.account);
|
|
10049
|
+
if (account === null || account.revokedAt !== null || account.generation !== grant.generation || (kept.access === "act" && account.role !== "approver")) {
|
|
10050
|
+
this.revokeApiToken(token, "toolroll", now, "the person's sign-in changed");
|
|
10051
|
+
return invalid;
|
|
10052
|
+
}
|
|
10053
|
+
const mine = projectsFor(grant.account);
|
|
10054
|
+
if (!grant.projects.some(project => mine.includes(project) && (kept.projects === null || kept.projects.includes(project)))) {
|
|
10055
|
+
this.revokeApiToken(token, "toolroll", now, "its projects are no longer the person's");
|
|
10056
|
+
return invalid;
|
|
10057
|
+
}
|
|
10058
|
+
if (ask.scope !== undefined && !ask.scope.split(" ").every(one => one === kept.access || one === "read"))
|
|
10059
|
+
return { ok: false, reason: "invalid_scope" };
|
|
10060
|
+
const moved = this.db.prepare("UPDATE oauth_grant SET renew_hash = ?, access_expires_at = ? WHERE token = ? AND renew_hash = ?")
|
|
10061
|
+
.run(next.refreshHash, next.accessExpiresAt, token, ask.refreshHash);
|
|
10062
|
+
if (Number(moved.changes) !== 1)
|
|
10063
|
+
return invalid;
|
|
10064
|
+
this.db.prepare("UPDATE api_token SET secret_hash = ? WHERE id = ? AND revoked_at IS NULL").run(next.accessHash, token);
|
|
10065
|
+
this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token, next.refreshHash);
|
|
10066
|
+
return { ok: true, access: kept.access };
|
|
10067
|
+
});
|
|
10068
|
+
}
|
|
9840
10069
|
saveWebSession(session) {
|
|
9841
10070
|
this.db.prepare(`INSERT INTO web_session (id_hash, account, csrf, role, generation, created_at, last_seen, project, project_revision, sso_at, agent, address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
9842
10071
|
ON CONFLICT(id_hash) DO UPDATE SET last_seen = excluded.last_seen, project = excluded.project, project_revision = excluded.project_revision, sso_at = excluded.sso_at`)
|
package/dist/surface.js
CHANGED
|
@@ -135,6 +135,16 @@ const GUIDE_ROWS = [
|
|
|
135
135
|
{ invocation: "brief", synopsis: "with a saved central profile, --lead <id> --conversation <id> reads messages, proposals and saved work; --request-id inspects your saved receipt without resending; --local uses local DB catch-up, where --history selects the older operational report", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
136
136
|
{ invocation: "gaps", synopsis: "requirement gaps blocking dispatch", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag, repoFlag] },
|
|
137
137
|
{ invocation: "grants", synopsis: "list authority grants", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
138
|
+
{ invocation: "audit", synopsis: "what people did on this server with their API tokens, newest first: when, who, which token, from the CLI (api) or an agent (mcp), the command or tool, the project and the outcome (ok, refused, error); an approver sees everyone in their projects, anyone else only themselves",
|
|
139
|
+
audience: "agent", agentMayInvoke: true, mutation: "none",
|
|
140
|
+
flags: [jsonFlag, dbFlag,
|
|
141
|
+
{ name: "person", takesValue: true, meaning: "only this person's actions" },
|
|
142
|
+
{ name: "token", takesValue: true, meaning: "only actions with the token of this name (on the server's own machine)" },
|
|
143
|
+
{ name: "token-name", takesValue: true, meaning: "the same, where --token would be a credential (remotely)" },
|
|
144
|
+
{ name: "source", takesValue: true, meaning: "api (the CLI) or mcp (an agent)" },
|
|
145
|
+
{ name: "since", takesValue: true, meaning: "only the last N days, as Nd (7d)" },
|
|
146
|
+
{ name: "limit", takesValue: true, meaning: "actions per page (default 20, at most 100)" },
|
|
147
|
+
{ name: "cursor", takesValue: true, meaning: "the nextCursor of the previous page" }] },
|
|
138
148
|
{ invocation: "sync", synopsis: "refresh external-tracker mirrors and deliver write-backs — safe to run; fails closed", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
|
|
139
149
|
flags: [jsonFlag, dbFlag, repoFlag], notableReasons: ["no-op", "external"] },
|
|
140
150
|
{ invocation: "task add", synopsis: "queue work", audience: "agent", agentMayInvoke: true, mutation: "keyed",
|
|
@@ -377,6 +387,10 @@ const GUIDE_ROWS = [
|
|
|
377
387
|
operator("people invite", "mint a single-use sign-in link for one person — their powers are pinned when you mint, never after"),
|
|
378
388
|
operator("people projects", "set a person's project access — selected project, all projects, or no projects"),
|
|
379
389
|
operator("people revoke", "end a person's access — their sessions, invites, and signed modes end with them; history stays"),
|
|
390
|
+
operator("tokens create", "make one of your API tokens: --name, --access read|act, --days 30|90|365, optional --projects a,b; your password at the prompt or --password-stdin; the token is shown once"),
|
|
391
|
+
operatorRead("tokens list", "your API tokens: name, access, projects, made, last used, expiry — never a secret; your password at the prompt or --password-stdin"),
|
|
392
|
+
operator("tokens revoke", "end one of your API tokens now, by name or id; your password at the prompt or --password-stdin"),
|
|
393
|
+
operator("tokens rotate", "replace one of your API tokens on the same terms; the old one works 10 more minutes (--overlap), then stops; the new one is shown once"),
|
|
380
394
|
operatorRead("mode show", "the repository's operating mode, in full — or 'locked' when none is signed"),
|
|
381
395
|
operator("mode set", "sign a per-repository operating mode (password ceremony; standard or hands-off, always expiring)"),
|
|
382
396
|
operator("publish merge", "say yes to ONE waiting merge — it fires only when CI is seen green on the exact commit you authorized"),
|
|
@@ -411,6 +425,7 @@ const GUIDE_ROWS = [
|
|
|
411
425
|
operator("publish unblock", "lift a repair's merge hold"),
|
|
412
426
|
operator("publish rearm", "re-arm a refused merge after fixing the named cause"),
|
|
413
427
|
operator("serve", "the operations console (HTTP)"),
|
|
428
|
+
operatorRead("serve check-public", "is this server ready on its --public-url? checks the URL, --allow-host, the console on --port, the certificate, HSTS and the proxy, and names each problem; sends no credentials"),
|
|
414
429
|
operator("watch", "the unattended loop, kept running"),
|
|
415
430
|
operator("up", "console + worker + browser, one command"),
|
|
416
431
|
operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
|
|
@@ -436,7 +451,7 @@ const REMOTE_NO = [
|
|
|
436
451
|
// Workers, the loop and the services that run here.
|
|
437
452
|
"claim", "heartbeat", "release", "reap", "tick", "build", "reconcile",
|
|
438
453
|
"runner register", "runner retire", "runner bind", "runner capacity", "coordinator mint", "coordinator revoke", "mcp", "outbox deliver", "peek",
|
|
439
|
-
"serve", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
|
|
454
|
+
"serve", "serve check-public", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
|
|
440
455
|
// Provider keys and model tools on this machine.
|
|
441
456
|
"providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
|
|
442
457
|
// Publication and release.
|
|
@@ -452,11 +467,13 @@ const REMOTE_STEP_UP = [
|
|
|
452
467
|
"proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle", "routine approve", "knowledge apply", "memory apply",
|
|
453
468
|
"flows card approve", "flows card send-back", "flows script approve",
|
|
454
469
|
"approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
|
|
470
|
+
// A person's own API tokens: a password step-up on the server's machine, or the console. Never with a token.
|
|
471
|
+
"tokens create", "tokens list", "tokens revoke", "tokens rotate",
|
|
455
472
|
"mode show", "mode set", "mode revoke", "chat-approval on", "review on", "review off", "config set", "config clear",
|
|
456
473
|
"verify set", "verify clear", "verify level", "project checks", "project concurrency", "intake grant", "intake clear", "webhook primary",
|
|
457
474
|
];
|
|
458
475
|
const REMOTE_YES = [
|
|
459
|
-
"status", "integrations", "ready", "gaps", "grants", "sync",
|
|
476
|
+
"status", "integrations", "ready", "gaps", "grants", "sync", "audit",
|
|
460
477
|
"task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
|
|
461
478
|
"task state", "task block", "task unblock", "task next", "task steer", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
|
|
462
479
|
"task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API token notices (v111): 7 days and 1 day before a token expires, its person is told once each, in their own chats
|
|
3
|
+
* and browsers (a personal notification), and the ledger keeps it. Only the nearest notice newly due is sent, so a
|
|
4
|
+
* token made with a day left says so once. Replaced and revoked tokens get none. The same pass records the end of each
|
|
5
|
+
* rotated token whose overlap has passed; it already signs no one in (tokenLive), so a late pass grants nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { tokenLive } from "./api-tokens.js";
|
|
8
|
+
import type { Store } from "./store.js";
|
|
9
|
+
export declare const TOKEN_NOTICE_KIND = "api-token-expiry";
|
|
10
|
+
export declare function tokenNoticePass(store: Store, now: Date): {
|
|
11
|
+
sent: {
|
|
12
|
+
token: string;
|
|
13
|
+
days: number;
|
|
14
|
+
}[];
|
|
15
|
+
ended: number;
|
|
16
|
+
};
|
|
17
|
+
/** Whether a token's console card warns that it expires soon (within the first notice's days). */
|
|
18
|
+
export declare function expiresSoon(token: Parameters<typeof tokenLive>[0] & {
|
|
19
|
+
replacedBy: string | null;
|
|
20
|
+
}, now: number): boolean;
|
|
21
|
+
/** The loop beside the console: a pass a minute. */
|
|
22
|
+
export declare function startTokenNotices(store: Store, everyMs?: number): () => void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API token notices (v111): 7 days and 1 day before a token expires, its person is told once each, in their own chats
|
|
3
|
+
* and browsers (a personal notification), and the ledger keeps it. Only the nearest notice newly due is sent, so a
|
|
4
|
+
* token made with a day left says so once. Replaced and revoked tokens get none. The same pass records the end of each
|
|
5
|
+
* rotated token whose overlap has passed; it already signs no one in (tokenLive), so a late pass grants nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { expiryNoticeDue, EXPIRY_NOTICE_DAYS, tokenLive } from "./api-tokens.js";
|
|
8
|
+
export const TOKEN_NOTICE_KIND = "api-token-expiry";
|
|
9
|
+
export function tokenNoticePass(store, now) {
|
|
10
|
+
const ended = store.endRotatedApiTokens(now);
|
|
11
|
+
const sent = [];
|
|
12
|
+
const at = now.getTime();
|
|
13
|
+
for (const token of store.apiTokens(null)) {
|
|
14
|
+
const days = expiryNoticeDue(token, at);
|
|
15
|
+
if (days === null)
|
|
16
|
+
continue;
|
|
17
|
+
const key = (one) => `${TOKEN_NOTICE_KIND}:${token.id}:${one}`;
|
|
18
|
+
// A nearer notice already sent covers this one.
|
|
19
|
+
if (EXPIRY_NOTICE_DAYS.filter(one => one <= days).some(one => store.handle.prepare("SELECT 1 AS hit FROM notification WHERE dedupe_key = ?").get(key(one)) !== undefined))
|
|
20
|
+
continue;
|
|
21
|
+
const fresh = store.enqueueNotification({
|
|
22
|
+
dedupeKey: key(days), kind: TOKEN_NOTICE_KIND, pushClass: "attention", link: "/settings/sessions", recipient: token.account,
|
|
23
|
+
subject: `Your API token ${token.name} expires ${days === 1 ? "tomorrow" : `in ${days} days`}`,
|
|
24
|
+
body: `It stops working ${token.expiresAt.slice(0, 16).replace("T", " ")} UTC. Rotate it with toolroll tokens rotate, or make a new one in Settings → Sessions & tokens.`,
|
|
25
|
+
source: { installation: true },
|
|
26
|
+
}, now);
|
|
27
|
+
if (!fresh)
|
|
28
|
+
continue;
|
|
29
|
+
store.recordAction({ at: now.toISOString(), actor: "system", repo: null, taskId: null, runId: null, action: `API token expiry notice: ${token.name}`, outcome: "notified", source: "access",
|
|
30
|
+
detail: `${token.account}'s · id ${token.id} · ${days} day${days === 1 ? "" : "s"} left` });
|
|
31
|
+
sent.push({ token: token.id, days });
|
|
32
|
+
}
|
|
33
|
+
return { sent, ended };
|
|
34
|
+
}
|
|
35
|
+
/** Whether a token's console card warns that it expires soon (within the first notice's days). */
|
|
36
|
+
export function expiresSoon(token, now) {
|
|
37
|
+
return expiryNoticeDue(token, now) !== null;
|
|
38
|
+
}
|
|
39
|
+
/** The loop beside the console: a pass a minute. */
|
|
40
|
+
export function startTokenNotices(store, everyMs = 60_000) {
|
|
41
|
+
let stopped = false;
|
|
42
|
+
const tick = () => { if (stopped)
|
|
43
|
+
return; try {
|
|
44
|
+
tokenNoticePass(store, new Date());
|
|
45
|
+
}
|
|
46
|
+
catch { /* the next pass tries again */ } };
|
|
47
|
+
const timer = setInterval(tick, everyMs);
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
const first = setTimeout(tick, 5_000);
|
|
50
|
+
first.unref?.();
|
|
51
|
+
return () => { stopped = true; clearInterval(timer); clearTimeout(first); };
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export declare const TOKENS_ACTIONS: readonly ["create", "list", "revoke", "rotate"];
|
|
3
|
+
/** The most a piped password may be: a password, not a file. */
|
|
4
|
+
export declare const PASSWORD_STDIN_BYTES = 4096;
|
|
5
|
+
export type TokensCliContext = {
|
|
6
|
+
store: Store;
|
|
7
|
+
write: (line: string) => void;
|
|
8
|
+
json: boolean;
|
|
9
|
+
clock: () => Date;
|
|
10
|
+
/** Who runs this: a remote caller (an API token, over /api/cli or /mcp) and the lead never manage tokens. */
|
|
11
|
+
caller: "local" | "remote" | "lead";
|
|
12
|
+
/** The remembered local login's name only (never its password): the default for --as. */
|
|
13
|
+
rememberedName: string | null;
|
|
14
|
+
/** Whether someone is at this terminal to answer a prompt. */
|
|
15
|
+
interactive: () => boolean;
|
|
16
|
+
ask: (question: string) => Promise<string>;
|
|
17
|
+
askHidden: (question: string) => Promise<string>;
|
|
18
|
+
/** Standard input, read whole up to `limit` bytes; null past the limit. */
|
|
19
|
+
readStdin: (limit: number) => Promise<string | null>;
|
|
20
|
+
};
|
|
21
|
+
export declare function runTokensCommand(positional: readonly string[], flags: ReadonlyMap<string, string | true>, context: TokensCliContext): Promise<number>;
|