@smthrs/sync 0.0.0-stage → 1.0.0-rc.4

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 (266) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -2
  4. package/dist/cjs/BranchCommands.d.ts +228 -0
  5. package/dist/cjs/BranchCommands.d.ts.map +1 -0
  6. package/dist/cjs/BranchCommands.js +316 -0
  7. package/dist/cjs/BranchCommands.js.map +7 -0
  8. package/dist/cjs/BranchIds.d.ts +65 -0
  9. package/dist/cjs/BranchIds.d.ts.map +1 -0
  10. package/dist/cjs/BranchIds.js +53 -0
  11. package/dist/cjs/BranchIds.js.map +7 -0
  12. package/dist/cjs/BranchPresence.d.ts +195 -0
  13. package/dist/cjs/BranchPresence.d.ts.map +1 -0
  14. package/dist/cjs/BranchPresence.js +276 -0
  15. package/dist/cjs/BranchPresence.js.map +7 -0
  16. package/dist/cjs/BranchProjection.d.ts +179 -0
  17. package/dist/cjs/BranchProjection.d.ts.map +1 -0
  18. package/dist/cjs/BranchProjection.js +197 -0
  19. package/dist/cjs/BranchProjection.js.map +7 -0
  20. package/dist/cjs/BranchProtocol.d.ts +415 -0
  21. package/dist/cjs/BranchProtocol.d.ts.map +1 -0
  22. package/dist/cjs/BranchProtocol.js +172 -0
  23. package/dist/cjs/BranchProtocol.js.map +7 -0
  24. package/dist/cjs/BranchRpcs.d.ts +177 -0
  25. package/dist/cjs/BranchRpcs.d.ts.map +1 -0
  26. package/dist/cjs/BranchRpcs.js +85 -0
  27. package/dist/cjs/BranchRpcs.js.map +7 -0
  28. package/dist/cjs/BranchServer.d.ts +55 -0
  29. package/dist/cjs/BranchServer.d.ts.map +1 -0
  30. package/dist/cjs/BranchServer.js +147 -0
  31. package/dist/cjs/BranchServer.js.map +7 -0
  32. package/dist/cjs/BranchShare.d.ts +159 -0
  33. package/dist/cjs/BranchShare.d.ts.map +1 -0
  34. package/dist/cjs/BranchShare.js +147 -0
  35. package/dist/cjs/BranchShare.js.map +7 -0
  36. package/dist/cjs/RunCatalog.d.ts +207 -0
  37. package/dist/cjs/RunCatalog.d.ts.map +1 -0
  38. package/dist/cjs/RunCatalog.js +122 -0
  39. package/dist/cjs/RunCatalog.js.map +7 -0
  40. package/dist/cjs/SyncAuth.d.ts +70 -0
  41. package/dist/cjs/SyncAuth.d.ts.map +1 -0
  42. package/dist/cjs/SyncAuth.js +109 -0
  43. package/dist/cjs/SyncAuth.js.map +7 -0
  44. package/dist/cjs/SyncClient.d.ts +274 -0
  45. package/dist/cjs/SyncClient.d.ts.map +1 -0
  46. package/dist/cjs/SyncClient.js +474 -0
  47. package/dist/cjs/SyncClient.js.map +7 -0
  48. package/dist/cjs/SyncError.d.ts +107 -0
  49. package/dist/cjs/SyncError.d.ts.map +1 -0
  50. package/dist/cjs/SyncError.js +110 -0
  51. package/dist/cjs/SyncError.js.map +7 -0
  52. package/dist/cjs/SyncPrincipal.d.ts +100 -0
  53. package/dist/cjs/SyncPrincipal.d.ts.map +1 -0
  54. package/dist/cjs/SyncPrincipal.js +48 -0
  55. package/dist/cjs/SyncPrincipal.js.map +7 -0
  56. package/dist/cjs/SyncProtocol.d.ts +592 -0
  57. package/dist/cjs/SyncProtocol.d.ts.map +1 -0
  58. package/dist/cjs/SyncProtocol.js +165 -0
  59. package/dist/cjs/SyncProtocol.js.map +7 -0
  60. package/dist/cjs/SyncRpcs.d.ts +92 -0
  61. package/dist/cjs/SyncRpcs.d.ts.map +1 -0
  62. package/dist/cjs/SyncRpcs.js +35 -0
  63. package/dist/cjs/SyncRpcs.js.map +7 -0
  64. package/dist/cjs/SyncServer.d.ts +184 -0
  65. package/dist/cjs/SyncServer.d.ts.map +1 -0
  66. package/dist/cjs/SyncServer.js +513 -0
  67. package/dist/cjs/SyncServer.js.map +7 -0
  68. package/dist/cjs/WorkspaceShare.d.ts +174 -0
  69. package/dist/cjs/WorkspaceShare.d.ts.map +1 -0
  70. package/dist/cjs/WorkspaceShare.js +149 -0
  71. package/dist/cjs/WorkspaceShare.js.map +7 -0
  72. package/dist/cjs/index.d.ts +91 -0
  73. package/dist/cjs/index.d.ts.map +1 -0
  74. package/dist/cjs/index.js +67 -0
  75. package/dist/cjs/index.js.map +7 -0
  76. package/dist/cjs/internal/Admission.d.ts +43 -0
  77. package/dist/cjs/internal/Admission.d.ts.map +1 -0
  78. package/dist/cjs/internal/Admission.js +90 -0
  79. package/dist/cjs/internal/Admission.js.map +7 -0
  80. package/dist/cjs/internal/CauseText.d.ts +55 -0
  81. package/dist/cjs/internal/CauseText.d.ts.map +1 -0
  82. package/dist/cjs/internal/CauseText.js +38 -0
  83. package/dist/cjs/internal/CauseText.js.map +7 -0
  84. package/dist/cjs/internal/JournalErrorCode.d.ts +23 -0
  85. package/dist/cjs/internal/JournalErrorCode.d.ts.map +1 -0
  86. package/dist/cjs/internal/JournalErrorCode.js +34 -0
  87. package/dist/cjs/internal/JournalErrorCode.js.map +7 -0
  88. package/dist/cjs/internal/PolicyOptions.d.ts +57 -0
  89. package/dist/cjs/internal/PolicyOptions.d.ts.map +1 -0
  90. package/dist/cjs/internal/PolicyOptions.js +62 -0
  91. package/dist/cjs/internal/PolicyOptions.js.map +7 -0
  92. package/dist/cjs/internal/ProtocolVersion.d.ts +17 -0
  93. package/dist/cjs/internal/ProtocolVersion.d.ts.map +1 -0
  94. package/dist/cjs/internal/ProtocolVersion.js +44 -0
  95. package/dist/cjs/internal/ProtocolVersion.js.map +7 -0
  96. package/dist/cjs/internal/ShareSigner.d.ts +173 -0
  97. package/dist/cjs/internal/ShareSigner.d.ts.map +1 -0
  98. package/dist/cjs/internal/ShareSigner.js +150 -0
  99. package/dist/cjs/internal/ShareSigner.js.map +7 -0
  100. package/dist/cjs/internal/SnapshotBoundary.d.ts +17 -0
  101. package/dist/cjs/internal/SnapshotBoundary.d.ts.map +1 -0
  102. package/dist/cjs/internal/SnapshotBoundary.js +97 -0
  103. package/dist/cjs/internal/SnapshotBoundary.js.map +7 -0
  104. package/dist/cjs/package.json +1 -0
  105. package/dist/cjs/test/TestSocket.d.ts +55 -0
  106. package/dist/cjs/test/TestSocket.d.ts.map +1 -0
  107. package/dist/cjs/test/TestSocket.js +145 -0
  108. package/dist/cjs/test/TestSocket.js.map +7 -0
  109. package/dist/cjs/test/TestSync.d.ts +54 -0
  110. package/dist/cjs/test/TestSync.d.ts.map +1 -0
  111. package/dist/cjs/test/TestSync.js +96 -0
  112. package/dist/cjs/test/TestSync.js.map +7 -0
  113. package/dist/esm/BranchCommands.d.ts +228 -0
  114. package/dist/esm/BranchCommands.d.ts.map +1 -0
  115. package/dist/esm/BranchCommands.js +496 -0
  116. package/dist/esm/BranchCommands.js.map +1 -0
  117. package/dist/esm/BranchIds.d.ts +65 -0
  118. package/dist/esm/BranchIds.d.ts.map +1 -0
  119. package/dist/esm/BranchIds.js +61 -0
  120. package/dist/esm/BranchIds.js.map +1 -0
  121. package/dist/esm/BranchPresence.d.ts +195 -0
  122. package/dist/esm/BranchPresence.d.ts.map +1 -0
  123. package/dist/esm/BranchPresence.js +361 -0
  124. package/dist/esm/BranchPresence.js.map +1 -0
  125. package/dist/esm/BranchProjection.d.ts +179 -0
  126. package/dist/esm/BranchProjection.d.ts.map +1 -0
  127. package/dist/esm/BranchProjection.js +269 -0
  128. package/dist/esm/BranchProjection.js.map +1 -0
  129. package/dist/esm/BranchProtocol.d.ts +415 -0
  130. package/dist/esm/BranchProtocol.d.ts.map +1 -0
  131. package/dist/esm/BranchProtocol.js +323 -0
  132. package/dist/esm/BranchProtocol.js.map +1 -0
  133. package/dist/esm/BranchRpcs.d.ts +177 -0
  134. package/dist/esm/BranchRpcs.d.ts.map +1 -0
  135. package/dist/esm/BranchRpcs.js +103 -0
  136. package/dist/esm/BranchRpcs.js.map +1 -0
  137. package/dist/esm/BranchServer.d.ts +55 -0
  138. package/dist/esm/BranchServer.d.ts.map +1 -0
  139. package/dist/esm/BranchServer.js +168 -0
  140. package/dist/esm/BranchServer.js.map +1 -0
  141. package/dist/esm/BranchShare.d.ts +159 -0
  142. package/dist/esm/BranchShare.d.ts.map +1 -0
  143. package/dist/esm/BranchShare.js +213 -0
  144. package/dist/esm/BranchShare.js.map +1 -0
  145. package/dist/esm/RunCatalog.d.ts +207 -0
  146. package/dist/esm/RunCatalog.d.ts.map +1 -0
  147. package/dist/esm/RunCatalog.js +196 -0
  148. package/dist/esm/RunCatalog.js.map +1 -0
  149. package/dist/esm/SyncAuth.d.ts +70 -0
  150. package/dist/esm/SyncAuth.d.ts.map +1 -0
  151. package/dist/esm/SyncAuth.js +119 -0
  152. package/dist/esm/SyncAuth.js.map +1 -0
  153. package/dist/esm/SyncClient.d.ts +274 -0
  154. package/dist/esm/SyncClient.d.ts.map +1 -0
  155. package/dist/esm/SyncClient.js +586 -0
  156. package/dist/esm/SyncClient.js.map +1 -0
  157. package/dist/esm/SyncError.d.ts +107 -0
  158. package/dist/esm/SyncError.d.ts.map +1 -0
  159. package/dist/esm/SyncError.js +133 -0
  160. package/dist/esm/SyncError.js.map +1 -0
  161. package/dist/esm/SyncPrincipal.d.ts +100 -0
  162. package/dist/esm/SyncPrincipal.d.ts.map +1 -0
  163. package/dist/esm/SyncPrincipal.js +58 -0
  164. package/dist/esm/SyncPrincipal.js.map +1 -0
  165. package/dist/esm/SyncProtocol.d.ts +592 -0
  166. package/dist/esm/SyncProtocol.d.ts.map +1 -0
  167. package/dist/esm/SyncProtocol.js +434 -0
  168. package/dist/esm/SyncProtocol.js.map +1 -0
  169. package/dist/esm/SyncRpcs.d.ts +92 -0
  170. package/dist/esm/SyncRpcs.d.ts.map +1 -0
  171. package/dist/esm/SyncRpcs.js +35 -0
  172. package/dist/esm/SyncRpcs.js.map +1 -0
  173. package/dist/esm/SyncServer.d.ts +184 -0
  174. package/dist/esm/SyncServer.d.ts.map +1 -0
  175. package/dist/esm/SyncServer.js +897 -0
  176. package/dist/esm/SyncServer.js.map +1 -0
  177. package/dist/esm/WorkspaceShare.d.ts +174 -0
  178. package/dist/esm/WorkspaceShare.d.ts.map +1 -0
  179. package/dist/esm/WorkspaceShare.js +208 -0
  180. package/dist/esm/WorkspaceShare.js.map +1 -0
  181. package/dist/esm/index.d.ts +91 -0
  182. package/dist/esm/index.d.ts.map +1 -0
  183. package/dist/esm/index.js +91 -0
  184. package/dist/esm/index.js.map +1 -0
  185. package/dist/esm/internal/Admission.d.ts +43 -0
  186. package/dist/esm/internal/Admission.d.ts.map +1 -0
  187. package/dist/esm/internal/Admission.js +80 -0
  188. package/dist/esm/internal/Admission.js.map +1 -0
  189. package/dist/esm/internal/CauseText.d.ts +55 -0
  190. package/dist/esm/internal/CauseText.d.ts.map +1 -0
  191. package/dist/esm/internal/CauseText.js +67 -0
  192. package/dist/esm/internal/CauseText.js.map +1 -0
  193. package/dist/esm/internal/JournalErrorCode.d.ts +23 -0
  194. package/dist/esm/internal/JournalErrorCode.d.ts.map +1 -0
  195. package/dist/esm/internal/JournalErrorCode.js +44 -0
  196. package/dist/esm/internal/JournalErrorCode.js.map +1 -0
  197. package/dist/esm/internal/PolicyOptions.d.ts +57 -0
  198. package/dist/esm/internal/PolicyOptions.d.ts.map +1 -0
  199. package/dist/esm/internal/PolicyOptions.js +70 -0
  200. package/dist/esm/internal/PolicyOptions.js.map +1 -0
  201. package/dist/esm/internal/ProtocolVersion.d.ts +17 -0
  202. package/dist/esm/internal/ProtocolVersion.d.ts.map +1 -0
  203. package/dist/esm/internal/ProtocolVersion.js +24 -0
  204. package/dist/esm/internal/ProtocolVersion.js.map +1 -0
  205. package/dist/esm/internal/ShareSigner.d.ts +173 -0
  206. package/dist/esm/internal/ShareSigner.d.ts.map +1 -0
  207. package/dist/esm/internal/ShareSigner.js +219 -0
  208. package/dist/esm/internal/ShareSigner.js.map +1 -0
  209. package/dist/esm/internal/SnapshotBoundary.d.ts +17 -0
  210. package/dist/esm/internal/SnapshotBoundary.d.ts.map +1 -0
  211. package/dist/esm/internal/SnapshotBoundary.js +68 -0
  212. package/dist/esm/internal/SnapshotBoundary.js.map +1 -0
  213. package/dist/esm/test/TestSocket.d.ts +55 -0
  214. package/dist/esm/test/TestSocket.d.ts.map +1 -0
  215. package/dist/esm/test/TestSocket.js +128 -0
  216. package/dist/esm/test/TestSocket.js.map +1 -0
  217. package/dist/esm/test/TestSync.d.ts +54 -0
  218. package/dist/esm/test/TestSync.d.ts.map +1 -0
  219. package/dist/esm/test/TestSync.js +76 -0
  220. package/dist/esm/test/TestSync.js.map +1 -0
  221. package/docs/README.md +156 -0
  222. package/docs/api.md +328 -0
  223. package/docs/concepts/authorization.md +117 -0
  224. package/docs/concepts/branches.md +195 -0
  225. package/docs/concepts/compaction.md +144 -0
  226. package/docs/concepts/replay-then-follow.md +129 -0
  227. package/docs/concepts/scopes-and-cursors.md +135 -0
  228. package/docs/guides/authorize-a-connection.md +174 -0
  229. package/docs/guides/follow-a-run.md +174 -0
  230. package/docs/guides/handle-a-compacted-run.md +158 -0
  231. package/docs/guides/list-workspace-runs.md +120 -0
  232. package/docs/guides/run-a-long-soak.md +80 -0
  233. package/docs/guides/serve-the-read-path.md +146 -0
  234. package/docs/guides/test-a-follower.md +273 -0
  235. package/docs/installation.md +92 -0
  236. package/docs/protocol.md +183 -0
  237. package/docs/quickstart.md +159 -0
  238. package/docs/troubleshooting.md +234 -0
  239. package/package.json +304 -3
  240. package/src/BranchCommands.ts +684 -0
  241. package/src/BranchIds.ts +76 -0
  242. package/src/BranchPresence.ts +512 -0
  243. package/src/BranchProjection.ts +308 -0
  244. package/src/BranchProtocol.ts +411 -0
  245. package/src/BranchRpcs.ts +163 -0
  246. package/src/BranchServer.ts +202 -0
  247. package/src/BranchShare.ts +297 -0
  248. package/src/RunCatalog.ts +308 -0
  249. package/src/SyncAuth.ts +146 -0
  250. package/src/SyncClient.ts +902 -0
  251. package/src/SyncError.ts +147 -0
  252. package/src/SyncPrincipal.ts +115 -0
  253. package/src/SyncProtocol.ts +594 -0
  254. package/src/SyncRpcs.ts +40 -0
  255. package/src/SyncServer.ts +1184 -0
  256. package/src/WorkspaceShare.ts +292 -0
  257. package/src/index.ts +107 -0
  258. package/src/internal/Admission.ts +119 -0
  259. package/src/internal/CauseText.ts +74 -0
  260. package/src/internal/JournalErrorCode.ts +46 -0
  261. package/src/internal/PolicyOptions.ts +97 -0
  262. package/src/internal/ProtocolVersion.ts +28 -0
  263. package/src/internal/ShareSigner.ts +300 -0
  264. package/src/internal/SnapshotBoundary.ts +91 -0
  265. package/src/test/TestSocket.ts +192 -0
  266. package/src/test/TestSync.ts +113 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Execution-time validation of the numeric policies this package's
