@indigoai-us/hq-cloud 6.15.0 → 6.15.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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,630 +0,0 @@
1
- /**
2
- * US-010 — E2E acceptance test: cross-tenant isolation invariant (BLOCKING).
3
- *
4
- * ╔══════════════════════════════════════════════════════════════════════╗
5
- * ║ A FAILURE OF THIS TEST IS A P0 INCIDENT. ║
6
- * ║ ║
7
- * ║ Cross-tenant data leakage kills the event-driven-sync project ║
8
- * ║ regardless of any latency win. If this test goes red, STOP — do not ║
9
- * ║ merge, do not deploy, do not flip a feature flag. Treat it as a ║
10
- * ║ security incident: root-cause it, record it in the project journal ║
11
- * ║ (companies/indigo/projects/event-driven-sync-menubar), and only ║
12
- * ║ re-enable the pipeline once the leak path is closed AND this test ║
13
- * ║ is green again. (PRD US-010 AC#4 — "A failure is treated as a P0".) ║
14
- * ╚══════════════════════════════════════════════════════════════════════╝
15
- *
16
- * What this pins (the CLIENT-side invariant)
17
- * ───────────────────────────────────────────
18
- * This is the hq-cloud CLIENT mirror of hq-pro PR #112's server-side
19
- * `cross-tenant-isolation` gate. Where the server proves its push handler
20
- * rejects a forged cross-tenant `originTenantId`, THIS test proves the
21
- * client pipeline — the US-008 {@link PushEventEmitter} push side and the
22
- * US-009 receiver pull side — can never let one tenant's data reach another
23
- * tenant's device. The invariant, verbatim from the PRD `e2eTests`:
24
- *
25
- * "Given two tenants with two devices each, when tenant A saves a file,
26
- * then no tenant B device observes it via push or pull within 60s."
27
- *
28
- * Two-tenant, two-device-each pair-work simulation
29
- * ─────────────────────────────────────────────────
30
- * Built entirely on the REAL client seams shipped by US-007/008/009 — no
31
- * production code is re-implemented here:
32
- *
33
- * • Each tenant owns ONE fanout ({@link InMemoryFanout}) — the in-process
34
- * analogue of that tenant's SNS topic → per-client SQS queue. This is
35
- * the subscription boundary the whole isolation argument rests on.
36
- * • A tenant's outbound device ships PushEvents through a
37
- * {@link PushEventEmitter} (US-008) wired to a tenant-scoped
38
- * {@link PushTransport}. The transport routes a published event ONLY
39
- * into ITS OWN tenant's fanout AND refuses to ship any event whose
40
- * `originTenantId` is not its tenant — this models the server's
41
- * topic-per-tenant + cross-tenant rejection. So a tenant-A device
42
- * physically cannot publish onto tenant B's fanout.
43
- * • Each tenant's TWO devices run a {@link InMemoryPushReceiver} (US-009)
44
- * subscribed ONLY to their own tenant's fanout. A receiver never reads
45
- * another tenant's queue; isolation is enforced at the subscription
46
- * boundary, never by post-hoc filtering.
47
- *
48
- * Assertions (map to PRD US-010 acceptanceCriteria + e2eTests)
49
- * ─────────────────────────────────────────────────────────────
50
- * AC#1 Tenant A device saves a file; NO PushEvent reaches ANY tenant-B
51
- * device over a simulated 60s window. We model the save by emitting
52
- * through tenant A's emitter, then advance fake timers across a full
53
- * 60s window and assert every tenant-B receiver's syncFn fired ZERO
54
- * times and no tenant-B receiver advanced its dedupe/seen state.
55
- * AC#2 Tenant B's receiver never PULLS a tenant-A file: a tenant-B
56
- * {@link SqsPushReceiver} polling ITS OWN (empty) per-tenant queue —
57
- * while tenant A's queue is full of A's messages — drains zero
58
- * tenant-A events. Cross-tenant messages are filtered by the
59
- * subscription boundary (own-tenant queue only).
60
- * AC#3 Positive control: a tenant-A device emits and a SECOND tenant-A
61
- * device DOES receive it — proving the machinery is alive and the
62
- * negative assertions are load-bearing, not trivially-passing.
63
- * AC#4 Meta: the BLOCKING `cross-tenant-isolation` CI job in
64
- * .github/workflows/ci.yml still references THIS test file. A future
65
- * PR that silently drops the gate fails the very test it tried to
66
- * skip.
67
- *
68
- * Fake timers (no real 60s wait)
69
- * ───────────────────────────────
70
- * The PRD says "over a simulated 60s window". We use `vi.useFakeTimers()`
71
- * and `vi.advanceTimersByTimeAsync(60_000)` so the 60s observation window
72
- * elapses instantly. The receivers' fanout delivery is synchronous on
73
- * publish; the SQS poll loop uses an injected fake `sqs` + injected fast
74
- * `sleep`, so nothing in the test depends on wall-clock. For the negative
75
- * assertion ("nothing reached tenant B") there is no event to wait FOR —
76
- * absence is proven by advancing through the whole window and asserting the
77
- * tenant-B counters stayed at zero.
78
- */
79
-
80
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
81
- import { fileURLToPath } from "node:url";
82
- import { readFile as fsReadFile } from "node:fs/promises";
83
- import path from "node:path";
84
-
85
- import {
86
- StaticFlagProvider,
87
- PushEventEmitter,
88
- encodePushEvent,
89
- type PushEvent,
90
- type PushTransport,
91
- } from "../../../src/index.js";
92
- // The US-009 receiver/fanout seams are surfaced via the sync barrel (they are
93
- // intentionally not part of the top-level public package surface yet — the
94
- // production SQS path ships behind a noop default). Import them from the same
95
- // barrel the rest of the sync code uses.
96
- import {
97
- InMemoryFanout,
98
- InMemoryPushReceiver,
99
- SqsPushReceiver,
100
- type PushReceiverContext,
101
- type SqsClientLike,
102
- type SqsMessageLike,
103
- } from "../../../src/sync/index.js";
104
-
105
- // ─── Test doubles ────────────────────────────────────────────────────────────
106
-
107
- const ZERO_HASH =
108
- "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
109
-
110
- /**
111
- * A tenant-scoped {@link PushTransport} that ships an emitted PushEvent into
112
- * exactly ONE tenant's fanout — and ONLY when the event's `originTenantId`
113
- * matches this transport's bound tenant. This models the server's
114
- * topic-per-tenant boundary plus its cross-tenant-origin rejection: a device
115
- * physically cannot publish onto a foreign tenant's fanout, and a forged
116
- * `originTenantId` is dropped (recorded as `rejectedCount` for assertions).
117
- */
118
- class TenantScopedTransport implements PushTransport {
119
- private _connected = false;
120
- publishedCount = 0;
121
- rejectedCount = 0;
122
-
123
- constructor(
124
- private readonly tenantId: string,
125
- private readonly fanout: InMemoryFanout,
126
- ) {}
127
-
128
- get connected(): boolean {
129
- return this._connected;
130
- }
131
-
132
- async start(): Promise<void> {
133
- this._connected = true;
134
- }
135
-
136
- async publish(event: PushEvent): Promise<void> {
137
- // Server-side topic-per-tenant + cross-tenant-origin rejection, modeled
138
- // client-side: a forged originTenantId never lands on any fanout.
139
- if (event.originTenantId !== this.tenantId) {
140
- this.rejectedCount += 1;
141
- throw new Error(
142
- `TenantScopedTransport(${this.tenantId}): refusing cross-tenant ` +
143
- `originTenantId=${event.originTenantId}`,
144
- );
145
- }
146
- this.publishedCount += 1;
147
- this.fanout.publish(encodePushEvent(event));
148
- }
149
-
150
- async dispose(): Promise<void> {
151
- this._connected = false;
152
- }
153
- }
154
-
155
- /**
156
- * A fake {@link SqsClientLike} backed by an in-memory per-queue buffer. Models
157
- * the per-client SQS queue: `receiveMessage` drains from this queue ONLY
158
- * (never any other tenant's). `seed` pre-loads a queue. `receiveMessage`
159
- * resolves promptly on abort so `dispose()` never blocks. No real AWS.
160
- */
161
- class FakeSqs implements SqsClientLike {
162
- /** queueUrl → message buffer */
163
- private readonly queues = new Map<string, SqsMessageLike[]>();
164
- /** queueUrls this client was ever asked to receive from (assertion hook). */
165
- readonly polledQueueUrls = new Set<string>();
166
-
167
- seed(queueUrl: string, bodies: string[]): void {
168
- const buf = this.queues.get(queueUrl) ?? [];
169
- for (const body of bodies) {
170
- buf.push({ Body: body, ReceiptHandle: `rh-${buf.length}-${Math.random()}` });
171
- }
172
- this.queues.set(queueUrl, buf);
173
- }
174
-
175
- async receiveMessage(args: {
176
- queueUrl: string;
177
- maxMessages: number;
178
- waitTimeSeconds: number;
179
- signal: AbortSignal;
180
- }): Promise<{ messages: SqsMessageLike[] }> {
181
- this.polledQueueUrls.add(args.queueUrl);
182
- if (args.signal.aborted) return { messages: [] };
183
- const buf = this.queues.get(args.queueUrl) ?? [];
184
- const taken = buf.splice(0, args.maxMessages);
185
- if (taken.length > 0) return { messages: taken };
186
- // Real SQS long-poll posture: when the queue is empty, BLOCK until the
187
- // abort signal fires (dispose) rather than busy-returning empty arrays.
188
- // This keeps the receiver's poll loop from hot-spinning the event loop —
189
- // exactly what a real `waitTimeSeconds` long-poll does — so the test
190
- // observes "B drained nothing" without starving the runtime.
191
- await new Promise<void>((resolve) => {
192
- if (args.signal.aborted) return resolve();
193
- args.signal.addEventListener("abort", () => resolve(), { once: true });
194
- });
195
- return { messages: [] };
196
- }
197
-
198
- async deleteMessage(args: {
199
- queueUrl: string;
200
- receiptHandle: string;
201
- }): Promise<void> {
202
- const buf = this.queues.get(args.queueUrl);
203
- if (!buf) return;
204
- const idx = buf.findIndex((m) => m.ReceiptHandle === args.receiptHandle);
205
- if (idx >= 0) buf.splice(idx, 1);
206
- }
207
- }
208
-
209
- /** Build a well-formed PushEvent for a given tenant/device/path. */
210
- function makeEvent(opts: {
211
- tenantId: string;
212
- deviceId: string;
213
- relativePath: string;
214
- sequenceNumber: number;
215
- }): PushEvent {
216
- return {
217
- relativePath: opts.relativePath,
218
- kind: "upsert",
219
- contentHash: ZERO_HASH,
220
- mtime: "2026-05-21T12:00:00.000Z",
221
- originDeviceId: opts.deviceId,
222
- originTenantId: opts.tenantId,
223
- sequenceNumber: opts.sequenceNumber,
224
- eventTimestamp: "2026-05-21T12:00:00.000Z",
225
- };
226
- }
227
-
228
- /** A syncFn that records each relativePath it was asked to pull. */
229
- function recordingSyncFn(): {
230
- fn: (ctx: PushReceiverContext) => Promise<void>;
231
- pulledPaths: string[];
232
- } {
233
- const pulledPaths: string[] = [];
234
- return {
235
- pulledPaths,
236
- fn: async (ctx: PushReceiverContext) => {
237
- pulledPaths.push(ctx.event.relativePath);
238
- },
239
- };
240
- }
241
-
242
- // ─── Constants ───────────────────────────────────────────────────────────────
243
-
244
- const TENANT_A = "tenant-us010-A";
245
- const TENANT_B = "tenant-us010-B";
246
- const TENANT_A_SECRET_PATH = "companies/tenantA/notes/secret.md";
247
- /** PRD-specified observation window. Elapsed via fake timers, never real. */
248
- const OBSERVATION_WINDOW_MS = 60_000;
249
-
250
- // Track every disposable so a single failed assertion can't leak listeners.
251
- const disposers: Array<() => Promise<void> | void> = [];
252
-
253
- beforeEach(() => {
254
- disposers.length = 0;
255
- vi.useFakeTimers();
256
- });
257
-
258
- afterEach(async () => {
259
- // Run real timers for teardown so dispose() drains aren't stuck behind fakes.
260
- vi.useRealTimers();
261
- while (disposers.length > 0) {
262
- const d = disposers.pop();
263
- if (!d) continue;
264
- try {
265
- await d();
266
- } catch {
267
- /* dispose is best-effort + idempotent */
268
- }
269
- }
270
- });
271
-
272
- // ─── Tests ─────────────────────────────────────────────────────────────────
273
-
274
- describe("US-010: cross-tenant isolation (BLOCKING — failure is P0)", () => {
275
- it(
276
- "AC#1+AC#3 — tenant A device saves a file; over a simulated 60s window " +
277
- "NO tenant-B device observes a PushEvent via push, while a second " +
278
- "tenant-A device DOES (positive control proving the wiring is live)",
279
- async () => {
280
- // Two tenants, each with its own fanout (its SNS topic → per-client SQS).
281
- const fanoutA = new InMemoryFanout();
282
- const fanoutB = new InMemoryFanout();
283
-
284
- // Both tenants are flag-enabled so dormancy can't mask a leak: if the
285
- // pipeline is fully ON for BOTH tenants and tenant B STILL sees nothing,
286
- // the isolation boundary — not an OFF flag — is what's holding.
287
- const flagProvider = new StaticFlagProvider([TENANT_A, TENANT_B]);
288
-
289
- // ── Tenant A: device A1 ships through a tenant-A-scoped transport ──
290
- const transportA = new TenantScopedTransport(TENANT_A, fanoutA);
291
- await transportA.start();
292
- const emitterA1 = new PushEventEmitter({
293
- originTenantId: TENANT_A,
294
- originDeviceId: "device-A1",
295
- transport: transportA,
296
- flagProvider,
297
- });
298
-
299
- // ── Tenant A: device A2 receives from tenant A's fanout (control) ──
300
- const a2Sync = recordingSyncFn();
301
- const receiverA2 = new InMemoryPushReceiver({
302
- tenantId: TENANT_A,
303
- fanout: fanoutA,
304
- syncFn: a2Sync.fn,
305
- flagProvider,
306
- });
307
- disposers.push(() => receiverA2.dispose());
308
- await receiverA2.start();
309
-
310
- // ── Tenant B: BOTH devices subscribe ONLY to tenant B's fanout ──
311
- const b1Sync = recordingSyncFn();
312
- const receiverB1 = new InMemoryPushReceiver({
313
- tenantId: TENANT_B,
314
- fanout: fanoutB,
315
- syncFn: b1Sync.fn,
316
- flagProvider,
317
- });
318
- disposers.push(() => receiverB1.dispose());
319
- await receiverB1.start();
320
-
321
- const b2Sync = recordingSyncFn();
322
- const receiverB2 = new InMemoryPushReceiver({
323
- tenantId: TENANT_B,
324
- fanout: fanoutB,
325
- syncFn: b2Sync.fn,
326
- flagProvider,
327
- });
328
- disposers.push(() => receiverB2.dispose());
329
- await receiverB2.start();
330
-
331
- // ── Act: tenant A's device A1 "saves" the file → emits a PushEvent ──
332
- // emitForBatch with a single changed path. The batch maps absolutePath →
333
- // relativePath; PushEventEmitter hashes/stats the file, so we drive the
334
- // emit through the transport directly via a hand-built event to keep the
335
- // test filesystem-free while still exercising the real transport+receiver
336
- // path that carries the originTenantId.
337
- const eventA = makeEvent({
338
- tenantId: TENANT_A,
339
- deviceId: "device-A1",
340
- relativePath: TENANT_A_SECRET_PATH,
341
- sequenceNumber: 1,
342
- });
343
- // Sanity: the emitter is enabled for tenant A (not dormant).
344
- expect(emitterA1.enabled).toBe(true);
345
- await transportA.publish(eventA);
346
-
347
- // ── Advance through the FULL simulated 60s observation window ──
348
- await vi.advanceTimersByTimeAsync(OBSERVATION_WINDOW_MS);
349
- // Flush any microtask-scheduled receiver dispatch.
350
- await Promise.resolve();
351
-
352
- // ── AC#3 positive control: tenant-A device A2 DID receive A1's file ──
353
- expect(a2Sync.pulledPaths).toEqual([TENANT_A_SECRET_PATH]);
354
- expect(receiverA2.processedCount).toBe(1);
355
- expect(transportA.publishedCount).toBe(1);
356
- expect(transportA.rejectedCount).toBe(0);
357
-
358
- // ── AC#1: NO tenant-B device observed ANYTHING over the 60s window ──
359
- expect(b1Sync.pulledPaths).toEqual([]);
360
- expect(b2Sync.pulledPaths).toEqual([]);
361
- expect(receiverB1.processedCount).toBe(0);
362
- expect(receiverB2.processedCount).toBe(0);
363
- expect(receiverB1.dedupedCount).toBe(0);
364
- expect(receiverB2.dedupedCount).toBe(0);
365
- // No tenant-A bytes ever entered tenant B's fanout: a B receiver that
366
- // saw a decode/dispatch would have advanced one of these counters.
367
- expect(receiverB1.decodeFailureCount).toBe(0);
368
- expect(receiverB2.decodeFailureCount).toBe(0);
369
- },
370
- 20_000,
371
- );
372
-
373
- it(
374
- "AC#1 (mechanical) — a tenant-A device CANNOT publish onto tenant B: a " +
375
- "tenant-B-scoped transport rejects a tenant-A-origin event, and tenant " +
376
- "B's fanout stays empty",
377
- async () => {
378
- const fanoutB = new InMemoryFanout();
379
- const transportB = new TenantScopedTransport(TENANT_B, fanoutB);
380
- await transportB.start();
381
- disposers.push(() => transportB.dispose());
382
-
383
- const bReceived: string[] = [];
384
- fanoutB.subscribe((raw) => bReceived.push(raw));
385
-
386
- // A forged tenant-A event handed to tenant B's transport MUST be rejected
387
- // — the transport refuses to route a foreign originTenantId onto B's
388
- // fanout. This is the client-side analogue of the server's
389
- // cross-tenant-push-rejected 403.
390
- const forged = makeEvent({
391
- tenantId: TENANT_A,
392
- deviceId: "device-A1",
393
- relativePath: TENANT_A_SECRET_PATH,
394
- sequenceNumber: 1,
395
- });
396
- await expect(transportB.publish(forged)).rejects.toThrow(
397
- /cross-tenant/i,
398
- );
399
-
400
- expect(transportB.rejectedCount).toBe(1);
401
- expect(transportB.publishedCount).toBe(0);
402
- // Nothing reached tenant B's fanout.
403
- expect(bReceived).toEqual([]);
404
- },
405
- 20_000,
406
- );
407
-
408
- it(
409
- "AC#2 — tenant B's SQS receiver, polling ITS OWN queue, never PULLS a " +
410
- "tenant-A file even when tenant A's queue is full of A's messages " +
411
- "(receiver subscribes only to its own-tenant queue)",
412
- async () => {
413
- // This test exercises the real SqsPushReceiver poll loop, which is an
414
- // async loop driven off the (now long-poll-blocking) FakeSqs — not off
415
- // host timers. We run it under REAL timers so the loop's promises settle
416
- // naturally; the FakeSqs blocks on empty so there is no busy-spin.
417
- vi.useRealTimers();
418
- // A fast injected sleep keeps any backoff path from waiting real time.
419
- const fastSleep = async (): Promise<void> => Promise.resolve();
420
-
421
- const sqs = new FakeSqs();
422
- const queueUrlA = "https://sqs.local/queue/tenant-us010-A";
423
- const queueUrlB = "https://sqs.local/queue/tenant-us010-B";
424
-
425
- // Tenant A's queue is FULL of tenant-A messages. Tenant B's queue is
426
- // EMPTY. If isolation held only by content-filtering (it must not), a
427
- // bug that polled the wrong queue would surface here.
428
- sqs.seed(queueUrlA, [
429
- encodePushEvent(
430
- makeEvent({
431
- tenantId: TENANT_A,
432
- deviceId: "device-A1",
433
- relativePath: TENANT_A_SECRET_PATH,
434
- sequenceNumber: 1,
435
- }),
436
- ),
437
- encodePushEvent(
438
- makeEvent({
439
- tenantId: TENANT_A,
440
- deviceId: "device-A2",
441
- relativePath: "companies/tenantA/notes/other.md",
442
- sequenceNumber: 2,
443
- }),
444
- ),
445
- ]);
446
-
447
- const bSync = recordingSyncFn();
448
- const receiverB = new SqsPushReceiver({
449
- tenantId: TENANT_B,
450
- queueUrl: queueUrlB, // ← B subscribes ONLY to its own queue
451
- sqs,
452
- syncFn: bSync.fn,
453
- enabled: true,
454
- waitTimeSeconds: 0,
455
- sleep: fastSleep,
456
- });
457
- disposers.push(() => receiverB.dispose());
458
-
459
- await receiverB.start();
460
-
461
- // Let the poll loop run a real iteration against B's (empty) queue. The
462
- // first receiveMessage drains B's empty buffer then blocks on abort, so
463
- // a short real delay is enough to prove B never pulled anything; the
464
- // FakeSqs has no tenant-A queue exposure to B at all.
465
- await new Promise((r) => setTimeout(r, 50));
466
-
467
- // Tenant B pulled NOTHING — its queue was empty and it never touched A's.
468
- expect(bSync.pulledPaths).toEqual([]);
469
- expect(receiverB.processedCount).toBe(0);
470
- // The receiver only ever polled tenant B's own queue URL.
471
- expect(sqs.polledQueueUrls.has(queueUrlB)).toBe(true);
472
- expect(sqs.polledQueueUrls.has(queueUrlA)).toBe(false);
473
-
474
- // Dispose under real timers (afterEach switches back), drains cleanly.
475
- },
476
- 20_000,
477
- );
478
-
479
- it(
480
- "AC#4 — the BLOCKING `cross-tenant-isolation` CI job references THIS test " +
481
- "file; a PR that silently drops the gate fails the test it tried to skip",
482
- async () => {
483
- const here = fileURLToPath(import.meta.url);
484
- // <repo>/test/e2e/sync/cross-tenant-isolation.test.ts → up 4 to <repo>.
485
- const repoRoot = path.resolve(path.dirname(here), "..", "..", "..");
486
- const workflowText = await fsReadFile(
487
- path.join(repoRoot, ".github", "workflows", "ci.yml"),
488
- "utf8",
489
- );
490
-
491
- // The explicit job id the PRD asks for (job name `cross-tenant-isolation`).
492
- expect(workflowText).toContain("cross-tenant-isolation:");
493
- // The job must run THIS file by path.
494
- expect(workflowText).toContain(
495
- "test/e2e/sync/cross-tenant-isolation.test.ts",
496
- );
497
- // The gate must run on PRs — pin the YAML key form so a comment-only
498
- // mention can't satisfy the assertion.
499
- expect(workflowText).toContain("pull_request:");
500
- },
501
- 5_000,
502
- );
503
- });
504
-
505
- // ---------------------------------------------------------------------------
506
- // Phase 3 extension (US-018) — receive path with VENDED credentials
507
- // ---------------------------------------------------------------------------
508
- //
509
- // US-016 moved queue access from "client holds no way to read" to "subscribe
510
- // vends per-device STS creds scoped to exactly one queue ARN". The server-
511
- // side policy-document tests pin the IAM scoping; THIS extension pins the
512
- // client half of the same invariant: the startEventSync wiring polls ONLY
513
- // the queue URL its own subscribe returned — a tenant-B (or peer-device)
514
- // queue is never touched, and a same-tenant peer's events flow while the
515
- // device's own echoes are filtered.
516
-
517
- import {
518
- startEventSync,
519
- type SubscribeSyncResponse,
520
- } from "../../../src/sync/event-sync.js";
521
- import type {
522
- SqsClientLike,
523
- PushReceiverContext,
524
- } from "../../../src/sync/push-receiver.js";
525
- import type { PushEvent } from "../../../src/sync/push-event.js";
526
-
527
- describe("US-018: receive path with vended credentials (isolation extension)", () => {
528
- function vendedResponse(tenant: string, device: string): SubscribeSyncResponse {
529
- return {
530
- queueUrl: `https://sqs.us-east-1.amazonaws.com/000000000000/sync-push-${tenant}-${device}`,
531
- region: "us-east-1",
532
- credentials: {
533
- accessKeyId: `AKIA-${tenant}-${device}`,
534
- secretAccessKey: "s",
535
- sessionToken: "t",
536
- expiration: "2099-01-01T00:00:00.000Z",
537
- },
538
- };
539
- }
540
-
541
- function phase3Event(overrides: Partial<PushEvent>): PushEvent {
542
- return {
543
- relativePath: "companies/indigo/docs/x.md",
544
- kind: "upsert",
545
- contentHash: `sha256:${"b".repeat(64)}`,
546
- mtime: "2026-06-10T12:00:00.000Z",
547
- originDeviceId: "peer",
548
- originTenantId: "prs_A",
549
- sequenceNumber: 10,
550
- eventTimestamp: "2026-06-10T12:00:00.000Z",
551
- ...overrides,
552
- };
553
- }
554
-
555
- it("polls ONLY the queue URL its own subscribe returned, with its own vended creds", async () => {
556
- // The suite-level beforeEach arms fake timers for the simulated-60s
557
- // tests; this extension drives a REAL async poll loop (vi.waitFor +
558
- // receiver microtasks), so switch back. afterEach re-reals anyway.
559
- vi.useRealTimers();
560
- const polledQueues = new Set<string>();
561
- const credsUsed = new Set<string>();
562
- let deliver: ((body: string) => void) | undefined;
563
-
564
- const pulled: PushEvent[] = [];
565
- const handles = await startEventSync({
566
- hqRoot: "/tmp/hq-a1",
567
- apiUrl: "https://api.example.com",
568
- authToken: "jwt-A1",
569
- deviceId: "devA1",
570
- resolveTenantId: async () => "prs_A",
571
- syncFn: async (ctx: PushReceiverContext) => {
572
- pulled.push(ctx.event);
573
- },
574
- subscribe: async () => vendedResponse("prs_A", "devA1"),
575
- buildSqs: (resp): SqsClientLike => {
576
- credsUsed.add(resp.credentials.accessKeyId);
577
- return {
578
- receiveMessage: ({ queueUrl, signal }) =>
579
- new Promise((resolve) => {
580
- polledQueues.add(queueUrl);
581
- signal.addEventListener(
582
- "abort",
583
- () => resolve({ messages: [] }),
584
- { once: true },
585
- );
586
- deliver = (body: string) => {
587
- deliver = undefined;
588
- resolve({ messages: [{ Body: body, ReceiptHandle: "rh" }] });
589
- };
590
- }),
591
- deleteMessage: async () => {},
592
- };
593
- },
594
- log: () => {},
595
- });
596
- expect(handles).not.toBeNull();
597
-
598
- await vi.waitFor(() => {
599
- if (!deliver) throw new Error("not polling yet");
600
- });
601
-
602
- // The ONLY queue ever polled is this device's own vended queue, with this
603
- // device's own vended credentials — the structural mirror of the server
604
- // session policy (single queue ARN, nothing else).
605
- expect([...polledQueues]).toEqual([
606
- "https://sqs.us-east-1.amazonaws.com/000000000000/sync-push-prs_A-devA1",
607
- ]);
608
- expect([...credsUsed]).toEqual(["AKIA-prs_A-devA1"]);
609
-
610
- // A same-tenant PEER event is bridged into the targeted pull…
611
- deliver!(
612
- JSON.stringify(phase3Event({ originDeviceId: "devA2", sequenceNumber: 11 })),
613
- );
614
- await vi.waitFor(() => expect(pulled).toHaveLength(1));
615
- expect(pulled[0].originDeviceId).toBe("devA2");
616
-
617
- // …while the device's OWN echo is filtered before the sync engine.
618
- await vi.waitFor(() => {
619
- if (!deliver) throw new Error("not polling again yet");
620
- });
621
- deliver!(
622
- JSON.stringify(phase3Event({ originDeviceId: "devA1", sequenceNumber: 12 })),
623
- );
624
- // Give the loop a beat — the echo must NOT add a pull.
625
- await new Promise((r) => setTimeout(r, 20));
626
- expect(pulled).toHaveLength(1);
627
-
628
- await handles!.dispose();
629
- });
630
- });