@lanes-sh/link 0.1.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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@lanes-sh/link",
3
+ "version": "0.1.0",
4
+ "description": "A self-hosted MCP gateway for your accounts, memory, skills, and secrets",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://github.com/lanes-sh/link#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lanes-sh/link.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/lanes-sh/link/issues"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "agent",
18
+ "gateway",
19
+ "self-hosted",
20
+ "gmail",
21
+ "calendar",
22
+ "claude",
23
+ "codex"
24
+ ],
25
+ "type": "module",
26
+ "engines": {
27
+ "bun": ">=1.3.11"
28
+ },
29
+ "os": [
30
+ "darwin",
31
+ "linux"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "bin": {
37
+ "lanes": "./bin/lanes"
38
+ },
39
+ "files": [
40
+ "bin",
41
+ "src",
42
+ "!src/**/*.test.ts",
43
+ "instructions",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "test": "bun test",
49
+ "typecheck": "tsc --noEmit",
50
+ "lanes": "bun run ./src/cli/lanes.ts",
51
+ "audit": "bun pm scan",
52
+ "vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
53
+ },
54
+ "imports": {
55
+ "#audit": "./src/audit/index.ts",
56
+ "#audit/*": "./src/audit/*",
57
+ "#auth": "./src/auth/index.ts",
58
+ "#cli/*": "./src/cli/*",
59
+ "#connectivity": "./src/connectivity/index.ts",
60
+ "#connectivity/*": "./src/connectivity/*",
61
+ "#connectivity/mail": "./src/connectivity/mail/index.ts",
62
+ "#connectivity/transports": "./src/connectivity/transports/index.ts",
63
+ "#deployments/*": "./src/deployments/*",
64
+ "#dispatch": "./src/dispatch/index.ts",
65
+ "#policy": "./src/policy/index.ts",
66
+ "#profile": "./src/profile/index.ts",
67
+ "#providers/*": "./src/providers/*",
68
+ "#registry": "./src/registry/index.ts",
69
+ "#secrets": "./src/secrets/index.ts",
70
+ "#secrets/*": "./src/secrets/*",
71
+ "#server": "./src/server/index.ts",
72
+ "#server/endpoint.ts": "./src/server/endpoint.ts",
73
+ "#server/logging.ts": "./src/server/logging.ts",
74
+ "#server/mcp": "./src/server/mcp/index.ts",
75
+ "#stores/blobs": "./src/stores/blobs/index.ts",
76
+ "#stores/blobs/*": "./src/stores/blobs/*",
77
+ "#stores/state": "./src/stores/state/index.ts",
78
+ "#stores/state/*": "./src/stores/state/*"
79
+ },
80
+ "dependencies": {
81
+ "@modelcontextprotocol/client": "2.0.0",
82
+ "@modelcontextprotocol/core": "2.0.0",
83
+ "@modelcontextprotocol/server": "2.0.0",
84
+ "@rgrove/parse-xml": "4.2.3",
85
+ "mcp-from-openapi": "2.5.1",
86
+ "nodemailer": "9.0.3",
87
+ "postal-mime": "2.7.5",
88
+ "yaml": "2.9.0",
89
+ "zod": "4.4.3"
90
+ },
91
+ "devDependencies": {
92
+ "@types/bun": "latest",
93
+ "typescript": "^5"
94
+ }
95
+ }
@@ -0,0 +1,278 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AuditEvent } from './index.ts';
3
+
4
+ /**
5
+ * The integrity envelope around an event, and the record format on disk.
6
+ *
7
+ * One event is one object. `prev` holds the SHA-256 of the bytes of the
8
+ * previous record *of the same run*, so altering or removing a record breaks
9
+ * every link after it.
10
+ *
11
+ * **Why the chain is per run and not per file.** There is no file. Events are
12
+ * separate objects, several Cloud Run instances write concurrently, and a
13
+ * single global chain would need a lock across all of them — which is the
14
+ * database this change exists to remove. A run is one writer process, so its
15
+ * sequence is naturally ordered without coordinating with anybody.
16
+ *
17
+ * What this proves, and what it does not:
18
+ *
19
+ * - A `seq` gap, or a `prev` that does not match, proves a record was
20
+ * **removed from the middle of a run, or altered**. That is the case a
21
+ * database could not detect either: nothing stopped `UPDATE audit_events`.
22
+ * - Truncating a run's **tail** is invisible on its own, because nothing
23
+ * points forward. `closeRun` writes a marker at graceful shutdown so a
24
+ * clean exit is checkable; a killed process leaves a run that verifies as
25
+ * open, which is honest rather than reassuring.
26
+ * - Deleting a whole run nobody knew existed is undetectable. Runs are not
27
+ * enumerated anywhere else, and inventing a registry to hold them would
28
+ * just move the same problem up one level.
29
+ */
30
+ export interface ChainFields {
31
+ /** One writer process. */
32
+ readonly run: string;
33
+ /** Monotonic within `run`, from zero. */
34
+ readonly seq: number;
35
+ /** SHA-256 of the previous record's bytes in this run; `null` for the first. */
36
+ readonly prev: string | null;
37
+ }
38
+
39
+ export type ChainedEvent = AuditEvent & ChainFields;
40
+
41
+ /** A record as it was stored, kept as bytes because that is what the hash covers. */
42
+ export interface StoredRecord {
43
+ readonly key: string;
44
+ readonly bytes: Uint8Array;
45
+ }
46
+
47
+ export interface ChainBreak {
48
+ readonly run: string;
49
+ readonly seq: number;
50
+ readonly kind: 'gap' | 'hash' | 'malformed';
51
+ readonly detail: string;
52
+ }
53
+
54
+ export interface AuditVerification {
55
+ readonly events: number;
56
+ readonly runs: number;
57
+ readonly ok: boolean;
58
+ readonly breaks: readonly ChainBreak[];
59
+ }
60
+
61
+ /** What `closeRun` writes, so a cleanly-stopped run can be checked for truncation. */
62
+ export interface RunMarker {
63
+ readonly run: string;
64
+ readonly events: number;
65
+ readonly last: string;
66
+ }
67
+
68
+ export function hashBytes(bytes: Uint8Array): string {
69
+ return createHash('sha256').update(bytes).digest('hex');
70
+ }
71
+
72
+ export function newRunId(): string {
73
+ return `r_${crypto.randomUUID().slice(0, 8)}`;
74
+ }
75
+
76
+ /**
77
+ * Where one event is stored.
78
+ *
79
+ * Date-partitioned because `BlobStore.list` paginates to completion and takes
80
+ * no limit: a flat prefix would make `tail` enumerate every event ever written,
81
+ * which is a slow directory walk locally and a paid one deployed.
82
+ *
83
+ * The timestamp is compact ISO basic format — fixed width, no colons, and
84
+ * lexicographically chronological, so sorting keys within a day sorts by time
85
+ * without parsing anything. Colons are legal in object keys but awkward in a
86
+ * local path, and the two layouts have to stay identical.
87
+ */
88
+ export function auditKey(event: Pick<AuditEvent, 'id' | 'timestamp'>): string {
89
+ const iso = event.timestamp.toISOString();
90
+ const stamp = iso.replaceAll('-', '').replaceAll(':', '').replace('.', '');
91
+ return `${dayPrefix(event.timestamp)}${stamp}-${event.id}.json`;
92
+ }
93
+
94
+ /** The `YYYY/MM/DD/` prefix a day's events share. */
95
+ export function dayPrefix(date: Date): string {
96
+ return `${date.toISOString().slice(0, 10).replaceAll('-', '/')}/`;
97
+ }
98
+
99
+ /**
100
+ * The part of a key two events written in the same millisecond share.
101
+ *
102
+ * A reader sorting by key alone cannot order those two, because what follows
103
+ * is a random id. `seq` is the tiebreaker — this exists so a reader can tell
104
+ * where the ambiguity starts and read far enough to resolve it.
105
+ *
106
+ * The first `-` after the day prefix: the stamp has had its dashes stripped
107
+ * and the day is separated by slashes, so nothing before the separator can
108
+ * contain one.
109
+ */
110
+ export function stampOf(key: string): string {
111
+ const separator = key.indexOf('-', 'YYYY/MM/DD/'.length);
112
+ return separator === -1 ? key : key.slice(0, separator);
113
+ }
114
+
115
+ /** Where a run's close marker lives. Under a dot so it cannot look like a day. */
116
+ export function runMarkerKey(run: string): string {
117
+ return `runs.closed/${run}.json`;
118
+ }
119
+
120
+ /**
121
+ * The record, with keys written in a fixed order.
122
+ *
123
+ * Verification hashes the bytes it read rather than re-serialising, so key
124
+ * order is not load-bearing for correctness — but a stable order keeps two
125
+ * records of the same shape diffable, which is worth the explicit construction.
126
+ */
127
+ export function encodeEvent(event: ChainedEvent): Uint8Array {
128
+ const record: Record<string, unknown> = {
129
+ run: event.run,
130
+ seq: event.seq,
131
+ prev: event.prev,
132
+ id: event.id,
133
+ timestamp: event.timestamp.toISOString(),
134
+ profile: event.profile,
135
+ principal: event.principal,
136
+ ...(event.clientLabel !== undefined ? { clientLabel: event.clientLabel } : {}),
137
+ provider: event.provider,
138
+ ...(event.connection !== undefined ? { connection: event.connection } : {}),
139
+ capability: event.capability,
140
+ arguments: event.arguments,
141
+ authorization: event.authorization,
142
+ status: event.status,
143
+ durationMs: event.durationMs,
144
+ ...(event.error !== undefined ? { error: event.error } : {}),
145
+ };
146
+ return new TextEncoder().encode(JSON.stringify(record));
147
+ }
148
+
149
+ /**
150
+ * Parse a record, or `null` if it is not one.
151
+ *
152
+ * A record that will not parse is a record nothing can use, and one corrupt
153
+ * object must not take down a `tail` over the thousand beside it. `verify` is
154
+ * the command that reports it as `malformed`; readers skip it.
155
+ */
156
+ export function decodeEvent(bytes: Uint8Array): ChainedEvent | null {
157
+ let parsed: unknown;
158
+ try {
159
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
160
+ } catch {
161
+ return null;
162
+ }
163
+ if (typeof parsed !== 'object' || parsed === null) return null;
164
+
165
+ const record = parsed as Record<string, unknown>;
166
+ const { run, seq, prev, id, timestamp } = record;
167
+
168
+ if (typeof run !== 'string' || typeof seq !== 'number' || typeof id !== 'string') return null;
169
+ if (prev !== null && typeof prev !== 'string') return null;
170
+ if (typeof timestamp !== 'string') return null;
171
+
172
+ const when = new Date(timestamp);
173
+ if (Number.isNaN(when.getTime())) return null;
174
+
175
+ return {
176
+ run,
177
+ seq,
178
+ prev,
179
+ id,
180
+ timestamp: when,
181
+ profile: String(record['profile'] ?? ''),
182
+ principal: String(record['principal'] ?? ''),
183
+ ...(typeof record['clientLabel'] === 'string' ? { clientLabel: record['clientLabel'] } : {}),
184
+ provider: String(record['provider'] ?? ''),
185
+ ...(typeof record['connection'] === 'string' ? { connection: record['connection'] } : {}),
186
+ capability: String(record['capability'] ?? ''),
187
+ arguments: (record['arguments'] ?? {}) as Readonly<Record<string, unknown>>,
188
+ authorization: record['authorization'] as AuditEvent['authorization'],
189
+ status: record['status'] as AuditEvent['status'],
190
+ durationMs: typeof record['durationMs'] === 'number' ? record['durationMs'] : 0,
191
+ ...(isError(record['error']) ? { error: record['error'] } : {}),
192
+ };
193
+ }
194
+
195
+ function isError(value: unknown): value is { kind: string; message: string } {
196
+ if (typeof value !== 'object' || value === null) return false;
197
+ const candidate = value as Record<string, unknown>;
198
+ return typeof candidate['kind'] === 'string' && typeof candidate['message'] === 'string';
199
+ }
200
+
201
+ /**
202
+ * Walk every run and report the first break in each.
203
+ *
204
+ * One break per run rather than every downstream link: a single altered record
205
+ * invalidates every `prev` after it, and reporting three hundred consequences
206
+ * of one edit buries the edit.
207
+ */
208
+ export function verifyChain(
209
+ records: readonly StoredRecord[],
210
+ markers: readonly RunMarker[] = [],
211
+ ): AuditVerification {
212
+ const runs = new Map<string, Array<{ record: ChainedEvent; hash: string }>>();
213
+ const breaks: ChainBreak[] = [];
214
+ let events = 0;
215
+
216
+ for (const stored of records) {
217
+ const record = decodeEvent(stored.bytes);
218
+ if (!record) {
219
+ breaks.push({ run: '?', seq: -1, kind: 'malformed', detail: stored.key });
220
+ continue;
221
+ }
222
+ events += 1;
223
+ const bucket = runs.get(record.run) ?? [];
224
+ bucket.push({ record, hash: hashBytes(stored.bytes) });
225
+ runs.set(record.run, bucket);
226
+ }
227
+
228
+ for (const [run, entries] of runs) {
229
+ entries.sort((a, b) => a.record.seq - b.record.seq);
230
+
231
+ let previousHash: string | null = null;
232
+ let expected = 0;
233
+ let broken = false;
234
+
235
+ for (const { record, hash } of entries) {
236
+ if (broken) break;
237
+
238
+ if (record.seq !== expected) {
239
+ breaks.push({
240
+ run,
241
+ seq: expected,
242
+ kind: 'gap',
243
+ detail: `expected seq ${expected}, found ${record.seq}`,
244
+ });
245
+ broken = true;
246
+ break;
247
+ }
248
+ if (record.prev !== previousHash) {
249
+ breaks.push({
250
+ run,
251
+ seq: record.seq,
252
+ kind: 'hash',
253
+ detail: `prev is ${record.prev ?? 'null'}, expected ${previousHash ?? 'null'}`,
254
+ });
255
+ broken = true;
256
+ break;
257
+ }
258
+ previousHash = hash;
259
+ expected += 1;
260
+ }
261
+
262
+ if (broken) continue;
263
+
264
+ // A closed run states how far it got, which is the only way to notice that
265
+ // its tail was cut off — nothing inside a chain points forward.
266
+ const marker = markers.find((candidate) => candidate.run === run);
267
+ if (marker && marker.events !== entries.length) {
268
+ breaks.push({
269
+ run,
270
+ seq: entries.length,
271
+ kind: 'gap',
272
+ detail: `run closed at ${marker.events} events, found ${entries.length}`,
273
+ });
274
+ }
275
+ }
276
+
277
+ return { events, runs: runs.size, ok: breaks.length === 0, breaks };
278
+ }