@davesheffer/hunch 1.32.8 → 1.33.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/README.md +9 -3
- package/dist/cli/index.js +2 -0
- package/dist/cli/serve.js +28 -2
- package/dist/cli/state.d.ts +3 -0
- package/dist/cli/state.js +150 -0
- package/dist/client/state.d.ts +82 -14
- package/dist/client/state.js +16 -2
- package/dist/client/stateProof.d.ts +4 -0
- package/dist/client/stateProof.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +1 -1
- package/dist/constitution/schema.d.ts +2 -2
- package/dist/core/automaticReviewMemory.d.ts +5 -0
- package/dist/core/conventionDelivery.d.ts +8 -0
- package/dist/core/conventionDelivery.js +52 -0
- package/dist/core/fieldProvenance.d.ts +8 -0
- package/dist/core/fieldProvenance.js +72 -0
- package/dist/core/recordVisibility.d.ts +9 -0
- package/dist/core/recordVisibility.js +25 -0
- package/dist/core/stateCanonical.d.ts +3 -0
- package/dist/core/stateCanonical.js +34 -0
- package/dist/core/stateContract.d.ts +122 -7
- package/dist/core/stateContract.js +26 -31
- package/dist/core/stateDelivery.d.ts +3 -3
- package/dist/core/stateDelivery.js +10 -1
- package/dist/core/stateHttp.d.ts +280 -0
- package/dist/core/stateHttp.js +17 -0
- package/dist/core/stateProof.d.ts +13 -0
- package/dist/core/stateProof.js +34 -0
- package/dist/core/stateRecords.d.ts +127 -0
- package/dist/core/stateRecords.js +48 -0
- package/dist/core/types.d.ts +146 -4
- package/dist/core/types.js +8 -2
- package/dist/extractors/git.js +3 -10
- package/dist/mcp/server.js +10 -4
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +71 -30
- package/dist/serve/config.d.ts +16 -0
- package/dist/serve/config.js +27 -7
- package/dist/serve/operator.d.ts +4 -0
- package/dist/serve/operator.js +223 -0
- package/dist/serve/stateProof.d.ts +15 -0
- package/dist/serve/stateProof.js +105 -0
- package/dist/store/changeLedger.d.ts +6 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +18 -19
- package/dist/store/stateAccess.d.ts +13 -0
- package/dist/store/stateAccess.js +85 -0
- package/dist/store/stateBinding.d.ts +13 -18
- package/dist/store/stateBinding.js +161 -52
- package/dist/store/stateCapture.js +10 -2
- package/dist/store/stateError.d.ts +12 -0
- package/dist/store/stateError.js +12 -0
- package/dist/store/statePartition.d.ts +9 -0
- package/dist/store/statePartition.js +30 -0
- package/package.json +5 -1
- package/server.json +2 -2
package/dist/serve/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProofPublicKey } from '../core/stateProof.js';
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { type Principal, type Scope } from "../core/stateContract.js";
|
|
3
4
|
export declare const SERVE_CONFIG_VERSION: "nuryel.serve-config/1";
|
|
@@ -15,6 +16,11 @@ export declare const PartitionConfigSchema: z.ZodObject<{
|
|
|
15
16
|
}, z.core.$strict>;
|
|
16
17
|
export type PartitionConfig = z.infer<typeof PartitionConfigSchema>;
|
|
17
18
|
export declare const PrincipalConfigSchema: z.ZodObject<{
|
|
19
|
+
proof_key: z.ZodOptional<z.ZodObject<{
|
|
20
|
+
kty: z.ZodLiteral<"OKP">;
|
|
21
|
+
crv: z.ZodLiteral<"Ed25519">;
|
|
22
|
+
x: z.ZodString;
|
|
23
|
+
}, z.core.$strict>>;
|
|
18
24
|
id: z.ZodString;
|
|
19
25
|
kind: z.ZodEnum<{
|
|
20
26
|
service: "service";
|
|
@@ -35,6 +41,7 @@ export declare const PrincipalConfigSchema: z.ZodObject<{
|
|
|
35
41
|
}, z.core.$strict>;
|
|
36
42
|
export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
|
|
37
43
|
export declare const ServeConfigSchema: z.ZodObject<{
|
|
44
|
+
public_origin: z.ZodOptional<z.ZodString>;
|
|
38
45
|
schema: z.ZodLiteral<"nuryel.serve-config/1">;
|
|
39
46
|
port: z.ZodDefault<z.ZodNumber>;
|
|
40
47
|
partitions: z.ZodArray<z.ZodObject<{
|
|
@@ -50,6 +57,11 @@ export declare const ServeConfigSchema: z.ZodObject<{
|
|
|
50
57
|
root: z.ZodString;
|
|
51
58
|
}, z.core.$strict>>;
|
|
52
59
|
principals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
60
|
+
proof_key: z.ZodOptional<z.ZodObject<{
|
|
61
|
+
kty: z.ZodLiteral<"OKP">;
|
|
62
|
+
crv: z.ZodLiteral<"Ed25519">;
|
|
63
|
+
x: z.ZodString;
|
|
64
|
+
}, z.core.$strict>>;
|
|
53
65
|
id: z.ZodString;
|
|
54
66
|
kind: z.ZodEnum<{
|
|
55
67
|
service: "service";
|
|
@@ -78,6 +90,8 @@ export declare function readServeConfig(file: string): ServeConfig & {
|
|
|
78
90
|
};
|
|
79
91
|
export declare function writeServeConfig(file: string, config: ServeConfig): void;
|
|
80
92
|
/** Constant-time token → principal. Undefined for a missing or unknown token. */
|
|
93
|
+
export declare function resolveCredential(config: ServeConfig, token: string | undefined): PrincipalConfig | undefined;
|
|
94
|
+
/** Legacy bearer callers cannot resolve a key-bound credential without proof. */
|
|
81
95
|
export declare function resolvePrincipal(config: ServeConfig, token: string | undefined): Principal | undefined;
|
|
82
96
|
export declare function partitionFor(config: ServeConfig, scope: Scope): PartitionConfig | undefined;
|
|
83
97
|
/** `serve init`: ensure a partition directory declares its scope, and add a principal
|
|
@@ -91,7 +105,9 @@ export declare function initServeConfig(opts: {
|
|
|
91
105
|
id: string;
|
|
92
106
|
kind: "human" | "agent" | "service";
|
|
93
107
|
grants?: Scope[];
|
|
108
|
+
proofKey?: ProofPublicKey;
|
|
94
109
|
};
|
|
110
|
+
publicOrigin?: string;
|
|
95
111
|
port?: number;
|
|
96
112
|
}): {
|
|
97
113
|
config: ServeConfig;
|
package/dist/serve/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProofPublicKeySchema, PublicOriginSchema } from '../core/stateProof.js';
|
|
1
2
|
/**
|
|
2
3
|
* `hunch serve` configuration — the served partitions and the principals allowed in.
|
|
3
4
|
*
|
|
@@ -12,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
12
13
|
import { dirname, resolve } from "node:path";
|
|
13
14
|
import { z } from "zod";
|
|
14
15
|
import { writeFileAtomic } from "../core/io.js";
|
|
15
|
-
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
16
|
+
import { ScopeSchema, PartitionDeclarationSchema, scopePath } from "../core/stateContract.js";
|
|
16
17
|
export const SERVE_CONFIG_VERSION = "nuryel.serve-config/1";
|
|
17
18
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
18
19
|
export const PartitionConfigSchema = z.object({
|
|
@@ -21,6 +22,7 @@ export const PartitionConfigSchema = z.object({
|
|
|
21
22
|
root: z.string().min(1),
|
|
22
23
|
}).strict();
|
|
23
24
|
export const PrincipalConfigSchema = z.object({
|
|
25
|
+
proof_key: ProofPublicKeySchema.optional(),
|
|
24
26
|
id: z.string().regex(TOKEN),
|
|
25
27
|
kind: z.enum(["human", "agent", "service"]),
|
|
26
28
|
display: z.string().max(256).optional(),
|
|
@@ -29,11 +31,15 @@ export const PrincipalConfigSchema = z.object({
|
|
|
29
31
|
grants: z.array(ScopeSchema).min(1).max(64),
|
|
30
32
|
}).strict();
|
|
31
33
|
export const ServeConfigSchema = z.object({
|
|
34
|
+
public_origin: PublicOriginSchema.optional(),
|
|
32
35
|
schema: z.literal(SERVE_CONFIG_VERSION),
|
|
33
36
|
port: z.number().int().min(1).max(65535).default(7474),
|
|
34
37
|
partitions: z.array(PartitionConfigSchema).min(1).max(256),
|
|
35
38
|
principals: z.array(PrincipalConfigSchema).max(1024).default([]),
|
|
36
|
-
}).strict()
|
|
39
|
+
}).strict().superRefine((config, ctx) => {
|
|
40
|
+
if (config.principals.some(p => p.proof_key) && !config.public_origin)
|
|
41
|
+
ctx.addIssue({ code: 'custom', message: 'key-bound principals require public_origin for the HTTPS reverse proxy' });
|
|
42
|
+
});
|
|
37
43
|
export const PARTITION_GITIGNORE = [
|
|
38
44
|
"# hunch serve partition — derived runtime artifacts (regenerable from .hunch/*.json)",
|
|
39
45
|
".hunch/*.sqlite", ".hunch/*.sqlite-shm", ".hunch/*.sqlite-wal", ".hunch/*.sqlite-journal",
|
|
@@ -73,18 +79,23 @@ export function writeServeConfig(file, config) {
|
|
|
73
79
|
writeFileAtomic(resolve(file), JSON.stringify(ServeConfigSchema.parse(config), null, 2) + "\n");
|
|
74
80
|
}
|
|
75
81
|
/** Constant-time token → principal. Undefined for a missing or unknown token. */
|
|
76
|
-
export function
|
|
82
|
+
export function resolveCredential(config, token) {
|
|
77
83
|
if (!token)
|
|
78
84
|
return undefined;
|
|
79
85
|
const hash = Buffer.from(hashToken(token), "hex");
|
|
80
86
|
for (const p of config.principals) {
|
|
81
87
|
const candidate = Buffer.from(p.token_sha256, "hex");
|
|
82
88
|
if (candidate.length === hash.length && timingSafeEqual(candidate, hash)) {
|
|
83
|
-
return
|
|
89
|
+
return p;
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
return undefined;
|
|
87
93
|
}
|
|
94
|
+
/** Legacy bearer callers cannot resolve a key-bound credential without proof. */
|
|
95
|
+
export function resolvePrincipal(config, token) {
|
|
96
|
+
const p = resolveCredential(config, token);
|
|
97
|
+
return p && !p.proof_key ? { id: p.id, kind: p.kind, ...(p.display ? { display: p.display } : {}), grants: p.grants } : undefined;
|
|
98
|
+
}
|
|
88
99
|
export function partitionFor(config, scope) {
|
|
89
100
|
return config.partitions.find((p) => scopePath(p.scope) === scopePath(scope));
|
|
90
101
|
}
|
|
@@ -94,12 +105,21 @@ export function partitionFor(config, scope) {
|
|
|
94
105
|
export function initServeConfig(opts) {
|
|
95
106
|
const file = resolve(opts.file);
|
|
96
107
|
const existing = existsSync(file) ? readServeConfig(file) : null;
|
|
108
|
+
const publicOrigin = opts.publicOrigin ?? existing?.public_origin;
|
|
109
|
+
if (publicOrigin)
|
|
110
|
+
PublicOriginSchema.parse(publicOrigin);
|
|
111
|
+
const proofKey = opts.principal?.proofKey ?? existing?.principals.find(p => p.id === opts.principal?.id)?.proof_key;
|
|
112
|
+
if (proofKey) {
|
|
113
|
+
ProofPublicKeySchema.parse(proofKey);
|
|
114
|
+
if (!publicOrigin)
|
|
115
|
+
throw new Error("key-bound principals require an HTTPS public origin");
|
|
116
|
+
}
|
|
97
117
|
const root = resolve(opts.root);
|
|
98
118
|
const hunchDir = resolve(root, ".hunch");
|
|
99
119
|
mkdirSync(hunchDir, { recursive: true });
|
|
100
120
|
const partitionFile = resolve(hunchDir, "partition.json");
|
|
101
121
|
if (existsSync(partitionFile)) {
|
|
102
|
-
const declared =
|
|
122
|
+
const declared = PartitionDeclarationSchema.parse(JSON.parse(readFileSync(partitionFile, "utf8")));
|
|
103
123
|
if (scopePath(declared) !== scopePath(opts.scope))
|
|
104
124
|
throw new Error(`${root} already declares partition ${scopePath(declared)}, not ${scopePath(opts.scope)}`);
|
|
105
125
|
}
|
|
@@ -122,9 +142,9 @@ export function initServeConfig(opts) {
|
|
|
122
142
|
if (opts.principal) {
|
|
123
143
|
token = mintToken();
|
|
124
144
|
const grants = opts.principal.grants?.length ? opts.principal.grants : [opts.scope];
|
|
125
|
-
principals = [...principals.filter((p) => p.id !== opts.principal.id), { id: opts.principal.id, kind: opts.principal.kind, token_sha256: hashToken(token), grants }];
|
|
145
|
+
principals = [...principals.filter((p) => p.id !== opts.principal.id), { id: opts.principal.id, kind: opts.principal.kind, ...(proofKey ? { proof_key: proofKey } : {}), token_sha256: hashToken(token), grants }];
|
|
126
146
|
}
|
|
127
|
-
const config = { schema: SERVE_CONFIG_VERSION, port: opts.port ?? existing?.port ?? 7474, partitions, principals };
|
|
147
|
+
const config = { ...(publicOrigin ? { public_origin: publicOrigin } : {}), schema: SERVE_CONFIG_VERSION, port: opts.port ?? existing?.port ?? 7474, partitions, principals };
|
|
128
148
|
writeServeConfig(file, config);
|
|
129
149
|
return { config, token, partition };
|
|
130
150
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A static, read-only consumer of the existing state HTTP contract. No embedded user data. */
|
|
2
|
+
export declare const operatorHtml = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Hunch \u00B7 Shared state</title><link rel=\"icon\" href=\"data:,\"><link rel=\"stylesheet\" href=\"/operator.css\"><script src=\"/operator.js\" defer></script></head>\n<body><main>\n<header><div class=\"eyebrow\">HUNCH / SHARED RECORD</div><span class=\"badge\">Read-only view</span>\n<h1>Shared state</h1><p class=\"intro\">What was decided, what happened, and what still needs doing.</p></header>\n<section id=\"connection\" class=\"panel\"><h2>Open your workspace</h2><p>Use a token issued by this Hunch server. It stays in this tab\u2019s memory until you disconnect or reload.</p>\n<form id=\"connect-form\"><label for=\"token\">Access token</label><div class=\"row\"><input id=\"token\" type=\"password\" required autocomplete=\"off\" spellcheck=\"false\" placeholder=\"Paste your access token\"><button id=\"connect\" type=\"submit\">Connect</button></div><details><summary>Key-bound token</summary><label for=\"proof-key\">Private key file (Ed25519 JWK)</label><input id=\"proof-key\" type=\"file\" accept=\".json,application/json\"><p class=\"small muted\">Used locally to sign requests. The private key stays in this tab and is cleared on disconnect.</p></details></form>\n<p class=\"muted small\">The view makes no changes to records. The token keeps its existing server permissions.</p></section>\n<p id=\"status\" role=\"status\" aria-live=\"polite\"></p><p id=\"error\" role=\"alert\" hidden></p>\n<div id=\"workspace\" hidden>\n<section class=\"toolbar\"><div><label for=\"scope\">Workspace</label><select id=\"scope\"></select><p id=\"identity\" class=\"small muted\"></p></div><div class=\"row\"><button id=\"refresh\" class=\"secondary\">Refresh</button><button id=\"disconnect\" class=\"secondary\">Disconnect</button></div></section>\n<form id=\"subject-form\" class=\"panel\"><label for=\"subject\">Find a subject or record</label><div class=\"row\"><input id=\"subject\" maxlength=\"512\" required placeholder=\"A topic, customer:c1, or record ID\" autocomplete=\"off\"><button type=\"submit\">Show state</button><button id=\"inspect-id\" type=\"button\" class=\"secondary\">Inspect record ID</button></div><p class=\"small muted\">Show state for an exact topic or subject key. Inspect a record ID to see its stored version, including failed or retired records.</p></form>\n<section id=\"subject-view\" aria-labelledby=\"subject-title\"><div class=\"section-heading\"><div><div class=\"eyebrow\">STATE ON RECORD</div><h2 id=\"subject-title\">Choose a subject</h2></div></div><p id=\"subject-hint\" class=\"muted\">Open a subject to see the record your agents share.</p><div id=\"state-content\"></div></section>\n<section id=\"record-view\" class=\"panel\" hidden aria-labelledby=\"record-title\"><div class=\"section-heading\"><h2 id=\"record-title\">Stored record</h2><button id=\"close-record\" class=\"secondary\">Close record</button></div><p class=\"muted small\">Latest stored version. A past activity event may refer to an earlier revision.</p><div id=\"record-content\"></div></section>\n<section aria-labelledby=\"activity-title\"><div class=\"section-heading\"><div><div class=\"eyebrow\">CHANGE HISTORY</div><h2 id=\"activity-title\">Recent activity</h2></div></div><p id=\"activity-note\" class=\"muted small\"></p><div id=\"activity\"></div></section>\n<footer>Showing stored evidence, not a fresh check of external systems. Source content stays in its original system. Refresh to see changes made by other agents.</footer>\n</div><noscript>This view needs JavaScript to make authenticated reads from your Hunch server.</noscript>\n</main></body></html>";
|
|
3
|
+
export declare const operatorCss: string;
|
|
4
|
+
export declare const operatorJs: string;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** A static, read-only consumer of the existing state HTTP contract. No embedded user data. */
|
|
2
|
+
export const operatorHtml = `<!doctype html>
|
|
3
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>Hunch · Shared state</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/operator.css"><script src="/operator.js" defer></script></head>
|
|
5
|
+
<body><main>
|
|
6
|
+
<header><div class="eyebrow">HUNCH / SHARED RECORD</div><span class="badge">Read-only view</span>
|
|
7
|
+
<h1>Shared state</h1><p class="intro">What was decided, what happened, and what still needs doing.</p></header>
|
|
8
|
+
<section id="connection" class="panel"><h2>Open your workspace</h2><p>Use a token issued by this Hunch server. It stays in this tab’s memory until you disconnect or reload.</p>
|
|
9
|
+
<form id="connect-form"><label for="token">Access token</label><div class="row"><input id="token" type="password" required autocomplete="off" spellcheck="false" placeholder="Paste your access token"><button id="connect" type="submit">Connect</button></div><details><summary>Key-bound token</summary><label for="proof-key">Private key file (Ed25519 JWK)</label><input id="proof-key" type="file" accept=".json,application/json"><p class="small muted">Used locally to sign requests. The private key stays in this tab and is cleared on disconnect.</p></details></form>
|
|
10
|
+
<p class="muted small">The view makes no changes to records. The token keeps its existing server permissions.</p></section>
|
|
11
|
+
<p id="status" role="status" aria-live="polite"></p><p id="error" role="alert" hidden></p>
|
|
12
|
+
<div id="workspace" hidden>
|
|
13
|
+
<section class="toolbar"><div><label for="scope">Workspace</label><select id="scope"></select><p id="identity" class="small muted"></p></div><div class="row"><button id="refresh" class="secondary">Refresh</button><button id="disconnect" class="secondary">Disconnect</button></div></section>
|
|
14
|
+
<form id="subject-form" class="panel"><label for="subject">Find a subject or record</label><div class="row"><input id="subject" maxlength="512" required placeholder="A topic, customer:c1, or record ID" autocomplete="off"><button type="submit">Show state</button><button id="inspect-id" type="button" class="secondary">Inspect record ID</button></div><p class="small muted">Show state for an exact topic or subject key. Inspect a record ID to see its stored version, including failed or retired records.</p></form>
|
|
15
|
+
<section id="subject-view" aria-labelledby="subject-title"><div class="section-heading"><div><div class="eyebrow">STATE ON RECORD</div><h2 id="subject-title">Choose a subject</h2></div></div><p id="subject-hint" class="muted">Open a subject to see the record your agents share.</p><div id="state-content"></div></section>
|
|
16
|
+
<section id="record-view" class="panel" hidden aria-labelledby="record-title"><div class="section-heading"><h2 id="record-title">Stored record</h2><button id="close-record" class="secondary">Close record</button></div><p class="muted small">Latest stored version. A past activity event may refer to an earlier revision.</p><div id="record-content"></div></section>
|
|
17
|
+
<section aria-labelledby="activity-title"><div class="section-heading"><div><div class="eyebrow">CHANGE HISTORY</div><h2 id="activity-title">Recent activity</h2></div></div><p id="activity-note" class="muted small"></p><div id="activity"></div></section>
|
|
18
|
+
<footer>Showing stored evidence, not a fresh check of external systems. Source content stays in its original system. Refresh to see changes made by other agents.</footer>
|
|
19
|
+
</div><noscript>This view needs JavaScript to make authenticated reads from your Hunch server.</noscript>
|
|
20
|
+
</main></body></html>`;
|
|
21
|
+
export const operatorCss = String.raw `
|
|
22
|
+
:root{color-scheme:light dark;--bg:#f4f7f4;--paper:#fff;--ink:#162e24;--muted:#53685b;--line:#d5e1d8;--accent:#276540;--error:#9c352e}
|
|
23
|
+
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 system-ui,sans-serif}main{max-width:1120px;margin:auto;padding:48px 28px 80px}header{border-top:5px solid var(--accent);padding-top:26px;margin-bottom:36px;position:relative}.eyebrow{font-size:12px;letter-spacing:.1em;color:var(--muted);font-weight:650}header>.badge{float:right}.badge{display:inline-block;font-size:12px;border:1px solid var(--line);border-radius:30px;padding:3px 10px;color:var(--muted)}h1{font-size:clamp(36px,6vw,60px);line-height:1.12;letter-spacing:-.045em;margin:20px 0 12px}h2{font-size:23px;line-height:1.3;margin:8px 0 14px}h3{font-size:17px;line-height:1.5;margin:10px 0}p{margin:8px 0}.intro{font-size:19px;color:var(--muted);max-width:660px}.panel,article{background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:24px;margin:16px 0}.muted,dt{color:var(--muted)}.small{font-size:13px}label{display:block;font-size:13px;font-weight:650;margin:12px 0 6px}input,select,button{font:inherit;border-radius:7px;padding:10px 14px;min-height:46px}input,select{min-width:0;width:100%;background:var(--paper);color:var(--ink);border:1px solid var(--line)}button{cursor:pointer;background:var(--accent);border:1px solid var(--accent);color:var(--paper);font-weight:600;flex-shrink:0}button.secondary{color:var(--ink);background:var(--paper);border-color:var(--line)}button:disabled{opacity:.6;cursor:wait}.row{display:flex;gap:10px;align-items:center}.row>input{flex:1}.toolbar,.section-heading{display:flex;gap:20px;align-items:center;justify-content:space-between}.toolbar{margin-bottom:24px}.toolbar>div:first-child{min-width:0;flex:1;max-width:470px}.section-heading{margin-top:32px}.state-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.state-column>h3{border-bottom:1px solid var(--line);padding-bottom:12px}.state-column article{padding:18px}.record-title,.record-content{white-space:pre-wrap;overflow-wrap:anywhere}.record-content{font-size:14px}.metadata{font-size:12px;color:var(--muted)}.empty{border:1px dashed var(--line);border-radius:10px;padding:20px;color:var(--muted);font-size:14px}summary{cursor:pointer;padding:8px 0;font-size:13px;font-weight:600}details{margin-top:14px;border-top:1px solid var(--line);padding-top:6px}pre{white-space:pre-wrap;font:12px/1.6 ui-monospace,monospace;background:var(--bg);padding:12px;border-radius:6px}pre,code,dd,li,h2,h3,select{overflow-wrap:anywhere;word-break:break-word}dd{margin:0 0 8px;font-size:13px}dt{font-size:12px}ul{padding-left:20px}article.activity-item{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:18px 22px}.activity-item>div{min-width:0}.activity-item h3{margin:0 0 4px}.activity-actions{display:flex;gap:8px;flex-wrap:wrap}.activity-actions button{font-size:12px;min-height:40px;padding:7px 11px}#error{color:var(--error);border-left:3px solid var(--error);padding:12px 16px;background:var(--paper)}#status{color:var(--muted)}footer{margin-top:36px;border-top:1px solid var(--line);padding-top:20px;font-size:12px;color:var(--muted)}:focus-visible{outline:3px solid var(--accent);outline-offset:4px}
|
|
24
|
+
@media(prefers-color-scheme:dark){:root{--bg:#101b16;--paper:#17271e;--ink:#e3eee6;--muted:#a7b9ac;--line:#365041;--accent:#9cdbb0;--error:#f1a89e}}
|
|
25
|
+
@media(max-width:760px){main{padding:26px 20px 50px}.state-grid{grid-template-columns:1fr}.toolbar{align-items:stretch;flex-direction:column;gap:8px}.toolbar>div:first-child{max-width:none}.panel{padding:20px}.row{flex-wrap:wrap}.row input{flex-basis:100%}article.activity-item{flex-direction:column;gap:10px}.section-heading{flex-wrap:wrap}header>.badge{float:none;margin-top:12px}.section-heading h2{max-width:100%}}
|
|
26
|
+
`;
|
|
27
|
+
export const operatorJs = String.raw `
|
|
28
|
+
'use strict';
|
|
29
|
+
(() => {
|
|
30
|
+
const $ = id => document.getElementById(id);
|
|
31
|
+
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
|
|
32
|
+
const empty = text => node('p', text, 'empty');
|
|
33
|
+
let proofSigner, proofNonce;
|
|
34
|
+
let token = '', scopes = [], subject = '', cursor = null, generation = 0, controller;
|
|
35
|
+
const scope = () => scopes[Number($('scope').value)];
|
|
36
|
+
function clearSubject() {
|
|
37
|
+
subject = ''; cursor = null; $('state-content').replaceChildren(); $('subject-title').textContent = 'Choose a subject';
|
|
38
|
+
$('subject-hint').textContent = 'Open a subject to see the record your agents share.'; $('record-view').hidden = true; $('record-content').replaceChildren();
|
|
39
|
+
}
|
|
40
|
+
function disconnect() {
|
|
41
|
+
generation++; controller?.abort(); token = ''; proofSigner = undefined; proofNonce = undefined; $('proof-key').value = ''; scopes = []; clearSubject(); $('token').value = ''; $('subject').value = '';
|
|
42
|
+
$('scope').replaceChildren(); $('identity').textContent = ''; $('activity').replaceChildren(); $('activity-note').textContent = '';
|
|
43
|
+
$('workspace').hidden = true; $('connection').hidden = false; $('error').hidden = true; $('status').textContent = 'Disconnected. Workspace data cleared from this page.';
|
|
44
|
+
$('connect').disabled = false; $('refresh').disabled = false; $('token').focus();
|
|
45
|
+
}
|
|
46
|
+
async function run(label, work) {
|
|
47
|
+
controller?.abort(); controller = new AbortController(); const signal = controller.signal, id = ++generation;
|
|
48
|
+
$('error').hidden = true; $('status').textContent = label; $('connect').disabled = true; $('refresh').disabled = true;
|
|
49
|
+
try { await work(signal, () => id === generation); if (id === generation) $('status').textContent = 'Updated ' + new Date().toLocaleTimeString() + ' · refresh for newer records'; }
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (id !== generation || e.name === 'AbortError') return;
|
|
52
|
+
if (e.status === 401) disconnect();
|
|
53
|
+
$('error').textContent = e.status === 409 ? 'The records changed between pages. Show the subject again to restart from current state.' : e.status === 401 ? 'Those credentials were not accepted. Check the token and any required key, then reconnect.' : e.message || 'Could not reach this Hunch server. Try again.';
|
|
54
|
+
$('error').hidden = false; $('status').textContent = 'Update failed. Displayed records may be out of date.';
|
|
55
|
+
} finally { if (id === generation) { $('connect').disabled = false; $('refresh').disabled = false; } }
|
|
56
|
+
}
|
|
57
|
+
async function importProofSigner(file) {
|
|
58
|
+
if (location.protocol !== 'https:') throw new Error('Key-bound tokens require the HTTPS server address.');
|
|
59
|
+
if (file.size > 8192) throw new Error('Private key files are limited to 8 KiB.');
|
|
60
|
+
const jwk = JSON.parse(await file.text());
|
|
61
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string' || typeof jwk.d !== 'string') throw new Error('Choose an Ed25519 private JWK file.');
|
|
62
|
+
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']);
|
|
63
|
+
const publicKey = { crv: 'Ed25519', kty: 'OKP', x: jwk.x };
|
|
64
|
+
const base64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
65
|
+
const encode = value => base64(new TextEncoder().encode(JSON.stringify(value)));
|
|
66
|
+
const header = encode({ typ: 'dpop+jwt', alg: 'EdDSA', jwk: publicKey });
|
|
67
|
+
return async (method, url, accessToken, nonce) => {
|
|
68
|
+
const ath = base64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)));
|
|
69
|
+
const payload = encode({ jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), ath, ...(nonce ? { nonce } : {}) });
|
|
70
|
+
const message = header + '.' + payload;
|
|
71
|
+
return message + '.' + base64(await crypto.subtle.sign('Ed25519', key, new TextEncoder().encode(message)));
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function api(route, body, signal) {
|
|
75
|
+
const method = body === undefined ? 'GET' : 'POST', path = '/nuryel/v1/' + route, accessToken = token, signer = proofSigner;
|
|
76
|
+
const request = async () => fetch(path, { method, headers: { Authorization: (signer ? 'DPoP ' : 'Bearer ') + accessToken, ...(signer ? { DPoP: await signer(method, location.origin + path, accessToken, proofNonce) } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body), signal, cache: 'no-store', credentials: 'omit', redirect: 'error' });
|
|
77
|
+
let response = await request(), data = await response.json();
|
|
78
|
+
if (signer && response.status === 401 && data.title === 'use_dpop_nonce' && response.headers.has('dpop-nonce')) {
|
|
79
|
+
proofNonce = response.headers.get('dpop-nonce'); response = await request(); data = await response.json();
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) { const e = new Error(data.detail || 'The server could not complete this read.'); e.status = response.status; throw e; } return data;
|
|
82
|
+
}
|
|
83
|
+
function field(list, title, value) { if (value === undefined || value === null || value === '') return; list.append(node('dt', title), node('dd', typeof value === 'string' ? value : JSON.stringify(value))); }
|
|
84
|
+
// Dependencies have a fixed JSON schema. Match the contract's sorted-key hash,
|
|
85
|
+
// independent of their position in the dependency array; never fetch a source.
|
|
86
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
|
|
87
|
+
async function dependencyHash(value) {
|
|
88
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(canonical(value))));
|
|
89
|
+
return 'sha256:' + Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join('');
|
|
90
|
+
}
|
|
91
|
+
async function citationDetails(record, area) {
|
|
92
|
+
try {
|
|
93
|
+
const dependencies = new Map(await Promise.all(record.dependencies.map(async d => [await dependencyHash(d), d])));
|
|
94
|
+
for (const citation of record.field_provenance) {
|
|
95
|
+
const selector = citation.selector;
|
|
96
|
+
const detail = node('details'); detail.append(node('summary', selector.kind === 'text' ? 'Text ' + selector.start + '–' + selector.end : 'Field ' + (selector.path || '(root)')));
|
|
97
|
+
// A valid record can reuse hundreds of large sources across many fields.
|
|
98
|
+
// Expand only the field the person opens, with four source bodies per step.
|
|
99
|
+
let rendered = false;
|
|
100
|
+
detail.addEventListener('toggle', () => {
|
|
101
|
+
if (!detail.open || rendered) return; rendered = true;
|
|
102
|
+
let value = selector.kind === 'text' ? Array.from(record.content).slice(selector.start, selector.end).join('') : JSON.parse(record.content);
|
|
103
|
+
if (selector.kind === 'json_pointer') for (const key of selector.path === '' ? [] : selector.path.slice(1).split('/').map(k => k.replace(/~1/g, '/').replace(/~0/g, '~'))) {
|
|
104
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) { detail.append(node('p', 'Citation target unavailable.')); return; }
|
|
105
|
+
value = value[key];
|
|
106
|
+
}
|
|
107
|
+
detail.append(node('pre', JSON.stringify(value)), node('h3', 'Sources for this field'));
|
|
108
|
+
const bodies = node('div'), more = node('button', 'Show more sources', 'secondary'); let offset = 0;
|
|
109
|
+
const showSources = () => {
|
|
110
|
+
citation.dependency_hashes.slice(offset, offset + 4).forEach(hash => bodies.append(node('pre', JSON.stringify(dependencies.get(hash) || { unavailable_dependency_hash: hash }, null, 2))));
|
|
111
|
+
offset += 4; more.hidden = offset >= citation.dependency_hashes.length;
|
|
112
|
+
};
|
|
113
|
+
more.type = 'button'; more.onclick = showSources; detail.append(bodies, more); showSources();
|
|
114
|
+
});
|
|
115
|
+
area.append(detail);
|
|
116
|
+
}
|
|
117
|
+
} catch { area.append(node('p', 'Citation display unavailable. Exact citations remain in Sources & record details.', 'small muted')); }
|
|
118
|
+
}
|
|
119
|
+
function card(ref, records) {
|
|
120
|
+
const r = records[ref.id], article = node('article');
|
|
121
|
+
if (!r) { article.append(empty('Record body unavailable: ' + ref.id)); return article; }
|
|
122
|
+
article.append(node('span', (r.state || r.status || r.lifecycle || ref.facet) + ' · ' + ref.facet, 'badge'));
|
|
123
|
+
let observation;
|
|
124
|
+
if (typeof r.content === 'string') { try { const parsed = JSON.parse(r.content); if (parsed?.schema === 'nuryel.observation-content/1' && typeof parsed.statement === 'string') observation = parsed; } catch { /* Ordinary derived text is not JSON. */ } }
|
|
125
|
+
const title = r.title || r.statement || observation?.statement || (r.action_kind ? r.action_kind.replaceAll('_', ' ') + ' · ' + (r.target?.object_key || '') : r.name || r.key || r.subject || ref.id);
|
|
126
|
+
article.append(node('h3', title, 'record-title'));
|
|
127
|
+
const description = observation ? observation.relevance?.reason : r.value || r.content || r.decision || r.rationale;
|
|
128
|
+
if (description) article.append(node('p', description, 'record-content'));
|
|
129
|
+
if (r.visibility) article.append(node('p', 'Restricted record · access owner: ' + r.visibility.owner, 'metadata'));
|
|
130
|
+
if (r.owner || r.actor) article.append(node('p', (r.owner ? 'Owner: ' + r.owner : 'Actor: ' + r.actor), 'metadata'));
|
|
131
|
+
if (r.due) article.append(node('p', 'Due ' + r.due, 'metadata'));
|
|
132
|
+
if (r.occurred_at || r.computed_at || r.created_at) article.append(node('p', r.occurred_at || r.computed_at || r.created_at, 'metadata'));
|
|
133
|
+
if (r.field_provenance?.length) {
|
|
134
|
+
const citations = node('section', undefined, 'field-citations');
|
|
135
|
+
citations.append(node('h3', 'Field citations'), node('p', 'Writer-supplied source links. They do not verify truth, freshness, or uncited fields.', 'small muted'));
|
|
136
|
+
article.append(citations); void citationDetails(r, citations);
|
|
137
|
+
}
|
|
138
|
+
const detail = node('details'); detail.append(node('summary', 'Sources & record details'));
|
|
139
|
+
const list = node('dl'); field(list, 'Record ID', ref.id); field(list, 'Recorded by / source', r.provenance?.source);
|
|
140
|
+
field(list, 'Source reference', r.source); field(list, 'Action target', r.target); field(list, 'Closed by receipt', r.closed_by); field(list, 'Captured by', observation?.captured_by);
|
|
141
|
+
if (ref.record_hash) field(list, 'Record revision', ref.record_hash); detail.append(list);
|
|
142
|
+
const evidence = r.provenance?.evidence || []; if (evidence.length) { detail.append(node('h3', 'Evidence')); const ul = node('ul'); evidence.forEach(x => ul.append(node('li', typeof x === 'string' ? x : JSON.stringify(x)))); detail.append(ul); }
|
|
143
|
+
if (observation?.evidence) detail.append(node('h3', 'Source excerpts'), node('pre', JSON.stringify(observation.evidence, null, 2)));
|
|
144
|
+
if (r.dependencies?.length || r.rests_on?.length) { detail.append(node('h3', 'Depends on'), node('pre', JSON.stringify(r.dependencies || r.rests_on, null, 2))); }
|
|
145
|
+
detail.append(node('pre', JSON.stringify(r, null, 2))); article.append(detail); return article;
|
|
146
|
+
}
|
|
147
|
+
function group(title, refs, records, hint) {
|
|
148
|
+
const section = node('section', undefined, 'state-column'); section.append(node('h3', title + ' (' + refs.length + ')'));
|
|
149
|
+
if (hint) section.append(node('p', hint, 'small muted'));
|
|
150
|
+
if (!refs.length) section.append(empty('No matching records on file.')); else refs.forEach(ref => section.append(card(ref, records))); return section;
|
|
151
|
+
}
|
|
152
|
+
async function readSubject(value, next, signal, current) {
|
|
153
|
+
const result = await api('read', { scope: scope(), subject: value, observed_page: next ? { cursor: next } : {} }, signal);
|
|
154
|
+
if (!current()) return;
|
|
155
|
+
subject = value; cursor = result.state_of_record?.observed_page?.next_cursor || null;
|
|
156
|
+
$('subject').value = value; $('subject-title').textContent = value; $('subject-hint').textContent = 'Stored state for this subject. Status labels describe the record; they do not independently verify its claims.';
|
|
157
|
+
const state = result.state_of_record, records = result.records || {}, area = $('state-content'); area.replaceChildren();
|
|
158
|
+
if (!state) { area.append(empty('No state was returned for this subject.')); return; }
|
|
159
|
+
const grid = node('div', undefined, 'state-grid');
|
|
160
|
+
grid.append(group('Current records', state.current, records), group('Open commitments & rules', state.in_force, records), group('Completed work', state.done, records)); area.append(grid);
|
|
161
|
+
if (state.observed?.length) area.append(group('Observations', state.observed, records, 'Source-backed statements whose currentness is unverified.'));
|
|
162
|
+
if (result.conventions) {
|
|
163
|
+
const section = node('section'); section.append(node('h3', 'Explicit conventions'), node('p', 'Advisory. Resolve conflicts before applying a preference; no scope silently overrides another.', 'small muted'));
|
|
164
|
+
for (const item of result.conventions.items) {
|
|
165
|
+
const entry = card(item.ref, records); entry.prepend(node('p', item.ref.scope.kind + '/' + item.ref.scope.id + ' · ' + item.currentness + (item.conflict ? ' · CONFLICT' : ''), 'metadata')); section.append(entry);
|
|
166
|
+
}
|
|
167
|
+
if (result.conventions.truncated) section.append(node('p', 'More conventions exist; this view is incomplete.', 'small muted'));
|
|
168
|
+
area.append(section);
|
|
169
|
+
}
|
|
170
|
+
const page = state.observed_page;
|
|
171
|
+
if (page && page.total) area.append(node('p', 'Observations ' + ((next?.offset || 0) + 1) + '–' + ((next?.offset || 0) + (state.observed?.length || 0)) + ' of ' + page.total, 'small muted'));
|
|
172
|
+
if (cursor) { const more = node('button', 'Next observations', 'secondary'); more.onclick = () => run('Loading observations…', (s, c) => readSubject(subject, cursor, s, c)); area.append(more); }
|
|
173
|
+
if (next) { const first = node('button', 'First observations', 'secondary'); first.onclick = () => showSubject(subject); area.append(first); }
|
|
174
|
+
if (state.relationships_truncated) area.append(node('p', 'Some relationships are omitted by the server’s response limit.', 'small muted'));
|
|
175
|
+
if (state.invalidated_by?.length) area.append(node('p', 'Recorded invalidation signals: ' + state.invalidated_by.join(', '), 'small muted'));
|
|
176
|
+
const evidence = node('details'); evidence.append(node('summary', 'Read evidence'), node('pre', JSON.stringify({ receipt_id: result.receipt_id, depends_on: state.depends_on, denied_scopes: result.denied_scopes }, null, 2))); area.append(evidence);
|
|
177
|
+
}
|
|
178
|
+
function showSubject(value) {
|
|
179
|
+
clearSubject(); $('subject-title').textContent = value; $('subject-hint').textContent = 'Loading stored state…';
|
|
180
|
+
return run('Reading subject…', (signal, current) => readSubject(value, null, signal, current));
|
|
181
|
+
}
|
|
182
|
+
function inspectRecord(id) {
|
|
183
|
+
return run('Reading record…', async (signal, current) => {
|
|
184
|
+
$('record-content').replaceChildren(); $('record-view').hidden = false;
|
|
185
|
+
const result = await api('records', { scope: scope(), ids: [id] }, signal); if (!current()) return;
|
|
186
|
+
$('record-content').replaceChildren(result.records[id] ? card({ id, facet: result.facets[id] }, result.records) : empty(result.denied.includes(id) ? 'This record is outside your access.' : 'This record is no longer available.'));
|
|
187
|
+
$('record-view').scrollIntoView({ block: 'start' });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async function activity(signal, current) {
|
|
191
|
+
const result = await api('subscribe', { scope: scope(), after_seq: 0 }, signal); if (!current()) return;
|
|
192
|
+
const events = result.events.slice(-50).reverse(); $('activity').replaceChildren();
|
|
193
|
+
$('activity-note').textContent = 'Showing ' + events.length + ' most recent retained changes · ledger head ' + result.head_seq + '. ' + (result.floor_seq ? 'Earlier history was compacted through event ' + result.floor_seq + '. ' : '') + 'This is change history, not a complete inventory.';
|
|
194
|
+
if (!events.length) $('activity').append(empty('No retained activity in this workspace. You can still look up a subject or record above.'));
|
|
195
|
+
events.forEach(event => {
|
|
196
|
+
const item = node('article', undefined, 'activity-item'), info = node('div'), actions = node('div', undefined, 'activity-actions');
|
|
197
|
+
info.append(node('h3', event.subject || event.record_id), node('p', event.change + ' · ' + event.facet + ' · ' + event.at, 'metadata'));
|
|
198
|
+
if (event.cause?.principal) info.append(node('p', 'Recorded by ' + event.cause.principal, 'metadata'));
|
|
199
|
+
const open = node('button', 'Open subject', 'secondary'); open.onclick = () => { showSubject(event.subject || event.record_id); $('subject-view').scrollIntoView({ block: 'start' }); }; actions.append(open);
|
|
200
|
+
const inspect = node('button', 'Inspect record', 'secondary'); inspect.onclick = () => inspectRecord(event.record_id); actions.append(inspect); item.append(info, actions); $('activity').append(item);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
$('connect-form').onsubmit = event => {
|
|
204
|
+
event.preventDefault(); token = $('token').value.trim(); $('token').value = '';
|
|
205
|
+
run('Connecting…', async (signal, current) => {
|
|
206
|
+
const file = $('proof-key').files[0]; $('proof-key').value = '';
|
|
207
|
+
proofSigner = undefined; proofNonce = undefined;
|
|
208
|
+
if (file) { const signer = await importProofSigner(file); if (!current()) return; proofSigner = signer; }
|
|
209
|
+
const result = await api('capabilities', undefined, signal); if (!current()) return;
|
|
210
|
+
scopes = result.principal.grants; $('scope').replaceChildren(); scopes.forEach((s, i) => { const option = node('option', s.kind + ' / ' + s.id); option.value = String(i); $('scope').append(option); });
|
|
211
|
+
$('identity').textContent = 'Connected as ' + (result.principal.display || result.principal.id);
|
|
212
|
+
$('connection').hidden = true; $('workspace').hidden = false; await activity(signal, current);
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
$('scope').onchange = () => { clearSubject(); $('subject').value = ''; $('activity').replaceChildren(); $('activity-note').textContent = ''; run('Loading workspace…', activity); };
|
|
216
|
+
$('subject-form').onsubmit = event => { event.preventDefault(); const value = $('subject').value.trim(); if (value) showSubject(value); };
|
|
217
|
+
$('inspect-id').onclick = () => { if ($('subject').reportValidity()) inspectRecord($('subject').value.trim()); };
|
|
218
|
+
$('refresh').onclick = () => run('Refreshing workspace…', async (signal, current) => { $('record-view').hidden = true; $('record-content').replaceChildren(); await activity(signal, current); if (subject && current()) await readSubject(subject, null, signal, current); });
|
|
219
|
+
$('disconnect').onclick = disconnect; $('close-record').onclick = () => { $('record-view').hidden = true; $('record-content').replaceChildren(); };
|
|
220
|
+
window.addEventListener('pagehide', disconnect);
|
|
221
|
+
})();
|
|
222
|
+
`;
|
|
223
|
+
//# sourceMappingURL=operator.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ProofPublicKey } from '../core/stateProof.js';
|
|
2
|
+
export declare class StateProofError extends Error {
|
|
3
|
+
readonly code: 'invalid_dpop_proof' | 'use_dpop_nonce';
|
|
4
|
+
readonly nonce?: string | undefined;
|
|
5
|
+
constructor(code: 'invalid_dpop_proof' | 'use_dpop_nonce', nonce?: string | undefined);
|
|
6
|
+
}
|
|
7
|
+
export declare function verifyStateProof(input: {
|
|
8
|
+
proof?: string;
|
|
9
|
+
key: ProofPublicKey;
|
|
10
|
+
method: string;
|
|
11
|
+
url: string;
|
|
12
|
+
token: string;
|
|
13
|
+
stateDir: string;
|
|
14
|
+
now?: number;
|
|
15
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { withWriteLock } from './writelock.js';
|
|
2
|
+
/** Shared-disk nonce/replay state survives restarts and serializes independent server processes. */
|
|
3
|
+
import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto';
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { writeFileAtomicIfAbsent } from '../core/io.js';
|
|
8
|
+
import { ProofPublicKeySchema, proofThumbprint, proofTarget, tokenProofHash } from '../core/stateProof.js';
|
|
9
|
+
const Header = z.object({ typ: z.literal('dpop+jwt'), alg: z.literal('EdDSA'), jwk: ProofPublicKeySchema }).strict();
|
|
10
|
+
const Claims = z.object({ jti: z.string().min(16).max(128), htm: z.string().max(16), htu: z.string().max(2048), iat: z.number().int().nonnegative(), ath: z.string().regex(/^[A-Za-z0-9_-]{43}$/), nonce: z.string().max(128).optional() }).strict();
|
|
11
|
+
export class StateProofError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
nonce;
|
|
14
|
+
constructor(code, nonce) {
|
|
15
|
+
super(code === 'use_dpop_nonce' ? 'a fresh server nonce is required' : 'request proof is invalid or already used');
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.nonce = nonce;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function ordinaryDirectory(path) {
|
|
21
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
22
|
+
const stat = lstatSync(path);
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
24
|
+
throw new Error('proof state must be an ordinary private directory');
|
|
25
|
+
if ((stat.mode & 0o777) !== 0o700)
|
|
26
|
+
chmodSync(path, 0o700);
|
|
27
|
+
}
|
|
28
|
+
function equal(a, b) { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
|
|
29
|
+
function decode(segment) {
|
|
30
|
+
if (!/^[A-Za-z0-9_-]+$/.test(segment) || Buffer.from(segment, 'base64url').toString('base64url') !== segment)
|
|
31
|
+
throw new StateProofError('invalid_dpop_proof');
|
|
32
|
+
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
|
33
|
+
}
|
|
34
|
+
const lastSweep = new Map();
|
|
35
|
+
export async function verifyStateProof(input) {
|
|
36
|
+
const now = input.now ?? Math.floor(Date.now() / 1000), thumbprint = proofThumbprint(input.key);
|
|
37
|
+
ordinaryDirectory(input.stateDir);
|
|
38
|
+
const secretFile = join(input.stateDir, 'nonce-key');
|
|
39
|
+
if (!existsSync(secretFile))
|
|
40
|
+
writeFileAtomicIfAbsent(secretFile, randomBytes(32).toString('hex'));
|
|
41
|
+
const stat = lstatSync(secretFile);
|
|
42
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== 64)
|
|
43
|
+
throw new Error('proof nonce key is invalid');
|
|
44
|
+
if ((stat.mode & 0o777) !== 0o600)
|
|
45
|
+
chmodSync(secretFile, 0o600);
|
|
46
|
+
const secret = readFileSync(secretFile, 'utf8');
|
|
47
|
+
if (!/^[a-f0-9]{64}$/.test(secret))
|
|
48
|
+
throw new Error('proof nonce key is invalid');
|
|
49
|
+
const epoch = Math.floor(now / 60), tokenHash = tokenProofHash(input.token);
|
|
50
|
+
const nonceAt = (period) => `${period}.${createHmac('sha256', Buffer.from(secret, 'hex')).update(`${period}:${thumbprint}:${tokenHash}`).digest('base64url')}`;
|
|
51
|
+
const nonce = nonceAt(epoch);
|
|
52
|
+
if (!input.proof)
|
|
53
|
+
throw new StateProofError('use_dpop_nonce', nonce);
|
|
54
|
+
let claims;
|
|
55
|
+
try {
|
|
56
|
+
if (input.proof.length > 8192)
|
|
57
|
+
throw new Error('oversized');
|
|
58
|
+
const parts = input.proof.split('.');
|
|
59
|
+
if (parts.length !== 3)
|
|
60
|
+
throw new Error('JWT');
|
|
61
|
+
const header = Header.parse(decode(parts[0]));
|
|
62
|
+
claims = Claims.parse(decode(parts[1]));
|
|
63
|
+
const signature = Buffer.from(parts[2], 'base64url');
|
|
64
|
+
if (signature.length !== 64 || signature.toString('base64url') !== parts[2])
|
|
65
|
+
throw new Error('signature');
|
|
66
|
+
if (!equal(proofThumbprint(header.jwk), thumbprint))
|
|
67
|
+
throw new Error('key');
|
|
68
|
+
if (!verify(null, Buffer.from(parts[0] + '.' + parts[1]), createPublicKey({ key: header.jwk, format: 'jwk' }), signature))
|
|
69
|
+
throw new Error('signature');
|
|
70
|
+
if (claims.htm !== input.method || claims.htu !== proofTarget(input.url) || !equal(claims.ath, tokenHash) || claims.iat < now - 60 || claims.iat > now + 5)
|
|
71
|
+
throw new Error('binding');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new StateProofError('invalid_dpop_proof');
|
|
75
|
+
}
|
|
76
|
+
if (!claims.nonce || (!equal(claims.nonce, nonce) && !equal(claims.nonce, nonceAt(epoch - 1))))
|
|
77
|
+
throw new StateProofError('use_dpop_nonce', nonce);
|
|
78
|
+
await withWriteLock(input.stateDir, () => {
|
|
79
|
+
const replayRoot = join(input.stateDir, 'used');
|
|
80
|
+
ordinaryDirectory(replayRoot);
|
|
81
|
+
// One atomic filename per key/jti, independent of iat: concurrent proofs with
|
|
82
|
+
// different timestamps cannot each win in a separate bucket.
|
|
83
|
+
if (lastSweep.get(replayRoot) !== epoch) {
|
|
84
|
+
for (const entry of readdirSync(replayRoot, { withFileTypes: true })) {
|
|
85
|
+
if (!/^[a-f0-9]{64}$/.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
|
|
86
|
+
continue;
|
|
87
|
+
const file = join(replayRoot, entry.name);
|
|
88
|
+
try {
|
|
89
|
+
const expires = Number(readFileSync(file, 'utf8'));
|
|
90
|
+
if (Number.isFinite(expires) && expires < now)
|
|
91
|
+
rmSync(file);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code !== 'ENOENT')
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
lastSweep.set(replayRoot, epoch);
|
|
99
|
+
}
|
|
100
|
+
const id = createHash('sha256').update(thumbprint + '\0' + claims.jti).digest('hex');
|
|
101
|
+
if (!writeFileAtomicIfAbsent(join(replayRoot, id), String(claims.iat + 60)))
|
|
102
|
+
throw new StateProofError('invalid_dpop_proof');
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=stateProof.js.map
|
|
@@ -25,6 +25,11 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
25
25
|
head_seq: z.ZodNumber;
|
|
26
26
|
floor_seq: z.ZodDefault<z.ZodNumber>;
|
|
27
27
|
events: z.ZodArray<z.ZodObject<{
|
|
28
|
+
visibility: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
owner: z.ZodString;
|
|
30
|
+
readers: z.ZodArray<z.ZodString>;
|
|
31
|
+
writers: z.ZodArray<z.ZodString>;
|
|
32
|
+
}, z.core.$strict>>;
|
|
28
33
|
schema: z.ZodLiteral<"nuryel.state.subscribe/1">;
|
|
29
34
|
seq: z.ZodNumber;
|
|
30
35
|
at: z.ZodString;
|
|
@@ -47,6 +52,7 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
47
52
|
derived: "derived";
|
|
48
53
|
entities: "entities";
|
|
49
54
|
relationships: "relationships";
|
|
55
|
+
conventions: "conventions";
|
|
50
56
|
}>;
|
|
51
57
|
record_id: z.ZodString;
|
|
52
58
|
record_hash: z.ZodString;
|
|
@@ -191,7 +191,7 @@ export declare class HunchStore {
|
|
|
191
191
|
* relevance ordering (liveness/provenance/recency + topic-chain promotion)
|
|
192
192
|
* go through hybridSearch/searchScoped, where rerankByPriors applies.
|
|
193
193
|
* Falls back to LIKE if the query has no FTS-tokenizable terms. */
|
|
194
|
-
search(query: string, limit?: number): SearchHit[];
|
|
194
|
+
search(query: string, limit?: number, allowedIds?: readonly string[]): SearchHit[];
|
|
195
195
|
/** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
|
|
196
196
|
* commitments, failed receipts, retired entities): indexed and findable, but ranked BELOW
|
|
197
197
|
* the live record of the same subject. bm25 is negative (lower = better), so a history
|
|
@@ -341,6 +341,7 @@ export declare class HunchStore {
|
|
|
341
341
|
* history-inclusive view (backward-compatible default). */
|
|
342
342
|
why(target: string, opts?: {
|
|
343
343
|
asOf?: string;
|
|
344
|
+
canRead?: (record: unknown) => boolean;
|
|
344
345
|
}): WhyResult;
|
|
345
346
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
346
347
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
@@ -488,7 +489,7 @@ export declare class HunchStore {
|
|
|
488
489
|
* available at edit time, so this surfaces the risk as context, not a block. */
|
|
489
490
|
retiredForFile(file: string): RetiredNote[];
|
|
490
491
|
/** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
|
|
491
|
-
bugLineage(symptomOrSymbol: string): Bug[];
|
|
492
|
+
bugLineage(symptomOrSymbol: string, canRead?: (record: unknown) => boolean): Bug[];
|
|
492
493
|
/** Ranked fragility report (hunch fragile). fragility = weighted churn + bugs + fan-in. */
|
|
493
494
|
fragility(limit?: number): FragileNode[];
|
|
494
495
|
/** Convenience: load a single entity from JSON by id (any kind). */
|
|
@@ -507,6 +508,7 @@ export declare class HunchStore {
|
|
|
507
508
|
* why, then blast radius and bug history — trimmed to a rough token budget. */
|
|
508
509
|
assembleContext(target: string, budget?: number, opts?: {
|
|
509
510
|
asOf?: string;
|
|
511
|
+
canRead?: (record: unknown) => boolean;
|
|
510
512
|
}): AssembledContext;
|
|
511
513
|
}
|
|
512
514
|
/** The graph-served repo shape (hunch_structure) — orient without grep rounds. */
|