@digimuza/saas-core 0.0.2

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 (474) hide show
  1. package/CLAUDE.md +1165 -0
  2. package/LICENSE +21 -0
  3. package/README.md +234 -0
  4. package/dist/api-keys/index.d.ts +9 -0
  5. package/dist/api-keys/index.d.ts.map +1 -0
  6. package/dist/api-keys/index.js +37 -0
  7. package/dist/api-keys/index.js.map +1 -0
  8. package/dist/api-keys/keys.d.ts +55 -0
  9. package/dist/api-keys/keys.d.ts.map +1 -0
  10. package/dist/api-keys/keys.js +92 -0
  11. package/dist/api-keys/keys.js.map +1 -0
  12. package/dist/api-keys/service.d.ts +111 -0
  13. package/dist/api-keys/service.d.ts.map +1 -0
  14. package/dist/api-keys/service.js +210 -0
  15. package/dist/api-keys/service.js.map +1 -0
  16. package/dist/audit/index.d.ts +5 -0
  17. package/dist/audit/index.d.ts.map +1 -0
  18. package/dist/audit/index.js +44 -0
  19. package/dist/audit/index.js.map +1 -0
  20. package/dist/audit/service.d.ts +88 -0
  21. package/dist/audit/service.d.ts.map +1 -0
  22. package/dist/audit/service.js +128 -0
  23. package/dist/audit/service.js.map +1 -0
  24. package/dist/auth/client.d.ts +5 -0
  25. package/dist/auth/client.d.ts.map +1 -0
  26. package/dist/auth/client.js +26 -0
  27. package/dist/auth/client.js.map +1 -0
  28. package/dist/auth/cookies.d.ts +51 -0
  29. package/dist/auth/cookies.d.ts.map +1 -0
  30. package/dist/auth/cookies.js +58 -0
  31. package/dist/auth/cookies.js.map +1 -0
  32. package/dist/auth/index.d.ts +14 -0
  33. package/dist/auth/index.d.ts.map +1 -0
  34. package/dist/auth/index.js +46 -0
  35. package/dist/auth/index.js.map +1 -0
  36. package/dist/auth/service.d.ts +67 -0
  37. package/dist/auth/service.d.ts.map +1 -0
  38. package/dist/auth/service.js +275 -0
  39. package/dist/auth/service.js.map +1 -0
  40. package/dist/auth/types.d.ts +56 -0
  41. package/dist/auth/types.d.ts.map +1 -0
  42. package/dist/auth/types.js +2 -0
  43. package/dist/auth/types.js.map +1 -0
  44. package/dist/authorization/index.d.ts +4 -0
  45. package/dist/authorization/index.d.ts.map +1 -0
  46. package/dist/authorization/index.js +24 -0
  47. package/dist/authorization/index.js.map +1 -0
  48. package/dist/authorization/service.d.ts +92 -0
  49. package/dist/authorization/service.d.ts.map +1 -0
  50. package/dist/authorization/service.js +254 -0
  51. package/dist/authorization/service.js.map +1 -0
  52. package/dist/billing/index.d.ts +16 -0
  53. package/dist/billing/index.d.ts.map +1 -0
  54. package/dist/billing/index.js +47 -0
  55. package/dist/billing/index.js.map +1 -0
  56. package/dist/billing/provider.d.ts +114 -0
  57. package/dist/billing/provider.d.ts.map +1 -0
  58. package/dist/billing/provider.js +2 -0
  59. package/dist/billing/provider.js.map +1 -0
  60. package/dist/billing/revolut.d.ts +68 -0
  61. package/dist/billing/revolut.d.ts.map +1 -0
  62. package/dist/billing/revolut.js +181 -0
  63. package/dist/billing/revolut.js.map +1 -0
  64. package/dist/billing/service.d.ts +124 -0
  65. package/dist/billing/service.d.ts.map +1 -0
  66. package/dist/billing/service.js +470 -0
  67. package/dist/billing/service.js.map +1 -0
  68. package/dist/cli/commands/dev.d.ts +43 -0
  69. package/dist/cli/commands/dev.d.ts.map +1 -0
  70. package/dist/cli/commands/dev.js +212 -0
  71. package/dist/cli/commands/dev.js.map +1 -0
  72. package/dist/cli/commands/project.d.ts +12 -0
  73. package/dist/cli/commands/project.d.ts.map +1 -0
  74. package/dist/cli/commands/project.js +49 -0
  75. package/dist/cli/commands/project.js.map +1 -0
  76. package/dist/cli/docker.d.ts +50 -0
  77. package/dist/cli/docker.d.ts.map +1 -0
  78. package/dist/cli/docker.js +84 -0
  79. package/dist/cli/docker.js.map +1 -0
  80. package/dist/cli/index.d.ts +6 -0
  81. package/dist/cli/index.d.ts.map +1 -0
  82. package/dist/cli/index.js +198 -0
  83. package/dist/cli/index.js.map +1 -0
  84. package/dist/cli/output.d.ts +30 -0
  85. package/dist/cli/output.d.ts.map +1 -0
  86. package/dist/cli/output.js +66 -0
  87. package/dist/cli/output.js.map +1 -0
  88. package/dist/cli/templates.d.ts +52 -0
  89. package/dist/cli/templates.d.ts.map +1 -0
  90. package/dist/cli/templates.js +225 -0
  91. package/dist/cli/templates.js.map +1 -0
  92. package/dist/config/client.d.ts +60 -0
  93. package/dist/config/client.d.ts.map +1 -0
  94. package/dist/config/client.js +94 -0
  95. package/dist/config/client.js.map +1 -0
  96. package/dist/config/config-loader.d.ts +2 -0
  97. package/dist/config/config-loader.d.ts.map +1 -0
  98. package/dist/config/config-loader.js +2 -0
  99. package/dist/config/config-loader.js.map +1 -0
  100. package/dist/config/env.d.ts +24 -0
  101. package/dist/config/env.d.ts.map +1 -0
  102. package/dist/config/env.js +40 -0
  103. package/dist/config/env.js.map +1 -0
  104. package/dist/config/errors.d.ts +17 -0
  105. package/dist/config/errors.d.ts.map +1 -0
  106. package/dist/config/errors.js +26 -0
  107. package/dist/config/errors.js.map +1 -0
  108. package/dist/config/index.d.ts +72 -0
  109. package/dist/config/index.d.ts.map +1 -0
  110. package/dist/config/index.js +106 -0
  111. package/dist/config/index.js.map +1 -0
  112. package/dist/config/runtime.d.ts +15 -0
  113. package/dist/config/runtime.d.ts.map +1 -0
  114. package/dist/config/runtime.js +22 -0
  115. package/dist/config/runtime.js.map +1 -0
  116. package/dist/config/sections.d.ts +226 -0
  117. package/dist/config/sections.d.ts.map +1 -0
  118. package/dist/config/sections.js +141 -0
  119. package/dist/config/sections.js.map +1 -0
  120. package/dist/db/client.d.ts +43 -0
  121. package/dist/db/client.d.ts.map +1 -0
  122. package/dist/db/client.js +77 -0
  123. package/dist/db/client.js.map +1 -0
  124. package/dist/db/errors.d.ts +39 -0
  125. package/dist/db/errors.d.ts.map +1 -0
  126. package/dist/db/errors.js +67 -0
  127. package/dist/db/errors.js.map +1 -0
  128. package/dist/db/index.d.ts +10 -0
  129. package/dist/db/index.d.ts.map +1 -0
  130. package/dist/db/index.js +10 -0
  131. package/dist/db/index.js.map +1 -0
  132. package/dist/db/migrate.d.ts +21 -0
  133. package/dist/db/migrate.d.ts.map +1 -0
  134. package/dist/db/migrate.js +28 -0
  135. package/dist/db/migrate.js.map +1 -0
  136. package/dist/db/schema/_shared.d.ts +30 -0
  137. package/dist/db/schema/_shared.d.ts.map +1 -0
  138. package/dist/db/schema/_shared.js +36 -0
  139. package/dist/db/schema/_shared.js.map +1 -0
  140. package/dist/db/schema/apikeys.d.ts +263 -0
  141. package/dist/db/schema/apikeys.d.ts.map +1 -0
  142. package/dist/db/schema/apikeys.js +70 -0
  143. package/dist/db/schema/apikeys.js.map +1 -0
  144. package/dist/db/schema/authorization.d.ts +465 -0
  145. package/dist/db/schema/authorization.d.ts.map +1 -0
  146. package/dist/db/schema/authorization.js +134 -0
  147. package/dist/db/schema/authorization.js.map +1 -0
  148. package/dist/db/schema/billing.d.ts +1100 -0
  149. package/dist/db/schema/billing.d.ts.map +1 -0
  150. package/dist/db/schema/billing.js +223 -0
  151. package/dist/db/schema/billing.js.map +1 -0
  152. package/dist/db/schema/events.d.ts +161 -0
  153. package/dist/db/schema/events.d.ts.map +1 -0
  154. package/dist/db/schema/events.js +66 -0
  155. package/dist/db/schema/events.js.map +1 -0
  156. package/dist/db/schema/features.d.ts +243 -0
  157. package/dist/db/schema/features.d.ts.map +1 -0
  158. package/dist/db/schema/features.js +87 -0
  159. package/dist/db/schema/features.js.map +1 -0
  160. package/dist/db/schema/files.d.ts +253 -0
  161. package/dist/db/schema/files.d.ts.map +1 -0
  162. package/dist/db/schema/files.js +74 -0
  163. package/dist/db/schema/files.js.map +1 -0
  164. package/dist/db/schema/index.d.ts +38 -0
  165. package/dist/db/schema/index.d.ts.map +1 -0
  166. package/dist/db/schema/index.js +38 -0
  167. package/dist/db/schema/index.js.map +1 -0
  168. package/dist/db/schema/invitations.d.ts +214 -0
  169. package/dist/db/schema/invitations.d.ts.map +1 -0
  170. package/dist/db/schema/invitations.js +80 -0
  171. package/dist/db/schema/invitations.js.map +1 -0
  172. package/dist/db/schema/messaging.d.ts +442 -0
  173. package/dist/db/schema/messaging.d.ts.map +1 -0
  174. package/dist/db/schema/messaging.js +120 -0
  175. package/dist/db/schema/messaging.js.map +1 -0
  176. package/dist/db/schema/users.d.ts +246 -0
  177. package/dist/db/schema/users.d.ts.map +1 -0
  178. package/dist/db/schema/users.js +60 -0
  179. package/dist/db/schema/users.js.map +1 -0
  180. package/dist/db/schema/webhooks.d.ts +693 -0
  181. package/dist/db/schema/webhooks.d.ts.map +1 -0
  182. package/dist/db/schema/webhooks.js +147 -0
  183. package/dist/db/schema/webhooks.js.map +1 -0
  184. package/dist/db/schema/workspaces.d.ts +196 -0
  185. package/dist/db/schema/workspaces.d.ts.map +1 -0
  186. package/dist/db/schema/workspaces.js +57 -0
  187. package/dist/db/schema/workspaces.js.map +1 -0
  188. package/dist/db/types.d.ts +18 -0
  189. package/dist/db/types.d.ts.map +1 -0
  190. package/dist/db/types.js +2 -0
  191. package/dist/db/types.js.map +1 -0
  192. package/dist/email/adapters.d.ts +29 -0
  193. package/dist/email/adapters.d.ts.map +1 -0
  194. package/dist/email/adapters.js +60 -0
  195. package/dist/email/adapters.js.map +1 -0
  196. package/dist/email/index.d.ts +20 -0
  197. package/dist/email/index.d.ts.map +1 -0
  198. package/dist/email/index.js +79 -0
  199. package/dist/email/index.js.map +1 -0
  200. package/dist/email/provider.d.ts +47 -0
  201. package/dist/email/provider.d.ts.map +1 -0
  202. package/dist/email/provider.js +7 -0
  203. package/dist/email/provider.js.map +1 -0
  204. package/dist/email/resend.d.ts +22 -0
  205. package/dist/email/resend.d.ts.map +1 -0
  206. package/dist/email/resend.js +51 -0
  207. package/dist/email/resend.js.map +1 -0
  208. package/dist/email/service.d.ts +55 -0
  209. package/dist/email/service.d.ts.map +1 -0
  210. package/dist/email/service.js +87 -0
  211. package/dist/email/service.js.map +1 -0
  212. package/dist/email/templates.d.ts +81 -0
  213. package/dist/email/templates.d.ts.map +1 -0
  214. package/dist/email/templates.js +84 -0
  215. package/dist/email/templates.js.map +1 -0
  216. package/dist/entitlements/index.d.ts +10 -0
  217. package/dist/entitlements/index.d.ts.map +1 -0
  218. package/dist/entitlements/index.js +28 -0
  219. package/dist/entitlements/index.js.map +1 -0
  220. package/dist/entitlements/service.d.ts +83 -0
  221. package/dist/entitlements/service.d.ts.map +1 -0
  222. package/dist/entitlements/service.js +158 -0
  223. package/dist/entitlements/service.js.map +1 -0
  224. package/dist/errors.d.ts +85 -0
  225. package/dist/errors.d.ts.map +1 -0
  226. package/dist/errors.js +95 -0
  227. package/dist/errors.js.map +1 -0
  228. package/dist/events/core-events.d.ts +120 -0
  229. package/dist/events/core-events.d.ts.map +1 -0
  230. package/dist/events/core-events.js +109 -0
  231. package/dist/events/core-events.js.map +1 -0
  232. package/dist/events/index.d.ts +15 -0
  233. package/dist/events/index.d.ts.map +1 -0
  234. package/dist/events/index.js +58 -0
  235. package/dist/events/index.js.map +1 -0
  236. package/dist/events/publisher.d.ts +46 -0
  237. package/dist/events/publisher.d.ts.map +1 -0
  238. package/dist/events/publisher.js +54 -0
  239. package/dist/events/publisher.js.map +1 -0
  240. package/dist/events/registry.d.ts +87 -0
  241. package/dist/events/registry.d.ts.map +1 -0
  242. package/dist/events/registry.js +34 -0
  243. package/dist/events/registry.js.map +1 -0
  244. package/dist/events/service.d.ts +78 -0
  245. package/dist/events/service.d.ts.map +1 -0
  246. package/dist/events/service.js +182 -0
  247. package/dist/events/service.js.map +1 -0
  248. package/dist/features/bucket.d.ts +22 -0
  249. package/dist/features/bucket.d.ts.map +1 -0
  250. package/dist/features/bucket.js +42 -0
  251. package/dist/features/bucket.js.map +1 -0
  252. package/dist/features/index.d.ts +15 -0
  253. package/dist/features/index.d.ts.map +1 -0
  254. package/dist/features/index.js +78 -0
  255. package/dist/features/index.js.map +1 -0
  256. package/dist/features/registry.d.ts +51 -0
  257. package/dist/features/registry.d.ts.map +1 -0
  258. package/dist/features/registry.js +52 -0
  259. package/dist/features/registry.js.map +1 -0
  260. package/dist/features/service.d.ts +78 -0
  261. package/dist/features/service.d.ts.map +1 -0
  262. package/dist/features/service.js +207 -0
  263. package/dist/features/service.js.map +1 -0
  264. package/dist/files/index.d.ts +6 -0
  265. package/dist/files/index.d.ts.map +1 -0
  266. package/dist/files/index.js +64 -0
  267. package/dist/files/index.js.map +1 -0
  268. package/dist/files/service.d.ts +92 -0
  269. package/dist/files/service.d.ts.map +1 -0
  270. package/dist/files/service.js +303 -0
  271. package/dist/files/service.js.map +1 -0
  272. package/dist/index.d.ts +33 -0
  273. package/dist/index.d.ts.map +1 -0
  274. package/dist/index.js +33 -0
  275. package/dist/index.js.map +1 -0
  276. package/dist/jobs/backend.d.ts +23 -0
  277. package/dist/jobs/backend.d.ts.map +1 -0
  278. package/dist/jobs/backend.js +23 -0
  279. package/dist/jobs/backend.js.map +1 -0
  280. package/dist/jobs/index.d.ts +29 -0
  281. package/dist/jobs/index.d.ts.map +1 -0
  282. package/dist/jobs/index.js +114 -0
  283. package/dist/jobs/index.js.map +1 -0
  284. package/dist/jobs/service.d.ts +67 -0
  285. package/dist/jobs/service.d.ts.map +1 -0
  286. package/dist/jobs/service.js +139 -0
  287. package/dist/jobs/service.js.map +1 -0
  288. package/dist/jobs/types.d.ts +78 -0
  289. package/dist/jobs/types.d.ts.map +1 -0
  290. package/dist/jobs/types.js +4 -0
  291. package/dist/jobs/types.js.map +1 -0
  292. package/dist/logger/default.d.ts +7 -0
  293. package/dist/logger/default.d.ts.map +1 -0
  294. package/dist/logger/default.js +28 -0
  295. package/dist/logger/default.js.map +1 -0
  296. package/dist/logger/index.d.ts +24 -0
  297. package/dist/logger/index.d.ts.map +1 -0
  298. package/dist/logger/index.js +90 -0
  299. package/dist/logger/index.js.map +1 -0
  300. package/dist/logger/redact.d.ts +40 -0
  301. package/dist/logger/redact.d.ts.map +1 -0
  302. package/dist/logger/redact.js +178 -0
  303. package/dist/logger/redact.js.map +1 -0
  304. package/dist/logger/service.d.ts +14 -0
  305. package/dist/logger/service.d.ts.map +1 -0
  306. package/dist/logger/service.js +56 -0
  307. package/dist/logger/service.js.map +1 -0
  308. package/dist/logger/sinks.d.ts +37 -0
  309. package/dist/logger/sinks.d.ts.map +1 -0
  310. package/dist/logger/sinks.js +135 -0
  311. package/dist/logger/sinks.js.map +1 -0
  312. package/dist/logger/types.d.ts +47 -0
  313. package/dist/logger/types.d.ts.map +1 -0
  314. package/dist/logger/types.js +8 -0
  315. package/dist/logger/types.js.map +1 -0
  316. package/dist/notifications/index.d.ts +8 -0
  317. package/dist/notifications/index.d.ts.map +1 -0
  318. package/dist/notifications/index.js +72 -0
  319. package/dist/notifications/index.js.map +1 -0
  320. package/dist/notifications/service.d.ts +79 -0
  321. package/dist/notifications/service.d.ts.map +1 -0
  322. package/dist/notifications/service.js +164 -0
  323. package/dist/notifications/service.js.map +1 -0
  324. package/dist/realtime/channels.d.ts +37 -0
  325. package/dist/realtime/channels.d.ts.map +1 -0
  326. package/dist/realtime/channels.js +58 -0
  327. package/dist/realtime/channels.js.map +1 -0
  328. package/dist/realtime/index.d.ts +19 -0
  329. package/dist/realtime/index.d.ts.map +1 -0
  330. package/dist/realtime/index.js +118 -0
  331. package/dist/realtime/index.js.map +1 -0
  332. package/dist/realtime/postgres.d.ts +48 -0
  333. package/dist/realtime/postgres.d.ts.map +1 -0
  334. package/dist/realtime/postgres.js +151 -0
  335. package/dist/realtime/postgres.js.map +1 -0
  336. package/dist/realtime/service.d.ts +75 -0
  337. package/dist/realtime/service.d.ts.map +1 -0
  338. package/dist/realtime/service.js +103 -0
  339. package/dist/realtime/service.js.map +1 -0
  340. package/dist/realtime/sse.d.ts +55 -0
  341. package/dist/realtime/sse.d.ts.map +1 -0
  342. package/dist/realtime/sse.js +123 -0
  343. package/dist/realtime/sse.js.map +1 -0
  344. package/dist/realtime/transport.d.ts +52 -0
  345. package/dist/realtime/transport.d.ts.map +1 -0
  346. package/dist/realtime/transport.js +2 -0
  347. package/dist/realtime/transport.js.map +1 -0
  348. package/dist/storage/index.d.ts +18 -0
  349. package/dist/storage/index.d.ts.map +1 -0
  350. package/dist/storage/index.js +89 -0
  351. package/dist/storage/index.js.map +1 -0
  352. package/dist/storage/keys.d.ts +50 -0
  353. package/dist/storage/keys.d.ts.map +1 -0
  354. package/dist/storage/keys.js +120 -0
  355. package/dist/storage/keys.js.map +1 -0
  356. package/dist/storage/memory.d.ts +32 -0
  357. package/dist/storage/memory.d.ts.map +1 -0
  358. package/dist/storage/memory.js +84 -0
  359. package/dist/storage/memory.js.map +1 -0
  360. package/dist/storage/provider.d.ts +93 -0
  361. package/dist/storage/provider.d.ts.map +1 -0
  362. package/dist/storage/provider.js +2 -0
  363. package/dist/storage/provider.js.map +1 -0
  364. package/dist/storage/s3.d.ts +34 -0
  365. package/dist/storage/s3.d.ts.map +1 -0
  366. package/dist/storage/s3.js +175 -0
  367. package/dist/storage/s3.js.map +1 -0
  368. package/dist/storage/service.d.ts +86 -0
  369. package/dist/storage/service.d.ts.map +1 -0
  370. package/dist/storage/service.js +107 -0
  371. package/dist/storage/service.js.map +1 -0
  372. package/dist/storage/sigv4.d.ts +58 -0
  373. package/dist/storage/sigv4.d.ts.map +1 -0
  374. package/dist/storage/sigv4.js +114 -0
  375. package/dist/storage/sigv4.js.map +1 -0
  376. package/dist/streams/client.d.ts +91 -0
  377. package/dist/streams/client.d.ts.map +1 -0
  378. package/dist/streams/client.js +128 -0
  379. package/dist/streams/client.js.map +1 -0
  380. package/dist/streams/events.d.ts +68 -0
  381. package/dist/streams/events.d.ts.map +1 -0
  382. package/dist/streams/events.js +81 -0
  383. package/dist/streams/events.js.map +1 -0
  384. package/dist/streams/index.d.ts +47 -0
  385. package/dist/streams/index.d.ts.map +1 -0
  386. package/dist/streams/index.js +47 -0
  387. package/dist/streams/index.js.map +1 -0
  388. package/dist/streams/realtime.d.ts +27 -0
  389. package/dist/streams/realtime.d.ts.map +1 -0
  390. package/dist/streams/realtime.js +50 -0
  391. package/dist/streams/realtime.js.map +1 -0
  392. package/dist/streams/state.d.ts +26 -0
  393. package/dist/streams/state.d.ts.map +1 -0
  394. package/dist/streams/state.js +34 -0
  395. package/dist/streams/state.js.map +1 -0
  396. package/dist/users/index.d.ts +9 -0
  397. package/dist/users/index.d.ts.map +1 -0
  398. package/dist/users/index.js +23 -0
  399. package/dist/users/index.js.map +1 -0
  400. package/dist/users/service.d.ts +43 -0
  401. package/dist/users/service.d.ts.map +1 -0
  402. package/dist/users/service.js +113 -0
  403. package/dist/users/service.js.map +1 -0
  404. package/dist/webhooks/inbound.d.ts +70 -0
  405. package/dist/webhooks/inbound.d.ts.map +1 -0
  406. package/dist/webhooks/inbound.js +146 -0
  407. package/dist/webhooks/inbound.js.map +1 -0
  408. package/dist/webhooks/index.d.ts +12 -0
  409. package/dist/webhooks/index.d.ts.map +1 -0
  410. package/dist/webhooks/index.js +65 -0
  411. package/dist/webhooks/index.js.map +1 -0
  412. package/dist/webhooks/outgoing.d.ts +89 -0
  413. package/dist/webhooks/outgoing.d.ts.map +1 -0
  414. package/dist/webhooks/outgoing.js +375 -0
  415. package/dist/webhooks/outgoing.js.map +1 -0
  416. package/dist/webhooks/signature.d.ts +40 -0
  417. package/dist/webhooks/signature.d.ts.map +1 -0
  418. package/dist/webhooks/signature.js +77 -0
  419. package/dist/webhooks/signature.js.map +1 -0
  420. package/dist/workspaces/context.d.ts +30 -0
  421. package/dist/workspaces/context.d.ts.map +1 -0
  422. package/dist/workspaces/context.js +12 -0
  423. package/dist/workspaces/context.js.map +1 -0
  424. package/dist/workspaces/index.d.ts +5 -0
  425. package/dist/workspaces/index.d.ts.map +1 -0
  426. package/dist/workspaces/index.js +21 -0
  427. package/dist/workspaces/index.js.map +1 -0
  428. package/dist/workspaces/service.d.ts +126 -0
  429. package/dist/workspaces/service.d.ts.map +1 -0
  430. package/dist/workspaces/service.js +495 -0
  431. package/dist/workspaces/service.js.map +1 -0
  432. package/docs/README.md +71 -0
  433. package/docs/api-keys.md +184 -0
  434. package/docs/audit.md +198 -0
  435. package/docs/auth.md +196 -0
  436. package/docs/authorization.md +225 -0
  437. package/docs/billing.md +293 -0
  438. package/docs/cli.md +238 -0
  439. package/docs/config.md +163 -0
  440. package/docs/db.md +219 -0
  441. package/docs/email.md +222 -0
  442. package/docs/entitlements.md +194 -0
  443. package/docs/errors.md +158 -0
  444. package/docs/events.md +279 -0
  445. package/docs/feature-flags.md +237 -0
  446. package/docs/files.md +229 -0
  447. package/docs/jobs.md +288 -0
  448. package/docs/logger.md +269 -0
  449. package/docs/notifications.md +179 -0
  450. package/docs/publishing.md +128 -0
  451. package/docs/realtime.md +360 -0
  452. package/docs/storage.md +279 -0
  453. package/docs/streams.md +296 -0
  454. package/docs/users.md +117 -0
  455. package/docs/webhooks.md +272 -0
  456. package/docs/workspaces.md +269 -0
  457. package/drizzle/0000_init_users.sql +21 -0
  458. package/drizzle/0001_workspaces_roles_permissions.sql +86 -0
  459. package/drizzle/0002_billing_entitlements.sql +97 -0
  460. package/drizzle/0003_events.sql +17 -0
  461. package/drizzle/0004_notifications_audit.sql +64 -0
  462. package/drizzle/0005_api_keys_webhooks.sql +77 -0
  463. package/drizzle/0006_feature_flags.sql +22 -0
  464. package/drizzle/0007_files.sql +23 -0
  465. package/drizzle/meta/0000_snapshot.json +160 -0
  466. package/drizzle/meta/0001_snapshot.json +837 -0
  467. package/drizzle/meta/0002_snapshot.json +1580 -0
  468. package/drizzle/meta/0003_snapshot.json +1751 -0
  469. package/drizzle/meta/0004_snapshot.json +2109 -0
  470. package/drizzle/meta/0005_snapshot.json +2719 -0
  471. package/drizzle/meta/0006_snapshot.json +2909 -0
  472. package/drizzle/meta/0007_snapshot.json +3113 -0
  473. package/drizzle/meta/_journal.json +62 -0
  474. package/package.json +180 -0