3
+ * constructors accept.
4
+ *
5
+ * Every `Options` field is typed `number`, and TypeScript admits `NaN`,
6
+ * `Infinity`, zero, and a negative under that type. Each of them disables the
7
+ * thing it configures rather than tightening it: `maxFrameBytes: NaN` makes
8
+ * every `bytes > maxFrameBytes` comparison false, so the ceiling silently
9
+ * stops existing; `concurrency: 0` reaches a bounded `Stream.flatMap`
10
+ * unchecked. A policy is checked once, where it enters, and a bad one is a
11
+ * typed refusal rather than a bound that quietly does nothing.
12
+ *
13
+ * @since 1.0.0-rc.0
14
+ */
15
+
16
+ import * as Effect from "effect/Effect"
17
+ import { SyncError } from "../SyncError.ts"
18
+
19
+ /**
20
+ * The caller's value when it is a positive safe integer, the fallback when the
21
+ * caller supplied none, and a typed `invalid_request` refusal otherwise. The
22
+ * message names the option so the offending field is identifiable without the
23
+ * error carrying anything but the scalar it was given.
24
+ *
25
+ * @category validation
26
+ * @since 1.0.0-rc.0
27
+ */
28
+ export const positiveInt = (
29
+ name: string,
30
+ value: number | undefined,
31
+ fallback: number
32
+ ): Effect.Effect<number, SyncError> => {
33
+ if (value === undefined) return Effect.succeed(fallback)
34
+ return Number.isSafeInteger(value) && value > 0 ? Effect.succeed(value) : Effect.fail(
35
+ new SyncError({
36
+ code: "invalid_request",
37
+ message: `${name} must be a positive safe integer, not ${value}`
38
+ })
39
+ )
40
+ }
41
+
42
+ /**
43
+ * A count carried by a REQUEST rather than by a policy: floored at one,
44
+ * ceilinged at what the wire schema allows, and refused otherwise.
45
+ *
46
+ * `SyncProtocol` already bounds `limit` and `credit` on both sides, so a
47
+ * decoded request cannot carry a bad one. An in-process caller that builds the
48
+ * request value directly never meets that schema, and the TypeScript type it
49
+ * does meet is `number`: `NaN`, zero, and a negative all satisfy it. Each of
50
+ * them disables the bound instead of tightening it — `Math.min(NaN, ceiling)`
51
+ * is `NaN`, every `length >= NaN` comparison answers false, and the value
52
+ * reaches the journal as a page size — so the floor is checked here, where
53
+ * both callers share one refusal and one sentence.
54
+ *
55
+ * @category validation
56
+ * @since 1.0.0-rc.0
57
+ */
58
+ export const requestCount = (
59
+ name: string,
60
+ value: number,
61
+ ceiling: number
62
+ ): Effect.Effect<number, SyncError> =>
63
+ Number.isSafeInteger(value) && value > 0 ? Effect.succeed(Math.min(value, ceiling)) : Effect.fail(
64
+ new SyncError({
65
+ code: "invalid_request",
66
+ message: `${name} must be a positive safe integer, not ${value}`
67
+ })
68
+ )
69
+
70
+ /**
71
+ * {@link positiveInt} plus a ceiling, for a policy the WIRE also bounds.
72
+ *
73
+ * A value the local schema would accept and the wire schema would refuse is
74
+ * not a transport failure, but that is what it looked like: the RPC client's
75
+ * own decode failure arrived through the same channel a dropped connection
76
+ * does, and the follow retried it under backoff forever. The refusal belongs
77
+ * here, before a request is built.
78
+ *
79
+ * @category validation
80
+ * @since 1.0.0-rc.0
81
+ */
82
+ export const boundedInt = (
83
+ name: string,
84
+ value: number | undefined,
85
+ fallback: number,
86
+ maximum: number
87
+ ): Effect.Effect<number, SyncError> =>
88
+ Effect.flatMap(
89
+ positiveInt(name, value, fallback),
90
+ (accepted) =>
91
+ accepted <= maximum ? Effect.succeed(accepted) : Effect.fail(
92
+ new SyncError({
93
+ code: "invalid_request",
94
+ message: `${name} must be at most ${maximum}, not ${accepted}`
95
+ })
96
+ )
97
+ )
@@ -0,0 +1,28 @@
1
+ /** The one protocol-version comparison every sync boundary answers with.
2
+ * @since 1.0.0-rc.0
3
+ */
4
+
5
+ import * as Effect from "effect/Effect"
6
+ import { SyncError } from "../SyncError.ts"
7
+ import { protocolVersion } from "../SyncProtocol.ts"
8
+
9
+ /** Refuse a version this revision does not speak, on every path, with one code.
10
+ *
11
+ * Read, subscribe and snapshot all decode a mismatched version so the refusal
12
+ * is this typed `protocol_violation` rather than three different decode
13
+ * failures, and the accepted number is only ever
14
+ * {@link SyncProtocol.protocolVersion}.
15
+ *
16
+ * @category validation
17
+ * @since 1.0.0-rc.0
18
+ */
19
+ export const requireVersion = (version: number | undefined): Effect.Effect<void, SyncError> =>
20
+ version === protocolVersion
21
+ ? Effect.void
22
+ : Effect.fail(
23
+ new SyncError({
24
+ code: "protocol_violation",
25
+ message: `Expected sync protocol version ${protocolVersion}; received ${version}`,
26
+ cause: "protocol_version_mismatch"
27
+ })
28
+ )
@@ -0,0 +1,300 @@
1
+ /**
2
+ * HMAC-SHA-256 signing primitives shared by the branch and workspace share
3
+ * authorities.
4
+ *
5
+ * Both authorities sign a length-prefixed encoding of their claims: without
6
+ * length prefixes a field ending in the separator could be re-cut into a
7
+ * different, still-validly-signed claim set. Web Crypto is used directly so
8
+ * the same module runs in the browser and on node, and signature comparison
9
+ * is length-independent so a check leaks no prefix length.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+
14
+ import * as Clock from "effect/Clock"
15
+ import * as Effect from "effect/Effect"
16
+ import * as Redacted from "effect/Redacted"
17
+ import type { Access } from "../BranchProtocol.ts"
18
+ import { SyncError } from "../SyncError.ts"
19
+ import { causeText } from "./CauseText.ts"
20
+
21
+ const encoder = new TextEncoder()
22
+
23
+ /**
24
+ * Length-prefixes each field so no two distinct field sequences share an
25
+ * encoding.
26
+ *
27
+ * The prefix counts UTF-8 BYTES, not UTF-16 code units, because the bytes are
28
+ * what {@link signHmac} covers. Counting code units let a two-byte field claim
29
+ * length one, so the prefix no longer separated the fields the signature was
30
+ * taken over.
31
+ *
32
+ * @category encoding
33
+ * @since 0.1.0
34
+ */
35
+ export const lengthPrefixed = (fields: ReadonlyArray<string>): string =>
36
+ fields.map((field) => `${encoder.encode(field).length}:${field}`).join("")
37
+
38
+ const decoder = new TextDecoder()
39
+
40
+ /**
41
+ * Whether a string is exactly what its own UTF-8 bytes decode back to.
42
+ *
43
+ * `TextEncoder` replaces every unpaired surrogate with U+FFFD, so two claim
44
+ * sets differing only in a lone surrogate sign to identical bytes: a
45
+ * capability minted for a lone-surrogate branch id verifies after its
46
+ * `branchId` is rewritten to U+FFFD, which names a different branch. A length
47
+ * prefix cannot separate them either, because both encode to the same three
48
+ * bytes. The only correct answer is to refuse such a claim set before it is
49
+ * signed, and the round trip is the exact question — does this string survive
50
+ * the encoding the signature is taken over — rather than a proxy for it.
51
+ *
52
+ * @category encoding
53
+ * @since 1.0.0-rc.0
54
+ */
55
+ export const utf8RoundTrips = (value: string, bytes: Uint8Array): boolean => decoder.decode(bytes) === value
56
+
57
+ /**
58
+ * Renders signature bytes as lowercase hex.
59
+ *
60
+ * @category encoding
61
+ * @since 0.1.0
62
+ */
63
+ export const hex = (bytes: Uint8Array): string =>
64
+ Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
65
+
66
+ /**
67
+ * Length-independent comparison, so a signature check leaks no prefix length.
68
+ *
69
+ * @category comparison
70
+ * @since 0.1.0
71
+ */
72
+ export const constantTimeEquals = (left: string, right: string): boolean => {
73
+ let difference = left.length ^ right.length
74
+ // `charCodeAt` past the end is NaN, and `NaN | 0` is 0, so the loop reads
75
+ // both strings to the longer length without an early return.
76
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
77
+ difference |= (left.charCodeAt(index) | 0) ^ (right.charCodeAt(index) | 0)
78
+ }
79
+ return difference === 0
80
+ }
81
+
82
+ /**
83
+ * The fewest UTF-8 bytes a signing secret may carry: the HMAC-SHA-256 output
84
+ * length, the minimum RFC 2104 section 3 recommends.
85
+ *
86
+ * Every capability carries its claims and their signature in the clear, so one
87
+ * observed capability is an offline oracle for the secret. A short secret such
88
+ * as `a` falls to exhaustive search in seconds, and the attacker then mints
89
+ * branch and workspace capabilities of their own.
90
+ *
91
+ * @category constants
92
+ * @since 1.0.0-rc.1
93
+ */
94
+ export const minSecretBytes = 32
95
+
96
+ /**
97
+ * Imports a raw secret as a non-extractable Web Crypto HMAC-SHA-256 signing
98
+ * key. Fails with `invalid_request` when the secret does not survive UTF-8
99
+ * (an unpaired surrogate) or is shorter than {@link minSecretBytes}, and with
100
+ * a `SyncError` carrying the rejection as `cause` when Web Crypto refuses the
101
+ * import.
102
+ *
103
+ * @category crypto
104
+ * @since 0.1.0
105
+ */
106
+ export const importHmacKey = (secret: string): Effect.Effect<CryptoKey, SyncError> => {
107
+ // An unpaired surrogate encodes to U+FFFD's bytes, so two distinct
108
+ // secrets would import the same key and rotating between them revokes
109
+ // nothing. Refused here, as `signHmac` refuses such claims.
110
+ const bytes = encoder.encode(secret)
111
+ if (!utf8RoundTrips(secret, bytes)) {
112
+ return Effect.fail(
113
+ new SyncError({
114
+ code: "invalid_request",
115
+ message: "The HMAC signing secret carries an unpaired surrogate"
116
+ })
117
+ )
118
+ }
119
+ if (bytes.length < minSecretBytes) {
120
+ return Effect.fail(
121
+ new SyncError({
122
+ code: "invalid_request",
123
+ message: `The HMAC signing secret must be at least ${minSecretBytes} bytes of UTF-8`
124
+ })
125
+ )
126
+ }
127
+ return Effect.tryPromise({
128
+ try: () =>
129
+ crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, false, [
130
+ "sign"
131
+ ]),
132
+ catch: (cause) =>
133
+ new SyncError({
134
+ code: "unknown",
135
+ message: "Web Crypto could not import the HMAC signing key",
136
+ cause: causeText(cause)
137
+ })
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Signs a canonical claim encoding, returning the signature as lowercase hex.
143
+ *
144
+ * @category crypto
145
+ * @since 0.1.0
146
+ */
147
+ export const signHmac = (key: CryptoKey, canonical: string): Effect.Effect<string, SyncError> => {
148
+ const bytes = encoder.encode(canonical)
149
+ if (!utf8RoundTrips(canonical, bytes)) {
150
+ return Effect.fail(
151
+ new SyncError({
152
+ code: "invalid_request",
153
+ message: "Share claims carry an unpaired surrogate and cannot be signed"
154
+ })
155
+ )
156
+ }
157
+ return Effect.map(
158
+ Effect.tryPromise({
159
+ try: () => crypto.subtle.sign("HMAC", key, bytes),
160
+ catch: (cause) =>
161
+ new SyncError({
162
+ code: "unknown",
163
+ message: "Web Crypto could not sign the share claims",
164
+ cause: causeText(cause)
165
+ })
166
+ }),
167
+ (signature) => hex(new Uint8Array(signature))
168
+ )
169
+ }
170
+
171
+ /**
172
+ * The refusal both authorities issue. Declared once so a message added to one
173
+ * authority's verification cannot land under a different error code in the
174
+ * other.
175
+ *
176
+ * @category errors
177
+ * @since 1.0.0-rc.0
178
+ */
179
+ export const unauthorized = (message: string): SyncError => new SyncError({ code: "unauthorized", message })
180
+
181
+ /**
182
+ * One named signing key. The secret is `Redacted` so a keyring never renders
183
+ * it into logs or inspection output.
184
+ *
185
+ * @category models
186
+ * @since 1.0.0-rc.0
187
+ */
188
+ export interface Key {
189
+ readonly kid: string
190
+ readonly secret: Redacted.Redacted<string>
191
+ }
192
+
193
+ /**
194
+ * A keyring: the key that signs new capabilities plus every key still
195
+ * accepted for verification. Rotation adds a new active key and keeps the
196
+ * retired one in `keys` until its outstanding capabilities expire.
197
+ *
198
+ * @category models
199
+ * @since 1.0.0-rc.0
200
+ */
201
+ export interface Keyring {
202
+ readonly activeKid: string
203
+ readonly keys: ReadonlyArray<Key>
204
+ }
205
+
206
+ /**
207
+ * A keyring whose every secret is already an imported Web Crypto key: the
208
+ * signer for new capabilities, and the map a verifier selects a retired key
209
+ * from by `kid`.
210
+ *
211
+ * @category models
212
+ * @since 1.0.0-rc.0
213
+ */
214
+ export interface ImportedKeyring {
215
+ readonly activeKid: string
216
+ readonly active: CryptoKey
217
+ readonly verification: ReadonlyMap<string, CryptoKey>
218
+ }
219
+
220
+ /**
221
+ * Imports every key in a keyring, so a misconfigured ring — an unknown active
222
+ * kid, a duplicate kid, or a key Web Crypto refuses — fails at construction
223
+ * rather than at the first request. `subject` names the authority in the
224
+ * refusal ("branch", "workspace").
225
+ *
226
+ * @category crypto
227
+ * @since 1.0.0-rc.0
228
+ */
229
+ export const importKeyring = (keyring: Keyring, subject: string): Effect.Effect<ImportedKeyring, SyncError> =>
230
+ Effect.gen(function*() {
231
+ // Copied BEFORE the first import, for the reason snapshots exist on the
232
+ // verify path: the keyring belongs to the caller and every import below is
233
+ // an await, so a `for..of` over the caller's own array re-reads it between
234
+ // them. A splice landing in that window changed which keys the authority
235
+ // ended up holding, and reading `activeKid` only after the loop let it name
236
+ // a key the ring was validated with rather than the one the caller passed.
237
+ const activeKid = keyring.activeKid
238
+ const keys = keyring.keys.map((key): Key => ({ kid: key.kid, secret: key.secret }))
239
+ const verification = new Map<string, CryptoKey>()
240
+ for (const key of keys) {
241
+ if (verification.has(key.kid)) {
242
+ return yield* Effect.fail(
243
+ new SyncError({ code: "invalid_request", message: `The ${subject} keyring names kid ${key.kid} twice` })
244
+ )
245
+ }
246
+ verification.set(key.kid, yield* importHmacKey(Redacted.value(key.secret)))
247
+ }
248
+ const active = verification.get(activeKid)
249
+ if (active === undefined) {
250
+ return yield* Effect.fail(
251
+ new SyncError({
252
+ code: "invalid_request",
253
+ message: `The ${subject} keyring's active kid names no key in the ring`
254
+ })
255
+ )
256
+ }
257
+ return { activeKid, active, verification }
258
+ })
259
+
260
+ /**
261
+ * The authorization both capability authorities run once their signing key is
262
+ * selected: the signature, then the expiry, then the requested access.
263
+ *
264
+ * The order is the contract. A capability is authenticated before anything it
265
+ * claims is reported back, so a forged claim set is refused as a bad signature
266
+ * and never as a scope or an expiry. `scope` is the one check an authority
267
+ * adds for itself — the branch authority's cross-branch refusal — and it runs
268
+ * after the signature for exactly that reason.
269
+ *
270
+ * Both authorities called this sequence out by hand, which left a fix to the
271
+ * expiry boundary or the read-only refusal landing in one and not the other.
272
+ *
273
+ * @category crypto
274
+ * @since 1.0.0-rc.0
275
+ */
276
+ export const verifyClaims = (options: {
277
+ readonly key: CryptoKey
278
+ readonly canonical: string
279
+ readonly signature: string
280
+ readonly expiresAtMs: number
281
+ readonly granted: Access
282
+ readonly requested: Access
283
+ /** The refused thing, named as it appears in the message: "The share capability". */
284
+ readonly subject: string
285
+ readonly scope?: Effect.Effect<void, SyncError>
286
+ }): Effect.Effect<void, SyncError> =>
287
+ Effect.gen(function*() {
288
+ const expected = yield* signHmac(options.key, options.canonical)
289
+ if (!constantTimeEquals(expected, options.signature)) {
290
+ return yield* Effect.fail(unauthorized(`${options.subject} signature is invalid`))
291
+ }
292
+ if (options.scope !== undefined) yield* options.scope
293
+ const nowMs = yield* Clock.currentTimeMillis
294
+ if (nowMs >= options.expiresAtMs) {
295
+ return yield* Effect.fail(unauthorized(`${options.subject} has expired`))
296
+ }
297
+ if (options.requested === "write" && options.granted !== "write") {
298
+ return yield* Effect.fail(unauthorized(`${options.subject} is read-only`))
299
+ }
300
+ })
@@ -0,0 +1,91 @@
1
+ /** Snapshot validation shared by the hosted and browser-side boundaries.
2
+ * @since 1.0.0-rc.0
3
+ */
4
+
5
+ import * as Effect from "effect/Effect"
6
+ import * as Schema from "effect/Schema"
7
+ import { SyncError } from "../SyncError.ts"
8
+ import * as Protocol from "../SyncProtocol.ts"
9
+ import { causeCode } from "./CauseText.ts"
10
+ import { requireVersion } from "./ProtocolVersion.ts"
11
+
12
+ /** Decode a detached request before callbacks can mutate its expected identity.
13
+ * @category validation
14
+ * @since 1.0.0-rc.0
15
+ */
16
+ export const request = (value: unknown): Effect.Effect<Protocol.SnapshotRequest, SyncError> =>
17
+ Effect.try({
18
+ try: () => Schema.decodeUnknownSync(Protocol.SnapshotRequest)(value, { onExcessProperty: "error" }),
19
+ catch: (cause) =>
20
+ new SyncError({ code: "invalid_request", message: "Invalid public snapshot request", cause: causeCode(cause) })
21
+ }).pipe(
22
+ // The schema admits any integer version so this refusal, not a decode
23
+ // failure, is what a version mismatch produces here as on every other RPC.
24
+ Effect.tap((decoded) => requireVersion(decoded.protocolVersion)),
25
+ Effect.map((decoded) => ({ ...decoded }))
26
+ )
27
+
28
+ /** Copy JSON bytes once, bound them, then check the exact requested projection.
29
+ * @category validation
30
+ * @since 1.0.0-rc.0
31
+ */
32
+ export const response = (
33
+ expected: Protocol.SnapshotRequest,
34
+ value: unknown,
35
+ maxBytes: number
36
+ ): Effect.Effect<Protocol.Snapshot, SyncError> =>
37
+ Effect.gen(function*() {
38
+ const text = yield* Effect.try({
39
+ try: () => JSON.stringify(value),
40
+ catch: (cause) =>
41
+ new SyncError({
42
+ code: "decode_failed",
43
+ message: "Public snapshot is not JSON encodable",
44
+ cause: causeCode(cause)
45
+ })
46
+ })
47
+ if (text === undefined) {
48
+ return yield* Effect.fail(
49
+ new SyncError({ code: "decode_failed", message: "Public snapshot is missing", cause: "undefined" })
50
+ )
51
+ }
52
+ if (new TextEncoder().encode(text).length > maxBytes) {
53
+ return yield* Effect.fail(
54
+ new SyncError({ code: "frame_too_large", message: "Public snapshot exceeds the response byte limit" })
55
+ )
56
+ }
57
+ const snapshot = yield* Effect.try({
58
+ try: () => {
59
+ // JSON.stringify alone silently drops undefined/functions and turns
60
+ // non-finite numbers into null. Reject such provider values rather than
61
+ // returning a different state under the same checkpoint sequence.
62
+ Schema.decodeUnknownSync(Protocol.Snapshot)(value, { onExcessProperty: "error" })
63
+ return Schema.decodeUnknownSync(Protocol.Snapshot)(JSON.parse(text), { onExcessProperty: "error" })
64
+ },
65
+ catch: (cause) =>
66
+ new SyncError({ code: "decode_failed", message: "Invalid public snapshot response", cause: causeCode(cause) })
67
+ })
68
+ yield* requireVersion(snapshot.protocolVersion)
69
+ if (
70
+ snapshot.runId !== expected.runId || snapshot.lineageId !== expected.lineageId ||
71
+ snapshot.projection !== expected.projection || snapshot.projectionVersion !== expected.projectionVersion
72
+ ) {
73
+ return yield* Effect.fail(
74
+ new SyncError({
75
+ code: "protocol_violation",
76
+ message: "Public snapshot identity does not match the request",
77
+ cause: "snapshot_identity_mismatch"
78
+ })
79
+ )
80
+ }
81
+ if (snapshot.seq < expected.atLeastSeq) {
82
+ return yield* Effect.fail(
83
+ new SyncError({
84
+ code: "compacted",
85
+ message: "Public snapshot does not cover the required history",
86
+ resync: { runId: expected.runId, checkpointSeq: expected.atLeastSeq }
87
+ })
88
+ )
89
+ }
90
+ return snapshot
91
+ })