@smthrs/sync 0.0.0-stage → 1.0.0-rc.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 (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,292 @@
1
+ /**
2
+ * Workspace share capabilities: the credential a sync connection presents to
3
+ * read the workspace's non-branch runs.
4
+ *
5
+ * The scheme extends the branch share shape rather than inventing a parallel
6
+ * one: a claim set and an HMAC-SHA-256 signature over its length-prefixed
7
+ * canonical encoding, verified with a constant-time comparison. Two things
8
+ * are added. A `kid` names the signing key inside the claims, so keys rotate
9
+ * without invalidating capabilities minted under a retired key that is still
10
+ * in the verification keyring. And the canonical encoding starts with a
11
+ * scheme label, so a workspace capability can never verify as a branch
12
+ * capability even if one secret is misconfigured into both authorities.
13
+ *
14
+ * Secrets enter through {@link Key} as `Redacted` values — a keyring never
15
+ * holds a plain string — and {@link layerConfig} provisions the keyring from
16
+ * configuration without any secret appearing in code.
17
+ *
18
+ * @since 0.1.0
19
+ */
20
+
21
+ import * as Clock from "effect/Clock"
22
+ import * as Config from "effect/Config"
23
+ import * as Context from "effect/Context"
24
+ import * as Effect from "effect/Effect"
25
+ import * as Layer from "effect/Layer"
26
+ import * as Schema from "effect/Schema"
27
+ import { Access } from "./BranchProtocol.ts"
28
+ import * as Admission from "./internal/Admission.ts"
29
+ import * as shareSigner from "./internal/ShareSigner.ts"
30
+ import type { SyncError } from "./SyncError.ts"
31
+
32
+ /**
33
+ * Schema for the claims a workspace capability carries.
34
+ *
35
+ * `kid` names the key that signed the claims and is itself signed, so a
36
+ * verifier both selects the right key and refuses a capability whose key
37
+ * name was swapped after minting.
38
+ *
39
+ * @category schemas
40
+ * @since 0.1.0
41
+ */
42
+ export class WorkspaceClaims extends Schema.Class<WorkspaceClaims>("@smthrs/sync/WorkspaceShare/WorkspaceClaims")({
43
+ kid: Schema.NonEmptyString,
44
+ capabilityId: Schema.NonEmptyString,
45
+ access: Access,
46
+ issuedAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
47
+ expiresAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
48
+ }) {}
49
+
50
+ /**
51
+ * Schema for a signed, expiring, workspace-scoped capability.
52
+ *
53
+ * @category schemas
54
+ * @since 0.1.0
55
+ */
56
+ export class WorkspaceCapability extends Schema.Class<WorkspaceCapability>(
57
+ "@smthrs/sync/WorkspaceShare/WorkspaceCapability"
58
+ )({
59
+ claims: WorkspaceClaims,
60
+ signature: Schema.String
61
+ }) {}
62
+
63
+ /**
64
+ * The access one authorization request needs.
65
+ *
66
+ * @category models
67
+ * @since 0.1.0
68
+ */
69
+ export interface AuthorizeRequest {
70
+ readonly access: Access
71
+ }
72
+
73
+ /**
74
+ * What a freshly minted workspace capability grants.
75
+ *
76
+ * @category models
77
+ * @since 0.1.0
78
+ */
79
+ export const MintRequest = Schema.Struct({
80
+ capabilityId: Schema.NonEmptyString,
81
+ access: Access,
82
+ ttlMs: Schema.Int.check(Schema.isGreaterThan(0))
83
+ })
84
+
85
+ /**
86
+ * What a freshly minted workspace capability grants.
87
+ *
88
+ * @category models
89
+ * @since 0.1.0
90
+ */
91
+ export type MintRequest = typeof MintRequest.Type
92
+
93
+ /**
94
+ * Workspace share capability operations.
95
+ *
96
+ * `mint` fails with a `SyncError` when the Web Crypto signing operation
97
+ * rejects; `verify` fails with a `SyncError` when signing rejects or the
98
+ * capability is refused.
99
+ *
100
+ * @category models
101
+ * @since 0.1.0
102
+ */
103
+ export interface Service {
104
+ readonly mint: (request: MintRequest) => Effect.Effect<WorkspaceCapability, SyncError>
105
+ readonly verify: (
106
+ capability: WorkspaceCapability,
107
+ request: AuthorizeRequest
108
+ ) => Effect.Effect<WorkspaceClaims, SyncError>
109
+ }
110
+
111
+ /**
112
+ * The workspace share-capability authority.
113
+ *
114
+ * @category services
115
+ * @since 0.1.0
116
+ */
117
+ export class WorkspaceShare extends Context.Service<WorkspaceShare, Service>()("@smthrs/sync/WorkspaceShare") {}
118
+
119
+ const denied = shareSigner.unauthorized
120
+
121
+ /**
122
+ * Constructs a workspace share authority that mints nothing and trusts
123
+ * nothing.
124
+ *
125
+ * @category constructors
126
+ * @since 0.1.0
127
+ */
128
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
129
+ WorkspaceShare.of({
130
+ // Both operations FAIL. `mint` used to die, and this is the authority the
131
+ // shipped CLI gateway wires, so a consumer handling `SyncError` on that
132
+ // composition got a crash where the type promised a refusal.
133
+ mint: () => Effect.fail(denied("Workspace sharing is unavailable")),
134
+ verify: () => Effect.fail(denied("Workspace sharing is unavailable")),
135
+ ...overrides
136
+ })
137
+
138
+ /**
139
+ * Provides a workspace share authority that refuses every capability.
140
+ *
141
+ * @category layers
142
+ * @since 0.1.0
143
+ */
144
+ export const layerNoop: Layer.Layer<WorkspaceShare> = Layer.succeed(WorkspaceShare, makeNoop())
145
+
146
+ /**
147
+ * One named signing key. The secret is `Redacted` so a keyring never renders
148
+ * it into logs or inspection output.
149
+ *
150
+ * @category models
151
+ * @since 0.1.0
152
+ */
153
+ export type Key = shareSigner.Key
154
+
155
+ /**
156
+ * A keyring: the key that signs new capabilities plus every key still
157
+ * accepted for verification. Rotation adds a new active key and keeps the
158
+ * retired one in `keys` until its outstanding capabilities expire.
159
+ *
160
+ * @category models
161
+ * @since 0.1.0
162
+ */
163
+ export type Keyring = shareSigner.Keyring
164
+
165
+ /**
166
+ * Domain separation: the label leads the signed encoding, so a workspace
167
+ * signature can never be replayed as any other scheme's signature under a
168
+ * shared secret.
169
+ */
170
+ const schemeLabel = "@smthrs/sync/WorkspaceShare/v1"
171
+
172
+ const canonical = (claims: WorkspaceClaims): string =>
173
+ shareSigner.lengthPrefixed([
174
+ schemeLabel,
175
+ claims.kid,
176
+ claims.capabilityId,
177
+ claims.access,
178
+ String(claims.issuedAtMs),
179
+ String(claims.expiresAtMs)
180
+ ])
181
+
182
+ /**
183
+ * The claim fields, copied out of the capability the caller owns.
184
+ *
185
+ * `Schema.Class` instances are not frozen, and `verify` awaits Web Crypto
186
+ * between signing the claims and authorizing them, so the checks must read a
187
+ * snapshot rather than an object the caller can still mutate.
188
+ */
189
+ const snapshot = (claims: WorkspaceClaims): WorkspaceClaims =>
190
+ new WorkspaceClaims({
191
+ kid: claims.kid,
192
+ capabilityId: claims.capabilityId,
193
+ access: claims.access,
194
+ issuedAtMs: claims.issuedAtMs,
195
+ expiresAtMs: claims.expiresAtMs
196
+ })
197
+
198
+ /**
199
+ * Constructs the HMAC-SHA-256 workspace share authority over a keyring.
200
+ *
201
+ * Every key is imported up front, so a misconfigured keyring — an unknown
202
+ * active kid, a duplicate kid, or a key Web Crypto refuses — fails at
203
+ * construction rather than at the first request.
204
+ *
205
+ * @category constructors
206
+ * @since 0.1.0
207
+ */
208
+ export const makeHmac = (keyring: Keyring): Effect.Effect<Service, SyncError> =>
209
+ Effect.map(
210
+ shareSigner.importKeyring(keyring, "workspace"),
211
+ (ring) => {
212
+ const mint = Effect.fn("WorkspaceShare.mint")(function*(request: MintRequest) {
213
+ // Decoded, not read: the declared parameter type admits an empty
214
+ // capability id and a `ttlMs` of 0 or NaN that the schema forbids, and
215
+ // reading them straight into `new WorkspaceClaims` turned a caller's
216
+ // bad argument into a defect out of an operation whose type promises a
217
+ // `SyncError`.
218
+ const admitted = yield* Admission.decode(MintRequest, request, "invalid_request")
219
+ yield* Effect.annotateCurrentSpan({ access: admitted.access })
220
+ const issuedAtMs = yield* Clock.currentTimeMillis
221
+ const claims = new WorkspaceClaims({
222
+ kid: ring.activeKid,
223
+ capabilityId: admitted.capabilityId,
224
+ access: admitted.access,
225
+ issuedAtMs,
226
+ expiresAtMs: issuedAtMs + admitted.ttlMs
227
+ })
228
+ return new WorkspaceCapability({
229
+ claims,
230
+ signature: yield* shareSigner.signHmac(ring.active, canonical(claims))
231
+ })
232
+ })
233
+
234
+ const verify = Effect.fn("WorkspaceShare.verify")(function*(
235
+ capability: WorkspaceCapability,
236
+ request: AuthorizeRequest
237
+ ) {
238
+ yield* Effect.annotateCurrentSpan({ access: request.access })
239
+ // Everything authorized below is read from these locals, never from the
240
+ // caller's objects, which may change while Web Crypto is awaited.
241
+ const claims = snapshot(capability.claims)
242
+ const signature = capability.signature
243
+ const key = ring.verification.get(claims.kid)
244
+ if (key === undefined) {
245
+ return yield* Effect.fail(denied("The workspace capability names an unknown signing key"))
246
+ }
247
+ yield* shareSigner.verifyClaims({
248
+ key,
249
+ canonical: canonical(claims),
250
+ signature,
251
+ expiresAtMs: claims.expiresAtMs,
252
+ granted: claims.access,
253
+ requested: request.access,
254
+ subject: "The workspace capability"
255
+ })
256
+ return claims
257
+ })
258
+
259
+ return WorkspaceShare.of({ mint, verify })
260
+ }
261
+ )
262
+
263
+ /**
264
+ * Provides the HMAC-SHA-256 workspace share authority over a keyring.
265
+ *
266
+ * @category layers
267
+ * @since 0.1.0
268
+ */
269
+ export const layerHmac = (keyring: Keyring): Layer.Layer<WorkspaceShare, SyncError> =>
270
+ Layer.effect(WorkspaceShare, makeHmac(keyring))
271
+
272
+ /**
273
+ * Provides the workspace share authority from configuration: the secret from
274
+ * `SMITHERS_SYNC_SECRET` (read as `Redacted`, never logged) and the key name
275
+ * from `SMITHERS_SYNC_KEY_ID`, defaulting to `primary`. Rotation beyond one
276
+ * configured key uses {@link layerHmac} with an explicit keyring.
277
+ *
278
+ * There is deliberately no default secret: a deployment that configures
279
+ * neither name fails to construct the authority, and the read path stays
280
+ * closed.
281
+ *
282
+ * @category layers
283
+ * @since 0.1.0
284
+ */
285
+ export const layerConfig: Layer.Layer<WorkspaceShare, SyncError | Config.ConfigError> = Layer.effect(
286
+ WorkspaceShare,
287
+ Effect.gen(function*() {
288
+ const secret = yield* Config.Redacted("SMITHERS_SYNC_SECRET")
289
+ const kid = yield* Config.String("SMITHERS_SYNC_KEY_ID").pipe(Config.withDefault("primary"))
290
+ return yield* makeHmac({ activeKid: kid, keys: [{ kid, secret }] })
291
+ })
292
+ )
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Read-only journal synchronization services for flows.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ /**
8
+ * @category errors
9
+ * @since 0.1.0
10
+ */
11
+ export * as SyncError from "./SyncError.ts"
12
+
13
+ /**
14
+ * @category protocol
15
+ * @since 0.1.0
16
+ */
17
+ export * as SyncProtocol from "./SyncProtocol.ts"
18
+
19
+ /**
20
+ * @category protocol
21
+ * @since 0.1.0
22
+ */
23
+ export * as BranchProtocol from "./BranchProtocol.ts"
24
+
25
+ /**
26
+ * @category projections
27
+ * @since 0.1.0
28
+ */
29
+ export * as BranchProjection from "./BranchProjection.ts"
30
+
31
+ /**
32
+ * @category rpc
33
+ * @since 0.1.0
34
+ */
35
+ export * as SyncRpcs from "./SyncRpcs.ts"
36
+
37
+ /**
38
+ * @category rpc
39
+ * @since 0.1.0
40
+ */
41
+ export * as BranchRpcs from "./BranchRpcs.ts"
42
+
43
+ /**
44
+ * @category services
45
+ * @since 0.1.0
46
+ */
47
+ export * as RunCatalog from "./RunCatalog.ts"
48
+
49
+ /**
50
+ * @category services
51
+ * @since 0.1.0
52
+ */
53
+ export * as SyncServer from "./SyncServer.ts"
54
+
55
+ /**
56
+ * @category services
57
+ * @since 0.1.0
58
+ */
59
+ export * as SyncClient from "./SyncClient.ts"
60
+
61
+ /**
62
+ * @category services
63
+ * @since 0.1.0
64
+ */
65
+ export * as BranchShare from "./BranchShare.ts"
66
+
67
+ /**
68
+ * @category services
69
+ * @since 0.1.0
70
+ */
71
+ export * as WorkspaceShare from "./WorkspaceShare.ts"
72
+
73
+ /**
74
+ * @category services
75
+ * @since 0.1.0
76
+ */
77
+ export * as SyncPrincipal from "./SyncPrincipal.ts"
78
+
79
+ /**
80
+ * @category rpc
81
+ * @since 0.1.0
82
+ */
83
+ export * as SyncAuth from "./SyncAuth.ts"
84
+
85
+ /**
86
+ * @category services
87
+ * @since 0.1.0
88
+ */
89
+ export * as BranchPresence from "./BranchPresence.ts"
90
+
91
+ /**
92
+ * @category services
93
+ * @since 0.1.0
94
+ */
95
+ export * as BranchCommands from "./BranchCommands.ts"
96
+
97
+ /**
98
+ * @category services
99
+ * @since 0.1.0
100
+ */
101
+ export * as BranchServer from "./BranchServer.ts"
102
+
103
+ /**
104
+ * @category services
105
+ * @since 0.1.0
106
+ */
107
+ export * as BranchIds from "./BranchIds.ts"
@@ -0,0 +1,119 @@
1
+ /** Admission of host and transport values before any progress is committed.
2
+ * @since 1.0.0-rc.0
3
+ */
4
+
5
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+ import { BranchId, branchOfRunId, CommandEvent, CommandEventPayload } from "../BranchProtocol.ts"
9
+ import { SyncError } from "../SyncError.ts"
10
+ import { causeCode } from "./CauseText.ts"
11
+
12
+ /** Decode a boundary value while retaining a safe cause classification.
13
+ * @category validation
14
+ * @since 1.0.0-rc.0
15
+ */
16
+ export const decode = <S extends Schema.Top & { readonly DecodingServices: never }>(
17
+ schema: S,
18
+ value: unknown,
19
+ code: "decode_failed" | "invalid_request" = "decode_failed"
20
+ ): Effect.Effect<S["Type"], SyncError> =>
21
+ Effect.try({
22
+ try: () => Schema.decodeUnknownSync(schema)(value),
23
+ catch: (cause) => new SyncError({ code, message: "Invalid sync envelope", cause: causeCode(cause) })
24
+ })
25
+
26
+ const decodeEntries = Schema.decodeUnknownSync(Schema.Array(JournalEvent.Entry))
27
+ const decodeJson = Schema.decodeUnknownSync(Schema.Struct({ payload: Schema.Json, meta: Schema.Json }))
28
+ const decodeCommand = Schema.decodeUnknownSync(CommandEventPayload)
29
+ const decodeBranch = Schema.decodeUnknownSync(Schema.Struct({ branchId: Schema.optional(BranchId) }))
30
+
31
+ /** One admitted entry beside the command payload admission decoded from it.
32
+ * `command` is `undefined` for every event type other than `CommandEvent`.
33
+ * @category models
34
+ * @since 1.0.0-rc.0
35
+ */
36
+ export interface Admitted {
37
+ readonly entry: JournalEvent.Entry
38
+ readonly command: CommandEventPayload | undefined
39
+ }
40
+
41
+ /** The one validation `records`, `withCommands`, and `entries` share. It keeps
42
+ * each command's decoded payload beside its entry rather than discarding it.
43
+ */
44
+ const admit = (values: ReadonlyArray<JournalEvent.Entry>): Effect.Effect<ReadonlyArray<Admitted>, SyncError> =>
45
+ Effect.try({
46
+ // Capture getters once and detach nested payloads before any application
47
+ // callback can mutate the producer's next entry after admission.
48
+ try: () =>
49
+ decodeEntries(structuredClone(values)).map((entry): Admitted => {
50
+ decodeJson(entry)
51
+ if (entry.eventType !== CommandEvent) return { entry, command: undefined }
52
+ const command = decodeCommand(entry.payload)
53
+ const identity = decodeBranch(entry.payload)
54
+ if (identity.branchId !== undefined && identity.branchId !== branchOfRunId(entry.runId)) {
55
+ throw new SyncError({
56
+ code: "protocol_violation",
57
+ message: "Command belongs to another branch",
58
+ cause: "foreign_branch"
59
+ })
60
+ }
61
+ return { entry, command }
62
+ }),
63
+ catch: (cause) =>
64
+ cause instanceof SyncError
65
+ ? cause
66
+ : new SyncError({ code: "decode_failed", message: "Invalid sync record", cause: causeCode(cause) })
67
+ })
68
+
69
+ const entriesOf = (admitted: ReadonlyArray<Admitted>): ReadonlyArray<JournalEvent.Entry> =>
70
+ admitted.map(({ entry }) => entry)
71
+
72
+ /** Known branch records fail closed; unknown namespaces remain open JSON envelopes.
73
+ * Defaults on persisted command args/target keep their existing additive schema meaning.
74
+ * @category validation
75
+ * @since 1.0.0-rc.0
76
+ */
77
+ export const records = (
78
+ values: ReadonlyArray<JournalEvent.Entry>
79
+ ): Effect.Effect<ReadonlyArray<JournalEvent.Entry>, SyncError> => Effect.map(admit(values), entriesOf)
80
+
81
+ /** Admit an entire run batch before its caller publishes or updates a cursor,
82
+ * handing back each command beside the payload admission decoded, so a caller
83
+ * that needs a command field reads it instead of decoding the payload again.
84
+ * @category validation
85
+ * @since 1.0.0-rc.0
86
+ */
87
+ export const withCommands = (
88
+ values: ReadonlyArray<JournalEvent.Entry>,
89
+ runId: JournalEvent.RunId,
90
+ after: number
91
+ ): Effect.Effect<ReadonlyArray<Admitted>, SyncError> =>
92
+ Effect.gen(function*() {
93
+ const admitted = yield* admit(values)
94
+ let previous = after
95
+ for (const { entry } of admitted) {
96
+ if (entry.runId !== runId || entry.seq <= previous) {
97
+ return yield* Effect.fail(
98
+ new SyncError({
99
+ code: "protocol_violation",
100
+ message: "Journal batch must belong to the requested run and ascend strictly above its cursor",
101
+ cause: entry.runId !== runId ? "foreign_run" : "non_monotonic_sequence"
102
+ })
103
+ )
104
+ }
105
+ previous = entry.seq
106
+ }
107
+ return admitted
108
+ })
109
+
110
+ /** Admit an entire run batch before its caller publishes or updates a cursor.
111
+ * @category validation
112
+ * @since 1.0.0-rc.0
113
+ */
114
+ export const entries = (
115
+ values: ReadonlyArray<JournalEvent.Entry>,
116
+ runId: JournalEvent.RunId,
117
+ after: number
118
+ ): Effect.Effect<ReadonlyArray<JournalEvent.Entry>, SyncError> =>
119
+ Effect.map(withCommands(values, runId, after), entriesOf)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * How a host failure crosses the sync boundary: what a follower is told about
3
+ * it, and what it is not.
4
+ *
5
+ * `SyncError.cause` crosses the wire to a follower that may hold nothing but a
6
+ * branch share link, so it carries a short string rather than the host object
7
+ * that failed. An arbitrary `unknown` had three problems at once: it counted
8
+ * against no ceiling, a class instance or a cyclic value has no defined wire
9
+ * form, and whatever the host threw — a driver message with SQL text, a
10
+ * rejected credential — went out verbatim.
11
+ *
12
+ * @since 1.0.0-rc.0
13
+ */
14
+
15
+ /**
16
+ * Longest cause rendering that crosses the wire. Long enough to name a failure
17
+ * and short enough that an error can never be the largest thing a follower
18
+ * receives.
19
+ */
20
+ const maxCauseTextLength = 512
21
+
22
+ /**
23
+ * Clamps one rendering to {@link maxCauseTextLength}.
24
+ *
25
+ * Every string this module produces leaves through here, and so does every
26
+ * other string a `SyncError` carries out of a host failure: `message` is
27
+ * `Schema.String` and is as unbounded as `cause` was.
28
+ *
29
+ * @category encoding
30
+ * @since 1.0.0-rc.0
31
+ */
32
+ export const boundedText = (text: string): string =>
33
+ text.length <= maxCauseTextLength ? text : `${text.slice(0, maxCauseTextLength)} (truncated)`
34
+
35
+ /**
36
+ * Renders one host failure as a bounded string.
37
+ *
38
+ * An `Error` renders as `name: message`; anything else renders as its type
39
+ * alone, because a non-`Error` value is arbitrary host data and its contents
40
+ * are not this boundary's to publish. Nothing is retained beyond the returned
41
+ * string, so an oversized input does not keep its subject alive.
42
+ *
43
+ * @category encoding
44
+ * @since 1.0.0-rc.0
45
+ */
46
+ export const causeText = (cause: unknown): string =>
47
+ boundedText(
48
+ cause instanceof Error ? `${cause.name}: ${cause.message}` : Object.prototype.toString.call(cause)
49
+ )
50
+
51
+ /**
52
+ * Renders one failure as its TYPE alone, with no message.
53
+ *
54
+ * The journal's message is the SQLite driver's, and it routinely carries SQL
55
+ * text, table and column names, and constraint identifiers. A follower may
56
+ * hold nothing but a branch share link, so what it learns about a storage
57
+ * fault is the stable code the journal enumerates, never the sentence the
58
+ * driver wrote. `decodeCapability` already refuses to be a parsing oracle;
59
+ * this is the same rule for the read and write paths.
60
+ *
61
+ * The result is bounded like {@link causeText}: a `Journal` is a host seam, so
62
+ * the `name` and `code` this reads are as much the host's to choose as the
63
+ * message it refuses to publish.
64
+ *
65
+ * @category encoding
66
+ * @since 1.0.0-rc.0
67
+ */
68
+ export const causeCode = (cause: unknown): string => {
69
+ if (cause instanceof Error) {
70
+ const code = (cause as { readonly code?: unknown }).code
71
+ return boundedText(typeof code === "string" ? `${cause.name}(${code})` : cause.name)
72
+ }
73
+ return boundedText(Object.prototype.toString.call(cause))
74
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How a journal failure's stable code maps onto the sync boundary's error
3
+ * vocabulary.
4
+ *
5
+ * @since 1.0.0-rc.0
6
+ */
7
+
8
+ /**
9
+ * The journal codes this boundary can state in its own vocabulary.
10
+ *
11
+ * Only codes whose meaning is identical on both sides are here. A journal that
12
+ * is shut down and a sync boundary that is closed are the same fact; an
13
+ * admission queue that overflowed and backpressure are the same fact; a
14
+ * payload the journal could not decode and one this boundary could not decode
15
+ * are the same fact. Everything else — a fence lost, a sequence conflict, a
16
+ * projection fault — is a storage-layer distinction a follower cannot act on,
17
+ * and inventing a sync code for it would say more than is known.
18
+ */
19
+ const journalCodes: Readonly<Record<string, "backpressure" | "closed" | "decode_failed">> = {
20
+ decode_failed: "decode_failed",
21
+ journal_closed: "closed",
22
+ queue_overflow: "backpressure"
23
+ }
24
+
25
+ /**
26
+ * Projects one journal failure's stable code onto this boundary's vocabulary,
27
+ * or `unknown` when it has no counterpart.
28
+ *
29
+ * `SyncError.ErrorCode` already declares `closed`, `backpressure`, and
30
+ * `decode_failed`, so collapsing every journal failure but `compacted` into
31
+ * `unknown` threw away a classification the wire could already carry, and left
32
+ * a follower unable to tell a shut-down journal from an unexplained fault.
33
+ * Nothing here widens what a follower learns: `causeCode` already publishes
34
+ * the journal's own enumerated code, and the journal's MESSAGE stays refused
35
+ * on every path.
36
+ *
37
+ * @category encoding
38
+ * @since 1.0.0-rc.0
39
+ */
40
+ export const journalErrorCode = (
41
+ cause: unknown
42
+ ): "backpressure" | "closed" | "decode_failed" | "unknown" => {
43
+ if (!(cause instanceof Error)) return "unknown"
44
+ const code = (cause as { readonly code?: unknown }).code
45
+ return typeof code === "string" ? journalCodes[code] ?? "unknown" : "unknown"
46
+ }