@hraness/oh 0.2.3

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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +598 -0
  3. package/dist/canonical.d.ts +32 -0
  4. package/dist/canonical.d.ts.map +1 -0
  5. package/dist/cli.d.ts +4 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +3419 -0
  8. package/dist/contract.d.ts +33 -0
  9. package/dist/contract.d.ts.map +1 -0
  10. package/dist/graph.d.ts +67 -0
  11. package/dist/graph.d.ts.map +1 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +1988 -0
  15. package/dist/libsql.d.ts +57 -0
  16. package/dist/libsql.d.ts.map +1 -0
  17. package/dist/libsql.js +2662 -0
  18. package/dist/memory.d.ts +366 -0
  19. package/dist/memory.d.ts.map +1 -0
  20. package/dist/memory.js +3650 -0
  21. package/dist/ontology.d.ts +242 -0
  22. package/dist/ontology.d.ts.map +1 -0
  23. package/dist/operation.d.ts +24 -0
  24. package/dist/operation.d.ts.map +1 -0
  25. package/dist/projection-public.d.ts +59 -0
  26. package/dist/projection-public.d.ts.map +1 -0
  27. package/dist/projection-public.js +1682 -0
  28. package/dist/projection-suss.d.ts +17 -0
  29. package/dist/projection-suss.d.ts.map +1 -0
  30. package/dist/projection-suss.js +1721 -0
  31. package/dist/projection.d.ts +315 -0
  32. package/dist/projection.d.ts.map +1 -0
  33. package/dist/schema.d.ts +45 -0
  34. package/dist/schema.d.ts.map +1 -0
  35. package/dist/sdk.d.ts +51 -0
  36. package/dist/sdk.d.ts.map +1 -0
  37. package/dist/sdk.js +3072 -0
  38. package/dist/search.d.ts +34 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/semantic.d.ts +83 -0
  41. package/dist/semantic.d.ts.map +1 -0
  42. package/dist/semantic.js +706 -0
  43. package/dist/sqlite/driver.d.ts +6 -0
  44. package/dist/sqlite/driver.d.ts.map +1 -0
  45. package/dist/sqlite/index.d.ts +5 -0
  46. package/dist/sqlite/index.d.ts.map +1 -0
  47. package/dist/sqlite/index.js +2840 -0
  48. package/dist/sqlite/migrations.d.ts +10 -0
  49. package/dist/sqlite/migrations.d.ts.map +1 -0
  50. package/dist/sqlite/port.d.ts +40 -0
  51. package/dist/sqlite/port.d.ts.map +1 -0
  52. package/dist/sqlite/runtime.d.ts +23 -0
  53. package/dist/sqlite/runtime.d.ts.map +1 -0
  54. package/dist/sqlite/store.d.ts +85 -0
  55. package/dist/sqlite/store.d.ts.map +1 -0
  56. package/dist/store.d.ts +236 -0
  57. package/dist/store.d.ts.map +1 -0
  58. package/dist/store.js +996 -0
  59. package/dist/sync.d.ts +57 -0
  60. package/dist/sync.d.ts.map +1 -0
  61. package/dist/sync.js +1328 -0
  62. package/package.json +121 -0
  63. package/skills/oh/SKILL.md +206 -0
  64. package/skills/oh/agents/openai.yaml +4 -0
  65. package/spec/README.md +74 -0
  66. package/spec/manifest.json +41 -0
  67. package/spec/v1/canonical-json.md +59 -0
  68. package/spec/v1/contract.json +28 -0
  69. package/spec/v1/contract.schema.json +58 -0
  70. package/spec/v1/embedding-profile.json +11 -0
  71. package/spec/v1/embedding.md +56 -0
  72. package/spec/v1/graph.md +87 -0
  73. package/spec/v1/memory.md +193 -0
  74. package/spec/v1/migration.md +92 -0
  75. package/spec/v1/ontology.json +55 -0
  76. package/spec/v1/ontology.md +80 -0
  77. package/spec/v1/operation.schema.json +138 -0
  78. package/spec/v1/projection-identity.schema.json +58 -0
  79. package/spec/v1/projection-query.schema.json +60 -0
  80. package/spec/v1/projection-result.schema.json +452 -0
  81. package/spec/v1/projection-rule-pack.schema.json +182 -0
  82. package/spec/v1/projection.md +165 -0
  83. package/spec/v1/record.schema.json +95 -0
  84. package/spec/v1/schema-evolution.md +51 -0
  85. package/spec/v1/schema-revision.schema.json +178 -0
  86. package/spec/v1/storage.md +88 -0
  87. package/spec/v1/store.md +131 -0
  88. package/spec/v1/sync-bundle.schema.json +51 -0
  89. package/spec/v1/sync.md +67 -0
  90. package/src/canonical.test.ts +46 -0
  91. package/src/canonical.ts +203 -0
  92. package/src/cli.test.ts +103 -0
  93. package/src/cli.ts +308 -0
  94. package/src/contract.ts +87 -0
  95. package/src/contracts.test.ts +147 -0
  96. package/src/graph.ts +248 -0
  97. package/src/index.ts +8 -0
  98. package/src/libsql.test.ts +657 -0
  99. package/src/libsql.ts +1687 -0
  100. package/src/memory.test.ts +783 -0
  101. package/src/memory.ts +1684 -0
  102. package/src/ontology.ts +573 -0
  103. package/src/operation.ts +80 -0
  104. package/src/projection-public.ts +53 -0
  105. package/src/projection-suss.ts +129 -0
  106. package/src/projection.test.ts +418 -0
  107. package/src/projection.ts +1457 -0
  108. package/src/schema.ts +156 -0
  109. package/src/sdk.ts +96 -0
  110. package/src/search.ts +66 -0
  111. package/src/semantic.test.ts +480 -0
  112. package/src/semantic.ts +333 -0
  113. package/src/sqlite/driver.ts +47 -0
  114. package/src/sqlite/index.ts +4 -0
  115. package/src/sqlite/migrations.test.ts +44 -0
  116. package/src/sqlite/migrations.ts +178 -0
  117. package/src/sqlite/port.test.ts +127 -0
  118. package/src/sqlite/port.ts +120 -0
  119. package/src/sqlite/runtime.test.ts +68 -0
  120. package/src/sqlite/runtime.ts +53 -0
  121. package/src/sqlite/store.test.ts +295 -0
  122. package/src/sqlite/store.ts +988 -0
  123. package/src/store.test.ts +121 -0
  124. package/src/store.ts +701 -0
  125. package/src/sync.test.ts +117 -0
  126. package/src/sync.ts +227 -0
