sealkeep 0.9.0 → 0.11.1
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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +332 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5940 -297
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { readSourceBytes } from "./source-reader.js";
|
|
2
|
+
import { isRecalledMemoryLine } from "./search.js";
|
|
3
|
+
import { fail, isSealkeepError } from "./errors.js";
|
|
4
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
5
|
+
const string = (value) => typeof value === "string" ? value : undefined;
|
|
6
|
+
function textContent(value) {
|
|
7
|
+
if (typeof value === "string")
|
|
8
|
+
return value;
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return "";
|
|
11
|
+
return value.flatMap((part) => {
|
|
12
|
+
const item = object(part);
|
|
13
|
+
if (!item)
|
|
14
|
+
return [];
|
|
15
|
+
if (typeof item.text === "string")
|
|
16
|
+
return [item.text];
|
|
17
|
+
if (item.type === "tool_result")
|
|
18
|
+
return [textContent(item.content)];
|
|
19
|
+
if (item.type === "tool_use")
|
|
20
|
+
return [JSON.stringify(item.input ?? {})];
|
|
21
|
+
return [];
|
|
22
|
+
}).join("\n");
|
|
23
|
+
}
|
|
24
|
+
/** Select an honest excerpt of a long message, not a synthetic summary.
|
|
25
|
+
* Bound candidate work; explicit cursors always retain their exact offset. */
|
|
26
|
+
function queryTextOffset(text, terms, width) {
|
|
27
|
+
if (!terms.length || text.length <= width)
|
|
28
|
+
return 0;
|
|
29
|
+
const lower = text.toLowerCase();
|
|
30
|
+
const positions = new Set([0]);
|
|
31
|
+
for (const term of terms) {
|
|
32
|
+
let at = -1;
|
|
33
|
+
for (let count = 0; count < 64; count++) {
|
|
34
|
+
at = lower.indexOf(term, at + 1);
|
|
35
|
+
if (at < 0)
|
|
36
|
+
break;
|
|
37
|
+
positions.add(Math.max(0, at - Math.floor(width / 4)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let selected = 0, coverage = -1;
|
|
41
|
+
for (const at of positions) {
|
|
42
|
+
const passage = lower.slice(at, at + width);
|
|
43
|
+
const score = terms.filter(term => passage.includes(term)).length;
|
|
44
|
+
if (score > coverage) {
|
|
45
|
+
coverage = score;
|
|
46
|
+
selected = at;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Never cut a UTF-16 surrogate pair in half.
|
|
50
|
+
if (selected > 0 && /[\uDC00-\uDFFF]/.test(text[selected]))
|
|
51
|
+
selected--;
|
|
52
|
+
return selected;
|
|
53
|
+
}
|
|
54
|
+
/** Newer native Codex logs distinguish real input from injected role:user blocks. */
|
|
55
|
+
function annotatedUserText(payload) {
|
|
56
|
+
if (payload.type !== "message" || payload.role !== "user")
|
|
57
|
+
return;
|
|
58
|
+
const metadata = object(payload.internal_chat_message_metadata_passthrough);
|
|
59
|
+
const kinds = metadata?.content_item_kinds;
|
|
60
|
+
if (!Array.isArray(payload.content) || !Array.isArray(kinds) || kinds.length !== payload.content.length
|
|
61
|
+
|| !kinds.every(kind => typeof kind === "string"))
|
|
62
|
+
return;
|
|
63
|
+
const outerTurn = string(payload.turn_id);
|
|
64
|
+
const nativeTurn = string(metadata?.turn_id);
|
|
65
|
+
if (outerTurn && nativeTurn && outerTurn !== nativeTurn)
|
|
66
|
+
return;
|
|
67
|
+
const indexes = [];
|
|
68
|
+
const text = [];
|
|
69
|
+
for (let at = 0; at < payload.content.length; at++) {
|
|
70
|
+
const part = object(payload.content[at]);
|
|
71
|
+
if (kinds[at] !== "user.text" || part?.type !== "input_text" || typeof part.text !== "string"
|
|
72
|
+
|| !part.text || isRecalledMemoryLine(part.text))
|
|
73
|
+
continue;
|
|
74
|
+
indexes.push(at);
|
|
75
|
+
text.push(part.text);
|
|
76
|
+
}
|
|
77
|
+
if (!indexes.length)
|
|
78
|
+
return;
|
|
79
|
+
return { text: text.join("\n"), indexes, ...(nativeTurn || outerTurn ? { turnId: nativeTurn || outerTurn } : {}) };
|
|
80
|
+
}
|
|
81
|
+
const retrievalName = (name) => !!name
|
|
82
|
+
&& /(?:^|__|[.:/])sealkeep_(?:search|read_context)$/.test(name);
|
|
83
|
+
/** Native direct MCP calls and code-mode calls retain the actual tool identity. */
|
|
84
|
+
function retrievalCall(name, input) {
|
|
85
|
+
if (retrievalName(name))
|
|
86
|
+
return true;
|
|
87
|
+
let args = object(input);
|
|
88
|
+
if (!args && typeof input === "string") {
|
|
89
|
+
try {
|
|
90
|
+
args = object(JSON.parse(input));
|
|
91
|
+
}
|
|
92
|
+
catch { /* code-mode input, not JSON */ }
|
|
93
|
+
}
|
|
94
|
+
if (retrievalName(string(args?.tool) ?? string(args?.name)))
|
|
95
|
+
return true;
|
|
96
|
+
if (name && /(?:^|[.:])exec$/.test(name) && typeof input === "string") {
|
|
97
|
+
// Match an executed tool member, not incidental prose naming a tool.
|
|
98
|
+
return /\btools\.[\w]*sealkeep_(?:search|read_context)\s*\(/.test(input);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
/** Recognize typed search/context replies when a bounded window omits the call. */
|
|
103
|
+
function retrievedMemoryResult(text) {
|
|
104
|
+
let remaining = 256 * 1024;
|
|
105
|
+
let nodes = 32;
|
|
106
|
+
const inspect = (value, depth) => {
|
|
107
|
+
if (depth > 12 || --nodes < 0)
|
|
108
|
+
return false;
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
if (value.length > remaining)
|
|
111
|
+
return false;
|
|
112
|
+
remaining -= value.length;
|
|
113
|
+
let candidate = value.trim();
|
|
114
|
+
const output = candidate.indexOf("\nOutput:\n");
|
|
115
|
+
if (output >= 0)
|
|
116
|
+
candidate = candidate.slice(output + "\nOutput:\n".length).trim();
|
|
117
|
+
if (!/^[{[\"]/.test(candidate))
|
|
118
|
+
return false;
|
|
119
|
+
try {
|
|
120
|
+
return inspect(JSON.parse(candidate), depth + 1);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
return value.slice(0, 16).some(item => inspect(item, depth + 1));
|
|
128
|
+
const row = object(value);
|
|
129
|
+
if (!row)
|
|
130
|
+
return false;
|
|
131
|
+
if ((row.mode === "content" || row.mode === "metadata") && Array.isArray(row.hits))
|
|
132
|
+
return true;
|
|
133
|
+
const source = object(row.source);
|
|
134
|
+
if (source && typeof source.sourceSha256 === "string" && typeof source.archiveId === "string"
|
|
135
|
+
&& Array.isArray(row.messages) && (row.status === "ok" || row.status === "unsupported"))
|
|
136
|
+
return true;
|
|
137
|
+
// Only known response envelopes are traversed, not arbitrary code/input.
|
|
138
|
+
return [row.content, row.structuredContent, row.result, row.output, row.type === "text" ? row.text : undefined]
|
|
139
|
+
.some(item => item !== undefined && inspect(item, depth + 1));
|
|
140
|
+
};
|
|
141
|
+
return inspect(text, 0);
|
|
142
|
+
}
|
|
143
|
+
/** Byte locations refer to the immutable original JSONL, never reconstructed prose. */
|
|
144
|
+
export function parseContextMessages(bytes, source, byteStart = 0) {
|
|
145
|
+
const messages = [];
|
|
146
|
+
let offset = 0;
|
|
147
|
+
let turnId;
|
|
148
|
+
const calls = new Map();
|
|
149
|
+
while (offset < bytes.length) {
|
|
150
|
+
const newline = bytes.indexOf(10, offset);
|
|
151
|
+
const end = newline < 0 ? bytes.length : newline;
|
|
152
|
+
const raw = bytes.subarray(offset, end).toString("utf8");
|
|
153
|
+
const from = byteStart + offset;
|
|
154
|
+
offset = newline < 0 ? bytes.length : newline + 1;
|
|
155
|
+
if (!raw.trim())
|
|
156
|
+
continue;
|
|
157
|
+
let row;
|
|
158
|
+
try {
|
|
159
|
+
row = object(JSON.parse(raw));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!row)
|
|
165
|
+
continue;
|
|
166
|
+
const payload = object(row.payload) ?? row;
|
|
167
|
+
const nativeUser = row.type === "response_item" ? annotatedUserText(payload) : undefined;
|
|
168
|
+
// Mixed native rows may contain both genuine user text and hook context.
|
|
169
|
+
// Only positively annotated, individually filtered user text survives.
|
|
170
|
+
if (isRecalledMemoryLine(raw) && !nativeUser)
|
|
171
|
+
continue;
|
|
172
|
+
if (row.type === "turn_context") {
|
|
173
|
+
const nativeTurn = string(payload.turn_id);
|
|
174
|
+
if (nativeTurn) {
|
|
175
|
+
turnId = nativeTurn;
|
|
176
|
+
const pendingUser = messages[messages.length - 1];
|
|
177
|
+
// Only the immediately pending, provisionally identified user event
|
|
178
|
+
// can acquire this native turn. A new turn cannot relabel old history.
|
|
179
|
+
if (pendingUser?.actualUserMessage && pendingUser.turnId === pendingUser.messageId)
|
|
180
|
+
pendingUser.turnId = nativeTurn;
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let role = "unknown";
|
|
185
|
+
let text = "";
|
|
186
|
+
let actualUserMessage = false;
|
|
187
|
+
let userEvidence;
|
|
188
|
+
let callId;
|
|
189
|
+
let parentCallId;
|
|
190
|
+
let kind = "message";
|
|
191
|
+
let toolName;
|
|
192
|
+
let recalled = false;
|
|
193
|
+
let unresolvedToolResult = false;
|
|
194
|
+
if (row.type === "event_msg") {
|
|
195
|
+
if (payload.type === "user_message") {
|
|
196
|
+
role = "user";
|
|
197
|
+
text = string(payload.message) ?? "";
|
|
198
|
+
actualUserMessage = true;
|
|
199
|
+
userEvidence = "native_user_event";
|
|
200
|
+
}
|
|
201
|
+
else if (payload.type === "agent_message") {
|
|
202
|
+
role = "assistant";
|
|
203
|
+
text = string(payload.message) ?? "";
|
|
204
|
+
}
|
|
205
|
+
else
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
else if (row.type === "response_item") {
|
|
209
|
+
if (payload.type === "function_call" || payload.type === "custom_tool_call") {
|
|
210
|
+
role = "assistant";
|
|
211
|
+
text = string(payload.arguments) ?? string(payload.input) ?? "";
|
|
212
|
+
callId = string(payload.call_id);
|
|
213
|
+
kind = "tool_call";
|
|
214
|
+
toolName = string(payload.name);
|
|
215
|
+
recalled = retrievalCall(toolName, text);
|
|
216
|
+
}
|
|
217
|
+
else if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") {
|
|
218
|
+
role = "tool";
|
|
219
|
+
text = textContent(payload.output);
|
|
220
|
+
parentCallId = string(payload.call_id);
|
|
221
|
+
kind = "tool_result";
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const rawRole = string(payload.role);
|
|
225
|
+
if (rawRole === "user" || rawRole === "assistant" || rawRole === "tool")
|
|
226
|
+
role = rawRole;
|
|
227
|
+
else if (rawRole === "system" || rawRole === "developer")
|
|
228
|
+
continue;
|
|
229
|
+
if (rawRole === "tool") {
|
|
230
|
+
kind = "tool_result";
|
|
231
|
+
parentCallId = string(payload.call_id) ?? string(payload.tool_call_id);
|
|
232
|
+
}
|
|
233
|
+
text = nativeUser?.text ?? textContent(payload.content);
|
|
234
|
+
if (nativeUser) {
|
|
235
|
+
actualUserMessage = true;
|
|
236
|
+
userEvidence = "native_user_text";
|
|
237
|
+
}
|
|
238
|
+
else if (rawRole === "user") {
|
|
239
|
+
const kinds = object(payload.internal_chat_message_metadata_passthrough)?.content_item_kinds;
|
|
240
|
+
if (Array.isArray(kinds) && kinds.length && kinds.every(kind => typeof kind === "string" && /^(?:plugins|environments|hooks)\./.test(kind)))
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (row.type === "user" || row.type === "assistant") {
|
|
246
|
+
const message = object(row.message);
|
|
247
|
+
const content = message?.content ?? row.message;
|
|
248
|
+
const result = Array.isArray(content) ? content.map(object).find((part) => part?.type === "tool_result") : undefined;
|
|
249
|
+
const call = Array.isArray(content) ? content.map(object).find((part) => part?.type === "tool_use") : undefined;
|
|
250
|
+
role = result ? "tool" : row.type;
|
|
251
|
+
text = textContent(content);
|
|
252
|
+
parentCallId = string(result?.tool_use_id);
|
|
253
|
+
callId = string(call?.id);
|
|
254
|
+
kind = result ? "tool_result" : call ? "tool_call" : "message";
|
|
255
|
+
toolName = string(call?.name);
|
|
256
|
+
recalled = !!call && retrievalCall(toolName, call.input);
|
|
257
|
+
// Claude also transports automated notifications in role:user rows.
|
|
258
|
+
// Preserve their contents, but never present them as a human instruction
|
|
259
|
+
// or let them start a new user turn. Some historical rows lack isMeta.
|
|
260
|
+
const automatedUser = row.type === "user" && (row.isMeta === true || row.isCompactSummary === true
|
|
261
|
+
|| /^\s*<(?:task-notification|local-command-stdout|local-command-stderr|system-reminder)(?:\s|>)/.test(text));
|
|
262
|
+
if (automatedUser && !result)
|
|
263
|
+
role = "unknown";
|
|
264
|
+
actualUserMessage = row.type === "user" && !result && !automatedUser;
|
|
265
|
+
if (actualUserMessage)
|
|
266
|
+
userEvidence = "claude_user_message";
|
|
267
|
+
}
|
|
268
|
+
else
|
|
269
|
+
continue;
|
|
270
|
+
if (callId)
|
|
271
|
+
calls.set(callId, { name: toolName, recalled });
|
|
272
|
+
if (parentCallId) {
|
|
273
|
+
const call = calls.get(parentCallId);
|
|
274
|
+
toolName = call?.name;
|
|
275
|
+
recalled = call?.recalled ?? false;
|
|
276
|
+
}
|
|
277
|
+
// A byte window may start after its tool call. Absence of a retrieval
|
|
278
|
+
// marker is not proof that this output was generated by the current task.
|
|
279
|
+
unresolvedToolResult = kind === "tool_result" && !toolName;
|
|
280
|
+
if (kind === "tool_result" && !recalled)
|
|
281
|
+
recalled = retrievedMemoryResult(text);
|
|
282
|
+
if (!text)
|
|
283
|
+
continue;
|
|
284
|
+
const nativeId = string(payload.id) ?? string(row.uuid) ?? string(object(row.message)?.id);
|
|
285
|
+
const messageId = `${source.sourceSha256}:${from}${nativeId ? `:${nativeId}` : ""}`;
|
|
286
|
+
const previous = messages[messages.length - 1];
|
|
287
|
+
// Codex records both event and response representations of some messages.
|
|
288
|
+
// Never promote an earlier unproven row or collapse identical new prompts.
|
|
289
|
+
const explicitUserTurn = nativeUser?.turnId ?? string(payload.turn_id);
|
|
290
|
+
const userDuplicate = actualUserMessage && previous?.actualUserMessage && previous.userEvidence !== userEvidence
|
|
291
|
+
&& (!explicitUserTurn || previous.turnId === previous.messageId || previous.turnId === explicitUserTurn);
|
|
292
|
+
if (previous && previous.role === role && previous.kind === kind && previous.text === text
|
|
293
|
+
&& (!actualUserMessage || userDuplicate)) {
|
|
294
|
+
if (userDuplicate) {
|
|
295
|
+
if (explicitUserTurn && previous.turnId === previous.messageId)
|
|
296
|
+
previous.turnId = explicitUserTurn;
|
|
297
|
+
turnId = previous.turnId;
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (actualUserMessage)
|
|
302
|
+
turnId = nativeUser?.turnId ?? string(payload.turn_id) ?? messageId;
|
|
303
|
+
messages.push({ messageId, role, kind, provenance: recalled ? "retrieved_memory" : unresolvedToolResult ? "unknown" : "session", text, byteStart: from, byteEnd: byteStart + end,
|
|
304
|
+
...(turnId ? { turnId } : {}), ...(actualUserMessage ? { actualUserMessage: true } : {}),
|
|
305
|
+
...(userEvidence ? { userEvidence } : {}), ...(nativeUser ? { userContentIndexes: nativeUser.indexes } : {}),
|
|
306
|
+
...(toolName ? { toolName } : {}),
|
|
307
|
+
...(callId ? { callId } : {}), ...(parentCallId ? { parentCallId } : {}) });
|
|
308
|
+
}
|
|
309
|
+
return messages;
|
|
310
|
+
}
|
|
311
|
+
/** Conservative byte accounting bounds text tokens without depending on a model tokenizer. */
|
|
312
|
+
export async function readContext(dataDir, phrase, options) {
|
|
313
|
+
const maxTokens = options.maxTokens ?? 8192;
|
|
314
|
+
if (!Number.isSafeInteger(maxTokens) || maxTokens < 2048 || maxTokens > 32768)
|
|
315
|
+
fail("invalid_argument", "maxTokens must be between 2048 and 32768");
|
|
316
|
+
if (options.cursor && options.cursor.sourceSha256 !== options.source.sourceSha256)
|
|
317
|
+
fail("invalid_argument", "The continuation belongs to a different immutable source");
|
|
318
|
+
const requestedStart = options.cursor?.byteStart ?? options.byteStart ?? 0;
|
|
319
|
+
if (!Number.isSafeInteger(requestedStart) || requestedStart < 0 || requestedStart > options.source.sourceBytes)
|
|
320
|
+
fail("invalid_argument", "The context byte position must be a safe integer within this source");
|
|
321
|
+
if (options.cursor?.textOffset !== undefined && (!Number.isSafeInteger(options.cursor.textOffset) || options.cursor.textOffset < 0))
|
|
322
|
+
fail("invalid_argument", "The continuation text offset must be a non-negative safe integer");
|
|
323
|
+
const maximum = options.maxDecodedBytes ?? 16 * 1024 * 1024;
|
|
324
|
+
// Reparse bounded small sources from their beginning so continuations retain
|
|
325
|
+
// observed turn/call provenance instead of relabeling an isolated tool echo.
|
|
326
|
+
const range = options.source.sourceBytes <= maximum
|
|
327
|
+
? { start: 0, end: options.source.sourceBytes }
|
|
328
|
+
: { start: requestedStart, end: Math.min(options.source.sourceBytes, requestedStart + Math.max(1, Math.min(Math.floor(maximum / 2), 256 * 1024))) };
|
|
329
|
+
let read;
|
|
330
|
+
try {
|
|
331
|
+
read = await readSourceBytes(dataDir, phrase, options.source, { ...options, range });
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (isSealkeepError(error) && error.code === "provider_unsupported") {
|
|
335
|
+
return { status: "unsupported", source: options.source, messages: [],
|
|
336
|
+
reason: error.details.reason ?? "partial_read_unsupported", message: error.message };
|
|
337
|
+
}
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const lastNewline = read.bytes.lastIndexOf(10);
|
|
342
|
+
const hasIncompleteTail = read.byteEnd < read.source.sourceBytes && lastNewline !== read.bytes.length - 1;
|
|
343
|
+
const incompleteByteStart = hasIncompleteTail ? read.byteStart + lastNewline + 1 : undefined;
|
|
344
|
+
if (hasIncompleteTail && lastNewline < 0) {
|
|
345
|
+
await read.assertCurrent();
|
|
346
|
+
return { status: "unsupported", source: read.source, messages: [], reason: "record_exceeds_context_window",
|
|
347
|
+
message: "The JSONL message crosses this bounded window. Increase the decoded budget or explicitly restore the source.",
|
|
348
|
+
continuation: { sourceSha256: read.source.sourceSha256, byteStart: read.byteStart }, truncated: true };
|
|
349
|
+
}
|
|
350
|
+
const all = parseContextMessages(hasIncompleteTail ? read.bytes.subarray(0, lastNewline + 1) : read.bytes, read.source, read.byteStart);
|
|
351
|
+
const terms = [...new Set((options.query ?? "").toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])];
|
|
352
|
+
let anchor = 0;
|
|
353
|
+
if (options.cursor || options.byteStart !== undefined) {
|
|
354
|
+
const next = all.findIndex((message) => message.byteStart >= requestedStart);
|
|
355
|
+
anchor = next < 0 ? all.length : next;
|
|
356
|
+
}
|
|
357
|
+
else if (terms.length) {
|
|
358
|
+
const frequency = terms.map(term => all.filter(message => message.provenance === "session"
|
|
359
|
+
&& message.text.toLowerCase().includes(term)).length);
|
|
360
|
+
let best = [];
|
|
361
|
+
for (let index = 0; index < all.length; index++) {
|
|
362
|
+
const message = all[index];
|
|
363
|
+
const matches = terms.filter((term) => message.text.toLowerCase().includes(term)).length;
|
|
364
|
+
const rarity = terms.reduce((sum, term, at) => sum + (message.text.toLowerCase().includes(term)
|
|
365
|
+
? 1 / Math.max(1, frequency[at]) : 0), 0);
|
|
366
|
+
// Imported memory is useful context but not this source's answer.
|
|
367
|
+
// Among genuine evidence, coverage still lets a patch/test beat prose;
|
|
368
|
+
// prose wins only equal coverage, with later decisions breaking ties.
|
|
369
|
+
const score = [matches > 0 ? 1 : 0, message.provenance === "session" ? 1 : 0,
|
|
370
|
+
matches, rarity, message.role === "assistant" || message.role === "tool" ? 1 : 0,
|
|
371
|
+
message.kind === "message" ? 1 : 0, index];
|
|
372
|
+
const difference = score.findIndex((value, at) => value !== (best[at] ?? -1));
|
|
373
|
+
if (difference >= 0 && score[difference] > (best[difference] ?? -1)) {
|
|
374
|
+
anchor = index;
|
|
375
|
+
best = score;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const selected = all[anchor];
|
|
380
|
+
if (options.cursor?.textOffset !== undefined && options.cursor.textOffset > 0
|
|
381
|
+
&& (!selected || selected.byteStart !== requestedStart || options.cursor.textOffset > selected.text.length)) {
|
|
382
|
+
fail("invalid_argument", "The continuation text offset is outside its source message");
|
|
383
|
+
}
|
|
384
|
+
const user = selected ? all.slice(0, anchor + 1).reverse().find((message) => message.actualUserMessage
|
|
385
|
+
&& (!selected.turnId || message.turnId === selected.turnId)) : undefined;
|
|
386
|
+
const neighbors = Math.max(0, Math.min(options.neighboringMessages ?? 3, 12));
|
|
387
|
+
const start = options.cursor || !selected ? anchor : Math.max(0, anchor - neighbors);
|
|
388
|
+
const end = Math.min(all.length, anchor + neighbors + 1);
|
|
389
|
+
// A continuation is forward-only. Keep its initiating-user metadata, but
|
|
390
|
+
// do not spend the next page on earlier prompt text and jump its cursor
|
|
391
|
+
// backwards. Continuing within that prompt itself remains valid.
|
|
392
|
+
const includeUserText = user && (!options.cursor || user.byteStart >= requestedStart);
|
|
393
|
+
const candidates = [...new Set([...(selected ? [selected] : []), ...(includeUserText ? [user] : []), ...all.slice(start, end)])];
|
|
394
|
+
const messages = [];
|
|
395
|
+
const base = { status: "ok", source: read.source, extractionVersion: 3,
|
|
396
|
+
userAnchor: user ? { status: "observed", messageId: user.messageId, byteStart: user.byteStart }
|
|
397
|
+
: { status: "unavailable", reason: "No actual user event is present in this verified source window" },
|
|
398
|
+
range: { byteStart: read.byteStart, byteEnd: read.byteEnd },
|
|
399
|
+
usage: { wireBytes: read.wireBytes, decodedBytes: read.decodedBytes, tokenBudget: maxTokens, accounting: "conservative_utf8_bytes" } };
|
|
400
|
+
let remaining = maxTokens - Buffer.byteLength(JSON.stringify({ ...base, messages: [], truncated: true })) - 220;
|
|
401
|
+
if (remaining < 128)
|
|
402
|
+
fail("invalid_argument", "The token budget is too small for this citation's provenance");
|
|
403
|
+
let continuation;
|
|
404
|
+
for (const message of candidates) {
|
|
405
|
+
const offset = options.cursor && message.byteStart === options.cursor.byteStart ? options.cursor.textOffset ?? 0
|
|
406
|
+
: !options.cursor && options.byteStart === undefined && message === selected
|
|
407
|
+
? queryTextOffset(message.text, terms, Math.max(128, Math.floor((remaining - 700) / 2))) : 0;
|
|
408
|
+
const text = message.text.slice(offset);
|
|
409
|
+
const overhead = Buffer.byteLength(JSON.stringify({ ...message, text: "", textOffset: offset, truncated: true }), "utf8") + 4;
|
|
410
|
+
if (remaining <= overhead) {
|
|
411
|
+
continuation = { sourceSha256: read.source.sourceSha256, byteStart: message.byteStart, ...(offset ? { textOffset: offset } : {}) };
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
const allowance = remaining - overhead;
|
|
415
|
+
let low = 0;
|
|
416
|
+
let high = text.length;
|
|
417
|
+
while (low < high) {
|
|
418
|
+
const midpoint = Math.ceil((low + high) / 2);
|
|
419
|
+
if (Buffer.byteLength(JSON.stringify(text.slice(0, midpoint))) - 2 <= allowance)
|
|
420
|
+
low = midpoint;
|
|
421
|
+
else
|
|
422
|
+
high = midpoint - 1;
|
|
423
|
+
}
|
|
424
|
+
let kept = text.slice(0, low);
|
|
425
|
+
if (kept.length && /[\uD800-\uDBFF]$/.test(kept))
|
|
426
|
+
kept = kept.slice(0, -1);
|
|
427
|
+
const truncated = kept.length < text.length;
|
|
428
|
+
messages.push({ ...message, text: kept, ...(offset ? { textOffset: offset, omittedPrefix: true } : {}), ...(truncated ? { truncated: true } : {}) });
|
|
429
|
+
remaining -= overhead + Buffer.byteLength(JSON.stringify(kept)) - 2;
|
|
430
|
+
if (truncated) {
|
|
431
|
+
continuation = { sourceSha256: read.source.sourceSha256, byteStart: message.byteStart, textOffset: offset + kept.length };
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!continuation && end < all.length)
|
|
436
|
+
continuation = { sourceSha256: read.source.sourceSha256, byteStart: all[end].byteStart };
|
|
437
|
+
if (!continuation && read.byteEnd < read.source.sourceBytes)
|
|
438
|
+
continuation = { sourceSha256: read.source.sourceSha256,
|
|
439
|
+
byteStart: incompleteByteStart ?? read.byteEnd };
|
|
440
|
+
await read.assertCurrent();
|
|
441
|
+
return { ...base, messages,
|
|
442
|
+
...(continuation ? { continuation, truncated: true } : { truncated: false }) };
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
read.bytes.fill(0);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -9,13 +9,33 @@ import type { ControlPlaneStore, Device } from "./store.js";
|
|
|
9
9
|
* changes the string, and replaying it verbatim is caught by the nonce cache.
|
|
10
10
|
*/
|
|
11
11
|
export declare const SIGNATURE_WINDOW_MS: number;
|
|
12
|
+
/**
|
|
13
|
+
* The request-signing contract, mid-rename.
|
|
14
|
+
*
|
|
15
|
+
* These four header names and the signing prefix below are the one place where
|
|
16
|
+
* a rename cannot be done on one side alone: the prefix is INSIDE the signed
|
|
17
|
+
* message, so changing it changes every signature. Rename the client first and
|
|
18
|
+
* every request 401s; rename the server first and nothing breaks, because a
|
|
19
|
+
* server that accepts both is compatible with a client that has not moved yet.
|
|
20
|
+
*
|
|
21
|
+
* So the server half lands first and reads either spelling. The client keeps
|
|
22
|
+
* sending the old names until the deployed plane is known to accept the new
|
|
23
|
+
* ones — flipping `SIGNS_AS` is the whole of that second step.
|
|
24
|
+
*/
|
|
12
25
|
export declare const HEADERS: {
|
|
26
|
+
readonly device: "x-sealkeep-device";
|
|
27
|
+
readonly timestamp: "x-sealkeep-timestamp";
|
|
28
|
+
readonly nonce: "x-sealkeep-nonce";
|
|
29
|
+
readonly signature: "x-sealkeep-signature";
|
|
30
|
+
};
|
|
31
|
+
export declare const LEGACY_HEADERS: {
|
|
13
32
|
readonly device: "x-vaultline-device";
|
|
14
33
|
readonly timestamp: "x-vaultline-timestamp";
|
|
15
34
|
readonly nonce: "x-vaultline-nonce";
|
|
16
35
|
readonly signature: "x-vaultline-signature";
|
|
17
36
|
};
|
|
18
|
-
export declare
|
|
37
|
+
export declare const SIGNING_PREFIXES: readonly ["sealkeep-request:v1", "vaultline-request:v1"];
|
|
38
|
+
export declare function signingString(method: string, path: string, timestamp: string, nonce: string, body: Buffer | string, prefix?: string): string;
|
|
19
39
|
/** Client helper: produces the four headers a signed request needs. */
|
|
20
40
|
export declare function signRequest(input: {
|
|
21
41
|
method: string;
|
|
@@ -54,9 +74,17 @@ export declare function hashEnrollmentToken(token: string): string;
|
|
|
54
74
|
export declare function enrollmentTokenMatches(token: string, hash: string): boolean;
|
|
55
75
|
/** A token bucket per key. Abuse control that does not need shared state. */
|
|
56
76
|
export declare class RateLimiter {
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
readonly capacity: number;
|
|
78
|
+
readonly refillPerSecond: number;
|
|
79
|
+
private readonly maxBuckets;
|
|
80
|
+
private readonly idleTtlMs;
|
|
59
81
|
private readonly buckets;
|
|
60
|
-
constructor(capacity?: number, refillPerSecond?: number);
|
|
82
|
+
constructor(capacity?: number, refillPerSecond?: number, maxBuckets?: number, idleTtlMs?: number);
|
|
83
|
+
consume(key: string, now?: number): {
|
|
84
|
+
allowed: boolean;
|
|
85
|
+
remaining: number;
|
|
86
|
+
retryAfterMs: number;
|
|
87
|
+
resetAt: number;
|
|
88
|
+
};
|
|
61
89
|
take(key: string, now?: number): boolean;
|
|
62
90
|
}
|
|
@@ -7,15 +7,43 @@ import { createHash, createPublicKey, sign as edSign, verify as edVerify, timing
|
|
|
7
7
|
* changes the string, and replaying it verbatim is caught by the nonce cache.
|
|
8
8
|
*/
|
|
9
9
|
export const SIGNATURE_WINDOW_MS = 5 * 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* The request-signing contract, mid-rename.
|
|
12
|
+
*
|
|
13
|
+
* These four header names and the signing prefix below are the one place where
|
|
14
|
+
* a rename cannot be done on one side alone: the prefix is INSIDE the signed
|
|
15
|
+
* message, so changing it changes every signature. Rename the client first and
|
|
16
|
+
* every request 401s; rename the server first and nothing breaks, because a
|
|
17
|
+
* server that accepts both is compatible with a client that has not moved yet.
|
|
18
|
+
*
|
|
19
|
+
* So the server half lands first and reads either spelling. The client keeps
|
|
20
|
+
* sending the old names until the deployed plane is known to accept the new
|
|
21
|
+
* ones — flipping `SIGNS_AS` is the whole of that second step.
|
|
22
|
+
*/
|
|
10
23
|
export const HEADERS = {
|
|
24
|
+
device: "x-sealkeep-device",
|
|
25
|
+
timestamp: "x-sealkeep-timestamp",
|
|
26
|
+
nonce: "x-sealkeep-nonce",
|
|
27
|
+
signature: "x-sealkeep-signature"
|
|
28
|
+
};
|
|
29
|
+
export const LEGACY_HEADERS = {
|
|
11
30
|
device: "x-vaultline-device",
|
|
12
31
|
timestamp: "x-vaultline-timestamp",
|
|
13
32
|
nonce: "x-vaultline-nonce",
|
|
14
33
|
signature: "x-vaultline-signature"
|
|
15
34
|
};
|
|
35
|
+
export const SIGNING_PREFIXES = ["sealkeep-request:v1", "vaultline-request:v1"];
|
|
36
|
+
/**
|
|
37
|
+
* What the CLIENT signs and sends today. Still the old spelling, deliberately:
|
|
38
|
+
* the deployed control plane has not been updated yet, and a client that signs
|
|
39
|
+
* with a prefix the server does not know is a client that cannot talk to its
|
|
40
|
+
* own account. Flip to index 0 in the same change that deploys the plane.
|
|
41
|
+
*/
|
|
42
|
+
const SIGNS_AS = SIGNING_PREFIXES[1];
|
|
43
|
+
const SENDS = LEGACY_HEADERS;
|
|
16
44
|
const sha256hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
17
|
-
export function signingString(method, path, timestamp, nonce, body) {
|
|
18
|
-
return
|
|
45
|
+
export function signingString(method, path, timestamp, nonce, body, prefix = SIGNS_AS) {
|
|
46
|
+
return `${prefix}\n${method.toUpperCase()}\n${path}\n${timestamp}\n${nonce}\n${sha256hex(body)}`;
|
|
19
47
|
}
|
|
20
48
|
/** Client helper: produces the four headers a signed request needs. */
|
|
21
49
|
export function signRequest(input) {
|
|
@@ -23,10 +51,10 @@ export function signRequest(input) {
|
|
|
23
51
|
const message = signingString(input.method, input.path, timestamp, input.nonce, input.body ?? "");
|
|
24
52
|
const key = typeof input.privateKey === "string" ? createPublicKey(input.privateKey) : input.privateKey;
|
|
25
53
|
return {
|
|
26
|
-
[
|
|
27
|
-
[
|
|
28
|
-
[
|
|
29
|
-
[
|
|
54
|
+
[SENDS.device]: input.deviceId,
|
|
55
|
+
[SENDS.timestamp]: timestamp,
|
|
56
|
+
[SENDS.nonce]: input.nonce,
|
|
57
|
+
[SENDS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
|
|
30
58
|
};
|
|
31
59
|
}
|
|
32
60
|
export class NonceCache {
|
|
@@ -52,10 +80,11 @@ export class NonceCache {
|
|
|
52
80
|
}
|
|
53
81
|
export function authenticate(store, request, path, body, nonces, now = Date.now()) {
|
|
54
82
|
const header = (name) => { const value = request.headers[name]; return typeof value === "string" ? value : undefined; };
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
83
|
+
const either = (name) => header(HEADERS[name]) ?? header(LEGACY_HEADERS[name]);
|
|
84
|
+
const deviceId = either("device");
|
|
85
|
+
const timestamp = either("timestamp");
|
|
86
|
+
const nonce = either("nonce");
|
|
87
|
+
const signature = either("signature");
|
|
59
88
|
if (!deviceId || !timestamp || !nonce || !signature)
|
|
60
89
|
return { ok: false, status: 401, code: "unauthorized", message: "A signed device request is required" };
|
|
61
90
|
const at = Date.parse(timestamp);
|
|
@@ -70,13 +99,21 @@ export function authenticate(store, request, path, body, nonces, now = Date.now(
|
|
|
70
99
|
return { ok: false, status: 401, code: "unauthorized", message: "Unknown device", deviceId };
|
|
71
100
|
if (device.revokedAt)
|
|
72
101
|
return { ok: false, status: 403, code: "forbidden", message: `Device revoked at ${device.revokedAt}`, deviceId };
|
|
73
|
-
|
|
102
|
+
// Either prefix verifies, so a client on the old spelling keeps working while
|
|
103
|
+
// the plane is ahead of it. Both are checked because the prefix is signed:
|
|
104
|
+
// there is no way to tell from the outside which one the client used.
|
|
105
|
+
const key = publicKeyFromRaw(device.publicKey);
|
|
106
|
+
const claimed = Buffer.from(signature, "base64");
|
|
74
107
|
let valid = false;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
108
|
+
for (const prefix of SIGNING_PREFIXES) {
|
|
109
|
+
const message = Buffer.from(signingString(request.method ?? "GET", path, timestamp, nonce, body, prefix));
|
|
110
|
+
try {
|
|
111
|
+
if (edVerify(null, message, key, claimed)) {
|
|
112
|
+
valid = true;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* try the other */ }
|
|
80
117
|
}
|
|
81
118
|
if (!valid)
|
|
82
119
|
return { ok: false, status: 401, code: "unauthorized", message: "Request signature does not verify", deviceId };
|
|
@@ -104,20 +141,44 @@ export function enrollmentTokenMatches(token, hash) {
|
|
|
104
141
|
export class RateLimiter {
|
|
105
142
|
capacity;
|
|
106
143
|
refillPerSecond;
|
|
144
|
+
maxBuckets;
|
|
145
|
+
idleTtlMs;
|
|
107
146
|
buckets = new Map();
|
|
108
|
-
constructor(capacity = 60, refillPerSecond = 1) {
|
|
147
|
+
constructor(capacity = 60, refillPerSecond = 1, maxBuckets = 10_000, idleTtlMs = 15 * 60_000) {
|
|
109
148
|
this.capacity = capacity;
|
|
110
149
|
this.refillPerSecond = refillPerSecond;
|
|
150
|
+
this.maxBuckets = maxBuckets;
|
|
151
|
+
this.idleTtlMs = idleTtlMs;
|
|
111
152
|
}
|
|
112
|
-
|
|
153
|
+
consume(key, now = Date.now()) {
|
|
154
|
+
// The key is an untrusted network cardinality even when each value is an
|
|
155
|
+
// unspoofable remote address. Bound memory under distributed scans. The
|
|
156
|
+
// O(n) sweep runs only when a new key would cross the cap, never on the
|
|
157
|
+
// normal hot path.
|
|
158
|
+
if (!this.buckets.has(key) && this.buckets.size >= this.maxBuckets) {
|
|
159
|
+
for (const [candidate, bucket] of this.buckets) {
|
|
160
|
+
if (now - bucket.updatedAt > this.idleTtlMs)
|
|
161
|
+
this.buckets.delete(candidate);
|
|
162
|
+
}
|
|
163
|
+
while (this.buckets.size >= this.maxBuckets) {
|
|
164
|
+
const oldest = this.buckets.keys().next().value;
|
|
165
|
+
if (oldest === undefined)
|
|
166
|
+
break;
|
|
167
|
+
this.buckets.delete(oldest);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
113
170
|
const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
|
|
114
171
|
const refill = ((now - bucket.updatedAt) / 1000) * this.refillPerSecond;
|
|
115
172
|
const tokens = Math.min(this.capacity, bucket.tokens + refill);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.buckets.
|
|
121
|
-
|
|
173
|
+
const allowed = tokens >= 1;
|
|
174
|
+
const settled = allowed ? tokens - 1 : tokens;
|
|
175
|
+
// Refresh insertion order so emergency eviction removes the least recently
|
|
176
|
+
// consumed bucket instead of an arbitrary long-lived customer.
|
|
177
|
+
this.buckets.delete(key);
|
|
178
|
+
this.buckets.set(key, { tokens: settled, updatedAt: now });
|
|
179
|
+
const retryAfterMs = allowed ? 0 : this.refillPerSecond > 0 ? Math.ceil(((1 - settled) / this.refillPerSecond) * 1_000) : 60_000;
|
|
180
|
+
const resetMs = this.refillPerSecond > 0 ? Math.ceil(((this.capacity - settled) / this.refillPerSecond) * 1_000) : 60_000;
|
|
181
|
+
return { allowed, remaining: Math.max(0, Math.floor(settled)), retryAfterMs, resetAt: now + resetMs };
|
|
122
182
|
}
|
|
183
|
+
take(key, now = Date.now()) { return this.consume(key, now).allowed; }
|
|
123
184
|
}
|