@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,163 @@
1
+ /**
2
+ * The branch collaboration wire contract: the RPC projection of the write
3
+ * and presence paths that complement the read-only {@link SyncRpcs}.
4
+ *
5
+ * Authorization has two stages. Opening a branch is the bootstrap: no
6
+ * capability can exist yet, so `CreateBranch` requires an authenticated
7
+ * workspace principal supplied by the group's {@link SyncAuth} middleware,
8
+ * and its handler refuses any other principal.
9
+ *
10
+ * Every later procedure carries its own share capability and authorizes
11
+ * through {@link BranchShare}: past the bootstrap the capability IS the
12
+ * credential, and cross-branch or expired capabilities are refused by the
13
+ * same service boundary the in-process callers use.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+
18
+ import * as Schema from "effect/Schema"
19
+ import { Rpc, RpcGroup } from "effect/unstable/rpc"
20
+ import {
21
+ Access,
22
+ Announcement,
23
+ BranchId,
24
+ CommandReceipt,
25
+ LeaveRequest,
26
+ Participant,
27
+ RosterRequest,
28
+ ShareCapability,
29
+ SubmitRequest
30
+ } from "./BranchProtocol.ts"
31
+ import { SyncError } from "./SyncError.ts"
32
+ import { SyncAuth } from "./SyncRpcs.ts"
33
+
34
+ /**
35
+ * Maximum lifetime the branch bootstrap may mint.
36
+ *
37
+ * @category constants
38
+ * @since 0.1.0
39
+ */
40
+ export const maximumBranchTtlMs = 24 * 60 * 60 * 1000
41
+
42
+ /**
43
+ * Schema for a request to open a new shared branch.
44
+ *
45
+ * The RPC authentication middleware must establish the workspace principal
46
+ * before the handler mints the first write capability.
47
+ *
48
+ * @category schemas
49
+ * @since 0.1.0
50
+ */
51
+ export const CreateBranchPayload = Schema.Struct({
52
+ ttlMs: Schema.Int.check(
53
+ Schema.isGreaterThan(0),
54
+ Schema.isLessThanOrEqualTo(maximumBranchTtlMs)
55
+ )
56
+ })
57
+
58
+ /**
59
+ * A request to open a new shared branch.
60
+ *
61
+ * @category models
62
+ * @since 0.1.0
63
+ */
64
+ export type CreateBranchPayload = typeof CreateBranchPayload.Type
65
+
66
+ /**
67
+ * Schema for the freshly opened branch and its owner capability.
68
+ *
69
+ * @category schemas
70
+ * @since 0.1.0
71
+ */
72
+ export const CreateBranchResponse = Schema.Struct({
73
+ branchId: BranchId,
74
+ capability: ShareCapability
75
+ })
76
+
77
+ /**
78
+ * The freshly opened branch and its owner capability.
79
+ *
80
+ * @category models
81
+ * @since 0.1.0
82
+ */
83
+ export type CreateBranchResponse = typeof CreateBranchResponse.Type
84
+
85
+ /**
86
+ * Schema for a request to mint a share link for a branch.
87
+ *
88
+ * The presenting capability must hold write access to the branch — only a
89
+ * collaborator can invite one — and the minted link never outlives the
90
+ * capability it was minted from.
91
+ *
92
+ * @category schemas
93
+ * @since 0.1.0
94
+ */
95
+ export const MintSharePayload = Schema.Struct({
96
+ capability: ShareCapability,
97
+ access: Access,
98
+ ttlMs: Schema.Int.check(Schema.isGreaterThan(0))
99
+ })
100
+
101
+ /**
102
+ * A request to mint a share link for a branch.
103
+ *
104
+ * @category models
105
+ * @since 0.1.0
106
+ */
107
+ export type MintSharePayload = typeof MintSharePayload.Type
108
+
109
+ /**
110
+ * Schema for one roster emission on a branch watch stream.
111
+ *
112
+ * @category schemas
113
+ * @since 0.1.0
114
+ */
115
+ export const RosterFrame = Schema.Struct({
116
+ participants: Schema.Array(Participant)
117
+ })
118
+
119
+ /**
120
+ * One roster emission on a branch watch stream.
121
+ *
122
+ * @category models
123
+ * @since 0.1.0
124
+ */
125
+ export type RosterFrame = typeof RosterFrame.Type
126
+
127
+ /**
128
+ * The remote procedures of branch collaboration.
129
+ *
130
+ * The capability-bearing payloads ARE the `BranchProtocol` request schemas the
131
+ * branch services take, not copies of them. Four wire payloads used to
132
+ * re-declare the message their service already owned, and the announce copy
133
+ * had already drifted: it accepted `displayName: ""` where the service
134
+ * requires a non-empty name, so a wire-legal announce reached a `Participant`
135
+ * constructor and threw a defect outside the declared `SyncError` channel.
136
+ * Naming one schema on both sides makes that drift impossible rather than
137
+ * fixed once, and keeps this group free of the services themselves.
138
+ *
139
+ * @category groups
140
+ * @since 0.1.0
141
+ */
142
+ export const BranchRpcs = RpcGroup.make(
143
+ Rpc.make("Branch.CreateBranch", {
144
+ payload: CreateBranchPayload,
145
+ success: CreateBranchResponse,
146
+ error: SyncError
147
+ }),
148
+ Rpc.make("Branch.MintShare", { payload: MintSharePayload, success: ShareCapability, error: SyncError }),
149
+ Rpc.make("Branch.Submit", { payload: SubmitRequest, success: CommandReceipt, error: SyncError }),
150
+ Rpc.make("Branch.Announce", { payload: Announcement, success: Participant, error: SyncError }),
151
+ Rpc.make("Branch.Leave", { payload: LeaveRequest, success: Schema.Null, error: SyncError }),
152
+ Rpc.make("Branch.Roster", {
153
+ payload: RosterRequest,
154
+ success: Schema.Array(Participant),
155
+ error: SyncError
156
+ }),
157
+ Rpc.make("Branch.PresenceOn", {
158
+ payload: RosterRequest,
159
+ success: Schema.Literals(["unknown", "present", "empty"]),
160
+ error: SyncError
161
+ }),
162
+ Rpc.make("Branch.WatchRoster", { payload: RosterRequest, success: RosterFrame, error: SyncError, stream: true })
163
+ ).middleware(SyncAuth)
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The server-side handlers of {@link BranchRpcs}: a thin projection of the
3
+ * branch services onto the wire.
4
+ *
5
+ * Five procedures forward to the service that owns their boundary and add
6
+ * nothing: `Branch.Submit` to {@link BranchCommands}, and `Branch.Announce`,
7
+ * `Branch.Leave`, `Branch.Roster`, and `Branch.WatchRoster` to
8
+ * {@link BranchPresence}. Two enforce policy here, on top of what
9
+ * {@link BranchShare} checks: `Branch.CreateBranch` refuses any principal that
10
+ * is not an authenticated workspace, and `Branch.MintShare` verifies write
11
+ * access, refuses an expired parent, and caps the child at the parent's
12
+ * expiry. `BranchShare.mint` performs none of those checks, so an in-process
13
+ * host that mints through the service directly must gate the principal and
14
+ * the parent capability itself.
15
+ *
16
+ * @since 0.1.0
17
+ */
18
+
19
+ import * as Clock from "effect/Clock"
20
+ import * as Effect from "effect/Effect"
21
+ import type * as Layer from "effect/Layer"
22
+ import * as Stream from "effect/Stream"
23
+ import type * as Rpc from "effect/unstable/rpc/Rpc"
24
+ import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"
25
+ import * as BranchCommands from "./BranchCommands.ts"
26
+ import * as BranchIds from "./BranchIds.ts"
27
+ import * as BranchPresence from "./BranchPresence.ts"
28
+ import { BranchId, type Participant } from "./BranchProtocol.ts"
29
+ import { BranchRpcs } from "./BranchRpcs.ts"
30
+ import * as BranchShare from "./BranchShare.ts"
31
+ import * as Admission from "./internal/Admission.ts"
32
+ import { SyncError } from "./SyncError.ts"
33
+ import * as SyncPrincipal from "./SyncPrincipal.ts"
34
+
35
+ /**
36
+ * Whether two roster emissions say the same thing.
37
+ *
38
+ * `list` returns participants sorted by id, so the comparison is positional.
39
+ * The lease itself is part of the key: a heartbeat that only extends a lease
40
+ * IS a change the watcher wants, because it is the evidence the participant is
41
+ * still there.
42
+ *
43
+ * The rendering is JSON rather than delimiter-joined fields. Participant ids
44
+ * and display names are `NonEmptyString` with no charset constraint, so every
45
+ * separator is a character they may carry, including the control bytes this
46
+ * used to join on. Two rosters differing only in where a field boundary falls
47
+ * then rendered identically and a watcher never observed the change. JSON
48
+ * quotes and escapes each field, so distinct rosters render distinctly.
49
+ */
50
+ const rosterKey = (participants: ReadonlyArray<Participant>): string =>
51
+ JSON.stringify(
52
+ participants.map((participant) => [
53
+ participant.participantId,
54
+ participant.displayName,
55
+ participant.sessionId,
56
+ participant.kind,
57
+ participant.where,
58
+ participant.watching,
59
+ participant.for_member,
60
+ participant.leaseExpiresAtMs,
61
+ participant.cursor === null ? null : [participant.cursor.cardId, participant.cursor.offset]
62
+ ])
63
+ )
64
+
65
+ const sameRoster = (
66
+ left: ReadonlyArray<Participant>,
67
+ right: ReadonlyArray<Participant>
68
+ ): boolean => rosterKey(left) === rosterKey(right)
69
+
70
+ /**
71
+ * Provides the branch RPC handlers over the branch services.
72
+ *
73
+ * With {@link BranchPresence.layerNoop}, roster reads and watches fail with
74
+ * `unsupported`: unavailable presence cannot establish an empty roster.
75
+ *
76
+ * A roster watch emits the roster as of subscription, then re-lists on every
77
+ * presence change for the branch AND at least once per second, emitting
78
+ * only when the roster it read differs from the one it last sent.
79
+ *
80
+ * All three of those are triggers into ONE sequential reader. The roster is
81
+ * mutable state behind an effect, so concurrent readers can complete out of
82
+ * order, and a watcher that receives an older roster after a newer one keeps
83
+ * the older one: `changesWith` compares against the previous emission, not
84
+ * against the freshest state. Serializing the reads is what makes the last
85
+ * value a watcher holds the most recent one that was read.
86
+ *
87
+ * The periodic re-list is what makes expiry observable. A lapsed lease
88
+ * publishes nothing — the roster drops it the next time somebody lists — so a
89
+ * watch driven by change events alone never saw the LAST participant leave,
90
+ * and a burst of announcements on unrelated branches could slide the one
91
+ * notification this watch needed out of the shared feed. Both are bounded by
92
+ * one second: a departure is visible within 1 s of lease expiry,
93
+ * whether or not a survivor reports it.
94
+ *
95
+ * @category layers
96
+ * @since 0.1.0
97
+ */
98
+ export const layerHandlers: Layer.Layer<
99
+ Rpc.ToHandler<RpcGroup.Rpcs<typeof BranchRpcs>>,
100
+ never,
101
+ | BranchShare.BranchShare
102
+ | BranchPresence.BranchPresence
103
+ | BranchCommands.BranchCommands
104
+ | BranchIds.BranchIds
105
+ > = BranchRpcs.toLayer(
106
+ Effect.gen(function*() {
107
+ const share = yield* BranchShare.BranchShare
108
+ const presence = yield* BranchPresence.BranchPresence
109
+ const commands = yield* BranchCommands.BranchCommands
110
+ const ids = yield* BranchIds.BranchIds
111
+
112
+ return BranchRpcs.of({
113
+ "Branch.CreateBranch": ({ ttlMs }) =>
114
+ Effect.gen(function*() {
115
+ const principal = yield* SyncPrincipal.SyncPrincipal
116
+ if (!SyncPrincipal.isWorkspace(principal)) {
117
+ return yield* new SyncError({
118
+ code: "unauthorized",
119
+ message: "Creating a branch requires an authenticated workspace principal"
120
+ })
121
+ }
122
+ // Decoded, not branded: `BranchIds` is a host port whose `fresh` is
123
+ // typed `Effect<string>`, so a host id source yielding "" turned an
124
+ // asserted `BranchId` into a schema defect inside `mint` rather than
125
+ // the `SyncError` this handler declares.
126
+ const branchId = yield* Admission.decode(BranchId, yield* ids.fresh, "invalid_request")
127
+ const capability = yield* share.mint({
128
+ branchId,
129
+ capabilityId: yield* ids.fresh,
130
+ access: "write",
131
+ ttlMs
132
+ })
133
+ return { branchId: capability.claims.branchId, capability }
134
+ }),
135
+ "Branch.MintShare": ({ capability, access, ttlMs }) =>
136
+ Effect.gen(function*() {
137
+ const claims = yield* share.verify(capability, {
138
+ branchId: capability.claims.branchId,
139
+ access: "write"
140
+ })
141
+ const capabilityId = yield* ids.fresh
142
+ const nowMs = yield* Clock.currentTimeMillis
143
+ // ID generation may outlast the parent. Refuse before minting, and
144
+ // pass the absolute ceiling so the signing clock cannot extend it.
145
+ if (nowMs >= claims.expiresAtMs) {
146
+ return yield* new SyncError({
147
+ code: "unauthorized",
148
+ message: "The share capability has expired"
149
+ })
150
+ }
151
+ // A link never outlives the capability it was minted from.
152
+ return yield* share.mint({
153
+ branchId: claims.branchId,
154
+ capabilityId,
155
+ access,
156
+ ttlMs,
157
+ maxExpiresAtMs: claims.expiresAtMs
158
+ })
159
+ }),
160
+ "Branch.Submit": (payload) => commands.submit(payload),
161
+ "Branch.Announce": (payload) => presence.announce(payload),
162
+ "Branch.Leave": (payload) => Effect.as(presence.leave(payload), null),
163
+ "Branch.Roster": (payload) => presence.list(payload),
164
+ "Branch.PresenceOn": (payload) => presence.presenceOn(payload),
165
+ "Branch.WatchRoster": (payload) =>
166
+ Stream.merge(
167
+ // The subscription's own snapshot is a TRIGGER like the other two,
168
+ // not a fourth reader. Reading it here concurrently with them let
169
+ // the roster a change produced overtake it: `presence.list` is an
170
+ // effect against mutable state, three of them ran at once, and the
171
+ // slowest one won the merge. `changesWith` compares an emission only
172
+ // against the one before it, so the older roster passed the filter
173
+ // and the watcher's LAST value said a participant who had already
174
+ // left was still present, until the next change or lease tick.
175
+ Stream.succeed(undefined),
176
+ Stream.merge(
177
+ presence.changes.pipe(
178
+ Stream.filter((branchId) => branchId === payload.branchId),
179
+ Stream.map(() => undefined)
180
+ ),
181
+ // A lapsed lease publishes nothing, so a watch driven only by
182
+ // change events never observes the LAST participant leaving, and
183
+ // an unrelated branch's burst can slide the one notification this
184
+ // watch needed out of the shared feed. Re-listing at least once per second
185
+ // bounds both: a departure is visible within 1 s of expiry, whether or not anyone reports it.
186
+ Stream.fromEffectRepeat(Effect.sleep(Math.min(presence.leaseMs, 1_000))).pipe(Stream.map(() => undefined))
187
+ )
188
+ ).pipe(
189
+ // ONE reader, sequential by default, so a roster is read in the
190
+ // order its trigger arrived and every emission is at least as fresh
191
+ // as the one before it.
192
+ Stream.mapEffect(() => presence.list(payload)),
193
+ // Deduplicated over the WHOLE stream, initial emission included.
194
+ // Applied to the change half alone, its first value always passed,
195
+ // so a change that left the roster identical still sent the watcher
196
+ // a second copy of what it had just been given.
197
+ Stream.changesWith(sameRoster),
198
+ Stream.map((participants) => ({ participants }))
199
+ )
200
+ })
201
+ })
202
+ )
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Share capabilities: the authorization boundary of a shared branch.
3
+ *
4
+ * A share link carries a capability, not a session. The capability names
5
+ * exactly one branch, one access level, and one expiry, and it is signed, so
6
+ * the holder cannot widen it. Every branch operation authorizes through
7
+ * `verify`, which is therefore the single place cross-branch access and
8
+ * expired links are refused.
9
+ *
10
+ * The signature is HMAC-SHA-256 over a length-prefixed encoding of the claims,
11
+ * led by a scheme label. Length prefixes matter: without them a branch id
12
+ * ending in the separator could be re-cut into a different, still-validly-
13
+ * signed claim set. The label matters for the same reason across schemes: a
14
+ * branch capability can never verify as a workspace capability even if one
15
+ * secret is misconfigured into both authorities. Web Crypto is used directly
16
+ * so the same module runs in the browser and on node.
17
+ *
18
+ * A `kid` inside the claims names the key that signed them, so a branch secret
19
+ * rotates the way the workspace secret already did: the new key signs, the
20
+ * retired key stays in the verification keyring, and the share links already
21
+ * out keep working until they expire.
22
+ *
23
+ * @since 0.1.0
24
+ */
25
+
26
+ import * as Clock from "effect/Clock"
27
+ import * as Config from "effect/Config"
28
+ import * as Context from "effect/Context"
29
+ import * as Effect from "effect/Effect"
30
+ import * as Layer from "effect/Layer"
31
+ import * as Schema from "effect/Schema"
32
+ import { Access, BranchId, ShareCapability, ShareClaims } from "./BranchProtocol.ts"
33
+ import * as Admission from "./internal/Admission.ts"
34
+ import * as shareSigner from "./internal/ShareSigner.ts"
35
+ import type { SyncError } from "./SyncError.ts"
36
+
37
+ /**
38
+ * The branch and access one authorization request needs.
39
+ *
40
+ * @category models
41
+ * @since 0.1.0
42
+ */
43
+ export interface AuthorizeRequest {
44
+ readonly branchId: BranchId
45
+ readonly access: Access
46
+ }
47
+
48
+ /**
49
+ * What a freshly minted capability grants.
50
+ *
51
+ * @category models
52
+ * @since 0.1.0
53
+ */
54
+ export const MintRequest = Schema.Struct({
55
+ branchId: BranchId,
56
+ capabilityId: Schema.NonEmptyString,
57
+ access: Access,
58
+ ttlMs: Schema.Int.check(Schema.isGreaterThan(0)),
59
+ /** Absolute expiry ceiling for a capability delegated from a parent. */
60
+ maxExpiresAtMs: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))
61
+ })
62
+
63
+ /**
64
+ * What a freshly minted capability grants.
65
+ *
66
+ * @category models
67
+ * @since 0.1.0
68
+ */
69
+ export type MintRequest = typeof MintRequest.Type
70
+
71
+ /**
72
+ * Share capability operations.
73
+ *
74
+ * `mint` fails with a `SyncError` when the Web Crypto signing operation
75
+ * rejects or the absolute expiry ceiling has elapsed; `verify` fails with a
76
+ * `SyncError` when signing rejects or the capability is refused.
77
+ *
78
+ * @category models
79
+ * @since 0.1.0
80
+ */
81
+ export interface Service {
82
+ readonly mint: (request: MintRequest) => Effect.Effect<ShareCapability, SyncError>
83
+ readonly verify: (
84
+ capability: ShareCapability,
85
+ request: AuthorizeRequest
86
+ ) => Effect.Effect<ShareClaims, SyncError>
87
+ }
88
+
89
+ /**
90
+ * The branch share-capability authority.
91
+ *
92
+ * @category services
93
+ * @since 0.1.0
94
+ */
95
+ export class BranchShare extends Context.Service<BranchShare, Service>()("@smthrs/sync/BranchShare") {}
96
+
97
+ const denied = shareSigner.unauthorized
98
+
99
+ /**
100
+ * Constructs a share authority that mints nothing and trusts nothing.
101
+ *
102
+ * @category constructors
103
+ * @since 0.1.0
104
+ */
105
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
106
+ BranchShare.of({
107
+ // Both operations FAIL. `mint` used to die, which contradicted its own
108
+ // declared `Effect<ShareCapability, SyncError>` and left a consumer
109
+ // wired to this authority unable to tell "sharing is off" from a bug.
110
+ mint: () => Effect.fail(denied("Branch sharing is unavailable")),
111
+ verify: () => Effect.fail(denied("Branch sharing is unavailable")),
112
+ ...overrides
113
+ })
114
+
115
+ /**
116
+ * Provides a share authority that refuses every capability.
117
+ *
118
+ * @category layers
119
+ * @since 0.1.0
120
+ */
121
+ export const layerNoop: Layer.Layer<BranchShare> = Layer.succeed(BranchShare, makeNoop())
122
+
123
+ /**
124
+ * Domain separation: the label leads the signed encoding, so a branch
125
+ * signature can never be replayed as a workspace signature (or any later
126
+ * scheme's) under a shared secret. `WorkspaceShare` has always led with its
127
+ * own label; a one-sided label protects one direction, and the branch side is
128
+ * the one anonymous share-link holders reach.
129
+ */
130
+ const schemeLabel = "@smthrs/sync/BranchShare/v2"
131
+
132
+ /** Length-prefixed so no two distinct claim sets share an encoding. */
133
+ const canonical = (claims: ShareClaims): string =>
134
+ shareSigner.lengthPrefixed([
135
+ schemeLabel,
136
+ claims.kid,
137
+ claims.branchId,
138
+ claims.capabilityId,
139
+ claims.access,
140
+ String(claims.issuedAtMs),
141
+ String(claims.expiresAtMs)
142
+ ])
143
+
144
+ /**
145
+ * The claim fields, copied out of the capability the caller owns.
146
+ *
147
+ * `Schema.Class` instances are not frozen, and `verify` awaits Web Crypto
148
+ * between signing the claims and authorizing them. Reading the caller's object
149
+ * again after the await let an in-process holder of the same instance widen
150
+ * `access` — or move `expiresAtMs` — between the signature that was checked
151
+ * and the checks that follow it.
152
+ */
153
+ const snapshot = (claims: ShareClaims): ShareClaims =>
154
+ new ShareClaims({
155
+ kid: claims.kid,
156
+ branchId: claims.branchId,
157
+ capabilityId: claims.capabilityId,
158
+ access: claims.access,
159
+ issuedAtMs: claims.issuedAtMs,
160
+ expiresAtMs: claims.expiresAtMs
161
+ })
162
+
163
+ /**
164
+ * One named signing key. The secret is `Redacted` so a keyring never renders
165
+ * it into logs or inspection output.
166
+ *
167
+ * @category models
168
+ * @since 1.0.0-rc.0
169
+ */
170
+ export type Key = shareSigner.Key
171
+
172
+ /**
173
+ * A keyring: the key that signs new capabilities plus every key still
174
+ * accepted for verification. Rotation adds a new active key and keeps the
175
+ * retired one in `keys` until its outstanding links expire, so rotating the
176
+ * branch secret no longer breaks every share link that is already out.
177
+ *
178
+ * @category models
179
+ * @since 1.0.0-rc.0
180
+ */
181
+ export type Keyring = shareSigner.Keyring
182
+
183
+ /**
184
+ * Constructs the HMAC-SHA-256 share authority over a keyring.
185
+ *
186
+ * Every key is imported up front, so a misconfigured keyring — an unknown
187
+ * active kid, a duplicate kid, or a key Web Crypto refuses — fails at
188
+ * construction rather than at the first request. Secrets enter as `Redacted`
189
+ * values: an authority never holds a plain string that a log, a span, or an
190
+ * inspection of the keyring could render.
191
+ *
192
+ * @category constructors
193
+ * @since 0.1.0
194
+ */
195
+ export const makeHmac = (keyring: Keyring): Effect.Effect<Service, SyncError> =>
196
+ Effect.map(
197
+ shareSigner.importKeyring(keyring, "branch"),
198
+ (ring) => {
199
+ const mint = Effect.fn("BranchShare.mint")(function*(request: MintRequest) {
200
+ // Decoded, not read: the declared parameter type admits an empty
201
+ // branch id, an empty capability id and a non-positive ttl that the
202
+ // schema forbids, and reading them straight into `new ShareClaims`
203
+ // turned a caller's bad argument into a defect out of an operation
204
+ // whose type promises a `SyncError`. A `ttlMs` of 0 minted an
205
+ // already-expired capability instead of being refused.
206
+ const admitted = yield* Admission.decode(MintRequest, request, "invalid_request")
207
+ yield* Effect.annotateCurrentSpan({ branchId: admitted.branchId, access: admitted.access })
208
+ const issuedAtMs = yield* Clock.currentTimeMillis
209
+ const maxExpiresAtMs = admitted.maxExpiresAtMs ?? Infinity
210
+ if (issuedAtMs >= maxExpiresAtMs) {
211
+ return yield* Effect.fail(denied("The share capability has expired"))
212
+ }
213
+ const claims = new ShareClaims({
214
+ kid: ring.activeKid,
215
+ branchId: admitted.branchId,
216
+ capabilityId: admitted.capabilityId,
217
+ access: admitted.access,
218
+ issuedAtMs,
219
+ expiresAtMs: Math.min(issuedAtMs + admitted.ttlMs, maxExpiresAtMs)
220
+ })
221
+ return new ShareCapability({
222
+ claims,
223
+ signature: yield* shareSigner.signHmac(ring.active, canonical(claims))
224
+ })
225
+ })
226
+
227
+ const verify = Effect.fn("BranchShare.verify")(function*(
228
+ capability: ShareCapability,
229
+ request: AuthorizeRequest
230
+ ) {
231
+ yield* Effect.annotateCurrentSpan({ branchId: request.branchId, access: request.access })
232
+ // Everything authorized below is read from these locals, never from
233
+ // the caller's objects, which may change while Web Crypto is awaited.
234
+ const claims = snapshot(capability.claims)
235
+ const signature = capability.signature
236
+ const branchId = request.branchId
237
+ const key = ring.verification.get(claims.kid)
238
+ if (key === undefined) {
239
+ return yield* Effect.fail(denied("The share capability names an unknown signing key"))
240
+ }
241
+ yield* shareSigner.verifyClaims({
242
+ key,
243
+ canonical: canonical(claims),
244
+ signature,
245
+ expiresAtMs: claims.expiresAtMs,
246
+ granted: claims.access,
247
+ requested: request.access,
248
+ subject: "The share capability",
249
+ // The one check this authority adds, run after the signature so a
250
+ // forged claim set is refused as a bad signature and never reported
251
+ // back as the branch it names.
252
+ scope: claims.branchId === branchId
253
+ ? Effect.void
254
+ : Effect.fail(denied(`The share capability is scoped to branch ${claims.branchId}`))
255
+ })
256
+ return claims
257
+ })
258
+
259
+ return BranchShare.of({ mint, verify })
260
+ }
261
+ )
262
+
263
+ /**
264
+ * Provides the HMAC-SHA-256 share authority over a keyring.
265
+ *
266
+ * @category layers
267
+ * @since 0.1.0
268
+ */
269
+ export const layerHmac = (keyring: Keyring): Layer.Layer<BranchShare, SyncError> =>
270
+ Layer.effect(BranchShare, makeHmac(keyring))
271
+
272
+ /**
273
+ * Provides the branch share authority from configuration: the secret from
274
+ * `SMITHERS_SYNC_BRANCH_SECRET` (read as `Redacted`, never logged) and the key
275
+ * name from `SMITHERS_SYNC_BRANCH_KEY_ID`, defaulting to `primary`. Rotation
276
+ * beyond one configured key uses {@link layerHmac} with an explicit keyring.
277
+ *
278
+ * The names are the branch authority's own, not the workspace's: the scheme
279
+ * label makes one secret in both authorities survivable, not advisable, and a
280
+ * deployment that rotates one credential should not silently invalidate the
281
+ * other's outstanding capabilities.
282
+ *
283
+ * There is deliberately no default secret: a deployment that configures
284
+ * neither name fails to construct the authority, and every branch operation
285
+ * stays closed.
286
+ *
287
+ * @category layers
288
+ * @since 1.0.0-rc.0
289
+ */
290
+ export const layerConfig: Layer.Layer<BranchShare, SyncError | Config.ConfigError> = Layer.effect(
291
+ BranchShare,
292
+ Effect.gen(function*() {
293
+ const secret = yield* Config.Redacted("SMITHERS_SYNC_BRANCH_SECRET")
294
+ const kid = yield* Config.String("SMITHERS_SYNC_BRANCH_KEY_ID").pipe(Config.withDefault("primary"))
295
+ return yield* makeHmac({ activeKid: kid, keys: [{ kid, secret }] })
296
+ })
297
+ )