@@ -0,0 +1,88 @@
1
+ # SQLite storage V1
2
+
3
+ SQLite schema version `2` is the local authority for an Oh space. The default
4
+ CLI database is `.oh/oh.sqlite`; callers may select another path or use an
5
+ in-memory database.
6
+
7
+ ## Connection policy
8
+
9
+ The Bun SQLite driver opens in strict mode and applies:
10
+
11
+ ```sql
12
+ PRAGMA foreign_keys = ON;
13
+ PRAGMA journal_mode = WAL;
14
+ PRAGMA synchronous = NORMAL;
15
+ PRAGMA busy_timeout = 5000;
16
+ PRAGMA trusted_schema = OFF;
17
+ ```
18
+
19
+ Each migration and committed operation runs inside `BEGIN IMMEDIATE`. Failure
20
+ rolls back the transaction and preserves the original error.
21
+
22
+ ## Tables
23
+
24
+ | Table | Role |
25
+ | --- | --- |
26
+ | `oh_migrations` | Applied migration version, immutable name, SQL digest, and timestamp. |
27
+ | `oh_contracts` | Exact contract manifest and digest accepted by the store. |
28
+ | `oh_spaces` | Current compare-and-swap head for each logical space. |
29
+ | `oh_operations` | Append-only canonical operation chain. |
30
+ | `oh_operation_records` | Ordered record changes for each operation. |
31
+ | `oh_records` | Current canonical record materialization. |
32
+ | `oh_dependencies` | Current explicit dependency edges. |
33
+ | `oh_sync_outbox` | Local operations eligible for sync. |
34
+ | `oh_sync_state` | Last settled state for a named remote. |
35
+ | `oh_search_documents` | Derived keyword text bound to a record digest. |
36
+ | `oh_search_fts` | Derived FTS5 index. |
37
+ | `oh_space_bindings` | Host-selected realm, lifecycle profile, capabilities, and application-profile digest for a supported store port. |
38
+ | `oh_space_purges` | Minimal receipt that permanently reserves the identifier of a purged working space. |
39
+
40
+ The first migration is named `0001_oh_core`; its released bytes remain
41
+ unchanged. Schema version 2 adds `0002_store_realms`. An implementation MUST
42
+ store and check the exact SHA-256 digest of applied migration SQL. It MUST
43
+ refuse to run when the same migration version or name has different bytes.
44
+
45
+ ## Realm profiles and purge
46
+
47
+ A promise-based store port MAY bind one space to one host-selected realm and
48
+ profile. The binding includes the exact Oh contract digest, an optional
49
+ application-profile digest, and declared capabilities. A supported runtime
50
+ MUST reject a later attempt to open the same space under different binding
51
+ bytes. A working profile disables operation replication and enables only
52
+ host-controlled whole-space purge. A canonical profile cannot be purged by
53
+ that API.
54
+
55
+ Purge deletes the space head, complete operation history, current records,
56
+ dependency and operation materializations, sync state, and derived keyword
57
+ rows in one immediate transaction. It leaves only a content-free receipt with
58
+ the prior head, binding digest, purge instant, and receipt digest. The purged
59
+ space identifier cannot be reopened in the same database. A host that deletes
60
+ an entire database file MUST retain any required deletion evidence in its own
61
+ control plane.
62
+
63
+ Realm and profile binding is additive store control metadata. V1 operation
64
+ digest preimages do not contain the binding. It therefore protects supported
65
+ opens and operations but is not a portable cryptographic claim about a V1
66
+ history. Such a claim requires a new wire contract rather than a change to V1
67
+ operation bytes.
68
+
69
+ ## Authority and derivation
70
+
71
+ `oh_operations`, `oh_spaces`, and the canonical current records define state.
72
+ The dependency and operation-record tables are checked materializations. Search
73
+ documents, FTS rows, and the separate QMD cache are derived and rebuildable.
74
+
75
+ Deleting an embedding or FTS index MUST NOT delete authored records or
76
+ operations. A search result MUST be rejoined to a current record and digest
77
+ before it is returned as current.
78
+
79
+ ## Replay verification
80
+
81
+ `oh verify` runs SQLite `integrity_check`, parses every canonical operation,
82
+ replays the operation chain from an empty graph, recomputes record-set and graph
83
+ revision digests, checks dependencies, compares the materialized records, and
84
+ requires the reconstructed head to equal the stored head.
85
+
86
+ Backup and restore procedures SHOULD preserve the database and WAL atomically.
87
+ An application SHOULD run replay verification after an untrusted transfer or
88
+ restore before treating the space as authoritative.
@@ -0,0 +1,131 @@
1
+ # Store ports, profiles, and direct libSQL authority
2
+
3
+ The graph and operation contract remains V1. This document defines additive
4
+ host APIs that preserve those bytes across local SQLite and direct libSQL
5
+ authorities.
6
+
7
+ ## Promise-based store port
8
+
9
+ `@hraness/oh/store` has no dependency on `bun:sqlite`. Its methods return
10
+ promises and expose:
11
+
12
+ - the current exact head;
13
+ - a current or historical snapshot at an exact sequence and operation digest;
14
+ - a bounded contiguous change page through a pinned head;
15
+ - compare-and-swap commit;
16
+ - dependency-closure export;
17
+ - replay and materialization verification; and
18
+ - close.
19
+
20
+ A historical read MUST fail if its sequence is absent or identifies a
21
+ different operation digest. A change page MUST name its source cursor, pinned
22
+ through-head, returned cursor, and whether more operations remain.
23
+
24
+ ## Semantic bundle ingress
25
+
26
+ Model-facing code SHOULD use `OhSemanticBundleIngressV1` instead of generic
27
+ record puts. The ingress seals its codec registry, requires a registered codec
28
+ for every put kind, parses all values, creates canonical record envelopes, and
29
+ submits all puts and tombstones as one compare-and-swap operation. Missing
30
+ codecs, invalid values, duplicate keys, stale heads, and incomplete dependency
31
+ closures fail before the authority head moves.
32
+
33
+ ## Profiles and host control
34
+
35
+ A binding combines one exact contract, application profile, lifecycle profile,
36
+ realm, and space. The built-in canonical profile permits operation replication
37
+ and forbids whole-space purge. The built-in working profile forbids operation
38
+ replication and permits purge.
39
+
40
+ Creating an authority returns separate `store` and `host` objects. The ordinary
41
+ store never has a purge method. Trusted control-plane code retains the host
42
+ object and does not pass it to an agent tool or model. This is an API and
43
+ custody boundary, not protection from code that already holds raw database
44
+ credentials or direct filesystem access.
45
+
46
+ ## Dependency-closure capsules
47
+
48
+ A closure export pins the source binding and exact head, sorts unique roots,
49
+ and includes exactly the records reachable through declared dependencies. The
50
+ capsule digest binds all of those fields. Verification rejects a missing
51
+ dependency, an extra unrelated record, a changed record digest, reordered
52
+ roots or records, and a false capsule digest. V1 bounds a capsule to 1,024
53
+ roots, 8,192 records, and 67,108,864 canonical UTF-8 bytes.
54
+
55
+ A closure is content evidence for a later reviewed adoption. It does not copy
56
+ source authority, review state, credentials, or operation history into a
57
+ destination.
58
+
59
+ ## Direct libSQL authority
60
+
61
+ `@hraness/oh/libsql` accepts the `execute` and transactional `batch` shape of
62
+ `@libsql/client`. It is Node 24 and serverless compatible and does not import
63
+ `bun:sqlite`. Unlike the V1 sync transport, it treats libSQL as the current
64
+ record and operation authority.
65
+
66
+ `bootstrapOhLibSqlAuthorityV1` is the only API that creates schema objects.
67
+ Run it in a deployment or migration step with a short-lived schema credential.
68
+ `createOhLibSqlStoreAuthorityV1` is the runtime open: it only verifies the
69
+ installed schema and contract before reading or creating a bound data space,
70
+ so a runtime token does not need schema-change permission.
71
+
72
+ `openExistingOhLibSqlStoreAuthorityV1` is the least-privilege open for a
73
+ separately held reader or purge worker. It verifies the same exact schema,
74
+ contract, space head, and binding using reads only. It rejects a missing,
75
+ purged, or differently bound space and never inserts, updates, or deletes data
76
+ during open. A provider credential can therefore omit space and binding
77
+ creation while retaining only the data actions required by its later task.
78
+
79
+ `purgeOhLibSqlWorkingSpaceV1` is the one-shot lifecycle-worker boundary. It
80
+ purges an existing exact working binding or atomically writes an empty-head
81
+ purge receipt when creation never completed. Creation and absent-space fencing
82
+ race in the same authority transaction: whichever wins, the result converges
83
+ to one complete purge receipt, and a delayed creator cannot resurrect the
84
+ space. The credential still needs no permission to create spaces or bindings.
85
+
86
+ Runtime open verifies the exact installed table, index, and trigger set, not
87
+ only a schema marker. Every operation, binding, and purge receipt read parses
88
+ its canonical JSON and cross-checks each duplicated SQL column. Current reads
89
+ also prove contiguous operation coverage through the exact terminal head,
90
+ record provenance puts, record digests, and dependency materialization.
91
+
92
+ Its private implementation tables use the `oh_authority_` prefix:
93
+
94
+ | Table | Role |
95
+ | --- | --- |
96
+ | `oh_authority_schemas` | Exact adapter schema name and digest. |
97
+ | `oh_authority_contracts` | Exact Oh contract manifest. |
98
+ | `oh_authority_spaces` | Current compare-and-swap heads. |
99
+ | `oh_authority_operations` | Canonical append-only operations. |
100
+ | `oh_authority_operation_records` | Ordered changes per operation. |
101
+ | `oh_authority_records` | Current record materialization. |
102
+ | `oh_authority_dependencies` | Current dependency edges. |
103
+ | `oh_authority_bindings` | Realm and profile control metadata. |
104
+ | `oh_authority_purges` | Minimal whole-space purge receipts. |
105
+ | `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. |
106
+
107
+ A normal remote commit takes three atomic provider round trips: an idempotency,
108
+ head, and purge preflight; one exact current-materialization read; and one
109
+ guarded write batch. The final write guard aborts the complete transaction when
110
+ compare-and-swap did not settle at the declared operation. Its write-batch
111
+ readback must reproduce both the canonical operation and persisted head before
112
+ the adapter returns success.
113
+
114
+ Provider responses are bounded as part of the API. V1 accepts at most 64
115
+ changes, 512 dependencies, and 512 KiB of canonical operation JSON per commit.
116
+ A feed returns at most seven operations plus one checked sentinel and refuses a
117
+ page whose conservative transport estimate exceeds 9,000,000 bytes. Historical
118
+ replay is limited to 16,384 operations, 4 MiB of canonical operation JSON, and
119
+ the same response estimate. Current snapshot and full-verification result sets
120
+ are independently transport-estimated and SQL-gated before rows are returned.
121
+ Sizing, rows, and the pinned head are read in the same transaction, so a
122
+ concurrent append cannot grow an unchecked response between preflight and read.
123
+
124
+ Remote purge similarly inserts a receipt only for the expected working head,
125
+ deletes every payload and materialization row under that receipt in the same
126
+ write batch, and aborts if either the receipt or deletion is incomplete. A
127
+ later open returns the stored purge receipt instead of recreating the space.
128
+ Operation-record deletion resolves ownership through the canonical operation;
129
+ the purge postcondition also rejects any global orphan or cross-space owner
130
+ mismatch. Purge receipts are immutable and intentionally retain only binding,
131
+ prior-head, and purge-event evidence.
@@ -0,0 +1,51 @@
1
+ {
2
+ "$defs": {
3
+ "safeCode": {
4
+ "maxLength": 128,
5
+ "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$",
6
+ "type": "string"
7
+ },
8
+ "sha256": {
9
+ "pattern": "^[a-f0-9]{64}$",
10
+ "type": "string"
11
+ }
12
+ },
13
+ "$id": "https://oh.computer/spec/v1/sync-bundle.schema.json",
14
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
15
+ "$comment": "Runtime conformance additionally requires one contiguous operation chain for the selected space and a recomputed bundleSha256.",
16
+ "additionalProperties": false,
17
+ "properties": {
18
+ "bundleSha256": {
19
+ "$ref": "#/$defs/sha256"
20
+ },
21
+ "contractSha256": {
22
+ "const": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594"
23
+ },
24
+ "operations": {
25
+ "items": {
26
+ "$ref": "./operation.schema.json"
27
+ },
28
+ "maxItems": 1000,
29
+ "type": "array"
30
+ },
31
+ "protocol": {
32
+ "const": "oh.sync.v1"
33
+ },
34
+ "spaceId": {
35
+ "$ref": "#/$defs/safeCode"
36
+ },
37
+ "v": {
38
+ "const": 1
39
+ }
40
+ },
41
+ "required": [
42
+ "bundleSha256",
43
+ "contractSha256",
44
+ "operations",
45
+ "protocol",
46
+ "spaceId",
47
+ "v"
48
+ ],
49
+ "title": "Oh sync bundle V1",
50
+ "type": "object"
51
+ }
@@ -0,0 +1,67 @@
1
+ # Operation sync V1
2
+
3
+ The protocol identifier is `oh.sync.v1`. Sync exchanges canonical operations,
4
+ not current-row snapshots or semantic indexes.
5
+
6
+ ## Handshake
7
+
8
+ Before reading or writing a remote, a transport MUST compare the complete V1
9
+ contract manifest. The current contract digest is
10
+ `e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594`.
11
+ A different digest fails before operation exchange.
12
+
13
+ A transport implements four bounded methods:
14
+
15
+ ```ts
16
+ interface OhOperationSyncTransportV1 {
17
+ handshake(manifest: OhContractManifestV1): Promise<void>;
18
+ head(spaceId: string): Promise<OhSyncHeadV1>;
19
+ pull(spaceId: string, afterSequence: number, limit: number): Promise<OhSyncBundleV1>;
20
+ push(bundle: OhSyncBundleV1): Promise<OhSyncHeadV1>;
21
+ }
22
+ ```
23
+
24
+ ## Bundle
25
+
26
+ A bundle binds `oh.sync.v1`, the contract digest, one space ID, at most 1,000
27
+ ordered operations, and its own digest. Operations MUST form one contiguous
28
+ chain. An empty bundle is valid.
29
+
30
+ Parsing recomputes every operation digest and the bundle digest. Import also
31
+ requires the bundle space to equal the selected local space and each operation
32
+ to extend the exact local head.
33
+
34
+ ## Settlement
35
+
36
+ The synchronizer defaults to batches of 100 and at most 100 rounds. Each round
37
+ compares heads:
38
+
39
+ - Equal sequences and equal digests are settled.
40
+ - A lower local sequence pulls a chain that begins at the exact local head.
41
+ - A higher local sequence pushes a chain that begins at the exact remote head.
42
+ - Equal sequences with different digests, or any non-extending chain, are a
43
+ conflict.
44
+
45
+ The synchronizer never performs last-write-wins merging. Divergent append-only
46
+ histories remain intact for an explicit domain merge. Remote acknowledgments
47
+ must equal the final pushed operation.
48
+
49
+ ## libSQL and Turso seam
50
+
51
+ The included adapter accepts a client with `execute` and transactional `batch`
52
+ methods compatible with `@libsql/client`. It creates only:
53
+
54
+ - `oh_sync_contracts`, containing exact contract manifests; and
55
+ - `oh_sync_operations`, containing the ordered canonical operation log.
56
+
57
+ The application supplies the client, credentials, endpoint, access policy,
58
+ retry policy, and backups. The adapter parameterizes values and verifies
59
+ canonical operation JSON on pull. Semantic documents and vectors never cross
60
+ this seam.
61
+
62
+ ## Offline transfer
63
+
64
+ `oh sync export --after <sequence> --limit <count>` emits one canonical bundle.
65
+ `oh sync import --file <path>` verifies and applies a bundle idempotently. A
66
+ transfer process SHOULD preserve the exact bytes and SHOULD run `oh verify`
67
+ after the final import.
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { canonicalJson, canonicalSha256, parseCanonicalJson, sha256Hex } from "./canonical";
4
+
5
+ describe("canonical JSON", () => {
6
+ test("sorts object keys recursively and hashes exact bytes", () => {
7
+ const left = { z: [{ b: 2, a: 1 }], a: "é" };
8
+ const right = { a: "é", z: [{ a: 1, b: 2 }] };
9
+ expect(canonicalJson(left)).toBe('{"a":"é","z":[{"a":1,"b":2}]}');
10
+ expect(canonicalJson(right)).toBe(canonicalJson(left));
11
+ expect(canonicalSha256(right)).toBe(canonicalSha256(left));
12
+ expect(canonicalSha256(left)).toBe(sha256Hex(canonicalJson(left)));
13
+ });
14
+
15
+ test("rejects ambiguous and non-JSON values", () => {
16
+ expect(() => canonicalJson(-0)).toThrow("negative zero");
17
+ expect(() => canonicalJson(Number.NaN)).toThrow("finite");
18
+ expect(() => canonicalJson({ value: undefined })).toThrow("cannot encode");
19
+ expect(() => canonicalJson("\ud800")).toThrow("unpaired surrogate");
20
+ const cyclic: { self?: unknown } = {};
21
+ cyclic.self = cyclic;
22
+ expect(() => canonicalJson(cyclic)).toThrow("cycle");
23
+ expect(() => canonicalJson(Array(1))).toThrow("holes");
24
+ const symbolObject = { value: 1, [Symbol("hidden")]: 2 };
25
+ expect(() => canonicalJson(symbolObject)).toThrow("symbol property");
26
+ const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => 1 });
27
+ expect(() => canonicalJson(accessor)).toThrow("data property");
28
+ });
29
+
30
+ test("accepts only byte-canonical JSON", () => {
31
+ expect(parseCanonicalJson('{"a":1,"b":2}')).toEqual({ a: 1, b: 2 });
32
+ expect(() => parseCanonicalJson('{ "a": 1 }')).toThrow("not canonical");
33
+ expect(() => parseCanonicalJson('{"b":2,"a":1}')).toThrow("not canonical");
34
+ });
35
+
36
+ test("is invariant to insertion order across generated objects", () => {
37
+ for (let seed = 1; seed <= 250; seed += 1) {
38
+ const keys = Array.from({ length: 12 }, (_, index) => `k${(seed * 17 + index * 31) % 97}`);
39
+ const first: Record<string, number> = {};
40
+ const second: Record<string, number> = {};
41
+ keys.forEach((key, index) => { first[key] = index; });
42
+ [...keys].reverse().forEach((key) => { second[key] = first[key] as number; });
43
+ expect(canonicalJson(first)).toBe(canonicalJson(second));
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,203 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ export type JsonPrimitive = boolean | null | number | string;
4
+ export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject;
5
+ export type JsonObject = Readonly<{ [key: string]: JsonValue }>;
6
+
7
+ declare const sha256HexBrand: unique symbol;
8
+ export type Sha256Hex = string & { readonly [sha256HexBrand]: "Sha256Hex" };
9
+
10
+ export class OhValidationError extends Error {
11
+ readonly code: string;
12
+ readonly path: string;
13
+
14
+ constructor(code: string, path: string, message: string) {
15
+ super(`${path}: ${message}`);
16
+ this.name = "OhValidationError";
17
+ this.code = code;
18
+ this.path = path;
19
+ }
20
+ }
21
+
22
+ export function isPlainRecord(value: unknown): value is Record<string, unknown> {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
24
+ const prototype: unknown = Object.getPrototypeOf(value);
25
+ return prototype === Object.prototype || prototype === null;
26
+ }
27
+
28
+ export function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
29
+ const actual = Object.keys(value);
30
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
31
+ }
32
+
33
+ function assertUnicodeScalarString(value: string, path: string): void {
34
+ for (let index = 0; index < value.length; index += 1) {
35
+ const code = value.charCodeAt(index);
36
+ if (code >= 0xd800 && code <= 0xdbff) {
37
+ const next = value.charCodeAt(index + 1);
38
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
39
+ throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate");
40
+ }
41
+ index += 1;
42
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
43
+ throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate");
44
+ }
45
+ }
46
+ }
47
+
48
+ function encodeCanonical(value: unknown, path: string, ancestors: Set<object>): string {
49
+ if (value === null || typeof value === "boolean") return JSON.stringify(value);
50
+ if (typeof value === "string") {
51
+ assertUnicodeScalarString(value, path);
52
+ return JSON.stringify(value);
53
+ }
54
+ if (typeof value === "number") {
55
+ if (!Number.isFinite(value)) {
56
+ throw new OhValidationError("non-json-number", path, "must be finite");
57
+ }
58
+ if (Object.is(value, -0)) {
59
+ throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical");
60
+ }
61
+ return JSON.stringify(value);
62
+ }
63
+ if (typeof value !== "object" || value === null) {
64
+ throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`);
65
+ }
66
+ if (ancestors.has(value)) {
67
+ throw new OhValidationError("cycle", path, "contains a cycle");
68
+ }
69
+ ancestors.add(value);
70
+ try {
71
+ if (Array.isArray(value)) {
72
+ const encoded: string[] = [];
73
+ for (let index = 0; index < value.length; index += 1) {
74
+ if (!Object.hasOwn(value, index)) {
75
+ throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes");
76
+ }
77
+ encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors));
78
+ }
79
+ const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length"
80
+ && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length));
81
+ if (extraKeys.length > 0) {
82
+ throw new OhValidationError("non-json-property", path, "array has non-index properties");
83
+ }
84
+ return `[${encoded.join(",")}]`;
85
+ }
86
+ if (!isPlainRecord(value)) {
87
+ throw new OhValidationError("non-plain-object", path, "must be a plain object");
88
+ }
89
+ const ownKeys = Reflect.ownKeys(value);
90
+ if (ownKeys.some((key) => typeof key !== "string")) {
91
+ throw new OhValidationError("non-json-property", path, "object has a symbol property");
92
+ }
93
+ const keys = ownKeys as string[];
94
+ for (const key of keys) {
95
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
96
+ if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) {
97
+ throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property");
98
+ }
99
+ }
100
+ keys.sort();
101
+ const entries = keys.map((key) => {
102
+ assertUnicodeScalarString(key, `${path}.<key>`);
103
+ return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`;
104
+ });
105
+ return `{${entries.join(",")}}`;
106
+ } finally {
107
+ ancestors.delete(value);
108
+ }
109
+ }
110
+
111
+ /** RFC 8785-style canonical JSON for the JSON subset accepted by Oh. */
112
+ export function canonicalJson(value: unknown): string {
113
+ return encodeCanonical(value, "$", new Set());
114
+ }
115
+
116
+ export function parseCanonicalJson(text: string, maximumBytes = 16 * 1024 * 1024): JsonValue {
117
+ if (utf8ByteLength(text) > maximumBytes) {
118
+ throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit");
119
+ }
120
+ let value: unknown;
121
+ try {
122
+ value = JSON.parse(text);
123
+ } catch {
124
+ throw new OhValidationError("invalid-json", "$", "is not valid JSON");
125
+ }
126
+ if (canonicalJson(value) !== text) {
127
+ throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical");
128
+ }
129
+ return value as JsonValue;
130
+ }
131
+
132
+ export function utf8ByteLength(value: string): number {
133
+ return Buffer.byteLength(value, "utf8");
134
+ }
135
+
136
+ export function sha256Hex(value: string | Uint8Array): Sha256Hex {
137
+ return createHash("sha256").update(value).digest("hex") as Sha256Hex;
138
+ }
139
+
140
+ export function canonicalSha256(value: unknown): Sha256Hex {
141
+ return sha256Hex(canonicalJson(value));
142
+ }
143
+
144
+ export function parseSha256Hex(value: unknown): Sha256Hex | null {
145
+ return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value)
146
+ ? value as Sha256Hex
147
+ : null;
148
+ }
149
+
150
+ export function parseCanonicalInstantV1(value: unknown): string | null {
151
+ if (typeof value !== "string"
152
+ || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) {
153
+ return null;
154
+ }
155
+ const timestamp = Date.parse(value);
156
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null;
157
+ }
158
+
159
+ export function canonicalNow(): string {
160
+ return new Date().toISOString();
161
+ }
162
+
163
+ export function opaqueId(prefix: string): string {
164
+ if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) {
165
+ throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code");
166
+ }
167
+ return `${prefix}${randomBytes(12).toString("hex")}`;
168
+ }
169
+
170
+ export function safeCode(value: unknown, maximumLength = 128): string | null {
171
+ return typeof value === "string" && value.length <= maximumLength
172
+ && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value)
173
+ ? value
174
+ : null;
175
+ }
176
+
177
+ export function boundedText(value: unknown, maximumBytes = 64 * 1024): string | null {
178
+ if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value
179
+ || utf8ByteLength(value) > maximumBytes) return null;
180
+ try { assertUnicodeScalarString(value, "$text"); } catch { return null; }
181
+ for (const character of value) {
182
+ const code = character.codePointAt(0) ?? 0;
183
+ if (code <= 8 || (code >= 11 && code <= 12) || (code >= 14 && code <= 31)
184
+ || (code >= 127 && code <= 159)) return null;
185
+ }
186
+ return value;
187
+ }
188
+
189
+ export function orderedUnique<T>(values: readonly T[], key: (value: T) => string): boolean {
190
+ return values.every((value, index) => index === 0 || key(values[index - 1] as T) < key(value));
191
+ }
192
+
193
+ export function sortUnique<T>(values: readonly T[], key: (value: T) => string): readonly T[] {
194
+ const sorted = [...values].sort((left, right) => {
195
+ const leftKey = key(left);
196
+ const rightKey = key(right);
197
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
198
+ });
199
+ if (!orderedUnique(sorted, key)) {
200
+ throw new OhValidationError("duplicate", "$", "contains duplicate canonical values");
201
+ }
202
+ return sorted;
203
+ }