@@ -0,0 +1,360 @@
1
+ # Realtime
2
+
3
+ ```ts
4
+ import { realtime, createEventStream, workspaceChannel } from "@digimuza/saas-core/realtime";
5
+ ```
6
+
7
+ > Orientation and recipes: [`src/realtime/README.md`](../src/realtime/README.md).
8
+
9
+
10
+ Realtime **delivers** events to connected clients. It is not the source of truth —
11
+ the durable record is the `events` table, and a client that missed a message
12
+ re-reads from it:
13
+
14
+ ```
15
+ PostgreSQL → application events → realtime transport → browser
16
+ ```
17
+
18
+ That ordering is the whole design, and it is why a failed delivery never fails the
19
+ operation that published it.
20
+
21
+ ---
22
+
23
+ ## Wiring
24
+
25
+ ```ts
26
+ // once at startup
27
+ import { events } from "@digimuza/saas-core/events";
28
+ import { realtime } from "@digimuza/saas-core/realtime";
29
+
30
+ realtime.attach(events);
31
+ ```
32
+
33
+ `attach` uses the events service's existing `onAny` hook, so realtime is an
34
+ optional *consumer* of events and never a dependency of publishing. It returns an
35
+ unsubscribe function.
36
+
37
+ ## Serving a stream
38
+
39
+ ```ts
40
+ // app/api/realtime/route.ts
41
+ import { createEventStream } from "@digimuza/saas-core/realtime";
42
+ import { realtime } from "@digimuza/saas-core/realtime";
43
+ import { auth } from "@/lib/auth";
44
+
45
+ export async function GET(request: Request) {
46
+ const user = await auth.requireUser();
47
+
48
+ const stream = await createEventStream(realtime, {
49
+ userId: user.id, // from the session
50
+ channels: new URL(request.url).searchParams.getAll("channel"), // untrusted
51
+ signal: request.signal,
52
+ });
53
+
54
+ return new Response(stream.body, { headers: stream.headers });
55
+ }
56
+ ```
57
+
58
+ ## Consuming it
59
+
60
+ ```ts
61
+ const source = new EventSource(
62
+ `/api/realtime?channel=workspace:${workspaceId}`,
63
+ );
64
+
65
+ source.addEventListener("project.created", (message) => {
66
+ const { eventId, data, truncated } = JSON.parse(message.data);
67
+ if (truncated) {
68
+ // Payload was too large for the transport; re-read it by id.
69
+ return refetchEvent(eventId);
70
+ }
71
+ applyUpdate(data);
72
+ });
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Channels
78
+
79
+ Two kinds, both scoped to something the server can verify:
80
+
81
+ | Channel | Granted when |
82
+ | --- | --- |
83
+ | `workspace:<uuid>` | the caller is a member of that workspace |
84
+ | `user:<uuid>` | the uuid is the caller's own id |
85
+
86
+ **There is no global channel and no wildcard.** A client that could subscribe to
87
+ `*` would receive every tenant's traffic, and no amount of downstream filtering
88
+ makes that safe.
89
+
90
+ ```ts
91
+ workspaceChannel(workspaceId); // "workspace:<uuid>"
92
+ userChannel(userId); // "user:<uuid>"
93
+ parseChannel(value); // Channel | undefined
94
+ requireChannel(value); // throws InvalidInputError
95
+ ```
96
+
97
+ ### Channel names are untrusted input
98
+
99
+ A client names the channels it wants, so every one is parsed before it is checked.
100
+ `parseChannel` rejects wildcards, path traversal, wrong-case prefixes, surrounding
101
+ whitespace, embedded newlines, the nil UUID, and anything longer than a
102
+ well-formed channel. There are 21 tests on that function alone.
103
+
104
+ It returns `undefined` rather than throwing: a subscriber sending a batch should
105
+ have the bad ones denied, not the whole request rejected — a client on a stale
106
+ deploy would otherwise be unable to connect at all.
107
+
108
+ ---
109
+
110
+ ## `realtime.authorizeChannels(input)`
111
+
112
+ ```ts
113
+ function authorizeChannels(input: {
114
+ userId: string; // from the session
115
+ channels: readonly string[]; // from the client
116
+ }): Promise<{ granted: readonly string[]; denied: readonly string[] }>
117
+ ```
118
+
119
+ The **one** place channel access is decided. Transports do not filter — a test
120
+ enforces that they cannot even reach `resolveContext` — so there is exactly one
121
+ rule to audit.
122
+
123
+ Workspace channels are checked against `workspace_members`; user channels against
124
+ the session's own id. Revoking a membership stops granting immediately, which is
125
+ tested.
126
+
127
+ `denied` deliberately does not say *why*. "Not a member" and "no such workspace"
128
+ are the same answer, or a client could enumerate tenants by probing channels.
129
+
130
+ At most 32 channels per subscription, de-duplicated, so a client cannot multiply
131
+ the membership lookups a single request triggers.
132
+
133
+ ## `realtime.subscribe(input)`
134
+
135
+ ```ts
136
+ function subscribe(input: {
137
+ userId: string;
138
+ channels: readonly string[];
139
+ onMessage: (message: RealtimeMessage) => void;
140
+ }): Promise<RealtimeSubscription & { granted: readonly string[] }>
141
+ ```
142
+
143
+ Authorizes, then subscribes. Returns a subscription even when nothing was granted —
144
+ so a caller has one cleanup path — and in that case opens nothing against the
145
+ transport at all.
146
+
147
+ ## `realtime.publish(event, options?)`
148
+
149
+ ```ts
150
+ function publish(event: SaasEvent, options?: { channels?: readonly string[] }): Promise<void>
151
+ ```
152
+
153
+ Projects an event onto its channels. By default that is the workspace channel
154
+ derived from `event.workspaceId`; pass `channels` to add more — a user channel for
155
+ something addressed to one person, for instance.
156
+
157
+ ### What gets broadcast
158
+
159
+ The default filter is **workspace-scoped events only**. Platform-wide events
160
+ (`user.created`, with a null workspace) have no tenant channel to go to, and
161
+ broadcasting them would mean choosing an audience the event does not describe.
162
+
163
+ Narrow or widen it explicitly:
164
+
165
+ ```ts
166
+ createRealtimeService({
167
+ transport, workspaces,
168
+ broadcast: (event) => event.type.startsWith("job."),
169
+ });
170
+ ```
171
+
172
+ Worth doing deliberately. Event payloads carry identifiers rather than snapshots
173
+ (see [events.md](./events.md#payload-conventions)), so they are usually safe — but
174
+ your own event types are yours to audit.
175
+
176
+ ---
177
+
178
+ ## `RealtimeMessage`
179
+
180
+ ```ts
181
+ interface RealtimeMessage {
182
+ channel: string;
183
+ eventId: string; // the events.id this projects
184
+ type: string;
185
+ occurredAt: string; // ISO 8601
186
+ data?: unknown; // absent when truncated
187
+ truncated?: boolean;
188
+ }
189
+ ```
190
+
191
+ `eventId` is what makes realtime recoverable: a client that missed or received a
192
+ truncated message re-reads the event rather than guessing.
193
+
194
+ ---
195
+
196
+ ## The default transport: PostgreSQL LISTEN/NOTIFY
197
+
198
+ Chosen because it adds **no infrastructure** — the database is already there, and
199
+ the spec is explicit about preferring PostgreSQL where PostgreSQL is enough. No
200
+ Redis, no broker, no extra service to operate.
201
+
202
+ **Browsers never touch `LISTEN`/`NOTIFY`.** The server holds the listening
203
+ connection and re-emits only what a given subscriber was granted. PostgreSQL is
204
+ used *internally* as part of the pipeline, which is what the architecture allows.
205
+
206
+ ### Design details worth knowing
207
+
208
+ **One PG channel, not one per workspace.** `LISTEN` is per-connection and
209
+ per-name, so a channel-per-workspace design would mean thousands of `LISTEN`
210
+ statements and constant `LISTEN`/`UNLISTEN` churn as clients come and go. Instead
211
+ every server sees every message and filters to the channels it has authorized
212
+ subscribers for.
213
+
214
+ The trade-off is real: fan-out is O(servers), so at large scale every node decodes
215
+ traffic it discards. That is the point at which to swap in a broker — which is why
216
+ `RealtimeTransport` exists.
217
+
218
+ **One database listener shared across all local subscribers.** A `LISTEN` per HTTP
219
+ connection would exhaust the pool at a few hundred concurrent clients.
220
+
221
+ **Oversized payloads are truncated, not dropped.** `pg_notify` rejects payloads
222
+ over 8000 bytes. Rather than failing the notification, `data` is removed and
223
+ `truncated: true` set, so the client still learns *that* something happened and can
224
+ re-read it.
225
+
226
+ **Notifications are validated, not cast.** The payload arrives through the
227
+ database, so anything with `NOTIFY` privileges could have written it. A malformed
228
+ or hostile notification is discarded rather than reaching subscribers as if it were
229
+ a real event.
230
+
231
+ **Delivery failures are swallowed and reported.** The event is already committed;
232
+ a failed notification costs a client its live update, not the record — and must not
233
+ fail the operation that published it.
234
+
235
+ ### Constructing it explicitly
236
+
237
+ ```ts
238
+ import {
239
+ createPostgresTransport,
240
+ postgresJsNotifyClient,
241
+ PG_NOTIFY_CHANNEL,
242
+ } from "@digimuza/saas-core/realtime";
243
+ import { getDatabaseHandle } from "@digimuza/saas-core/db";
244
+
245
+ const transport = createPostgresTransport({
246
+ client: postgresJsNotifyClient(getDatabaseHandle().client),
247
+ // Separate environments sharing one database, so a staging event cannot
248
+ // surface in a production browser.
249
+ notifyChannel: `${PG_NOTIFY_CHANNEL}_staging`,
250
+ onError: (error) => logger.error({ error }, "realtime transport"),
251
+ });
252
+ ```
253
+
254
+ `NotifyCapableClient` is a three-method interface (`listen`, `unlisten`, `notify`),
255
+ so any driver that can do those works — the core's tests drive it with PGlite.
256
+
257
+ `encodeMessage` and `decodeMessage` are exported too, if you are writing a
258
+ transport that needs the same size-limit and validation behaviour.
259
+
260
+ ---
261
+
262
+ ## Server-sent events, not WebSockets
263
+
264
+ `createEventStream` returns bytes plus headers, deliberately **not** a `Response`:
265
+
266
+ ```ts
267
+ interface EventStream {
268
+ body: ReadableStream<Uint8Array>;
269
+ headers: Record<string, string>;
270
+ granted: readonly string[];
271
+ close(): Promise<void>;
272
+ }
273
+ ```
274
+
275
+ Returning a `Response` would tie the module to the fetch types and to one
276
+ framework; a stream and a header map compose with anything, including plain
277
+ `node:http`.
278
+
279
+ SSE because it is one-directional — server to browser — which is exactly what
280
+ event delivery needs. It reconnects automatically, survives proxies, and needs no
281
+ protocol upgrade. If you need client-to-server messaging, that is an HTTP request,
282
+ not a realtime concern.
283
+
284
+ Details the implementation handles:
285
+
286
+ - **`retry:` hint** on open, so the browser knows how long to wait before
287
+ reconnecting.
288
+ - **An immediate `: connected` comment**, which flushes headers so the connection
289
+ opens now rather than on the first real event.
290
+ - **Heartbeat comments every 25s** by default — under the 30-second idle timeout
291
+ most proxies and load balancers impose.
292
+ - **`X-Accel-Buffering: no`**, without which nginx buffers the response and SSE
293
+ never arrives at all.
294
+ - **Abort handling.** Pass `request.signal`; without it a dropped connection leaks
295
+ a subscription and a listener slot for the process's lifetime.
296
+ - **Messages arriving during setup are buffered.** `subscribe` must run before the
297
+ stream is constructed (`granted` is part of the result), and anything delivered
298
+ in that window would otherwise be silently dropped.
299
+
300
+ ### Serverless caveat
301
+
302
+ An SSE connection is a long-lived request. Platforms that cap function duration
303
+ will cut it — on Vercel that is the function `maxDuration`. The browser reconnects
304
+ automatically, so it degrades to polling at the cap rather than breaking, but a
305
+ long-lived host (a container, a persistent Node process) is where this transport
306
+ belongs.
307
+
308
+ ---
309
+
310
+ ## Replacing the transport
311
+
312
+ ```ts
313
+ interface RealtimeTransport {
314
+ readonly name: string;
315
+ publish(messages: readonly RealtimeMessage[]): Promise<void>;
316
+ subscribe(
317
+ channels: readonly string[],
318
+ onMessage: (message: RealtimeMessage) => void,
319
+ ): Promise<RealtimeSubscription>;
320
+ close?(): Promise<void>;
321
+ }
322
+ ```
323
+
324
+ Two obligations:
325
+
326
+ 1. **Do not authorize.** `channels` is already authorized; deliver to nothing
327
+ outside the set. Access is decided in the service, and a transport that also
328
+ filtered would create a second place for the rule to drift.
329
+ 2. **Do not throw for an unknown channel.** Publishing is best-effort.
330
+
331
+ ```ts
332
+ import { createRealtimeService, setDefaultRealtimeTransport } from "@digimuza/saas-core/realtime";
333
+
334
+ // Either build your own service...
335
+ const realtime = createRealtimeService({ transport: myTransport, workspaces });
336
+
337
+ // ...or replace what the default export uses, before first use.
338
+ setDefaultRealtimeTransport(myTransport);
339
+ ```
340
+
341
+ `REALTIME_URL` and `REALTIME_API_KEY` exist in the `realtime` config section for
342
+ exactly this — a deployment that outgrows LISTEN/NOTIFY and points at Centrifugo,
343
+ Soketi or a hosted service. The PostgreSQL transport ignores them;
344
+ `hasExternalRealtimeConfig()` reports whether they are set.
345
+
346
+ ---
347
+
348
+ ## Testing
349
+
350
+ The core's own tests run the full pipeline against real PostgreSQL — PGlite
351
+ supports `LISTEN`/`NOTIFY` — so cross-tenant isolation is verified through actual
352
+ notification delivery, not a stand-in that would deliver anything asked of it.
353
+
354
+ ```ts
355
+ const aliceSaw: RealtimeMessage[] = [];
356
+ const bobSaw: RealtimeMessage[] = [];
357
+ // ...subscribe both, publish to each workspace...
358
+ expect(aliceSaw.map((m) => m.channel)).toEqual([workspaceChannel(acme.id)]);
359
+ expect(bobSaw.map((m) => m.channel)).toEqual([workspaceChannel(globex.id)]);
360
+ ```
@@ -0,0 +1,279 @@
1
+ # Storage
2
+
3
+ ```ts
4
+ import { storage, createStorageService, createMemoryStorageProvider } from "@digimuza/saas-core/storage";
5
+ ```
6
+
7
+ > Orientation and recipes: [`src/storage/README.md`](../src/storage/README.md).
8
+
9
+ Object storage behind an adapter, with tenant scoping that cannot be bypassed.
10
+
11
+ ---
12
+
13
+ ## No database table
14
+
15
+ An object is referenced by key from whichever product table owns it. A core `files`
16
+ table would have to guess at metadata, ownership and lifecycle only the product
17
+ knows — and the spec's core-owned table list does not include one. Storage adds no
18
+ migration.
19
+
20
+ ---
21
+
22
+ ## `storage.forWorkspace(context)`
23
+
24
+ ```ts
25
+ function forWorkspace(context: WorkspaceContext): ScopedStorage
26
+ ```
27
+
28
+ The only way to address an object. Takes a verified
29
+ [`WorkspaceContext`](./workspaces.md#workspacecontext), and the returned API takes
30
+ **paths** rather than keys — so `workspaces/<id>/` is applied in exactly one place.
31
+
32
+ ```ts
33
+ interface ScopedStorage {
34
+ readonly workspaceId: string;
35
+
36
+ keyFor(path: string): string;
37
+
38
+ upload(input: UploadInput): Promise<StoredObject>;
39
+ delete(path: string): Promise<void>;
40
+ exists(path: string): Promise<boolean>;
41
+
42
+ getUrl(path: string, options?: UrlOptions): Promise<string>;
43
+ createUploadUrl(path: string, options?: UploadUrlOptions): Promise<string>;
44
+
45
+ urlForKey(key: string, options?: UrlOptions): Promise<string>;
46
+ deleteKey(key: string): Promise<void>;
47
+ }
48
+ ```
49
+
50
+ ### `upload(input)`
51
+
52
+ ```ts
53
+ await files.upload({
54
+ path: "avatars/me.png",
55
+ body, // Uint8Array | string
56
+ contentType: "image/png",
57
+ cacheControl: "public, max-age=31536000, immutable",
58
+ metadata: { uploadedBy: user.id },
59
+ maxBytes: 5 * 1024 * 1024, // overrides the service default
60
+ });
61
+ ```
62
+
63
+ Size is checked **before** the provider call, so an oversized body is never sent,
64
+ and measured in **bytes** rather than characters. Default limit 25 MB.
65
+
66
+ Never put a secret in `metadata` — it is stored alongside the object and returned
67
+ by a `HEAD`.
68
+
69
+ ### `keyFor(path)` / `urlForKey(key)` / `deleteKey(key)`
70
+
71
+ Store `keyFor(path)` in your own table, and pass it back through `urlForKey` or
72
+ `deleteKey`. Both **re-assert ownership**:
73
+
74
+ ```ts
75
+ await files.urlForKey(row.storageKey); // InvalidInputError if not this workspace
76
+ ```
77
+
78
+ That check matters because a key coming back out of your own database is untrusted
79
+ input — it may have been written before a bug was fixed, or by a different tenant.
80
+ The error deliberately does not distinguish "wrong tenant" from "not a scoped key".
81
+
82
+ ---
83
+
84
+ ## Object keys are the attack surface
85
+
86
+ A caller who can put `../` in a key reads out of its own prefix and into another
87
+ tenant's: a cross-tenant read with no database involved and nothing in an audit
88
+ log.
89
+
90
+ So keys are validated **before** being concatenated with a prefix, and validation
91
+ **rejects rather than sanitises** — silently rewriting `../secret` to `secret` would
92
+ store the object somewhere the caller did not ask for.
93
+
94
+ | Rejected | Why |
95
+ | --- | --- |
96
+ | `..`, `.` segments | Traversal out of the tenant prefix |
97
+ | Leading `/` | Absolute key defeats prefixing entirely |
98
+ | `\` | A separator on some receivers, data on others |
99
+ | Control chars, incl. NUL | A NUL can truncate a key in a C-based backend, so `a<NUL>../b` may be stored as `a` — traversal past a naive check |
100
+ | `a//b`, trailing `/` | Empty segment |
101
+ | ` a.png`, `a.png ` | Legal in S3, invisible in every UI |
102
+ | over 1024 bytes | S3's key limit |
103
+
104
+ ```ts
105
+ workspaceObjectKey(workspaceId, path): string // validates both, then prefixes
106
+ parseWorkspaceObjectKey(key): { workspaceId, path } | undefined
107
+ assertKeyBelongsTo(workspaceId, key): void
108
+ assertObjectPath(path): void // throws with a reason
109
+ isValidObjectPath(path): boolean
110
+ ```
111
+
112
+ `workspaceObjectKey` validates the **workspace id as a UUID** too — it is the only
113
+ thing separating one tenant's objects from another's, and a malformed one could
114
+ create a colliding prefix.
115
+
116
+ ---
117
+
118
+ ## URLs
119
+
120
+ ### `getUrl(path, options?)`
121
+
122
+ ```ts
123
+ interface UrlOptions {
124
+ expiresInSeconds?: number; // default 900, max 604800 (7 days)
125
+ downloadFilename?: string;
126
+ }
127
+ ```
128
+
129
+ Returns the **public URL** when the store has one (`STORAGE_PUBLIC_URL`), otherwise
130
+ a time-limited signed URL. Only a deployment with a public CDN origin can answer
131
+ that; a private bucket is the correct default for user uploads.
132
+
133
+ **Pass `downloadFilename` for anything a user uploaded.** It forces
134
+ `Content-Disposition: attachment`, which stops a browser rendering an uploaded HTML
135
+ or SVG file in your origin — that is stored XSS.
136
+
137
+ Requesting it also forces a *signed* URL even when a public one exists, because
138
+ handing back a bare CDN link would silently drop the protection the caller asked
139
+ for.
140
+
141
+ Quotes and backslashes are stripped from the filename, so it cannot break out of
142
+ the header value.
143
+
144
+ ### `createUploadUrl(path, options?)`
145
+
146
+ ```ts
147
+ const url = await files.createUploadUrl("imports/data.csv", {
148
+ contentType: "text/csv",
149
+ expiresInSeconds: 600,
150
+ });
151
+ ```
152
+
153
+ A URL the browser can `PUT` to directly. The reason the interface has this shape:
154
+ proxying uploads through the application means a request that holds a server for
155
+ the whole transfer, and a body limit on every platform in the path.
156
+
157
+ `contentType` is **signed into the URL**, so the client must send exactly that type
158
+ or be rejected. Without it, a client uploads anything and declares it anything.
159
+
160
+ ### The 7-day cap
161
+
162
+ `MAX_SIGNED_URL_SECONDS` is S3's own hard limit for a SigV4 presigned URL — beyond
163
+ it the signature is invalid. The service throws rather than clamping, because
164
+ clamping would hand back a URL that expires sooner than the caller was told.
165
+
166
+ ---
167
+
168
+ ## Providers
169
+
170
+ ```ts
171
+ interface StorageProvider {
172
+ readonly name: string;
173
+ put(input: PutObjectInput): Promise<StoredObject>;
174
+ delete(key: string): Promise<void>;
175
+ exists(key: string): Promise<boolean>;
176
+ publicUrl(key: string): string | undefined;
177
+ signedDownloadUrl(input: SignedDownloadInput): Promise<string>;
178
+ signedUploadUrl(input: SignedUploadInput): Promise<string>;
179
+ }
180
+ ```
181
+
182
+ Implementations do **no** key validation or tenant scoping. Both happen once, in
183
+ the service; a provider that also checked would give the rule two homes and one
184
+ would drift.
185
+
186
+ | Provider | Use |
187
+ | --- | --- |
188
+ | `createS3Provider(options)` | AWS S3, Cloudflare R2, MinIO, Backblaze, Supabase |
189
+ | `createMemoryStorageProvider(options?)` | Tests, and a local loop with no credentials |
190
+
191
+ `createConfiguredStorageProvider()` builds the S3 one from the `storage` config
192
+ section. The default `storage` export uses it, resolving configuration on first use.
193
+
194
+ ### Path style vs virtual-hosted
195
+
196
+ `forcePathStyle` defaults to **true when an endpoint is given**, false for AWS.
197
+ MinIO and most self-hosted stores cannot do wildcard subdomains, so
198
+ `https://minio.internal:9000/my-bucket/key` is required rather than
199
+ `https://my-bucket.minio.internal:9000/key`.
200
+
201
+ ### Errors never include the response body
202
+
203
+ S3 error documents echo the key and can echo request parameters, and the message
204
+ may reach a log.
205
+
206
+ A `404` on delete is treated as success — deleting something already gone is not an
207
+ error. A `403` on `HEAD` is treated as "not found", because that is what S3 returns
208
+ for a missing key on a bucket without `ListBucket`.
209
+
210
+ ---
211
+
212
+ ## SigV4, implemented rather than imported
213
+
214
+ ```ts
215
+ presign(input: PresignInput): PresignResult
216
+ signingKey(input): Buffer
217
+ uriEncode(value): string
218
+ encodeObjectPath(key): string
219
+ ```
220
+
221
+ **Verified against AWS's published worked example** — the test asserts both the
222
+ signature `aeeed9bb…` and the exact canonical request. So unlike the Revolut and
223
+ Resend adapters, this signing is known correct rather than plausible.
224
+
225
+ Why not `@aws-sdk/client-s3` plus the presigner: it is a large dependency tree for
226
+ a package many applications install, and a deployment on R2 or MinIO has no reason
227
+ to carry the AWS SDK. SigV4 is fully specified, which is precisely what makes
228
+ implementing it defensible — there are vectors to check against.
229
+
230
+ `PresignResult` exposes `canonicalRequest` and `stringToSign`, which is what makes
231
+ a signature mismatch debuggable rather than a shrug.
232
+
233
+ ### `objectDigest(body)`
234
+
235
+ ```ts
236
+ function objectDigest(body: ObjectBody): string // sha256 hex
237
+ ```
238
+
239
+ Content hash, for naming an object by its bytes — which gives you deduplication and
240
+ immutable caching for free:
241
+
242
+ ```ts
243
+ const digest = objectDigest(body);
244
+ await files.upload({
245
+ path: `uploads/${digest}${extension}`,
246
+ body,
247
+ cacheControl: "public, max-age=31536000, immutable",
248
+ });
249
+ ```
250
+
251
+ Re-uploading identical bytes then overwrites the same key rather than growing the
252
+ bucket, and the URL can be cached forever because the content cannot change.
253
+
254
+ Details that are easy to get wrong and are covered:
255
+
256
+ - `uriEncode` escapes `!'()*`, which `encodeURIComponent` leaves alone.
257
+ - `encodeObjectPath` preserves `/` — separators in a key, not data.
258
+ - The canonical query string is encoded **then** sorted. Sorting first gives a
259
+ different order for some inputs and the signature will not match.
260
+ - The signing key is four chained HMACs, each narrowing scope, so a leaked one is
261
+ good for one date, region and service rather than the account.
262
+
263
+ ---
264
+
265
+ ## Testing
266
+
267
+ ```ts
268
+ const provider = createMemoryStorageProvider();
269
+ const storage = createStorageService({ provider });
270
+
271
+ await storage.forWorkspace(context).upload({ path: "a.png", body: "bytes" });
272
+
273
+ expect(provider.keysUnder("workspaces/")).toEqual([`workspaces/${context.workspaceId}/a.png`]);
274
+ expect(provider.text(`workspaces/${context.workspaceId}/a.png`)).toBe("bytes");
275
+ ```
276
+
277
+ `keysUnder` exists specifically for asserting tenant scoping. Signed URLs from the
278
+ memory provider are **synthesised** — they carry an expiry and a signature-shaped
279
+ token so a test can assert on them, but nothing verifies them. It is not a store.