@clossys/butler 0.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/dist/audit-shape.check.d.ts +32 -0
  5. package/dist/audit-shape.check.d.ts.map +1 -0
  6. package/dist/audit-shape.check.js +7 -0
  7. package/dist/audit-shape.check.js.map +1 -0
  8. package/dist/cli.d.ts +54 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +426 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/contract.d.ts +256 -0
  13. package/dist/contract.d.ts.map +1 -0
  14. package/dist/contract.js +377 -0
  15. package/dist/contract.js.map +1 -0
  16. package/dist/inbound/index.d.ts +120 -0
  17. package/dist/inbound/index.d.ts.map +1 -0
  18. package/dist/inbound/index.js +125 -0
  19. package/dist/inbound/index.js.map +1 -0
  20. package/dist/index.d.ts +50 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +47 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/schema.d.ts +374 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +304 -0
  27. package/dist/schema.js.map +1 -0
  28. package/dist/validation.d.ts +74 -0
  29. package/dist/validation.d.ts.map +1 -0
  30. package/dist/validation.js +140 -0
  31. package/dist/validation.js.map +1 -0
  32. package/dist/web/index.d.ts +5 -0
  33. package/dist/web/index.d.ts.map +1 -0
  34. package/dist/web/index.js +25 -0
  35. package/dist/web/index.js.map +1 -0
  36. package/dist/web/internal/peer-version.d.ts +53 -0
  37. package/dist/web/internal/peer-version.d.ts.map +1 -0
  38. package/dist/web/internal/peer-version.js +136 -0
  39. package/dist/web/internal/peer-version.js.map +1 -0
  40. package/dist/web/useStandingWants.d.ts +75 -0
  41. package/dist/web/useStandingWants.d.ts.map +1 -0
  42. package/dist/web/useStandingWants.js +66 -0
  43. package/dist/web/useStandingWants.js.map +1 -0
  44. package/package.json +93 -0
  45. package/src/audit-shape.check.ts +37 -0
  46. package/src/cli.ts +445 -0
  47. package/src/contract.ts +534 -0
  48. package/src/inbound/index.ts +190 -0
  49. package/src/index.ts +113 -0
  50. package/src/schema.ts +622 -0
  51. package/src/validation.ts +172 -0
  52. package/src/web/index.ts +27 -0
  53. package/src/web/internal/peer-version.ts +159 -0
  54. package/src/web/useStandingWants.ts +139 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peer-version.d.ts","sourceRoot":"","sources":["../../../src/web/internal/peer-version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AA6FH,MAAM,WAAW,sBAAsB;IACrC,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,aAAa,EAAE,MAAM,CAAC;IACtB,4FAA4F;IAC5F,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,GAAG,IAAI,CAcrE"}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `assertPeerVersion` — the runtime half of `./web`'s "optional peer, no
