@sudobility/sider_lib 0.0.2

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.
@@ -0,0 +1,16 @@
1
+ import type { ToolSpec } from "@sudobility/sider_types";
2
+ export interface RunStepResult {
3
+ ok: boolean;
4
+ status?: number;
5
+ body?: unknown;
6
+ error?: string;
7
+ gate?: string;
8
+ }
9
+ /** Runs ONE tool by id with resolved args. The caller applies gates + fetch. */
10
+ export type StepRunner = (toolId: string, args: Record<string, unknown>) => Promise<RunStepResult>;
11
+ /** Reference, in a priorOutput binding, to the current element during a fan-out. */
12
+ declare const ITEM_REF = "$item";
13
+ export declare function executeComposite(tool: ToolSpec, ctx: {
14
+ args?: Record<string, unknown>;
15
+ }, run: StepRunner): Promise<RunStepResult>;
16
+ export { ITEM_REF };
@@ -0,0 +1,59 @@
1
+ // Composite/fan-out tool execution. A ToolSpec whose recipe has `compose` steps
2
+ // chains or fans out over other tools — e.g. "list sections → for each, fetch
3
+ // seats". This is PURE orchestration: the caller injects `run` (which applies
4
+ // the three gates + issues the actual request for one tool), and this threads
5
+ // prior outputs, resolves bindings, and parallelizes fan-out.
6
+ import { getByPath } from "./recipe";
7
+ /** Reference, in a priorOutput binding, to the current element during a fan-out. */
8
+ const ITEM_REF = "$item";
9
+ export async function executeComposite(tool, ctx, run) {
10
+ const steps = tool.recipe.compose ?? [];
11
+ if (steps.length === 0)
12
+ return run(tool.id, ctx.args ?? {});
13
+ const priorOutputs = {};
14
+ let last;
15
+ for (const step of steps) {
16
+ if (step.forEachJsonPath) {
17
+ // Fan out: run the step once per element of the referenced array (parallel).
18
+ const arr = getByPath(priorOutputs, step.forEachJsonPath);
19
+ const items = Array.isArray(arr) ? arr : [];
20
+ const results = await Promise.all(items.map((item) => run(step.toolId, resolveArgs(step.argBindings, ctx, priorOutputs, item))));
21
+ priorOutputs[step.ref] = results.map((r) => r.body);
22
+ last = { ok: results.every((r) => r.ok), body: results.map((r) => r.body) };
23
+ if (!last.ok)
24
+ break; // don't thread a failed fan-out into later steps
25
+ }
26
+ else {
27
+ const r = await run(step.toolId, resolveArgs(step.argBindings, ctx, priorOutputs, undefined));
28
+ priorOutputs[step.ref] = r.body;
29
+ last = r;
30
+ if (!r.ok)
31
+ break; // stop the chain on the first failure
32
+ }
33
+ }
34
+ return last ?? { ok: false, error: "empty compose" };
35
+ }
36
+ function resolveArgs(bindings, ctx, priorOutputs, item) {
37
+ const out = {};
38
+ for (const [key, b] of Object.entries(bindings)) {
39
+ switch (b.kind) {
40
+ case "literal":
41
+ out[key] = b.value;
42
+ break;
43
+ case "arg":
44
+ out[key] = ctx.args?.[b.argName];
45
+ break;
46
+ case "priorOutput":
47
+ out[key] =
48
+ b.stepRef === ITEM_REF ? getByPath(item, b.jsonPath) : getByPath(priorOutputs[b.stepRef], b.jsonPath);
49
+ break;
50
+ case "secret":
51
+ // Secrets belong to the sub-tool's own recipe (resolved at egress by
52
+ // the runner), never to compose-level args.
53
+ break;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ // keep the fan-out sentinel discoverable to callers/authors of recipes.
59
+ export { ITEM_REF };
@@ -0,0 +1,27 @@
1
+ import type { CompiledRequest, SecretSlot } from "@sudobility/sider_types";
2
+ import type { ToolSpec } from "@sudobility/sider_types";
3
+ import type { CompileContext } from "./recipe";
4
+ import type { GateContext } from "./gates";
5
+ export interface ResolvedRequest {
6
+ method: CompiledRequest["method"];
7
+ url: string;
8
+ headers: Record<string, string>;
9
+ body?: unknown;
10
+ credentials: "include";
11
+ }
12
+ /** Resolve a slot's current value from the caller's live session (never stored). */
13
+ export type SecretResolver = (slotId: string) => string | undefined;
14
+ export declare function detokenizeRequest(req: CompiledRequest, resolve: SecretResolver, slotsById: Record<string, SecretSlot>): ResolvedRequest;
15
+ export type PrepareResult = {
16
+ ok: true;
17
+ request: ResolvedRequest;
18
+ } | {
19
+ ok: false;
20
+ gate: "compile" | "same_origin" | "injection_location" | "safety";
21
+ reason: string;
22
+ };
23
+ /**
24
+ * compile → gate → detokenize, in one call. The browser executor should issue
25
+ * `result.request` only when `result.ok`.
26
+ */
27
+ export declare function prepareRequest(tool: ToolSpec, compileCtx: CompileContext, gateCtx: GateContext, resolve: SecretResolver): PrepareResult;
@@ -0,0 +1,58 @@
1
+ // Detokenization (the last mile, browser-side) + the single prepareRequest
2
+ // entry point. Detokenization runs ONLY after the gates pass; it reads each
3
+ // secret's current value from the caller's resolver (backed by the browser
4
+ // TokenMap) and substitutes it into the sentinel positions the gates already
5
+ // proved are confined to the declared location.
6
+ import { compileRecipe, secretSentinel } from "./recipe";
7
+ import { evaluateGates } from "./gates";
8
+ export function detokenizeRequest(req, resolve, slotsById) {
9
+ let url = req.url;
10
+ const headers = { ...req.headers };
11
+ let bodyStr = req.body !== undefined ? JSON.stringify(req.body) : undefined;
12
+ for (const slotId of req.referencedSlotIds) {
13
+ const slot = slotsById[slotId];
14
+ // auto_cookie secrets are attached by the browser; never substituted.
15
+ if (slot && (slot.role === "auto_cookie" || slot.injectionLocation.at === "cookie"))
16
+ continue;
17
+ const value = resolve(slotId);
18
+ if (value === undefined)
19
+ throw new Error(`Unresolved secret slot at egress: ${slotId}`);
20
+ const sen = secretSentinel(slotId);
21
+ url = url.split(sen).join(value);
22
+ for (const k of Object.keys(headers))
23
+ headers[k] = headers[k].split(sen).join(value);
24
+ if (bodyStr !== undefined) {
25
+ const innerEscaped = JSON.stringify(value).slice(1, -1); // JSON-string-safe
26
+ bodyStr = bodyStr.split(sen).join(innerEscaped);
27
+ }
28
+ }
29
+ return {
30
+ method: req.method,
31
+ url,
32
+ headers,
33
+ body: bodyStr !== undefined ? JSON.parse(bodyStr) : undefined,
34
+ credentials: "include",
35
+ };
36
+ }
37
+ /**
38
+ * compile → gate → detokenize, in one call. The browser executor should issue
39
+ * `result.request` only when `result.ok`.
40
+ */
41
+ export function prepareRequest(tool, compileCtx, gateCtx, resolve) {
42
+ let compiled;
43
+ try {
44
+ compiled = compileRecipe(tool, compileCtx);
45
+ }
46
+ catch (e) {
47
+ return { ok: false, gate: "compile", reason: e.message };
48
+ }
49
+ const gate = evaluateGates(compiled, gateCtx);
50
+ if (!gate.ok)
51
+ return gate;
52
+ try {
53
+ return { ok: true, request: detokenizeRequest(compiled, resolve, gateCtx.slotsById) };
54
+ }
55
+ catch (e) {
56
+ return { ok: false, gate: "injection_location", reason: e.message };
57
+ }
58
+ }
@@ -0,0 +1,12 @@
1
+ import type { CompiledRequest, GateOutcome, SecretSlot } from "@sudobility/sider_types";
2
+ export interface GateContext {
3
+ /** The site's origin. The request destination MUST equal this. */
4
+ siteOrigin: string;
5
+ /** Every secret slot the request may reference, keyed by slot id (placeholder). */
6
+ slotsById: Record<string, SecretSlot>;
7
+ /** True once the user has confirmed a write/financial step. */
8
+ confirmed?: boolean;
9
+ /** Site policy: whether mutations are permitted here at all. */
10
+ allowMutations?: boolean;
11
+ }
12
+ export declare function evaluateGates(req: CompiledRequest, ctx: GateContext): GateOutcome;
package/dist/gates.js ADDED
@@ -0,0 +1,106 @@
1
+ // The three egress gates. This is the security core: tokenization is only safe
2
+ // BECAUSE a secret cannot leave for the wrong destination (Gate A), cannot be
3
+ // relocated out of its declared slot (Gate B), and cannot ride a mutation
4
+ // without confirmation (Gate C). Run on a CompiledRequest whose secrets are
5
+ // still sentinels — never on values.
6
+ import { getByPath, secretSentinel } from "./recipe";
7
+ export function evaluateGates(req, ctx) {
8
+ // --- Gate A: same-origin destination ------------------------------------
9
+ let originHost;
10
+ try {
11
+ originHost = new URL(req.url).origin;
12
+ }
13
+ catch {
14
+ return { ok: false, gate: "same_origin", reason: `invalid url: ${req.url}` };
15
+ }
16
+ if (originHost !== ctx.siteOrigin) {
17
+ return {
18
+ ok: false,
19
+ gate: "same_origin",
20
+ reason: `destination ${originHost} != site ${ctx.siteOrigin}`,
21
+ };
22
+ }
23
+ for (const slotId of req.referencedSlotIds) {
24
+ const slot = ctx.slotsById[slotId];
25
+ if (!slot) {
26
+ return { ok: false, gate: "injection_location", reason: `unknown secret slot ${slotId}` };
27
+ }
28
+ if (slot.allowedDestination !== originHost) {
29
+ return {
30
+ ok: false,
31
+ gate: "same_origin",
32
+ reason: `slot ${slotId} allows ${slot.allowedDestination}, not ${originHost}`,
33
+ };
34
+ }
35
+ if (slot.role === "auto_cookie" || slot.injectionLocation.at === "cookie") {
36
+ return {
37
+ ok: false,
38
+ gate: "injection_location",
39
+ reason: `slot ${slotId} is cookie-borne (auto-attached); it must not be injected`,
40
+ };
41
+ }
42
+ // --- Gate B: sentinel appears ONLY in its declared location ------------
43
+ const b = injectionCheck(req, slot);
44
+ if (!b.ok)
45
+ return b;
46
+ }
47
+ // --- Gate C: mutations require confirmation -----------------------------
48
+ // Bind to the HTTP method, not just the server-declared (and therefore
49
+ // untrusted) safetyClass — a recipe labeled "read" with a POST/PUT/PATCH/
50
+ // DELETE still mutates and must not run unconfirmed.
51
+ const idempotent = req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS";
52
+ if (req.safetyClass !== "read" || !idempotent) {
53
+ if (ctx.allowMutations === false) {
54
+ return { ok: false, gate: "safety", reason: `mutations disabled for ${ctx.siteOrigin}` };
55
+ }
56
+ if (!ctx.confirmed) {
57
+ return { ok: false, gate: "safety", reason: `mutating request requires confirmation` };
58
+ }
59
+ }
60
+ return { ok: true };
61
+ }
62
+ function injectionCheck(req, slot) {
63
+ const sen = secretSentinel(slot.id);
64
+ const bodyStr = req.body !== undefined ? JSON.stringify(req.body) : "";
65
+ const total = count(req.url, sen) +
66
+ Object.values(req.headers).reduce((n, v) => n + count(v, sen), 0) +
67
+ count(bodyStr, sen);
68
+ let allowed = 0;
69
+ const loc = slot.injectionLocation;
70
+ if (loc.at === "header") {
71
+ allowed = count(req.headers[loc.name] ?? "", sen);
72
+ }
73
+ else if (loc.at === "query") {
74
+ let qv = "";
75
+ try {
76
+ qv = new URL(req.url).searchParams.get(loc.param) ?? "";
77
+ }
78
+ catch {
79
+ /* invalid url already caught in Gate A */
80
+ }
81
+ allowed = count(qv, sen);
82
+ }
83
+ else if (loc.at === "body") {
84
+ allowed = count(String(getByPath(req.body, loc.jsonPath) ?? ""), sen);
85
+ }
86
+ if (allowed < 1) {
87
+ return {
88
+ ok: false,
89
+ gate: "injection_location",
90
+ reason: `slot ${slot.id} absent from its declared ${loc.at} location`,
91
+ };
92
+ }
93
+ if (total !== allowed) {
94
+ return {
95
+ ok: false,
96
+ gate: "injection_location",
97
+ reason: `slot ${slot.id} appears outside its declared ${loc.at} location`,
98
+ };
99
+ }
100
+ return { ok: true };
101
+ }
102
+ function count(hay, needle) {
103
+ if (!needle)
104
+ return 0;
105
+ return hay.split(needle).length - 1;
106
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { test, expect } from "bun:test";
2
+ import { evaluateGates } from "./gates";
3
+ import { secretSentinel } from "./recipe";
4
+ const ORIGIN = "https://resale.fifa.com";
5
+ function slot(overrides = {}) {
6
+ return {
7
+ id: "slot:resale.fifa.com:auth_bearer",
8
+ siteId: "site1",
9
+ kind: "bearer",
10
+ role: "active_inject",
11
+ injectionLocation: { at: "header", name: "authorization" },
12
+ allowedDestination: ORIGIN,
13
+ resolverHints: [],
14
+ confidence: 1,
15
+ createdAt: "2026-01-01T00:00:00.000Z",
16
+ updatedAt: "2026-01-01T00:00:00.000Z",
17
+ ...overrides,
18
+ };
19
+ }
20
+ function req(overrides = {}) {
21
+ const sen = secretSentinel("slot:resale.fifa.com:auth_bearer");
22
+ return {
23
+ method: "GET",
24
+ url: `${ORIGIN}/api/seats`,
25
+ headers: { authorization: `Bearer ${sen}` },
26
+ body: undefined,
27
+ credentials: "include",
28
+ referencedSlotIds: ["slot:resale.fifa.com:auth_bearer"],
29
+ safetyClass: "read",
30
+ ...overrides,
31
+ };
32
+ }
33
+ const ctx = (extra = {}) => ({
34
+ siteOrigin: ORIGIN,
35
+ slotsById: { "slot:resale.fifa.com:auth_bearer": slot() },
36
+ ...extra,
37
+ });
38
+ test("Gate A: passes a same-origin read with a confined header secret", () => {
39
+ expect(evaluateGates(req(), ctx())).toEqual({ ok: true });
40
+ });
41
+ test("Gate A: refuses a cross-origin destination", () => {
42
+ const out = evaluateGates(req({ url: "https://evil.com/api/seats" }), ctx());
43
+ expect(out.ok).toBe(false);
44
+ if (!out.ok)
45
+ expect(out.gate).toBe("same_origin");
46
+ });
47
+ test("Gate A: refuses when the slot's allowedDestination differs", () => {
48
+ const s = slot({ allowedDestination: "https://other.com" });
49
+ const out = evaluateGates(req(), ctx({ slotsById: { [s.id]: s } }));
50
+ expect(out.ok).toBe(false);
51
+ if (!out.ok)
52
+ expect(out.gate).toBe("same_origin");
53
+ });
54
+ test("Gate B: refuses a sentinel relocated into the URL", () => {
55
+ const sen = secretSentinel("slot:resale.fifa.com:auth_bearer");
56
+ const out = evaluateGates(req({ url: `${ORIGIN}/api/seats?t=${sen}`, headers: { authorization: `Bearer ${sen}` } }), ctx());
57
+ expect(out.ok).toBe(false);
58
+ if (!out.ok)
59
+ expect(out.gate).toBe("injection_location");
60
+ });
61
+ test("Gate B: refuses when the sentinel is absent from its declared location", () => {
62
+ const out = evaluateGates(req({ headers: { authorization: "Bearer plain" } }), ctx());
63
+ expect(out.ok).toBe(false);
64
+ if (!out.ok)
65
+ expect(out.gate).toBe("injection_location");
66
+ });
67
+ test("Gate C: a POST (method-bound) requires confirmation even if labeled read", () => {
68
+ const out = evaluateGates(req({ method: "POST", safetyClass: "read" }), ctx());
69
+ expect(out.ok).toBe(false);
70
+ if (!out.ok)
71
+ expect(out.gate).toBe("safety");
72
+ });
73
+ test("Gate C: a confirmed POST passes", () => {
74
+ expect(evaluateGates(req({ method: "POST" }), ctx({ confirmed: true }))).toEqual({ ok: true });
75
+ });
76
+ test("Gate C: allowMutations=false blocks a mutation outright", () => {
77
+ const out = evaluateGates(req({ method: "POST" }), ctx({ confirmed: true, allowMutations: false }));
78
+ expect(out.ok).toBe(false);
79
+ if (!out.ok)
80
+ expect(out.gate).toBe("safety");
81
+ });
82
+ test("cookie-borne slot is refused (must be auto-attached, never injected)", () => {
83
+ const s = slot({ role: "auto_cookie", injectionLocation: { at: "cookie", name: "sid" } });
84
+ const out = evaluateGates(req(), ctx({ slotsById: { [s.id]: s } }));
85
+ expect(out.ok).toBe(false);
86
+ if (!out.ok)
87
+ expect(out.gate).toBe("injection_location");
88
+ });
@@ -0,0 +1,6 @@
1
+ export * from "./templating";
2
+ export * from "./tokenize";
3
+ export * from "./recipe";
4
+ export * from "./gates";
5
+ export * from "./execute";
6
+ export * from "./composite";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // @sudobility/sider_lib — pure Sider business logic.
2
+ //
3
+ // templating — cluster raw observations into endpoint templates
4
+ // tokenize — replace known secret values with placeholders (browser-side)
5
+ // recipe — compile a ToolSpec + args into a CompiledRequest (secrets = sentinels)
6
+ // gates — the three egress gates (same-origin / injection-location / safety)
7
+ // execute — detokenize after gates pass; prepareRequest = compile→gate→detokenize
8
+ //
9
+ // No Chrome/DOM/server dependencies — safe to import from sider_api,
10
+ // sider_extension, and sider_app alike.
11
+ export * from "./templating";
12
+ export * from "./tokenize";
13
+ export * from "./recipe";
14
+ export * from "./gates";
15
+ export * from "./execute";
16
+ export * from "./composite";
@@ -0,0 +1,18 @@
1
+ import type { CompiledRequest, InvocationRecipe, ToolSpec } from "@sudobility/sider_types";
2
+ export declare function secretSentinel(slotId: string): string;
3
+ export declare class RecipeCompileError extends Error {
4
+ }
5
+ export interface CompileContext {
6
+ /** Origin the recipe runs against (path templates are relative to it). */
7
+ siteOrigin: string;
8
+ /** User/LLM-supplied tool arguments. */
9
+ args?: Record<string, unknown>;
10
+ /** Prior tool results this run, keyed by ComposeStep.ref (for priorOutput bindings). */
11
+ priorOutputs?: Record<string, unknown>;
12
+ }
13
+ export declare function compileRecipe(tool: ToolSpec, ctx: CompileContext): CompiledRequest;
14
+ /** Read a dotted/bracketed path from an object: "data.sections[0].id". */
15
+ export declare function getByPath(obj: unknown, path: string): unknown;
16
+ /** Stable stringify (sorted keys) so two users' equivalent recipes hash equal. */
17
+ export declare function canonicalizeRecipe(recipe: InvocationRecipe): string;
18
+ export declare function hashRecipe(recipe: InvocationRecipe): string;
package/dist/recipe.js ADDED
@@ -0,0 +1,115 @@
1
+ // Recipe compiler: ToolSpec + args → CompiledRequest (secrets still placeholders).
2
+ //
3
+ // Secrets are compiled to an internal SENTINEL (a Private-Use-Area delimited
4
+ // token that cannot occur in real data), NOT to their values. The sentinel
5
+ // survives through the egress gates and is only replaced with a real value at
6
+ // detokenization (see execute.ts), after all three gates pass.
7
+ const S = ""; // sentinel delimiter (PUA)
8
+ export function secretSentinel(slotId) {
9
+ return `${S}${slotId}${S}`;
10
+ }
11
+ export class RecipeCompileError extends Error {
12
+ }
13
+ export function compileRecipe(tool, ctx) {
14
+ const recipe = tool.recipe;
15
+ const referenced = new Set();
16
+ const resolveKey = (key) => {
17
+ const b = recipe.bindings[key];
18
+ if (!b)
19
+ throw new RecipeCompileError(`Unbound placeholder {${key}} in tool "${tool.name}"`);
20
+ return resolveBinding(key, b, ctx, referenced);
21
+ };
22
+ const path = fillTemplate(recipe.urlTemplate, resolveKey);
23
+ const url = joinOrigin(ctx.siteOrigin, path);
24
+ const headers = {};
25
+ for (const h of recipe.headers)
26
+ headers[h.name] = fillTemplate(h.valueTemplate, resolveKey);
27
+ const body = recipe.bodyTemplate !== undefined ? resolveDeep(recipe.bodyTemplate, resolveKey) : undefined;
28
+ return {
29
+ method: recipe.method,
30
+ url,
31
+ headers,
32
+ body,
33
+ credentials: "include",
34
+ referencedSlotIds: [...referenced],
35
+ safetyClass: tool.safetyClass,
36
+ };
37
+ }
38
+ function resolveBinding(key, b, ctx, referenced) {
39
+ switch (b.kind) {
40
+ case "literal":
41
+ return b.value;
42
+ case "arg": {
43
+ const v = ctx.args?.[b.argName];
44
+ if (v === undefined)
45
+ throw new RecipeCompileError(`Missing arg "${b.argName}" for {${key}}`);
46
+ return String(v);
47
+ }
48
+ case "secret":
49
+ referenced.add(b.slotId);
50
+ return secretSentinel(b.slotId);
51
+ case "priorOutput": {
52
+ const v = getByPath(ctx.priorOutputs?.[b.stepRef], b.jsonPath);
53
+ if (v === undefined) {
54
+ throw new RecipeCompileError(`priorOutput ${b.stepRef}.${b.jsonPath} unresolved for {${key}}`);
55
+ }
56
+ return String(v);
57
+ }
58
+ }
59
+ }
60
+ function fillTemplate(tmpl, resolve) {
61
+ return tmpl.replace(/\{([^{}]+)\}/g, (_m, key) => resolve(key));
62
+ }
63
+ function resolveDeep(node, resolve) {
64
+ if (typeof node === "string")
65
+ return fillTemplate(node, resolve);
66
+ if (Array.isArray(node))
67
+ return node.map((n) => resolveDeep(n, resolve));
68
+ if (node && typeof node === "object") {
69
+ const out = {};
70
+ for (const [k, v] of Object.entries(node))
71
+ out[k] = resolveDeep(v, resolve);
72
+ return out;
73
+ }
74
+ return node;
75
+ }
76
+ function joinOrigin(origin, path) {
77
+ if (/^https?:\/\//i.test(path))
78
+ return path; // absolute (still same-origin-gated)
79
+ return `${origin.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
80
+ }
81
+ /** Read a dotted/bracketed path from an object: "data.sections[0].id". */
82
+ export function getByPath(obj, path) {
83
+ const parts = path
84
+ .replace(/\[(\d+)\]/g, ".$1")
85
+ .split(".")
86
+ .filter(Boolean);
87
+ let cur = obj;
88
+ for (const p of parts) {
89
+ if (cur == null || typeof cur !== "object")
90
+ return undefined;
91
+ cur = cur[p];
92
+ }
93
+ return cur;
94
+ }
95
+ // --- Recipe canonicalization for corroboration hashing --------------------
96
+ /** Stable stringify (sorted keys) so two users' equivalent recipes hash equal. */
97
+ export function canonicalizeRecipe(recipe) {
98
+ return stableStringify(recipe);
99
+ }
100
+ export function hashRecipe(recipe) {
101
+ const s = canonicalizeRecipe(recipe);
102
+ let h = 0;
103
+ for (let i = 0; i < s.length; i++)
104
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
105
+ return (h >>> 0).toString(16);
106
+ }
107
+ function stableStringify(v) {
108
+ if (v === null || typeof v !== "object")
109
+ return JSON.stringify(v) ?? "null";
110
+ if (Array.isArray(v))
111
+ return `[${v.map(stableStringify).join(",")}]`;
112
+ const obj = v;
113
+ const keys = Object.keys(obj).sort();
114
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
115
+ }
@@ -0,0 +1,13 @@
1
+ import type { Observation } from "@sudobility/sider_types";
2
+ /** Replace id-like path segments with `{id}`: /sections/120/seats → /sections/{id}/seats */
3
+ export declare function templatePath(rawUrl: string): string;
4
+ /**
5
+ * GraphQL keying: one URL serves many operations, so cluster on operation name,
6
+ * not path. Returns undefined for non-GraphQL requests.
7
+ */
8
+ export declare function graphqlOperationOf(url: string, body: unknown): string | undefined;
9
+ type Clusterable = Pick<Observation, "method" | "url" | "requestBody">;
10
+ /** Stable key identifying the endpoint template an observation belongs to. */
11
+ export declare function endpointKey(o: Clusterable): string;
12
+ export declare function clusterObservations<T extends Clusterable>(observations: T[]): Map<string, T[]>;
13
+ export {};
@@ -0,0 +1,66 @@
1
+ // Deterministic clustering & path templating. Runs BEFORE any AI: groups raw
2
+ // observations into endpoint templates so the brain infers semantics once per
3
+ // endpoint, not once per request. (This is the logic distill.ts inlined.)
4
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const LONGHEX_RE = /^[0-9a-f]{16,}$/i;
6
+ /** Replace id-like path segments with `{id}`: /sections/120/seats → /sections/{id}/seats */
7
+ export function templatePath(rawUrl) {
8
+ let pathname = rawUrl;
9
+ try {
10
+ pathname = new URL(rawUrl, "http://sider.local").pathname;
11
+ }
12
+ catch {
13
+ /* keep raw */
14
+ }
15
+ return pathname
16
+ .split("/")
17
+ .map((seg) => {
18
+ if (seg === "")
19
+ return seg;
20
+ if (/^\d+$/.test(seg))
21
+ return "{id}";
22
+ if (UUID_RE.test(seg))
23
+ return "{id}";
24
+ if (LONGHEX_RE.test(seg))
25
+ return "{id}";
26
+ return seg;
27
+ })
28
+ .join("/");
29
+ }
30
+ /**
31
+ * GraphQL keying: one URL serves many operations, so cluster on operation name,
32
+ * not path. Returns undefined for non-GraphQL requests.
33
+ */
34
+ export function graphqlOperationOf(url, body) {
35
+ if (body && typeof body === "object") {
36
+ const b = body;
37
+ if (typeof b["operationName"] === "string" && b["operationName"]) {
38
+ return b["operationName"];
39
+ }
40
+ if (typeof b["query"] === "string") {
41
+ const m = /\b(query|mutation|subscription)\s+([A-Za-z0-9_]+)/.exec(b["query"]);
42
+ if (m && m[2])
43
+ return m[2];
44
+ }
45
+ }
46
+ if (/\/graphql\b/i.test(url))
47
+ return "anonymous";
48
+ return undefined;
49
+ }
50
+ /** Stable key identifying the endpoint template an observation belongs to. */
51
+ export function endpointKey(o) {
52
+ const op = graphqlOperationOf(o.url, o.requestBody);
53
+ return `${o.method} ${templatePath(o.url)}${op ? ` #${op}` : ""}`;
54
+ }
55
+ export function clusterObservations(observations) {
56
+ const groups = new Map();
57
+ for (const o of observations) {
58
+ const key = endpointKey(o);
59
+ const g = groups.get(key);
60
+ if (g)
61
+ g.push(o);
62
+ else
63
+ groups.set(key, [o]);
64
+ }
65
+ return groups;
66
+ }
@@ -0,0 +1,27 @@
1
+ import type { HttpMethod, Observation, RequestContext, ResolverHint, SecretKind, SecretRole, SecretSlot } from "@sudobility/sider_types";
2
+ export interface RawObservation {
3
+ method: HttpMethod;
4
+ url: string;
5
+ requestHeaders: Record<string, string>;
6
+ requestBody?: unknown;
7
+ status: number;
8
+ responseBody?: unknown;
9
+ timingMs: number;
10
+ context: RequestContext;
11
+ }
12
+ /** A secret value the browser found in THIS user's session (value stays local). */
13
+ export interface KnownSecret {
14
+ value: string;
15
+ slotId: string;
16
+ kind: SecretKind;
17
+ role: SecretRole;
18
+ resolverHint: ResolverHint;
19
+ }
20
+ export type SecretSlotProposal = Omit<SecretSlot, "siteId" | "createdAt" | "updatedAt">;
21
+ export type TokenizedObservation = Omit<Observation, "id" | "batchId" | "createdAt">;
22
+ export interface TokenizeResult {
23
+ observation: TokenizedObservation;
24
+ slots: SecretSlotProposal[];
25
+ }
26
+ export declare function samplePlaceholder(slotId: string): string;
27
+ export declare function tokenizeObservation(raw: RawObservation, known: KnownSecret[]): TokenizeResult;