@gajae-code/coding-agent 0.12.21 → 0.13.0
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 +67 -2
- package/dist/types/cli/args.d.ts +5 -2
- package/dist/types/cli/root-flags.d.ts +246 -0
- package/dist/types/cli.d.ts +11 -19
- package/dist/types/commands/daemon.d.ts +0 -30
- package/dist/types/commands/launch.d.ts +11 -19
- package/dist/types/commands/quick-lane.d.ts +32 -0
- package/dist/types/commands/sdk.d.ts +21 -15
- package/dist/types/config/file-lock-gc.d.ts +2 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -17
- package/dist/types/config/model-profiles.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +68 -1
- package/dist/types/config/model-resolver.d.ts +15 -4
- package/dist/types/config/models-config-schema.d.ts +7 -2
- package/dist/types/config/provider-selection-policy.d.ts +62 -0
- package/dist/types/config/settings-schema.d.ts +36 -78
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/edit/renderer.d.ts +5 -2
- package/dist/types/edit/streaming.d.ts +13 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
- package/dist/types/internal-urls/local-protocol.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +0 -2
- package/dist/types/modes/components/provider-order-context.d.ts +45 -0
- package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
- package/dist/types/modes/components/settings-defs.d.ts +5 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/quick-lane/classify.d.ts +40 -0
- package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
- package/dist/types/sdk/broker/broker.d.ts +56 -2
- package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
- package/dist/types/sdk/broker/session-index.d.ts +83 -2
- package/dist/types/sdk/broker/state-version.d.ts +1 -1
- package/dist/types/sdk/bus/notification-service.d.ts +6 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
- package/dist/types/sdk/cli/rows.d.ts +98 -0
- package/dist/types/sdk/cli/session-cli.d.ts +35 -3
- package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
- package/dist/types/sdk/elevation/audit.d.ts +93 -0
- package/dist/types/sdk/elevation/capability.d.ts +3 -0
- package/dist/types/sdk/elevation/digest.d.ts +72 -0
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
- package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
- package/dist/types/sdk/elevation/owner.d.ts +48 -0
- package/dist/types/sdk/guides/cache.d.ts +98 -0
- package/dist/types/sdk/guides/catalog.d.ts +156 -0
- package/dist/types/sdk/guides/cli.d.ts +39 -0
- package/dist/types/sdk/guides/manifest.d.ts +66 -0
- package/dist/types/sdk/guides/verify.d.ts +66 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +3 -2
- package/dist/types/sdk/host/query/cursor.d.ts +10 -0
- package/dist/types/sdk/host/query/handlers.d.ts +21 -0
- package/dist/types/sdk/host/session-runtime.d.ts +9 -0
- package/dist/types/sdk/mcp/server.d.ts +2 -2
- package/dist/types/sdk/prompt-status.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +4 -0
- package/dist/types/sdk/transport/index.d.ts +1 -0
- package/dist/types/sdk/transport/relay.d.ts +1 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
- package/dist/types/session/agent-session.d.ts +32 -18
- package/dist/types/session/blob-store.d.ts +13 -7
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
- package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
- package/dist/types/session/session-manager.d.ts +139 -10
- package/dist/types/session/session-storage.d.ts +48 -0
- package/dist/types/setup/provider-onboarding.d.ts +7 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
- package/dist/types/stt/setup.d.ts +2 -0
- package/dist/types/task/executor.d.ts +10 -2
- package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/image-gen.d.ts +14 -18
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resource-gc.d.ts +2 -0
- package/dist/types/utils/pasted-image-path.d.ts +6 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/scripts/run-test-manifest.ts +61 -21
- package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
- package/src/cli/args.ts +62 -9
- package/src/cli/completion-cli.ts +1 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/root-flags.ts +100 -0
- package/src/cli/setup-cli.ts +6 -2
- package/src/cli.ts +42 -65
- package/src/commands/acp.ts +1 -1
- package/src/commands/daemon.ts +2 -31
- package/src/commands/gc.ts +1 -1
- package/src/commands/launch.ts +8 -129
- package/src/commands/quick-lane.ts +51 -0
- package/src/commands/sdk.ts +200 -26
- package/src/commands/setup.ts +4 -2
- package/src/config/file-lock-gc.ts +4 -1
- package/src/config/model-equivalence.ts +76 -7
- package/src/config/model-profile-activation.ts +1115 -265
- package/src/config/model-profiles.ts +134 -2
- package/src/config/model-registry.ts +352 -50
- package/src/config/model-resolver.ts +289 -38
- package/src/config/models-config-schema.ts +11 -4
- package/src/config/provider-selection-policy.ts +146 -0
- package/src/config/settings-schema.ts +50 -77
- package/src/config/settings.ts +40 -0
- package/src/coordinator-mcp/server.ts +32 -2
- package/src/edit/renderer.ts +175 -223
- package/src/edit/streaming.ts +108 -1
- package/src/export/html/index.ts +57 -7
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/extensions/compact-handler.ts +3 -2
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/gjc-runtime/gc-runtime.ts +379 -20
- package/src/gjc-runtime/launch-worktree.ts +5 -1
- package/src/gjc-runtime/team-runtime.ts +59 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +17 -13
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/main.ts +40 -3
- package/src/memories/index.ts +4 -2
- package/src/modes/acp/acp-agent.ts +235 -91
- package/src/modes/components/model-selector.ts +206 -42
- package/src/modes/components/provider-order-context.ts +197 -0
- package/src/modes/components/provider-order-editor.ts +441 -0
- package/src/modes/components/settings-defs.ts +21 -1
- package/src/modes/components/settings-selector.ts +45 -2
- package/src/modes/components/tool-execution.ts +47 -15
- package/src/modes/controllers/command-controller.ts +6 -3
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +73 -2
- package/src/modes/controllers/selector-controller.ts +260 -153
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +18 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/quick-lane/classify.ts +257 -0
- package/src/rlm/index.ts +1 -1
- package/src/runtime/stt-settings-setup.ts +57 -0
- package/src/sdk/acp/adapter.ts +2 -1
- package/src/sdk/broker/broker.ts +780 -12
- package/src/sdk/broker/lifecycle.ts +70 -29
- package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
- package/src/sdk/broker/session-index.ts +463 -48
- package/src/sdk/broker/state-version.ts +13 -9
- package/src/sdk/broker/transport.ts +14 -1
- package/src/sdk/bus/index.ts +34 -13
- package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
- package/src/sdk/bus/notification-service.ts +47 -1
- package/src/sdk/bus/prompt-reconciliation.ts +22 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +54 -3
- package/src/sdk/cli/rows.ts +247 -0
- package/src/sdk/cli/session-cli.ts +930 -79
- package/src/sdk/elevation/allowlist.ts +54 -0
- package/src/sdk/elevation/audit.ts +208 -0
- package/src/sdk/elevation/capability.ts +41 -0
- package/src/sdk/elevation/digest.ts +129 -0
- package/src/sdk/elevation/dispatch-receipt.ts +184 -0
- package/src/sdk/elevation/grant-ledger.ts +1142 -0
- package/src/sdk/elevation/owner.ts +109 -0
- package/src/sdk/guides/cache.ts +363 -0
- package/src/sdk/guides/catalog.ts +554 -0
- package/src/sdk/guides/cli.ts +235 -0
- package/src/sdk/guides/manifest.ts +152 -0
- package/src/sdk/guides/verify.ts +178 -0
- package/src/sdk/host/control/dispatch.ts +49 -1
- package/src/sdk/host/control/index.ts +1 -0
- package/src/sdk/host/control/operations.ts +8 -1
- package/src/sdk/host/query/cursor.ts +47 -3
- package/src/sdk/host/query/handlers.ts +155 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +391 -1
- package/src/sdk/host/session-runtime.ts +308 -73
- package/src/sdk/host/surface-policy.ts +3 -0
- package/src/sdk/host/websocket-transport.ts +7 -1
- package/src/sdk/mcp/server.ts +88 -15
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/protocol/operation-inventory.generated.json +62 -0
- package/src/sdk/protocol/operation-registry.ts +3 -2
- package/src/sdk/session.ts +87 -39
- package/src/sdk/transport/index.ts +1 -0
- package/src/sdk/transport/relay.ts +3 -0
- package/src/sdk/transport/serve-cli.ts +111 -24
- package/src/secrets/obfuscator.ts +92 -12
- package/src/session/agent-session.ts +322 -93
- package/src/session/blob-store.ts +60 -8
- package/src/session/fallback-chain-controller.ts +35 -0
- package/src/session/internal/managed-session-scope.ts +25 -3
- package/src/session/internal/managed-session-storage.ts +574 -73
- package/src/session/internal/session-memory-sidecar.ts +19 -29
- package/src/session/session-manager.ts +2166 -743
- package/src/session/session-storage.ts +715 -48
- package/src/setup/provider-onboarding.ts +19 -3
- package/src/setup/provider-presets.json +3 -1
- package/src/slash-commands/builtin-registry.ts +85 -3
- package/src/stt/downloader.ts +2 -3
- package/src/stt/setup.ts +29 -4
- package/src/task/executor.ts +22 -18
- package/src/task/index.ts +24 -1
- package/src/tools/archive-reader.ts +11 -2
- package/src/tools/browser/runtime-diagnostics.ts +249 -0
- package/src/tools/browser/tab-protocol.ts +4 -0
- package/src/tools/browser/tab-supervisor.ts +5 -0
- package/src/tools/browser/tab-worker.ts +36 -1
- package/src/tools/browser.ts +7 -0
- package/src/tools/image-gen.ts +110 -166
- package/src/tools/index.ts +2 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/resource-gc.ts +6 -1
- package/src/tools/tool-catalog.generated.ts +5 -1
- package/src/utils/pasted-image-path.ts +93 -0
- package/src/web/search/index.ts +3 -3
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fsSync from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { withFileLock } from "../../config/file-lock";
|
|
6
|
+
import { assertSupportedStateVersion, SDK_STATE_VERSION } from "../broker/state-version";
|
|
7
|
+
import { ElevationAudit, type ElevationAuditEntry } from "./audit";
|
|
8
|
+
import {
|
|
9
|
+
ELEVATION_OPERATION_KINDS,
|
|
10
|
+
type ElevationOperation,
|
|
11
|
+
elevationRequestDigest,
|
|
12
|
+
isKnownElevationOperation,
|
|
13
|
+
} from "./digest";
|
|
14
|
+
import {
|
|
15
|
+
claimReceipt,
|
|
16
|
+
dispatchReceipt,
|
|
17
|
+
type ElevationAnswerAuthority,
|
|
18
|
+
type ElevationClaimIdentity,
|
|
19
|
+
type ElevationDispatchOutcome,
|
|
20
|
+
type ElevationDispatchReceipt,
|
|
21
|
+
isElevationAnswerAuthority,
|
|
22
|
+
isElevationClaimIdentity,
|
|
23
|
+
isElevationDispatchOutcome,
|
|
24
|
+
isElevationDispatchReceipt,
|
|
25
|
+
isElevationRequestId,
|
|
26
|
+
receiptClaimMatches,
|
|
27
|
+
uncertainReceipt,
|
|
28
|
+
} from "./dispatch-receipt";
|
|
29
|
+
import {
|
|
30
|
+
type BrokerOwnerPrincipal,
|
|
31
|
+
classifyProcessIncarnationLiveness,
|
|
32
|
+
isBrokerOwnerPrincipal,
|
|
33
|
+
sameBrokerOwnerPrincipal,
|
|
34
|
+
} from "./owner";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Broker-owned elevation grant ledger.
|
|
38
|
+
*
|
|
39
|
+
* Durable state machine (per elevation request):
|
|
40
|
+
* `requested -> granted -> claimed -> dispatched(outcome)`; terminal
|
|
41
|
+
* `denied`/`expired`/`misused`/`target_unavailable`; crash truth
|
|
42
|
+
* `consumed` (claimed but outcome unknown). Grants are single-use: the claim
|
|
43
|
+
* CAS consumes the grant before dispatch, and a crash between consume and
|
|
44
|
+
* dispatch is replayed truthfully as `consumed` with outcome `unknown`
|
|
45
|
+
* (retry requires a new grant — fail closed).
|
|
46
|
+
*
|
|
47
|
+
* All mutations and reads serialize on one elevation-local
|
|
48
|
+
* `withFileLock` transaction covering grants (`grants.jsonl`), dispatch
|
|
49
|
+
* receipts (`receipts.jsonl`), the issue index (`index.json`), and the
|
|
50
|
+
* audit trail (`audit.jsonl`), so a claim, its receipt, and the issue
|
|
51
|
+
* sequence commit together.
|
|
52
|
+
*
|
|
53
|
+
* The answer boundary is internal operator-only: there is no public
|
|
54
|
+
* `elevation.answer` operation; answers are admitted only from the internal
|
|
55
|
+
* operator attestation, so requester self-approval is impossible by
|
|
56
|
+
* construction.
|
|
57
|
+
*/
|
|
58
|
+
export type ElevationGrantState =
|
|
59
|
+
| "requested"
|
|
60
|
+
| "granted"
|
|
61
|
+
| "claimed"
|
|
62
|
+
| "dispatched"
|
|
63
|
+
| "consumed"
|
|
64
|
+
| "denied"
|
|
65
|
+
| "expired"
|
|
66
|
+
| "misused"
|
|
67
|
+
| "target_unavailable";
|
|
68
|
+
|
|
69
|
+
export interface ElevationSessionIdentity {
|
|
70
|
+
sessionId: string;
|
|
71
|
+
/** Exact endpoint state root bound at issue time; never the repo root. */
|
|
72
|
+
endpointStateRoot: string;
|
|
73
|
+
endpointGeneration: number;
|
|
74
|
+
endpointIncarnation: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Audit-only requester binding; never an identity claim. */
|
|
78
|
+
export interface ElevationRequester {
|
|
79
|
+
source: "broker_connection";
|
|
80
|
+
connectionId: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ElevationGrantRecord {
|
|
84
|
+
version: typeof SDK_STATE_VERSION;
|
|
85
|
+
/** Durable correlation ID; UUID, path-safe. */
|
|
86
|
+
elevationRequestId: string;
|
|
87
|
+
/** Monotonic path-safe issue sequence, allocated under the ledger lock. */
|
|
88
|
+
issueIndex: number;
|
|
89
|
+
state: ElevationGrantState;
|
|
90
|
+
operation: ElevationOperation;
|
|
91
|
+
requestDigest: string;
|
|
92
|
+
sessionIdentity: ElevationSessionIdentity;
|
|
93
|
+
principal: BrokerOwnerPrincipal;
|
|
94
|
+
requester: ElevationRequester;
|
|
95
|
+
requestedAt: number;
|
|
96
|
+
expiresAt: number;
|
|
97
|
+
outcome?: ElevationDispatchOutcome;
|
|
98
|
+
ts: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ElevationErrorCode =
|
|
102
|
+
| "not_found"
|
|
103
|
+
| "invalid_input"
|
|
104
|
+
| "idempotency_conflict"
|
|
105
|
+
| "grant_spent"
|
|
106
|
+
| "elevation_claim_in_progress"
|
|
107
|
+
| "elevation_required"
|
|
108
|
+
| "elevation_unavailable"
|
|
109
|
+
| "elevation_not_required"
|
|
110
|
+
| "endpoint_stale"
|
|
111
|
+
| "target_unavailable"
|
|
112
|
+
| "expired"
|
|
113
|
+
| "misused"
|
|
114
|
+
| "duplicate_answer"
|
|
115
|
+
| "terminal_uncertain"
|
|
116
|
+
| (string & {});
|
|
117
|
+
|
|
118
|
+
export type ElevationResult<T> =
|
|
119
|
+
| { ok: true; value: T }
|
|
120
|
+
| { ok: false; error: { code: ElevationErrorCode; message: string } };
|
|
121
|
+
|
|
122
|
+
export interface ElevationIssueParams {
|
|
123
|
+
/** Optional caller-supplied correlation ID for idempotent retry; the broker generates a UUID otherwise. */
|
|
124
|
+
elevationRequestId?: string;
|
|
125
|
+
operation: ElevationOperation;
|
|
126
|
+
input: Record<string, unknown>;
|
|
127
|
+
sessionIdentity: ElevationSessionIdentity;
|
|
128
|
+
principal: BrokerOwnerPrincipal;
|
|
129
|
+
requester: ElevationRequester;
|
|
130
|
+
expiresInMs?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ElevationIssueValue {
|
|
134
|
+
replay: boolean;
|
|
135
|
+
elevationRequestId: string;
|
|
136
|
+
issueIndex: number;
|
|
137
|
+
requestDigest: string;
|
|
138
|
+
state: ElevationGrantState;
|
|
139
|
+
requestedAt: number;
|
|
140
|
+
expiresAt: number;
|
|
141
|
+
grant: ElevationGrantRecord;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ElevationAnswerParams {
|
|
145
|
+
elevationRequestId: string;
|
|
146
|
+
answer: "approve" | "deny";
|
|
147
|
+
/** Digest the answering surface presented from the gate payload. */
|
|
148
|
+
presentedDigest: string;
|
|
149
|
+
/** Broker-owner principal attesting the answer authority. */
|
|
150
|
+
principal: BrokerOwnerPrincipal;
|
|
151
|
+
answerer: ElevationAnswerAuthority;
|
|
152
|
+
/** Current broker-resolved target identity; undefined means the session is unreachable. */
|
|
153
|
+
currentSessionIdentity?: ElevationSessionIdentity;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type ElevationAnswerOutcome =
|
|
157
|
+
| "granted"
|
|
158
|
+
| "denied"
|
|
159
|
+
| "expired"
|
|
160
|
+
| "misused"
|
|
161
|
+
| "target_unavailable"
|
|
162
|
+
| "duplicate_answer";
|
|
163
|
+
|
|
164
|
+
export interface ElevationAnswerValue {
|
|
165
|
+
outcome: ElevationAnswerOutcome;
|
|
166
|
+
grant: ElevationGrantRecord;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ElevationClaimParams {
|
|
170
|
+
elevationRequestId: string;
|
|
171
|
+
claimIdentity: ElevationClaimIdentity;
|
|
172
|
+
/** Current broker-resolved target identity; undefined means the session is unreachable. */
|
|
173
|
+
currentSessionIdentity?: ElevationSessionIdentity;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ElevationClaimValue {
|
|
177
|
+
grant: ElevationGrantRecord;
|
|
178
|
+
receipt: ElevationDispatchReceipt;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ElevationDispatchParams {
|
|
182
|
+
elevationRequestId: string;
|
|
183
|
+
dispatchIdentity: ElevationClaimIdentity;
|
|
184
|
+
outcome: ElevationDispatchOutcome;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ElevationDispatchValue {
|
|
188
|
+
grant: ElevationGrantRecord;
|
|
189
|
+
receipt: ElevationDispatchReceipt;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ElevationReadValue {
|
|
193
|
+
grant: ElevationGrantRecord;
|
|
194
|
+
receipt?: ElevationDispatchReceipt;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ElevationListValue {
|
|
198
|
+
grants: ElevationReadValue[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ElevationLedgerOptions {
|
|
202
|
+
/** Defaults to the GJC_SDK_ELEVATION_ENABLED environment flag. */
|
|
203
|
+
enabled?: boolean;
|
|
204
|
+
/** Request lifetime before the grant expires. */
|
|
205
|
+
ttlMs?: number;
|
|
206
|
+
/** Injectable clock. */
|
|
207
|
+
now?: () => number;
|
|
208
|
+
/**
|
|
209
|
+
* Injectable tri-state process-incarnation liveness classifier (defaults to
|
|
210
|
+
* `classifyProcessIncarnationLiveness`). Test seam for crash-state replay:
|
|
211
|
+
* "unknown" must always fail closed as `elevation_claim_in_progress`.
|
|
212
|
+
*/
|
|
213
|
+
classifyLiveness?: typeof classifyProcessIncarnationLiveness;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const ELEVATION_DIR_NAME = "elevation";
|
|
217
|
+
const MAX_ELEVATION_FILE_BYTES = 64 * 1024 * 1024;
|
|
218
|
+
const MAX_ELEVATION_LINE_BYTES = 8 * 1024 * 1024;
|
|
219
|
+
const ELEVATION_REQUEST_TTL_MS = 60_000;
|
|
220
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
221
|
+
const CANONICAL_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
222
|
+
const ELEVATION_GRANT_STATES = [
|
|
223
|
+
"requested",
|
|
224
|
+
"granted",
|
|
225
|
+
"claimed",
|
|
226
|
+
"dispatched",
|
|
227
|
+
"consumed",
|
|
228
|
+
"denied",
|
|
229
|
+
"expired",
|
|
230
|
+
"misused",
|
|
231
|
+
"target_unavailable",
|
|
232
|
+
] as const;
|
|
233
|
+
|
|
234
|
+
const elevationUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
235
|
+
|
|
236
|
+
function parseElevationJson(bytes: Uint8Array): unknown {
|
|
237
|
+
return JSON.parse(elevationUtf8Decoder.decode(bytes));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function elevationEnabledByDefault(): boolean {
|
|
241
|
+
const raw = (process.env.GJC_SDK_ELEVATION_ENABLED ?? "").trim().toLowerCase();
|
|
242
|
+
return raw === "1" || raw === "true";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function elevationFailure<T>(code: ElevationErrorCode, message: string): ElevationResult<T> {
|
|
246
|
+
return { ok: false, error: { code, message } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isElevationOperation(value: unknown): value is ElevationOperation {
|
|
250
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
251
|
+
const operation = value as { kind?: unknown; sdkId?: unknown };
|
|
252
|
+
return (
|
|
253
|
+
typeof operation.kind === "string" &&
|
|
254
|
+
(ELEVATION_OPERATION_KINDS as readonly string[]).includes(operation.kind) &&
|
|
255
|
+
typeof operation.sdkId === "string" &&
|
|
256
|
+
operation.sdkId.length > 0 &&
|
|
257
|
+
operation.sdkId.length <= 128 &&
|
|
258
|
+
isKnownElevationOperation(operation.kind, operation.sdkId)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isElevationGrantState(value: unknown): value is ElevationGrantState {
|
|
263
|
+
return typeof value === "string" && (ELEVATION_GRANT_STATES as readonly string[]).includes(value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isElevationSessionIdentity(value: unknown): value is ElevationSessionIdentity {
|
|
267
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
268
|
+
const identity = value as {
|
|
269
|
+
sessionId?: unknown;
|
|
270
|
+
endpointStateRoot?: unknown;
|
|
271
|
+
endpointGeneration?: unknown;
|
|
272
|
+
endpointIncarnation?: unknown;
|
|
273
|
+
};
|
|
274
|
+
return (
|
|
275
|
+
typeof identity.sessionId === "string" &&
|
|
276
|
+
CANONICAL_SESSION_ID.test(identity.sessionId) &&
|
|
277
|
+
typeof identity.endpointStateRoot === "string" &&
|
|
278
|
+
identity.endpointStateRoot.length > 0 &&
|
|
279
|
+
Number.isSafeInteger(identity.endpointGeneration) &&
|
|
280
|
+
(identity.endpointGeneration as number) > 0 &&
|
|
281
|
+
typeof identity.endpointIncarnation === "string" &&
|
|
282
|
+
SHA256_HEX.test(identity.endpointIncarnation)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isElevationRequester(value: unknown): value is ElevationRequester {
|
|
287
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
288
|
+
const requester = value as { source?: unknown; connectionId?: unknown };
|
|
289
|
+
return (
|
|
290
|
+
requester.source === "broker_connection" &&
|
|
291
|
+
typeof requester.connectionId === "string" &&
|
|
292
|
+
requester.connectionId.length > 0
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isElevationGrantRecord(value: unknown): value is ElevationGrantRecord {
|
|
297
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
298
|
+
const record = value as Partial<ElevationGrantRecord>;
|
|
299
|
+
if (
|
|
300
|
+
record.version !== SDK_STATE_VERSION ||
|
|
301
|
+
!isElevationRequestId(record.elevationRequestId) ||
|
|
302
|
+
!Number.isSafeInteger(record.issueIndex) ||
|
|
303
|
+
(record.issueIndex as number) <= 0 ||
|
|
304
|
+
!isElevationGrantState(record.state) ||
|
|
305
|
+
!isElevationOperation(record.operation) ||
|
|
306
|
+
typeof record.requestDigest !== "string" ||
|
|
307
|
+
!SHA256_HEX.test(record.requestDigest) ||
|
|
308
|
+
!isElevationSessionIdentity(record.sessionIdentity) ||
|
|
309
|
+
!isBrokerOwnerPrincipal(record.principal) ||
|
|
310
|
+
!isElevationRequester(record.requester) ||
|
|
311
|
+
!Number.isSafeInteger(record.requestedAt) ||
|
|
312
|
+
(record.requestedAt as number) <= 0 ||
|
|
313
|
+
!Number.isSafeInteger(record.expiresAt) ||
|
|
314
|
+
(record.expiresAt as number) <= 0 ||
|
|
315
|
+
!Number.isSafeInteger(record.ts) ||
|
|
316
|
+
(record.ts as number) <= 0
|
|
317
|
+
)
|
|
318
|
+
return false;
|
|
319
|
+
const outcome = record.outcome;
|
|
320
|
+
if (record.state === "dispatched") return isElevationDispatchOutcome(outcome) && outcome.status !== "unknown";
|
|
321
|
+
if (record.state === "consumed") return isElevationDispatchOutcome(outcome) && outcome.status === "unknown";
|
|
322
|
+
return outcome === undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function sameSessionIdentity(left: ElevationSessionIdentity, right: ElevationSessionIdentity): boolean {
|
|
326
|
+
return (
|
|
327
|
+
left.sessionId === right.sessionId &&
|
|
328
|
+
path.resolve(left.endpointStateRoot) === path.resolve(right.endpointStateRoot) &&
|
|
329
|
+
left.endpointGeneration === right.endpointGeneration &&
|
|
330
|
+
left.endpointIncarnation === right.endpointIncarnation
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type ReconcileOutcome = "ok" | "expired" | "uncertain" | "in_progress";
|
|
335
|
+
export class ElevationLedger {
|
|
336
|
+
#dir: string;
|
|
337
|
+
#agentDir: string;
|
|
338
|
+
#grantsFile: string;
|
|
339
|
+
#receiptsFile: string;
|
|
340
|
+
#indexFile: string;
|
|
341
|
+
#audit: ElevationAudit;
|
|
342
|
+
#enabled: boolean;
|
|
343
|
+
#ttlMs: number;
|
|
344
|
+
#now: () => number;
|
|
345
|
+
#grants: ElevationGrantRecord[] = [];
|
|
346
|
+
#receipts: ElevationDispatchReceipt[] = [];
|
|
347
|
+
#byRequestId = new Map<string, ElevationGrantRecord>();
|
|
348
|
+
#byReceiptRequestId = new Map<string, ElevationDispatchReceipt>();
|
|
349
|
+
#nextIssueIndex = 1;
|
|
350
|
+
#warnings: string[] = [];
|
|
351
|
+
#classifyLiveness: typeof classifyProcessIncarnationLiveness;
|
|
352
|
+
|
|
353
|
+
constructor(agentDir: string, options: ElevationLedgerOptions = {}) {
|
|
354
|
+
this.#agentDir = agentDir;
|
|
355
|
+
this.#dir = path.join(agentDir, "sdk", ELEVATION_DIR_NAME);
|
|
356
|
+
this.#grantsFile = path.join(this.#dir, "grants.jsonl");
|
|
357
|
+
this.#receiptsFile = path.join(this.#dir, "receipts.jsonl");
|
|
358
|
+
this.#indexFile = path.join(this.#dir, "index.json");
|
|
359
|
+
this.#audit = new ElevationAudit(agentDir, { now: options.now, classifyLiveness: options.classifyLiveness });
|
|
360
|
+
this.#enabled = options.enabled ?? elevationEnabledByDefault();
|
|
361
|
+
this.#ttlMs = options.ttlMs ?? ELEVATION_REQUEST_TTL_MS;
|
|
362
|
+
this.#now = options.now ?? Date.now;
|
|
363
|
+
this.#classifyLiveness = options.classifyLiveness ?? classifyProcessIncarnationLiveness;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
get warnings(): readonly string[] {
|
|
367
|
+
return this.#warnings;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* One elevation-local lock serializes grants, receipts, and the issue
|
|
372
|
+
* index: the same transaction covers every file the ledger owns.
|
|
373
|
+
*/
|
|
374
|
+
async #mutate<T>(operation: () => Promise<T>): Promise<T> {
|
|
375
|
+
return withFileLock(this.#grantsFile, operation);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async open(): Promise<this> {
|
|
379
|
+
await this.#mutate(async () => this.#openUnderLock());
|
|
380
|
+
return this;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #openUnderLock(): Promise<void> {
|
|
384
|
+
await fs.mkdir(this.#dir, { recursive: true, mode: 0o700 });
|
|
385
|
+
this.#audit = await new ElevationAudit(this.#agentDir, {
|
|
386
|
+
now: this.#now,
|
|
387
|
+
classifyLiveness: this.#classifyLiveness,
|
|
388
|
+
}).open();
|
|
389
|
+
this.#grants = [];
|
|
390
|
+
this.#receipts = [];
|
|
391
|
+
this.#byRequestId.clear();
|
|
392
|
+
this.#byReceiptRequestId.clear();
|
|
393
|
+
this.#warnings = [];
|
|
394
|
+
this.#warnings.push(...this.#audit.warnings);
|
|
395
|
+
let nextIssueIndex = 1;
|
|
396
|
+
const indexRaw = await this.#readIndex();
|
|
397
|
+
if (indexRaw !== undefined) {
|
|
398
|
+
let parsed: unknown;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(indexRaw);
|
|
401
|
+
} catch {
|
|
402
|
+
throw new Error("Invalid elevation issue index");
|
|
403
|
+
}
|
|
404
|
+
assertSupportedStateVersion(this.#indexFile, parsed);
|
|
405
|
+
const index = parsed as { nextIssueIndex?: unknown };
|
|
406
|
+
if (!Number.isSafeInteger(index.nextIssueIndex) || (index.nextIssueIndex as number) < 1)
|
|
407
|
+
throw new Error("Invalid elevation issue index");
|
|
408
|
+
nextIssueIndex = index.nextIssueIndex as number;
|
|
409
|
+
}
|
|
410
|
+
const grantSource = await this.#readBoundedSource(this.#grantsFile);
|
|
411
|
+
let maxIssueIndex = 0;
|
|
412
|
+
if (grantSource) maxIssueIndex = await this.#loadGrantRows(grantSource);
|
|
413
|
+
const receiptSource = await this.#readBoundedSource(this.#receiptsFile);
|
|
414
|
+
if (receiptSource) await this.#loadReceiptRows(receiptSource);
|
|
415
|
+
// Grants are the source of truth for issued indexes: a crash between
|
|
416
|
+
// appending a grant row and rewriting index.json must never reuse it.
|
|
417
|
+
const healed = Math.max(nextIssueIndex, maxIssueIndex + 1);
|
|
418
|
+
if (healed !== nextIssueIndex) await this.#writeIndex(healed);
|
|
419
|
+
this.#nextIssueIndex = healed;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async assertSupportedStateVersions(): Promise<void> {
|
|
423
|
+
for (const file of [this.#grantsFile, this.#receiptsFile]) {
|
|
424
|
+
const source = await this.#readBoundedSource(file);
|
|
425
|
+
if (!source) continue;
|
|
426
|
+
let lineStart = 0;
|
|
427
|
+
for (let offset = 0; offset <= source.length; offset += 1) {
|
|
428
|
+
if (offset !== source.length && source[offset] !== 0x0a) continue;
|
|
429
|
+
const line = source.subarray(lineStart, offset);
|
|
430
|
+
lineStart = offset + 1;
|
|
431
|
+
if (line.length === 0 || line.length > MAX_ELEVATION_LINE_BYTES) continue;
|
|
432
|
+
try {
|
|
433
|
+
assertSupportedStateVersion(file, parseElevationJson(line));
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error instanceof Error && "code" in error && error.code === "unsupported_state_version") throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async #readIndex(): Promise<string | undefined> {
|
|
442
|
+
let handle: fs.FileHandle | undefined;
|
|
443
|
+
try {
|
|
444
|
+
handle = await fs.open(this.#indexFile, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
|
|
445
|
+
const stat = await handle.stat();
|
|
446
|
+
if (!stat.isFile()) throw new Error("Elevation issue index is not a regular file");
|
|
447
|
+
if (stat.size > MAX_ELEVATION_LINE_BYTES)
|
|
448
|
+
throw new Error("Elevation issue index exceeds the maximum byte length");
|
|
449
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
450
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
451
|
+
return elevationUtf8Decoder.decode(bytes.subarray(0, bytesRead));
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
454
|
+
throw error;
|
|
455
|
+
} finally {
|
|
456
|
+
if (handle) await handle.close();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async #readBoundedSource(file: string): Promise<Buffer | undefined> {
|
|
461
|
+
let handle: fs.FileHandle | undefined;
|
|
462
|
+
try {
|
|
463
|
+
handle = await fs.open(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
|
|
464
|
+
const stat = await handle.stat({ bigint: true });
|
|
465
|
+
if (!stat.isFile()) throw new Error(`Elevation ledger target is not a regular file: ${file}`);
|
|
466
|
+
if (stat.size > BigInt(MAX_ELEVATION_FILE_BYTES))
|
|
467
|
+
throw new Error(`Elevation ledger file exceeds the maximum byte length: ${file}`);
|
|
468
|
+
const bytes = Buffer.alloc(Number(stat.size) + 1);
|
|
469
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
470
|
+
return bytes.subarray(0, bytesRead);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
473
|
+
throw error;
|
|
474
|
+
} finally {
|
|
475
|
+
if (handle) await handle.close();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async #loadGrantRows(source: Buffer): Promise<number> {
|
|
480
|
+
let maxIssueIndex = 0;
|
|
481
|
+
const tornTail = source.length > 0 && source.at(-1) !== 0x0a;
|
|
482
|
+
let lineStart = 0;
|
|
483
|
+
for (let offset = 0; offset <= source.length; offset += 1) {
|
|
484
|
+
if (offset !== source.length && source[offset] !== 0x0a) continue;
|
|
485
|
+
const line = source.subarray(lineStart, offset);
|
|
486
|
+
lineStart = offset + 1;
|
|
487
|
+
if (line.length === 0) continue;
|
|
488
|
+
if (line.length > MAX_ELEVATION_LINE_BYTES)
|
|
489
|
+
throw new Error("Elevation grant row exceeds the maximum byte length");
|
|
490
|
+
try {
|
|
491
|
+
const value = parseElevationJson(line);
|
|
492
|
+
assertSupportedStateVersion(this.#grantsFile, value);
|
|
493
|
+
if (!isElevationGrantRecord(value)) throw new Error("invalid elevation grant record");
|
|
494
|
+
this.#grants.push(value);
|
|
495
|
+
this.#byRequestId.set(value.elevationRequestId, value);
|
|
496
|
+
if (value.issueIndex > maxIssueIndex) maxIssueIndex = value.issueIndex;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error instanceof Error && "code" in error && error.code === "unsupported_state_version") throw error;
|
|
499
|
+
await this.#quarantine(this.#grantsFile, line);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (tornTail) await this.#sealTornTail(this.#grantsFile);
|
|
503
|
+
return maxIssueIndex;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async #loadReceiptRows(source: Buffer): Promise<void> {
|
|
507
|
+
const tornTail = source.length > 0 && source.at(-1) !== 0x0a;
|
|
508
|
+
let lineStart = 0;
|
|
509
|
+
for (let offset = 0; offset <= source.length; offset += 1) {
|
|
510
|
+
if (offset !== source.length && source[offset] !== 0x0a) continue;
|
|
511
|
+
const line = source.subarray(lineStart, offset);
|
|
512
|
+
lineStart = offset + 1;
|
|
513
|
+
if (line.length === 0) continue;
|
|
514
|
+
if (line.length > MAX_ELEVATION_LINE_BYTES)
|
|
515
|
+
throw new Error("Elevation receipt row exceeds the maximum byte length");
|
|
516
|
+
try {
|
|
517
|
+
const value = parseElevationJson(line);
|
|
518
|
+
assertSupportedStateVersion(this.#receiptsFile, value);
|
|
519
|
+
if (!isElevationDispatchReceipt(value)) throw new Error("invalid elevation dispatch receipt");
|
|
520
|
+
this.#receipts.push(value);
|
|
521
|
+
this.#byReceiptRequestId.set(value.elevationRequestId, value);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (error instanceof Error && "code" in error && error.code === "unsupported_state_version") throw error;
|
|
524
|
+
await this.#quarantine(this.#receiptsFile, line);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (tornTail) await this.#sealTornTail(this.#receiptsFile);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async #openAppendRegular(file: string): Promise<fs.FileHandle> {
|
|
531
|
+
const handle = await fs.open(
|
|
532
|
+
file,
|
|
533
|
+
fsSync.constants.O_WRONLY | fsSync.constants.O_APPEND | fsSync.constants.O_CREAT | fsSync.constants.O_NOFOLLOW,
|
|
534
|
+
0o600,
|
|
535
|
+
);
|
|
536
|
+
try {
|
|
537
|
+
if (!(await handle.stat()).isFile()) throw new Error("Elevation ledger write target is not a regular file.");
|
|
538
|
+
return handle;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
await handle.close();
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async #sealTornTail(file: string): Promise<void> {
|
|
546
|
+
const h = await this.#openAppendRegular(file);
|
|
547
|
+
try {
|
|
548
|
+
await h.writeFile("\n");
|
|
549
|
+
await h.sync();
|
|
550
|
+
} finally {
|
|
551
|
+
await h.close();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async #quarantine(file: string, line: Uint8Array): Promise<void> {
|
|
556
|
+
const h = await this.#openAppendRegular(`${file}.corrupt`);
|
|
557
|
+
try {
|
|
558
|
+
await h.writeFile(line);
|
|
559
|
+
await h.writeFile("\n");
|
|
560
|
+
await h.sync();
|
|
561
|
+
} finally {
|
|
562
|
+
await h.close();
|
|
563
|
+
}
|
|
564
|
+
this.#warnings.push("Malformed elevation ledger entry quarantined");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async #syncDirectory(): Promise<void> {
|
|
568
|
+
const directory = await fs.open(this.#dir, fsSync.constants.O_RDONLY);
|
|
569
|
+
try {
|
|
570
|
+
await directory.sync();
|
|
571
|
+
} finally {
|
|
572
|
+
await directory.close();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async #writeIndex(nextIssueIndex: number): Promise<void> {
|
|
577
|
+
const contents = Buffer.from(`${JSON.stringify({ version: SDK_STATE_VERSION, nextIssueIndex })}\n`);
|
|
578
|
+
const temporary = path.join(
|
|
579
|
+
this.#dir,
|
|
580
|
+
`.index.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`,
|
|
581
|
+
);
|
|
582
|
+
let renamed = false;
|
|
583
|
+
try {
|
|
584
|
+
const h = await fs.open(
|
|
585
|
+
temporary,
|
|
586
|
+
fsSync.constants.O_WRONLY |
|
|
587
|
+
fsSync.constants.O_CREAT |
|
|
588
|
+
fsSync.constants.O_EXCL |
|
|
589
|
+
fsSync.constants.O_NOFOLLOW,
|
|
590
|
+
0o600,
|
|
591
|
+
);
|
|
592
|
+
try {
|
|
593
|
+
await h.writeFile(contents);
|
|
594
|
+
await h.sync();
|
|
595
|
+
} finally {
|
|
596
|
+
await h.close();
|
|
597
|
+
}
|
|
598
|
+
await fs.rename(temporary, this.#indexFile);
|
|
599
|
+
renamed = true;
|
|
600
|
+
await this.#syncDirectory();
|
|
601
|
+
} finally {
|
|
602
|
+
if (!renamed) await fs.unlink(temporary).catch(() => {});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async #appendGrant(record: ElevationGrantRecord): Promise<void> {
|
|
607
|
+
const line = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
608
|
+
if (line.length - 1 > MAX_ELEVATION_LINE_BYTES)
|
|
609
|
+
throw new Error("Elevation grant row exceeds the maximum byte length");
|
|
610
|
+
const h = await this.#openAppendRegular(this.#grantsFile);
|
|
611
|
+
try {
|
|
612
|
+
await h.writeFile(line);
|
|
613
|
+
await h.sync();
|
|
614
|
+
} finally {
|
|
615
|
+
await h.close();
|
|
616
|
+
}
|
|
617
|
+
this.#grants.push(record);
|
|
618
|
+
this.#byRequestId.set(record.elevationRequestId, record);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async #appendReceipt(receipt: ElevationDispatchReceipt): Promise<void> {
|
|
622
|
+
const line = Buffer.from(`${JSON.stringify(receipt)}\n`);
|
|
623
|
+
if (line.length - 1 > MAX_ELEVATION_LINE_BYTES)
|
|
624
|
+
throw new Error("Elevation receipt row exceeds the maximum byte length");
|
|
625
|
+
const h = await this.#openAppendRegular(this.#receiptsFile);
|
|
626
|
+
try {
|
|
627
|
+
await h.writeFile(line);
|
|
628
|
+
await h.sync();
|
|
629
|
+
} finally {
|
|
630
|
+
await h.close();
|
|
631
|
+
}
|
|
632
|
+
this.#receipts.push(receipt);
|
|
633
|
+
this.#byReceiptRequestId.set(receipt.elevationRequestId, receipt);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Appends one audit row for an elevation transition. Must be called under
|
|
638
|
+
* the same elevation-local lock as the grant/receipt appends so the audit
|
|
639
|
+
* row and the durable grant row commit together.
|
|
640
|
+
*/
|
|
641
|
+
async #appendAudit(entry: Omit<ElevationAuditEntry, "version">): Promise<void> {
|
|
642
|
+
await this.#audit.append(entry);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Truthful reconciliation before any access: heals a grant row that a
|
|
647
|
+
* crash left behind a terminal receipt, lazily expires overdue requests,
|
|
648
|
+
* and terminalizes claims left by provably dead broker tenures to
|
|
649
|
+
* `consumed`/`uncertain`. Unknown liveness always resolves to
|
|
650
|
+
* `in_progress` (fail closed — never fabricate an outcome).
|
|
651
|
+
*/
|
|
652
|
+
async #reconcile(
|
|
653
|
+
grant: ElevationGrantRecord,
|
|
654
|
+
receipt: ElevationDispatchReceipt | undefined,
|
|
655
|
+
claimIdentity: ElevationClaimIdentity,
|
|
656
|
+
now: number,
|
|
657
|
+
): Promise<ReconcileOutcome> {
|
|
658
|
+
if (receipt?.state === "dispatched") {
|
|
659
|
+
if (grant.state !== "dispatched")
|
|
660
|
+
await this.#appendGrant({ ...grant, state: "dispatched", outcome: receipt.outcome, ts: now });
|
|
661
|
+
return "ok";
|
|
662
|
+
}
|
|
663
|
+
if (receipt?.state === "uncertain") {
|
|
664
|
+
if (grant.state !== "consumed")
|
|
665
|
+
await this.#appendGrant({ ...grant, state: "consumed", outcome: receipt.outcome, ts: now });
|
|
666
|
+
return "uncertain";
|
|
667
|
+
}
|
|
668
|
+
if (receipt?.state === "claimed") {
|
|
669
|
+
if (receiptClaimMatches(receipt, claimIdentity)) return "in_progress";
|
|
670
|
+
const liveness = this.#classifyLiveness(receipt.claim.pid, receipt.claim.incarnation);
|
|
671
|
+
if (liveness === "dead") {
|
|
672
|
+
if (grant.state !== "consumed")
|
|
673
|
+
await this.#appendGrant({
|
|
674
|
+
...grant,
|
|
675
|
+
state: "consumed",
|
|
676
|
+
outcome: {
|
|
677
|
+
status: "unknown",
|
|
678
|
+
message: "Claiming broker tenure ended before dispatch outcome was recorded",
|
|
679
|
+
dispatchedAt: now,
|
|
680
|
+
},
|
|
681
|
+
ts: now,
|
|
682
|
+
});
|
|
683
|
+
await this.#appendReceipt(uncertainReceipt(receipt, now));
|
|
684
|
+
await this.#appendAudit({
|
|
685
|
+
ts: now,
|
|
686
|
+
elevationRequestId: grant.elevationRequestId,
|
|
687
|
+
issueIndex: grant.issueIndex,
|
|
688
|
+
event: {
|
|
689
|
+
kind: "consumed",
|
|
690
|
+
message: "Claiming broker tenure ended before dispatch outcome was recorded",
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
return "uncertain";
|
|
694
|
+
}
|
|
695
|
+
// Alive or unknown liveness: the claim may still be in flight.
|
|
696
|
+
return "in_progress";
|
|
697
|
+
}
|
|
698
|
+
if ((grant.state === "requested" || grant.state === "granted") && now > grant.expiresAt) {
|
|
699
|
+
await this.#appendGrant({ ...grant, state: "expired", ts: now });
|
|
700
|
+
await this.#appendAudit({
|
|
701
|
+
ts: now,
|
|
702
|
+
elevationRequestId: grant.elevationRequestId,
|
|
703
|
+
issueIndex: grant.issueIndex,
|
|
704
|
+
event: { kind: "expired" },
|
|
705
|
+
});
|
|
706
|
+
return "expired";
|
|
707
|
+
}
|
|
708
|
+
return "ok";
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async issue(params: ElevationIssueParams): Promise<ElevationResult<ElevationIssueValue>> {
|
|
712
|
+
if (!this.#enabled)
|
|
713
|
+
return elevationFailure("elevation_unavailable", "Elevation is not enabled (GJC_SDK_ELEVATION_ENABLED)");
|
|
714
|
+
if (!isElevationOperation(params.operation))
|
|
715
|
+
return elevationFailure("invalid_input", "operation must be an allowlisted control/query/global operation");
|
|
716
|
+
if (typeof params.input !== "object" || params.input === null || Array.isArray(params.input))
|
|
717
|
+
return elevationFailure("invalid_input", "input must be a JSON object");
|
|
718
|
+
if (!isElevationSessionIdentity(params.sessionIdentity))
|
|
719
|
+
return elevationFailure("invalid_input", "sessionIdentity is malformed");
|
|
720
|
+
if (!isBrokerOwnerPrincipal(params.principal))
|
|
721
|
+
return elevationFailure("invalid_input", "principal must be the broker-owner principal");
|
|
722
|
+
if (!isElevationRequester(params.requester)) return elevationFailure("invalid_input", "requester is malformed");
|
|
723
|
+
if (params.elevationRequestId !== undefined && !isElevationRequestId(params.elevationRequestId))
|
|
724
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
725
|
+
if (
|
|
726
|
+
params.expiresInMs !== undefined &&
|
|
727
|
+
(!Number.isSafeInteger(params.expiresInMs) || params.expiresInMs <= 0 || params.expiresInMs > 15 * 60_000)
|
|
728
|
+
)
|
|
729
|
+
return elevationFailure(
|
|
730
|
+
"invalid_input",
|
|
731
|
+
"expiresInMs must be a positive safe integer no greater than 15 minutes",
|
|
732
|
+
);
|
|
733
|
+
const digestResult = elevationRequestDigest({
|
|
734
|
+
kind: params.operation.kind,
|
|
735
|
+
sdkId: params.operation.sdkId,
|
|
736
|
+
input: params.input,
|
|
737
|
+
});
|
|
738
|
+
if (!digestResult.ok)
|
|
739
|
+
return elevationFailure(digestResult.error.code as ElevationErrorCode, digestResult.error.message);
|
|
740
|
+
const ttlMs = params.expiresInMs ?? this.#ttlMs;
|
|
741
|
+
const requestId = params.elevationRequestId ?? randomUUID();
|
|
742
|
+
return this.#mutate(async () => {
|
|
743
|
+
const now = this.#now();
|
|
744
|
+
const existing = this.#byRequestId.get(requestId);
|
|
745
|
+
if (existing) {
|
|
746
|
+
const identical =
|
|
747
|
+
existing.requestDigest === digestResult.digest &&
|
|
748
|
+
existing.operation.kind === params.operation.kind &&
|
|
749
|
+
existing.operation.sdkId === params.operation.sdkId &&
|
|
750
|
+
sameSessionIdentity(existing.sessionIdentity, params.sessionIdentity) &&
|
|
751
|
+
sameBrokerOwnerPrincipal(existing.principal, params.principal);
|
|
752
|
+
if (!identical)
|
|
753
|
+
return elevationFailure(
|
|
754
|
+
"idempotency_conflict",
|
|
755
|
+
"elevationRequestId was used with different request content",
|
|
756
|
+
);
|
|
757
|
+
return {
|
|
758
|
+
ok: true,
|
|
759
|
+
value: {
|
|
760
|
+
replay: true,
|
|
761
|
+
elevationRequestId: existing.elevationRequestId,
|
|
762
|
+
issueIndex: existing.issueIndex,
|
|
763
|
+
requestDigest: existing.requestDigest,
|
|
764
|
+
state: existing.state,
|
|
765
|
+
requestedAt: existing.requestedAt,
|
|
766
|
+
expiresAt: existing.expiresAt,
|
|
767
|
+
grant: existing,
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const grant: ElevationGrantRecord = {
|
|
772
|
+
version: SDK_STATE_VERSION,
|
|
773
|
+
elevationRequestId: requestId,
|
|
774
|
+
issueIndex: this.#nextIssueIndex,
|
|
775
|
+
state: "requested",
|
|
776
|
+
operation: params.operation,
|
|
777
|
+
requestDigest: digestResult.digest,
|
|
778
|
+
sessionIdentity: params.sessionIdentity,
|
|
779
|
+
principal: params.principal,
|
|
780
|
+
requester: params.requester,
|
|
781
|
+
requestedAt: now,
|
|
782
|
+
expiresAt: now + ttlMs,
|
|
783
|
+
ts: now,
|
|
784
|
+
};
|
|
785
|
+
this.#nextIssueIndex += 1;
|
|
786
|
+
await this.#appendGrant(grant);
|
|
787
|
+
await this.#appendAudit({
|
|
788
|
+
ts: now,
|
|
789
|
+
elevationRequestId: grant.elevationRequestId,
|
|
790
|
+
issueIndex: grant.issueIndex,
|
|
791
|
+
event: { kind: "issued" },
|
|
792
|
+
});
|
|
793
|
+
await this.#writeIndex(this.#nextIssueIndex);
|
|
794
|
+
return {
|
|
795
|
+
ok: true,
|
|
796
|
+
value: {
|
|
797
|
+
replay: false,
|
|
798
|
+
elevationRequestId: grant.elevationRequestId,
|
|
799
|
+
issueIndex: grant.issueIndex,
|
|
800
|
+
requestDigest: grant.requestDigest,
|
|
801
|
+
state: grant.state,
|
|
802
|
+
requestedAt: grant.requestedAt,
|
|
803
|
+
expiresAt: grant.expiresAt,
|
|
804
|
+
grant,
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async answer(params: ElevationAnswerParams): Promise<ElevationResult<ElevationAnswerValue>> {
|
|
811
|
+
if (!this.#enabled)
|
|
812
|
+
return elevationFailure("elevation_unavailable", "Elevation is not enabled (GJC_SDK_ELEVATION_ENABLED)");
|
|
813
|
+
if (!isElevationRequestId(params.elevationRequestId))
|
|
814
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
815
|
+
if (params.answer !== "approve" && params.answer !== "deny")
|
|
816
|
+
return elevationFailure("invalid_input", "answer must be approve or deny");
|
|
817
|
+
if (typeof params.presentedDigest !== "string" || !SHA256_HEX.test(params.presentedDigest))
|
|
818
|
+
return elevationFailure("invalid_input", "presentedDigest must be a SHA-256 hash");
|
|
819
|
+
if (!isBrokerOwnerPrincipal(params.principal))
|
|
820
|
+
return elevationFailure("invalid_input", "principal must be the broker-owner principal");
|
|
821
|
+
if (!isElevationAnswerAuthority(params.answerer))
|
|
822
|
+
return elevationFailure("invalid_input", "answerer is malformed");
|
|
823
|
+
if (params.currentSessionIdentity !== undefined && !isElevationSessionIdentity(params.currentSessionIdentity))
|
|
824
|
+
return elevationFailure("invalid_input", "currentSessionIdentity is malformed");
|
|
825
|
+
return this.#mutate(async () => {
|
|
826
|
+
const now = this.#now();
|
|
827
|
+
const grant = this.#byRequestId.get(params.elevationRequestId);
|
|
828
|
+
if (!grant) return elevationFailure("not_found", "elevation request was not found");
|
|
829
|
+
const receipt = this.#byReceiptRequestId.get(params.elevationRequestId);
|
|
830
|
+
if (receipt !== undefined) {
|
|
831
|
+
await this.#appendAudit({
|
|
832
|
+
ts: now,
|
|
833
|
+
elevationRequestId: grant.elevationRequestId,
|
|
834
|
+
issueIndex: grant.issueIndex,
|
|
835
|
+
event: { kind: "duplicate_answer" },
|
|
836
|
+
});
|
|
837
|
+
return { ok: true, value: { outcome: "duplicate_answer", grant } };
|
|
838
|
+
}
|
|
839
|
+
if ((grant.state === "requested" || grant.state === "granted") && now > grant.expiresAt) {
|
|
840
|
+
const expired: ElevationGrantRecord = { ...grant, state: "expired", ts: now };
|
|
841
|
+
await this.#appendGrant(expired);
|
|
842
|
+
await this.#appendAudit({
|
|
843
|
+
ts: now,
|
|
844
|
+
elevationRequestId: grant.elevationRequestId,
|
|
845
|
+
issueIndex: grant.issueIndex,
|
|
846
|
+
event: { kind: "answered", outcome: "expired" },
|
|
847
|
+
});
|
|
848
|
+
return { ok: true, value: { outcome: "expired", grant: expired } };
|
|
849
|
+
}
|
|
850
|
+
if (grant.state !== "requested") {
|
|
851
|
+
await this.#appendAudit({
|
|
852
|
+
ts: now,
|
|
853
|
+
elevationRequestId: grant.elevationRequestId,
|
|
854
|
+
issueIndex: grant.issueIndex,
|
|
855
|
+
event: { kind: "duplicate_answer" },
|
|
856
|
+
});
|
|
857
|
+
return { ok: true, value: { outcome: "duplicate_answer", grant } };
|
|
858
|
+
}
|
|
859
|
+
if (!sameBrokerOwnerPrincipal(params.principal, grant.principal)) {
|
|
860
|
+
const misused: ElevationGrantRecord = { ...grant, state: "misused", ts: now };
|
|
861
|
+
await this.#appendGrant(misused);
|
|
862
|
+
await this.#appendAudit({
|
|
863
|
+
ts: now,
|
|
864
|
+
elevationRequestId: grant.elevationRequestId,
|
|
865
|
+
issueIndex: grant.issueIndex,
|
|
866
|
+
event: { kind: "answered", outcome: "misused" },
|
|
867
|
+
});
|
|
868
|
+
return { ok: true, value: { outcome: "misused", grant: misused } };
|
|
869
|
+
}
|
|
870
|
+
if (params.presentedDigest !== grant.requestDigest) {
|
|
871
|
+
const misused: ElevationGrantRecord = { ...grant, state: "misused", ts: now };
|
|
872
|
+
await this.#appendGrant(misused);
|
|
873
|
+
await this.#appendAudit({
|
|
874
|
+
ts: now,
|
|
875
|
+
elevationRequestId: grant.elevationRequestId,
|
|
876
|
+
issueIndex: grant.issueIndex,
|
|
877
|
+
event: { kind: "answered", outcome: "misused" },
|
|
878
|
+
});
|
|
879
|
+
return { ok: true, value: { outcome: "misused", grant: misused } };
|
|
880
|
+
}
|
|
881
|
+
if (params.currentSessionIdentity === undefined) {
|
|
882
|
+
const unavailable: ElevationGrantRecord = { ...grant, state: "target_unavailable", ts: now };
|
|
883
|
+
await this.#appendGrant(unavailable);
|
|
884
|
+
await this.#appendAudit({
|
|
885
|
+
ts: now,
|
|
886
|
+
elevationRequestId: grant.elevationRequestId,
|
|
887
|
+
issueIndex: grant.issueIndex,
|
|
888
|
+
event: { kind: "answered", outcome: "target_unavailable" },
|
|
889
|
+
});
|
|
890
|
+
return { ok: true, value: { outcome: "target_unavailable", grant: unavailable } };
|
|
891
|
+
}
|
|
892
|
+
if (!sameSessionIdentity(params.currentSessionIdentity, grant.sessionIdentity)) {
|
|
893
|
+
const misused: ElevationGrantRecord = { ...grant, state: "misused", ts: now };
|
|
894
|
+
await this.#appendGrant(misused);
|
|
895
|
+
await this.#appendAudit({
|
|
896
|
+
ts: now,
|
|
897
|
+
elevationRequestId: grant.elevationRequestId,
|
|
898
|
+
issueIndex: grant.issueIndex,
|
|
899
|
+
event: { kind: "answered", outcome: "misused" },
|
|
900
|
+
});
|
|
901
|
+
return { ok: true, value: { outcome: "misused", grant: misused } };
|
|
902
|
+
}
|
|
903
|
+
if (params.answer === "deny") {
|
|
904
|
+
const denied: ElevationGrantRecord = { ...grant, state: "denied", ts: now };
|
|
905
|
+
await this.#appendGrant(denied);
|
|
906
|
+
await this.#appendAudit({
|
|
907
|
+
ts: now,
|
|
908
|
+
elevationRequestId: grant.elevationRequestId,
|
|
909
|
+
issueIndex: grant.issueIndex,
|
|
910
|
+
event: { kind: "answered", outcome: "denied" },
|
|
911
|
+
});
|
|
912
|
+
return { ok: true, value: { outcome: "denied", grant: denied } };
|
|
913
|
+
}
|
|
914
|
+
const granted: ElevationGrantRecord = { ...grant, state: "granted", ts: now };
|
|
915
|
+
await this.#appendGrant(granted);
|
|
916
|
+
await this.#appendAudit({
|
|
917
|
+
ts: now,
|
|
918
|
+
elevationRequestId: grant.elevationRequestId,
|
|
919
|
+
issueIndex: grant.issueIndex,
|
|
920
|
+
event: { kind: "answered", outcome: "granted" },
|
|
921
|
+
});
|
|
922
|
+
return { ok: true, value: { outcome: "granted", grant: granted } };
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async claim(params: ElevationClaimParams): Promise<ElevationResult<ElevationClaimValue>> {
|
|
927
|
+
if (!isElevationRequestId(params.elevationRequestId))
|
|
928
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
929
|
+
if (!isElevationClaimIdentity(params.claimIdentity))
|
|
930
|
+
return elevationFailure("invalid_input", "claimIdentity is malformed");
|
|
931
|
+
if (params.currentSessionIdentity !== undefined && !isElevationSessionIdentity(params.currentSessionIdentity))
|
|
932
|
+
return elevationFailure("invalid_input", "currentSessionIdentity is malformed");
|
|
933
|
+
return this.#mutate(async () => {
|
|
934
|
+
const now = this.#now();
|
|
935
|
+
const grant = this.#byRequestId.get(params.elevationRequestId);
|
|
936
|
+
if (!grant) return elevationFailure("not_found", "elevation request was not found");
|
|
937
|
+
const receipt = this.#byReceiptRequestId.get(params.elevationRequestId);
|
|
938
|
+
const reconciled = await this.#reconcile(grant, receipt, params.claimIdentity, now);
|
|
939
|
+
if (reconciled === "in_progress")
|
|
940
|
+
return elevationFailure("elevation_claim_in_progress", "elevation claim is already in progress");
|
|
941
|
+
if (reconciled === "uncertain")
|
|
942
|
+
return elevationFailure(
|
|
943
|
+
"terminal_uncertain",
|
|
944
|
+
"elevation grant was consumed with an unknown dispatch outcome; retry requires a new grant",
|
|
945
|
+
);
|
|
946
|
+
if (reconciled === "expired") return elevationFailure("expired", "elevation grant has expired");
|
|
947
|
+
const current = this.#byRequestId.get(params.elevationRequestId) ?? grant;
|
|
948
|
+
if (current.state === "requested")
|
|
949
|
+
return elevationFailure("elevation_required", "elevation request has not been granted");
|
|
950
|
+
if (current.state === "claimed")
|
|
951
|
+
return elevationFailure("elevation_claim_in_progress", "elevation claim is already in progress");
|
|
952
|
+
if (current.state !== "granted")
|
|
953
|
+
return elevationFailure("grant_spent", "elevation grant is already spent or terminal");
|
|
954
|
+
if (params.currentSessionIdentity === undefined)
|
|
955
|
+
return elevationFailure("target_unavailable", "session endpoint is unreachable at claim time");
|
|
956
|
+
if (!sameSessionIdentity(params.currentSessionIdentity, current.sessionIdentity))
|
|
957
|
+
return elevationFailure(
|
|
958
|
+
"endpoint_stale",
|
|
959
|
+
"session endpoint identity no longer matches the elevation grant",
|
|
960
|
+
);
|
|
961
|
+
const claimed: ElevationGrantRecord = { ...current, state: "claimed", ts: now };
|
|
962
|
+
// Receipt before grant: if a crash lands between the two appends
|
|
963
|
+
// the receipt is authoritative, and the next access truthfully
|
|
964
|
+
// terminalizes the claim.
|
|
965
|
+
const receiptRecord = claimReceipt(current.elevationRequestId, current.issueIndex, params.claimIdentity, now);
|
|
966
|
+
await this.#appendReceipt(receiptRecord);
|
|
967
|
+
await this.#appendGrant(claimed);
|
|
968
|
+
await this.#appendAudit({
|
|
969
|
+
ts: now,
|
|
970
|
+
elevationRequestId: current.elevationRequestId,
|
|
971
|
+
issueIndex: current.issueIndex,
|
|
972
|
+
event: {
|
|
973
|
+
kind: "claimed",
|
|
974
|
+
claim: {
|
|
975
|
+
ownerId: params.claimIdentity.ownerId,
|
|
976
|
+
epoch: params.claimIdentity.epoch,
|
|
977
|
+
pid: params.claimIdentity.pid,
|
|
978
|
+
incarnation: params.claimIdentity.incarnation,
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
return { ok: true, value: { grant: claimed, receipt: receiptRecord } };
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async dispatch(params: ElevationDispatchParams): Promise<ElevationResult<ElevationDispatchValue>> {
|
|
987
|
+
if (!isElevationRequestId(params.elevationRequestId))
|
|
988
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
989
|
+
if (!isElevationClaimIdentity(params.dispatchIdentity))
|
|
990
|
+
return elevationFailure("invalid_input", "dispatchIdentity is malformed");
|
|
991
|
+
if (!isElevationDispatchOutcome(params.outcome)) return elevationFailure("invalid_input", "outcome is malformed");
|
|
992
|
+
if (params.outcome.status === "unknown")
|
|
993
|
+
return elevationFailure("invalid_input", "dispatch outcome unknown is reserved for crash replay");
|
|
994
|
+
return this.#mutate(async () => {
|
|
995
|
+
const now = this.#now();
|
|
996
|
+
const grant = this.#byRequestId.get(params.elevationRequestId);
|
|
997
|
+
if (!grant) return elevationFailure("not_found", "elevation request was not found");
|
|
998
|
+
const receipt = this.#byReceiptRequestId.get(params.elevationRequestId);
|
|
999
|
+
if (!receipt)
|
|
1000
|
+
return elevationFailure("elevation_claim_in_progress", "elevation grant was not claimed before dispatch");
|
|
1001
|
+
if (receipt.state === "claimed" && !receiptClaimMatches(receipt, params.dispatchIdentity)) {
|
|
1002
|
+
const liveness = this.#classifyLiveness(receipt.claim.pid, receipt.claim.incarnation);
|
|
1003
|
+
if (liveness === "dead") {
|
|
1004
|
+
if (grant.state !== "consumed")
|
|
1005
|
+
await this.#appendGrant({
|
|
1006
|
+
...grant,
|
|
1007
|
+
state: "consumed",
|
|
1008
|
+
outcome: {
|
|
1009
|
+
status: "unknown",
|
|
1010
|
+
message: "Claiming broker tenure ended before dispatch outcome was recorded",
|
|
1011
|
+
dispatchedAt: now,
|
|
1012
|
+
},
|
|
1013
|
+
ts: now,
|
|
1014
|
+
});
|
|
1015
|
+
await this.#appendReceipt(uncertainReceipt(receipt, now));
|
|
1016
|
+
await this.#appendAudit({
|
|
1017
|
+
ts: now,
|
|
1018
|
+
elevationRequestId: grant.elevationRequestId,
|
|
1019
|
+
issueIndex: grant.issueIndex,
|
|
1020
|
+
event: {
|
|
1021
|
+
kind: "consumed",
|
|
1022
|
+
message: "Claiming broker tenure ended before dispatch outcome was recorded",
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
return elevationFailure(
|
|
1026
|
+
"terminal_uncertain",
|
|
1027
|
+
"elevation grant dispatch outcome is unknown; retry requires a new grant",
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
return elevationFailure(
|
|
1031
|
+
"elevation_claim_in_progress",
|
|
1032
|
+
"elevation grant is claimed by another broker tenure",
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
if (receipt.state === "dispatched") {
|
|
1036
|
+
if (
|
|
1037
|
+
receipt.dispatch === undefined ||
|
|
1038
|
+
receipt.dispatch.ownerId !== params.dispatchIdentity.ownerId ||
|
|
1039
|
+
receipt.dispatch.epoch !== params.dispatchIdentity.epoch
|
|
1040
|
+
)
|
|
1041
|
+
return elevationFailure("grant_spent", "elevation grant was dispatched by another broker tenure");
|
|
1042
|
+
return { ok: true, value: { grant, receipt } };
|
|
1043
|
+
}
|
|
1044
|
+
if (receipt.state === "uncertain")
|
|
1045
|
+
return elevationFailure(
|
|
1046
|
+
"terminal_uncertain",
|
|
1047
|
+
"elevation grant dispatch outcome is unknown; retry requires a new grant",
|
|
1048
|
+
);
|
|
1049
|
+
if (grant.state !== "claimed")
|
|
1050
|
+
return elevationFailure("grant_spent", "elevation grant is not in the claimed state");
|
|
1051
|
+
const dispatchedGrant: ElevationGrantRecord = {
|
|
1052
|
+
...grant,
|
|
1053
|
+
state: "dispatched",
|
|
1054
|
+
outcome: params.outcome,
|
|
1055
|
+
ts: now,
|
|
1056
|
+
};
|
|
1057
|
+
const dispatchedReceipt = dispatchReceipt(
|
|
1058
|
+
receipt,
|
|
1059
|
+
{
|
|
1060
|
+
ownerId: params.dispatchIdentity.ownerId,
|
|
1061
|
+
epoch: params.dispatchIdentity.epoch,
|
|
1062
|
+
dispatchedAt: now,
|
|
1063
|
+
},
|
|
1064
|
+
params.outcome,
|
|
1065
|
+
);
|
|
1066
|
+
await this.#appendReceipt(dispatchedReceipt);
|
|
1067
|
+
await this.#appendGrant(dispatchedGrant);
|
|
1068
|
+
await this.#appendAudit({
|
|
1069
|
+
ts: now,
|
|
1070
|
+
elevationRequestId: grant.elevationRequestId,
|
|
1071
|
+
issueIndex: grant.issueIndex,
|
|
1072
|
+
event: {
|
|
1073
|
+
kind: "dispatched",
|
|
1074
|
+
dispatch: {
|
|
1075
|
+
ownerId: params.dispatchIdentity.ownerId,
|
|
1076
|
+
epoch: params.dispatchIdentity.epoch,
|
|
1077
|
+
dispatchedAt: now,
|
|
1078
|
+
},
|
|
1079
|
+
outcome:
|
|
1080
|
+
params.outcome.status === "ok"
|
|
1081
|
+
? { status: "ok", dispatchedAt: params.outcome.dispatchedAt }
|
|
1082
|
+
: {
|
|
1083
|
+
status: "failed",
|
|
1084
|
+
code: params.outcome.status === "failed" ? params.outcome.code : "dispatch_failed",
|
|
1085
|
+
message: params.outcome.message,
|
|
1086
|
+
dispatchedAt: params.outcome.dispatchedAt,
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
return { ok: true, value: { grant: dispatchedGrant, receipt: dispatchedReceipt } };
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Pure read without reconciliation writes; used before actions. */
|
|
1095
|
+
async get(elevationRequestId: string): Promise<ElevationResult<ElevationReadValue>> {
|
|
1096
|
+
if (!isElevationRequestId(elevationRequestId))
|
|
1097
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
1098
|
+
return this.#mutate(async () => {
|
|
1099
|
+
const grant = this.#byRequestId.get(elevationRequestId);
|
|
1100
|
+
if (!grant) return elevationFailure("not_found", "elevation request was not found");
|
|
1101
|
+
return { ok: true, value: { grant, receipt: this.#byReceiptRequestId.get(elevationRequestId) } };
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Truthful read: reconciles expiry and crash-state claims before returning. */
|
|
1106
|
+
async resolve(
|
|
1107
|
+
elevationRequestId: string,
|
|
1108
|
+
claimIdentity: ElevationClaimIdentity,
|
|
1109
|
+
): Promise<ElevationResult<ElevationReadValue>> {
|
|
1110
|
+
if (!isElevationRequestId(elevationRequestId))
|
|
1111
|
+
return elevationFailure("invalid_input", "elevationRequestId must be a UUID");
|
|
1112
|
+
if (!isElevationClaimIdentity(claimIdentity))
|
|
1113
|
+
return elevationFailure("invalid_input", "claimIdentity is malformed");
|
|
1114
|
+
return this.#mutate(async () => {
|
|
1115
|
+
const grant = this.#byRequestId.get(elevationRequestId);
|
|
1116
|
+
if (!grant) return elevationFailure("not_found", "elevation request was not found");
|
|
1117
|
+
const receipt = this.#byReceiptRequestId.get(elevationRequestId);
|
|
1118
|
+
await this.#reconcile(grant, receipt, claimIdentity, this.#now());
|
|
1119
|
+
const current = this.#byRequestId.get(elevationRequestId) ?? grant;
|
|
1120
|
+
return { ok: true, value: { grant: current, receipt: this.#byReceiptRequestId.get(elevationRequestId) } };
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Truthful listing: reconciles every grant before returning. */
|
|
1125
|
+
async list(claimIdentity: ElevationClaimIdentity): Promise<ElevationResult<ElevationListValue>> {
|
|
1126
|
+
if (!isElevationClaimIdentity(claimIdentity))
|
|
1127
|
+
return elevationFailure("invalid_input", "claimIdentity is malformed");
|
|
1128
|
+
return this.#mutate(async () => {
|
|
1129
|
+
const now = this.#now();
|
|
1130
|
+
const items: ElevationReadValue[] = [];
|
|
1131
|
+
for (const grant of this.#byRequestId.values()) {
|
|
1132
|
+
const receipt = this.#byReceiptRequestId.get(grant.elevationRequestId);
|
|
1133
|
+
await this.#reconcile(grant, receipt, claimIdentity, now);
|
|
1134
|
+
const current = this.#byRequestId.get(grant.elevationRequestId) ?? grant;
|
|
1135
|
+
items.push({ grant: current, receipt: this.#byReceiptRequestId.get(grant.elevationRequestId) });
|
|
1136
|
+
}
|
|
1137
|
+
return { ok: true, value: { grants: items } };
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export { isElevationGrantRecord, isElevationOperation };
|