3
+ * install-time signal in either direction" problem. `react`/`react-dom` are
4
+ * declared `peerDependenciesMeta: { optional: true }` (see package.json) so
5
+ * a consumer can install `@clossys/butler` (the root, provider-
6
+ * neutral core) without ever installing React — only `./web` needs it. But
7
+ * an ABSENT or OUT-OF-RANGE `react` produces no signal of any kind without
8
+ * this guard: a consumer on an incompatible React version would otherwise
9
+ * learn about it from whatever `./web` happened to crash on deep inside
10
+ * React itself, with nothing naming a version range as the cause. See
11
+ * `web/index.ts`'s own guard call, evaluated once at import time via
12
+ * `react`'s own exported `version`, for where this is wired in.
13
+ *
14
+ * This is the same obligation this repository's own contribution guide
15
+ * states for every requirement this workspace takes on: requiring a
16
+ * prerequisite is legitimate; failing silently when it is unmet is not,
17
+ * because that turns a setup error into a debugging session inside
18
+ * somebody else's codebase.
19
+ *
20
+ * PORTED, NOT SHARED, from `packages/consent/src/web/internal/peer-version.ts`
21
+ * — identical algorithm, copied rather than imported across a package
22
+ * boundary for the structural reason that file's own header gives: that
23
+ * package does not expose this as part of its public API surface, and even
24
+ * if it did, `@clossys/butler` would gain nothing by taking a real
25
+ * runtime dependency on a sibling just to reach one shared utility, and its
26
+ * "zero runtime dependencies" claim would then be wrong. Keep the copies in
27
+ * sync by hand if the ported range algorithm ever changes.
28
+ *
29
+ * DELIBERATELY PURE — NO `node:*` IMPORTS IN THIS FILE. `./web`'s entry
30
+ * point is reachable from a browser bundle (a client component rendering
31
+ * a preference surface), not just a Node process, so the version check reads
32
+ * `react`'s own exported `version` directly rather than any Node-only
33
+ * fs-based resolver.
34
+ */
35
+ /** Strict x.y.z only — same as scripts/check-workspace-links.mjs's parseVersion(). */
36
+ function parseVersion(version) {
37
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version).trim());
38
+ if (!m)
39
+ return null;
40
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
41
+ }
42
+ function compareVersions(a, b) {
43
+ return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
44
+ }
45
+ /**
46
+ * An exact pin, a caret range, or a tilde range against a plain x.y.z. For
47
+ * `0.y.z`, BOTH `^` and `~` are minor-locked; above `0.y.z`, `^` is
48
+ * major-locked and only `~` is minor-locked. Returns `null` —
49
+ * unparseable — for anything else, including the `>=`/`<` forms
50
+ * `parseGteForm` below understands instead.
51
+ */
52
+ function parsePinCaretTilde(range) {
53
+ const m = /^(\^|~)?(\d+)\.(\d+)\.(\d+)$/.exec(String(range).trim());
54
+ if (!m)
55
+ return null;
56
+ const prefix = m[1] ?? "";
57
+ const major = Number(m[2]);
58
+ const minor = Number(m[3]);
59
+ const patch = Number(m[4]);
60
+ if (prefix === "")
61
+ return { lower: { major, minor, patch }, upper: { major, minor, patch: patch + 1 } };
62
+ if (major === 0)
63
+ return { lower: { major, minor, patch }, upper: { major, minor: minor + 1, patch: 0 } };
64
+ if (prefix === "^")
65
+ return { lower: { major, minor, patch }, upper: { major: major + 1, minor: 0, patch: 0 } };
66
+ return { lower: { major, minor, patch }, upper: { major, minor: minor + 1, patch: 0 } }; // "~"
67
+ }
68
+ /**
69
+ * A bounded `>=x[.y[.z]] <a[.b[.c]]>` range or an unbounded `>=x[.y[.z]]`
70
+ * range — the shape this package's own `peerDependencies` uses for React
71
+ * (`">=18"`). A version segment omitted from either side defaults to its
72
+ * lowest value (`18` reads as `18.0.0`), matching ordinary semver range
73
+ * convention. Returns `null` — unparseable — for anything else.
74
+ */
75
+ function parseGteForm(range) {
76
+ const trimmed = String(range).trim();
77
+ const segment = "(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?";
78
+ const bounded = new RegExp(`^>=\\s*${segment}\\s+<\\s*${segment}$`).exec(trimmed);
79
+ if (bounded) {
80
+ const [, lMaj, lMin, lPat, uMaj, uMin, uPat] = bounded;
81
+ return {
82
+ lower: { major: Number(lMaj), minor: Number(lMin ?? "0"), patch: Number(lPat ?? "0") },
83
+ upper: { major: Number(uMaj), minor: Number(uMin ?? "0"), patch: Number(uPat ?? "0") },
84
+ };
85
+ }
86
+ const unbounded = new RegExp(`^>=\\s*${segment}$`).exec(trimmed);
87
+ if (unbounded) {
88
+ const [, maj, min, pat] = unbounded;
89
+ return { lower: { major: Number(maj), minor: Number(min ?? "0"), patch: Number(pat ?? "0") }, upper: null };
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Returns `{ evaluated: false, reason }` when either side could not be
95
+ * parsed — a finding, never assumed satisfied — or `{ evaluated: true, ok
96
+ * }` once both sides parsed cleanly.
97
+ */
98
+ function satisfiesRange(versionStr, rangeStr) {
99
+ const bound = parsePinCaretTilde(rangeStr) ?? parseGteForm(rangeStr);
100
+ if (!bound) {
101
+ return {
102
+ evaluated: false,
103
+ reason: `"${rangeStr}" is not a range form this guard parses (an exact pin, ^x.y.z, ~x.y.z, ">=x.y.z <a.b.c>", or ">=x.y.z" are supported)`,
104
+ };
105
+ }
106
+ const version = parseVersion(versionStr);
107
+ if (!version) {
108
+ return { evaluated: false, reason: `the installed version "${versionStr}" is not a plain x.y.z semver this guard can compare` };
109
+ }
110
+ const geLower = compareVersions(version, bound.lower) >= 0;
111
+ const ltUpper = bound.upper === null ? true : compareVersions(version, bound.upper) < 0;
112
+ return { evaluated: true, ok: geLower && ltUpper };
113
+ }
114
+ /**
115
+ * Throws a named, actionable error naming the package, the declared range,
116
+ * and the version actually found. Never returns a boolean — a guard must
117
+ * state where control goes when it declines. A missing peer and an
118
+ * out-of-range peer throw genuinely DIFFERENT messages — "not installed"
119
+ * and "installed but incompatible" are different problems with different
120
+ * fixes. An unparseable declared range or installed version is a third,
121
+ * equally loud error, never an assumed pass.
122
+ */
123
+ export function assertPeerVersion(input) {
124
+ const { peer, declaredRange, foundVersion } = input;
125
+ if (foundVersion === undefined) {
126
+ throw new Error(`${peer} is required for this import but is not installed. Install ${peer}@"${declaredRange}" — see this package's README for its optional-peer setup.`);
127
+ }
128
+ const outcome = satisfiesRange(foundVersion, declaredRange);
129
+ if (!outcome.evaluated) {
130
+ throw new Error(`Could not verify ${peer}@${foundVersion} against this package's declared range "${declaredRange}": ${outcome.reason}. Refusing to assume this is compatible.`);
131
+ }
132
+ if (!outcome.ok) {
133
+ throw new Error(`${peer}@${foundVersion} is installed, but this package requires ${peer}@"${declaredRange}". Installed but incompatible — install a version of ${peer} that satisfies "${declaredRange}".`);
134
+ }
135
+ }
136
+ //# sourceMappingURL=peer-version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peer-version.js","sourceRoot":"","sources":["../../../src/web/internal/peer-version.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ import type { CurrencyWindow, PolicyVersion, StandingAction, StandingEvaluation, StandingEvaluationPolicy, StandingInstruction, StandingTopic } from "../schema.js";
2
+ /**
3
+ * The client-side counterpart to `StandingInstructionStore`.
4
+ *
5
+ * `StandingInstructionStore` is `Promise`-based host-implemented I/O, most
6
+ * naturally implemented behind a server boundary — a database read, a
7
+ * server-side session — that a browser cannot call directly as a plain
8
+ * async function. A hook that genuinely reads and writes standing
9
+ * instructions needs a client-shaped port instead: something a browser
10
+ * really can call, typically a `fetch` to a host-owned route that itself
11
+ * runs `decideStandingChange` and writes both the instruction and the
12
+ * audit event server-side. This is that port — still host-implemented,
13
+ * still carrying no opinion about transport, but shaped for the side of
14
+ * the boundary that actually runs in a browser.
15
+ */
16
+ export interface StandingWantsClient {
17
+ /** Reads every stored instruction for `subjectId`, however the host's own API/storage boundary is reached. */
18
+ read(subjectId: string): Promise<readonly StandingInstruction[]>;
19
+ /**
20
+ * Applies one decided action and returns the durably-stored instruction.
21
+ * The host is expected to run `decideStandingChange` (or equivalent) on
22
+ * its own server and write both the instruction and the audit event
23
+ * before resolving — this hook never computes or stores anything itself.
24
+ */
25
+ apply(subjectId: string, action: StandingAction): Promise<StandingInstruction>;
26
+ }
27
+ export interface UseStandingWantsOptions {
28
+ subjectId: string;
29
+ /** The topics this preference surface manages. Determines the keys of `evaluations`. */
30
+ topics: readonly StandingTopic[];
31
+ policyVersion: PolicyVersion;
32
+ /** The window written onto any instruction this surface decides. No default — see `CurrencyWindow`. */
33
+ currency: CurrencyWindow;
34
+ /** No default — see `StandingEvaluationPolicy`; the host must decide this explicitly. */
35
+ evaluationPolicy: StandingEvaluationPolicy;
36
+ /** The moment to evaluate currency against, as an ISO timestamp. Supplied by the caller so a surface renders the same answer on the server and on the client rather than drifting with an ambient clock. */
37
+ now: string;
38
+ client: StandingWantsClient;
39
+ }
40
+ export interface UseStandingWantsResult {
41
+ /** One evaluation per requested topic. A topic with no stored instruction evaluates to `absent`, never to a falsy value that could read as a denial or as permission. */
42
+ evaluations: Readonly<Record<StandingTopic, StandingEvaluation>>;
43
+ /** `true` while the initial `client.read` call is in flight. */
44
+ loading: boolean;
45
+ /** The error thrown by the most recent `client.read`/`client.apply` call, if any. Never thrown by this hook itself. */
46
+ error: unknown;
47
+ grant(topic: StandingTopic): Promise<void>;
48
+ deny(topic: StandingTopic): Promise<void>;
49
+ /**
50
+ * Withdraw is reachable through the SAME call shape as grant and deny —
51
+ * one topic, one promise, one function on the same object. That is
52
+ * withdrawal parity enforced structurally at the API surface, not
53
+ * asserted in prose: there is no separate, harder-to-reach function and
54
+ * no extra argument for revoking a want than for giving one. The
55
+ * `withdrawal-parity` gate measures the same property one layer out, in
56
+ * a consumer's real interface, where this hook cannot see.
57
+ */
58
+ withdraw(topic: StandingTopic): Promise<void>;
59
+ }
60
+ /**
61
+ * Manages a preference surface's standing wants: reads every stored
62
+ * instruction for `subjectId` once on mount, evaluates each of `topics`
63
+ * against `policyVersion` AND the supplied `now`, and exposes
64
+ * `grant`/`deny`/`withdraw` as identically-shaped async functions backed by
65
+ * the same `client.apply` call.
66
+ *
67
+ * The evaluation is currency-aware, which is the whole difference between
68
+ * this and a hook that reads a row: an instruction that exists, and was
69
+ * granted, and is a year past its own declared window comes back `stale`,
70
+ * not `granted`. Rendering a surface off `evaluations` therefore re-asks
71
+ * on its own rather than quietly continuing to act on an answer nobody has
72
+ * checked since it was written.
73
+ */
74
+ export declare function useStandingWants(options: UseStandingWantsOptions): UseStandingWantsResult;
75
+ //# sourceMappingURL=useStandingWants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStandingWants.d.ts","sourceRoot":"","sources":["../../src/web/useStandingWants.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,wBAAwB,EACxB,mBAAmB,EACnB,aAAa,EACd,MAAM,cAAc,CAAC;AAEtB;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,mBAAmB;IAClC,8GAA8G;IAC9G,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,mBAAmB,EAAE,CAAC,CAAC;IACjE;;;;;OAKG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IACjC,aAAa,EAAE,aAAa,CAAC;IAC7B,uGAAuG;IACvG,QAAQ,EAAE,cAAc,CAAC;IACzB,yFAAyF;IACzF,gBAAgB,EAAE,wBAAwB,CAAC;IAC3C,4MAA4M;IAC5M,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,mBAAmB,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACrC,yKAAyK;IACzK,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACjE,gEAAgE;IAChE,OAAO,EAAE,OAAO,CAAC;IACjB,uHAAuH;IACvH,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,sBAAsB,CAmDzF"}
@@ -0,0 +1,66 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { evaluateStandingInstruction } from "../contract.js";
3
+ /**
4
+ * Manages a preference surface's standing wants: reads every stored
5
+ * instruction for `subjectId` once on mount, evaluates each of `topics`
6
+ * against `policyVersion` AND the supplied `now`, and exposes
7
+ * `grant`/`deny`/`withdraw` as identically-shaped async functions backed by
8
+ * the same `client.apply` call.
9
+ *
10
+ * The evaluation is currency-aware, which is the whole difference between
11
+ * this and a hook that reads a row: an instruction that exists, and was
12
+ * granted, and is a year past its own declared window comes back `stale`,
13
+ * not `granted`. Rendering a surface off `evaluations` therefore re-asks
14
+ * on its own rather than quietly continuing to act on an answer nobody has
15
+ * checked since it was written.
16
+ */
17
+ export function useStandingWants(options) {
18
+ const { subjectId, topics, policyVersion, currency, evaluationPolicy, now, client } = options;
19
+ const [instructions, setInstructions] = useState({});
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(undefined);
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ setLoading(true);
25
+ setError(undefined);
26
+ client
27
+ .read(subjectId)
28
+ .then((all) => {
29
+ if (cancelled)
30
+ return;
31
+ const byTopic = {};
32
+ for (const instruction of all)
33
+ byTopic[instruction.topic] = instruction;
34
+ setInstructions(byTopic);
35
+ })
36
+ .catch((caught) => {
37
+ if (!cancelled)
38
+ setError(caught);
39
+ })
40
+ .finally(() => {
41
+ if (!cancelled)
42
+ setLoading(false);
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [client, subjectId]);
48
+ const applyAction = useCallback(async (action) => {
49
+ try {
50
+ const instruction = await client.apply(subjectId, action);
51
+ setInstructions((previous) => ({ ...previous, [instruction.topic]: instruction }));
52
+ }
53
+ catch (caught) {
54
+ setError(caught);
55
+ }
56
+ }, [client, subjectId]);
57
+ const grant = useCallback((topic) => applyAction({ kind: "grant", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
58
+ const deny = useCallback((topic) => applyAction({ kind: "deny", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
59
+ const withdraw = useCallback((topic) => applyAction({ kind: "withdraw", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
60
+ const evaluations = {};
61
+ for (const topic of topics) {
62
+ evaluations[topic] = evaluateStandingInstruction(instructions[topic], policyVersion, evaluationPolicy, now);
63
+ }
64
+ return { evaluations, loading, error, grant, deny, withdraw };
65
+ }
66
+ //# sourceMappingURL=useStandingWants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStandingWants.js","sourceRoot":"","sources":["../../src/web/useStandingWants.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@clossys/butler",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./inbound": {
16
+ "types": "./dist/inbound/index.d.ts",
17
+ "import": "./dist/inbound/index.js"
18
+ },
19
+ "./web": {
20
+ "types": "./dist/web/index.d.ts",
21
+ "import": "./dist/web/index.js"
22
+ }
23
+ },
24
+ "bin": {
25
+ "butler-check": "./dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "!src/**/*.test.ts",
31
+ "CHANGELOG.md",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "sideEffects": false,
36
+ "description": "The butler role: do we have what this person wants — this request in their own confirmation, and their standing instructions, still current? Dependency-free machinery for a consumer's own want records: a three-state standing model (absent/denied/granted) plus a stale evaluation status, structured intents carrying a confidence read against a declared floor, host-supplied storage and audit ports, an isolated ./inbound admission subpath, an optional ./web subpath whose React peer is asserted at import time, and three gates behind one butler-check bin — confirmation-completeness, which fails when an acted-on intent has no confirmation record, when it was acted on against a misread or unclear read-back, or when an intent below the declared confidence floor was acted on with no explicit hand-off; currency, which fails when a standing instruction is relied on past its declared window, after a policy version superseded it, or when nothing was on record to rely on at all; and withdrawal-parity, which fails when withdrawing takes more steps than granting or demands a contact or an account the matching grant path never required. Ships the schema and the checkers; every consumer authors its own values.",
37
+ "keywords": [
38
+ "butler",
39
+ "consent",
40
+ "intent",
41
+ "confirmation",
42
+ "currency",
43
+ "preferences",
44
+ "gate",
45
+ "typescript"
46
+ ],
47
+ "author": "Calvin Hung",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/clossys/foundry.git",
51
+ "directory": "packages/butler"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/clossys/foundry/issues"
55
+ },
56
+ "homepage": "https://github.com/clossys/foundry/tree/main/packages/butler#readme",
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.json",
62
+ "prepublishOnly": "node ../../scripts/check-name-collision.mjs . && npm run build",
63
+ "typecheck": "tsc -p tsconfig.json --noEmit",
64
+ "test": "vitest run"
65
+ },
66
+ "peerDependencies": {
67
+ "react": ">=18",
68
+ "react-dom": ">=18"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "react": {
72
+ "optional": true
73
+ },
74
+ "react-dom": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@testing-library/react": "^16.3.2",
80
+ "@types/node": "^22.10.0",
81
+ "@types/react": "^19.2.0",
82
+ "@types/react-dom": "^19.2.0",
83
+ "jsdom": "^26.1.0",
84
+ "react": "^19.2.8",
85
+ "react-dom": "^19.2.8",
86
+ "typescript": "~6.0.0",
87
+ "vitest": "^4.1.9"
88
+ },
89
+ "publishConfig": {
90
+ "registry": "https://registry.npmjs.org",
91
+ "access": "public"
92
+ }
93
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Compile-time proof that `StandingAuditEvent` (`./schema.ts`) carries no
3
+ * raw personal-data field — no email, name, phone, address, or IP — only
4
+ * the opaque `subjectId` and the separately-opaque `actorId`.
5
+ *
6
+ * Named `*.check.ts`, not `*.test.ts`, so it is part of the REAL `tsc` run
7
+ * (`npm run typecheck`) rather than only being transpiled, never
8
+ * type-checked, by vitest — see this repository's own contribution
9
+ * guide, "Type-level assertions live in `.check.ts(x)` files" entry, and
10
+ * `scripts/check-typechecked-assertions.mjs` for the gate that enforces it.
11
+ * Nothing here is ever imported by `index.ts` or any runtime code; its only
12
+ * job is to fail `tsc` if the contract regresses.
13
+ *
14
+ * `ExactKeys` fails to compile unless `Keys` is EXACTLY the allowed set —
15
+ * neither a subset (a required field silently dropped) nor a superset (a
16
+ * new field silently added, personal-data-shaped or not, without a human
17
+ * deciding it belongs here).
18
+ *
19
+ * `subjectId` and `actorId` are both in the allowed set, deliberately and
20
+ * separately. Merging them into one id would still pass a personal-data
21
+ * scan and would still be wrong: the whole point of this package is
22
+ * telling "the person asked for this" apart from "something acted on its
23
+ * own reading", and a single conflated identifier makes that distinction
24
+ * unrecoverable after the fact.
25
+ */
26
+ import type { StandingAuditEvent } from "./schema.js";
27
+
28
+ type ExactKeys<Keys extends string, Allowed extends string> = [Allowed] extends [Keys] ? ([Keys] extends [Allowed] ? true : never) : never;
29
+
30
+ const ALLOWED_KEYS = ["subjectId", "actorId", "topic", "type", "policyVersion", "occurredAt", "previousPolicyVersion"] as const;
31
+ type AllowedKey = (typeof ALLOWED_KEYS)[number];
32
+
33
+ // If `StandingAuditEvent` ever gains or loses a key relative to
34
+ // `ALLOWED_KEYS` above, this assignment stops compiling — including if
35
+ // someone adds an `email`, `name`, `ip`, `phone`, or `address`-shaped
36
+ // field, and including if `actorId` is ever folded into `subjectId`.
37
+ export const auditEventKeysAreExactlyTheAllowedSet: ExactKeys<keyof StandingAuditEvent, AllowedKey> = true;