@indigoai-us/hq-cloud 6.15.0 → 6.15.1

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 +14 -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 +64 -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 +27 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +52 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +31 -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 +82 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +221 -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,1694 +0,0 @@
1
- /**
2
- * VaultClient — typed SDK for vault-service membership operations (VLT-7 US-001).
3
- *
4
- * Wraps vault-service HTTP API with shared auth, retry, and typed errors.
5
- * Colocated with hq-cloud so /invite, /promote, /accept and future commands
6
- * share one client instead of each rolling its own HTTP layer.
7
- */
8
-
9
- import type { ClientInfo, VaultServiceConfig } from "./types.js";
10
- import { buildClientHeaders } from "./client-info.js";
11
- import { emitCloudTelemetry } from "./telemetry-events.js";
12
- import { z } from "zod";
13
- import { accessTokenFingerprint } from "./cognito-auth.js";
14
-
15
- // ---------------------------------------------------------------------------
16
- // Error classes
17
- // ---------------------------------------------------------------------------
18
-
19
- export class VaultClientError extends Error {
20
- constructor(
21
- message: string,
22
- public readonly statusCode: number,
23
- public readonly body?: string,
24
- ) {
25
- super(message);
26
- this.name = "VaultClientError";
27
- }
28
- }
29
-
30
- export class VaultAuthError extends VaultClientError {
31
- constructor(
32
- message = "Authentication failed — session expired or invalid",
33
- public readonly tokenFingerprint?: string,
34
- ) {
35
- super(message, 401);
36
- this.name = "VaultAuthError";
37
- }
38
- }
39
-
40
- export class VaultPermissionDeniedError extends VaultClientError {
41
- constructor(message = "Permission denied — admin role required") {
42
- super(message, 403);
43
- this.name = "VaultPermissionDeniedError";
44
- }
45
- }
46
-
47
- export class VaultNotFoundError extends VaultClientError {
48
- constructor(message = "Resource not found") {
49
- super(message, 404);
50
- this.name = "VaultNotFoundError";
51
- }
52
- }
53
-
54
- export class VaultConflictError extends VaultClientError {
55
- constructor(message = "Conflict — resource already exists or was already accepted") {
56
- super(message, 409);
57
- this.name = "VaultConflictError";
58
- }
59
- }
60
-
61
- // ---------------------------------------------------------------------------
62
- // Types
63
- // ---------------------------------------------------------------------------
64
-
65
- export type MembershipRole = "owner" | "admin" | "member" | "guest";
66
- export type MembershipStatus = "pending" | "active" | "revoked";
67
-
68
- export interface Membership {
69
- membershipKey: string;
70
- personUid: string;
71
- companyUid: string;
72
- role: MembershipRole;
73
- status: MembershipStatus;
74
- allowedPrefixes?: string[];
75
- inviteToken?: string;
76
- invitedBy: string;
77
- invitedAt: string;
78
- acceptedAt?: string;
79
- revokedAt?: string;
80
- createdAt: string;
81
- updatedAt: string;
82
- }
83
-
84
- export interface CreateInviteInput {
85
- personUid?: string;
86
- inviteeEmail?: string;
87
- companyUid: string;
88
- role: MembershipRole;
89
- allowedPrefixes?: string[];
90
- invitedBy: string;
91
- }
92
-
93
- export interface CreateInviteResult {
94
- membership: Membership;
95
- inviteToken: string;
96
- }
97
-
98
- export interface AcceptInviteResult {
99
- membership: Membership;
100
- }
101
-
102
- export interface UpdateRoleInput {
103
- membershipKey: string;
104
- newRole: MembershipRole;
105
- allowedPrefixes?: string[];
106
- updaterUid: string;
107
- /** Required so the server can authorize the caller as admin/owner of the company. */
108
- companyUid: string;
109
- }
110
-
111
- export interface EntityInfo {
112
- uid: string;
113
- slug: string;
114
- type: string;
115
- /** Human-readable display name — surfaced in UIs that list companies. */
116
- name?: string;
117
- bucketName?: string;
118
- status: string;
119
- createdAt: string;
120
- }
121
-
122
- /**
123
- * Pick the caller's OWN entity for the `--personal` slot when the runner is an
124
- * agent machine identity (username `machine-agt_*`, idToken
125
- * `custom:entityType=agent` / `custom:entityUid=agt_*`).
126
- *
127
- * The person-only `pickCanonicalPersonEntity` filters `type === "person"`, so
128
- * an agent's own entity (`type: "agent"`) is dropped and `--personal` for an
129
- * agent emits `setup-needed` — never reaching the STS vend / S3 read (the
130
- * US-004 hard-gate finding). This selector resolves the agent's own entity by
131
- * matching `selfUid` (the `custom:entityUid` claim) against the self-listing
132
- * the agent sees, so the personal slot resolves to `hq-vault-agt-<uid>`.
133
- *
134
- * Returns null when no entity matches the claimed self-uid (the caller then
135
- * falls back to the person-only pick / `setup-needed`, exactly as before).
136
- */
137
- export function pickAgentSelfEntity(
138
- list: EntityInfo[],
139
- selfUid: string,
140
- ): EntityInfo | null {
141
- if (!selfUid) return null;
142
- const own = list.find((e) => e.uid === selfUid && e.type === "agent");
143
- return own ?? null;
144
- }
145
-
146
- export function pickCanonicalPersonEntity(
147
- list: EntityInfo[],
148
- ): EntityInfo | null {
149
- // Defensive filter — callers today pass `entity.listByType("person")` so this is
150
- // a no-op, but a future caller passing a mixed list would otherwise silently get
151
- // back a non-person entity.
152
- const persons = list.filter((e) => e.type === "person");
153
- if (persons.length === 0) return null;
154
- const sorted = [...persons].sort((a, b) => {
155
- const ac = (a.createdAt as string | undefined) ?? "";
156
- const bc = (b.createdAt as string | undefined) ?? "";
157
- if (ac !== bc) return ac < bc ? -1 : 1;
158
- return a.uid < b.uid ? -1 : 1;
159
- });
160
- return sorted[0];
161
- }
162
-
163
- export interface PendingInviteByEmail {
164
- membershipKey: string;
165
- companyUid: string;
166
- role: MembershipRole;
167
- inviteToken?: string;
168
- invitedBy: string;
169
- invitedAt: string;
170
- }
171
-
172
- export interface CreateEntityInput {
173
- type: "person" | "company";
174
- slug: string;
175
- name: string;
176
- email?: string;
177
- ownerUid?: string;
178
- }
179
-
180
- export interface CreateEntityResult {
181
- entity: EntityInfo;
182
- }
183
-
184
- // -- Browse-vs-sync types (US-002, US-003, US-004) -----------------------
185
-
186
- /**
187
- * Source kind for an explicit per-company file-ACL grant. Mirrors the
188
- * server enum in hq-pro `vault-service/handlers/files-grants.ts`.
189
- *
190
- * `'open'` collapses two server-side shapes that are indistinguishable to
191
- * the caller — the legacy `acl.open === true` floor and an explicit
192
- * `granteeType: 'company-wide'` row. Both mean "every active member of
193
- * this company sees this prefix".
194
- */
195
- export type GrantSource = "creator" | "person" | "email" | "group" | "open";
196
-
197
- /** Permission level surfaced on a grant row. Matches `AclPermission`. */
198
- export type GrantPermission = "read" | "write" | "admin";
199
-
200
- /**
201
- * One row in the response of `GET /v1/files/grants?company={uid}`.
202
- *
203
- * Role-bypass (owner/admin) entries are intentionally excluded by the
204
- * server — this is the caller's EXPLICIT grant graph, not the full set
205
- * of prefixes they can touch by virtue of role.
206
- */
207
- export interface ExplicitGrant {
208
- companyUid: string;
209
- path: string;
210
- permission: GrantPermission;
211
- source: GrantSource;
212
- }
213
-
214
- /** Presign operation: download / upload / delete. */
215
- export type PresignOp = "get" | "put" | "delete";
216
-
217
- /** One object's metadata as returned by GET /v1/files/list. */
218
- export interface VaultListedObject {
219
- key: string;
220
- size: number;
221
- lastModified: string | null;
222
- /**
223
- * S3 ETag (quotes stripped) or null. Load-bearing for sync change-
224
- * detection: the client stores it as remote-content identity and compares
225
- * it to decide pull/skip/delete-safety — the same role `RemoteFile.etag`
226
- * plays on the STS path. Mirrors the hq-pro `files/list` field (PR #269).
227
- */
228
- etag: string | null;
229
- permission: GrantPermission;
230
- }
231
-
232
- /** One key in a batch presign request. */
233
- export interface PresignKeyInput {
234
- key: string;
235
- op?: PresignOp;
236
- contentType?: string;
237
- /** Custom object metadata to sign into a PUT (x-amz-meta-*). */
238
- metadata?: Record<string, string>;
239
- /**
240
- * Conditional-write fence for a PUT presign (S3 conditional writes). When
241
- * the server supports it (hq-pro files-presign follow-up), it signs
242
- * `If-Match: "<etag>"` / `If-None-Match: *` into the URL and echoes the
243
- * header in the result row's `headers` for verbatim replay. Servers that
244
- * predate the field ignore it — the PUT stays unconditional, never broken.
245
- */
246
- ifMatch?: string;
247
- ifNoneMatch?: "*";
248
- }
249
-
250
- /** One result row from POST /v1/files/presign (per key, request order). */
251
- export interface PresignResultRow {
252
- key: string;
253
- op: PresignOp;
254
- /** Present on success: the presigned URL. */
255
- url?: string;
256
- /**
257
- * Present on a PUT success: the EXACT headers to send on the PUT so the
258
- * SigV4 signature matches (Content-Type, SSE-KMS, every x-amz-meta-*).
259
- */
260
- headers?: Record<string, string>;
261
- expiresIn?: number;
262
- expiresAt?: string;
263
- /** Present on per-key denial/validation failure. */
264
- error?: string;
265
- code?: string;
266
- }
267
-
268
- /**
269
- * Effective sync mode for a single membership. Mirrors the server's
270
- * resolved view from `GET /v1/memberships/{id}/sync-config`:
271
- *
272
- * - `shared` — sync only `shared/` and the caller's `personal/` prefix
273
- * - `all` — sync every prefix the caller has read access to
274
- * - `custom` — sync the explicit `customPaths` list (server validates)
275
- *
276
- * `isDefault: true` means no row exists in DDB and the server is
277
- * falling back to its built-in default (currently `'all'` for legacy
278
- * memberships created pre-US-003). When `true`, `updatedAt`/`updatedBy`
279
- * are absent because there's no row to attribute.
280
- */
281
- export type SyncMode = "shared" | "all" | "custom";
282
-
283
- export interface MembershipSyncConfig {
284
- membershipId: string;
285
- syncMode: SyncMode;
286
- customPaths?: string[];
287
- /**
288
- * `true` when the server returned the built-in default because no
289
- * sync-config row exists for this membership. PUT always returns
290
- * `false` — writing the row is what makes it non-default.
291
- */
292
- isDefault: boolean;
293
- /** Present only when a sync-config row exists (i.e. `isDefault: false`). */
294
- updatedAt?: string;
295
- /** Present only when a sync-config row exists. PersonUid of the writer. */
296
- updatedBy?: string;
297
- }
298
-
299
- /**
300
- * Input shape for {@link VaultClient.setMembershipSyncConfig}. The server
301
- * validates the combination — `customPaths` is required when `syncMode`
302
- * is `'custom'` and rejected otherwise.
303
- */
304
- export interface SetMembershipSyncConfigInput {
305
- syncMode: SyncMode;
306
- customPaths?: string[];
307
- }
308
-
309
- // -- Raw vend (legacy POST /vend, purpose-aware after US-009) -------------
310
-
311
- /**
312
- * Why the caller is requesting STS-scoped credentials. Mirrors the
313
- * hq-pro vault-service enum (`src/vault-service/policy-builder.ts`).
314
- *
315
- * - `'sync'` — background machine sync. Role-bypass MUST NOT widen
316
- * the path set: credentials are scoped to exactly the requested
317
- * paths (which the sync engine has already narrowed via US-005).
318
- * - `'browse'` — interactive exploration (hq-console Explore,
319
- * `hq files browse`, admin spelunking). Admin/owner role-bypass
320
- * APPLIES — the caller may receive credentials covering paths
321
- * beyond their explicit ACL grants.
322
- *
323
- * The server defaults missing/empty to `'sync'` (the safer choice).
324
- * The client doesn't mirror that default — every caller should be
325
- * explicit about its intent so audit rows are accurate.
326
- */
327
- export type VendPurpose = "sync" | "browse";
328
-
329
- export type VaultOperation = "read-only" | "read-write" | "staged-write";
330
-
331
- /**
332
- * Input shape for {@link VaultClient.vend}. The server validates
333
- * combinations — e.g. `purpose: 'sync'` rejects bucket-wide `'*'` paths
334
- * as defense in depth against role-bypass widening on the sync path.
335
- */
336
- export interface VendInput {
337
- paths: string[];
338
- operations: VaultOperation;
339
- /** Why these credentials are being vended. See {@link VendPurpose}. */
340
- purpose: VendPurpose;
341
- /** STS session lifetime in seconds. Server default is 900 (15m). */
342
- duration?: number;
343
- }
344
-
345
- export interface VendCredentials {
346
- accessKeyId: string;
347
- secretAccessKey: string;
348
- sessionToken: string;
349
- /** ISO-8601 STS-native expiration string. */
350
- expiration: string;
351
- }
352
-
353
- export interface VendResult {
354
- credentials: VendCredentials;
355
- /** Echo of the server-resolved paths after ACL intersection. */
356
- paths: string[];
357
- operations: VaultOperation;
358
- /** Echo of the effective purpose (server-defaulted to 'sync' if absent). */
359
- purpose: VendPurpose;
360
- /**
361
- * Size of the rendered IAM session policy in characters. Lets the
362
- * caller detect when it's nearing the 2048-char IAM ceiling so it can
363
- * fan out across multiple vends or shrink its path set.
364
- */
365
- policySize: number;
366
- requestId?: string;
367
- }
368
-
369
- // -- STS child vending (VLT-8) --------------------------------------------
370
-
371
- export type TaskAction = "read" | "write";
372
-
373
- export interface TaskScope {
374
- /** S3 key prefixes the child may access (e.g. ["drafts/"]). */
375
- allowedPrefixes: string[];
376
- /** Defaults to ["read", "write"]. Use ["read"] for read-only children. */
377
- allowedActions?: TaskAction[];
378
- }
379
-
380
- export interface VendChildInput {
381
- companyUid: string;
382
- /** ULID generated by the parent task. Flows into STS session name for audit. */
383
- taskId: string;
384
- /** Short human-readable description (<256 chars). Logged alongside the session. */
385
- taskDescription: string;
386
- taskScope: TaskScope;
387
- /**
388
- * Child session duration in seconds. Defaults to 900 on the server — AWS STS
389
- * AssumeRole enforces a 900s floor. The task-scoped policy is the security
390
- * boundary, not the duration.
391
- */
392
- durationSeconds?: number;
393
- }
394
-
395
- export interface StsChildCredentials {
396
- accessKeyId: string;
397
- secretAccessKey: string;
398
- sessionToken: string;
399
- }
400
-
401
- export interface VendChildResult {
402
- credentials: StsChildCredentials;
403
- /** STS session name: `${parentPersonUid}--task--${taskId}` — used in CloudTrail.
404
- * (Dash-separated because AWS STS `roleSessionName` disallows colons.) */
405
- sessionName: string;
406
- /** ISO-8601 session expiration. */
407
- expiresAt: string;
408
- }
409
-
410
- // ---------------------------------------------------------------------------
411
- // Usage telemetry (hq-pro `/v1/usage` + `/v1/usage/opt-in`)
412
- // ---------------------------------------------------------------------------
413
-
414
- export interface TelemetryOptInResponse {
415
- enabled: boolean;
416
- updatedAt: string | null;
417
- /**
418
- * `true` when the person row carries NO `telemetryOptIn` attribute — i.e. the
419
- * consent question has never been answered server-side. Distinct from
420
- * `enabled: false`, which is a deliberate opt-OUT.
421
- *
422
- * Optional because older servers omit it entirely. Absent is treated as
423
- * `false`, so a client talking to one behaves exactly as before — no
424
- * self-heal, no surprise writes.
425
- */
426
- unset?: boolean;
427
- /**
428
- * The `prs_*` uid this answer belongs to — i.e. the authenticated caller.
429
- *
430
- * Needed because the local consent cache is a per-MACHINE file: if two people
431
- * sign in under the same OS user it holds whoever answered last. A client
432
- * must not replay it for a different account. Optional (older servers omit
433
- * it), and absence means the replay cannot be proven safe, so it is skipped.
434
- */
435
- personUid?: string;
436
- }
437
-
438
- export interface UsageBatch {
439
- machineId: string;
440
- installerVersion: string;
441
- /**
442
- * Sanitized event rows. Each row is a plain object containing only the
443
- * fields in the server's KEEP allowlist (sessionId, timestamp, uuid, cwd,
444
- * gitBranch, userType, model, inputTokens, outputTokens,
445
- * cacheCreationInputTokens, cacheReadInputTokens, and the optional
446
- * companyUid edge-attribution field — US-002). Any extra field is
447
- * rejected by hq-pro with `unexpected-event-field`, so the sanitizer in
448
- * `./telemetry.ts` is the only thing allowed to produce these.
449
- */
450
- events: Array<Record<string, unknown>>;
451
- }
452
-
453
- export interface UsageIngestResult {
454
- ok: boolean;
455
- written: number;
456
- skipped: Array<{ index: number; code: string; error: string }>;
457
- }
458
-
459
- // ---------------------------------------------------------------------------
460
- // Cloud action telemetry (hq-pro `/v1/telemetry/events`)
461
- // ---------------------------------------------------------------------------
462
-
463
- export interface RawTelemetryEventInput {
464
- eventName: string;
465
- app: "hq-cloud";
466
- source: string;
467
- occurredAt: string;
468
- companyUid?: string;
469
- agentUid?: string;
470
- sessionId?: string;
471
- schemaVersion?: 1;
472
- properties?: Record<string, unknown>;
473
- }
474
-
475
- export interface TelemetryEventsBatch {
476
- /**
477
- * Action events emitted by sync/CLI surfaces. `personUid` MUST NOT appear in
478
- * either the batch or any event — hq-pro resolves the caller server-side from
479
- * the Cognito JWT and rejects client-supplied person identifiers.
480
- */
481
- events: RawTelemetryEventInput[];
482
- }
483
-
484
- /**
485
- * Normalized response shape for `POST /v1/telemetry/events`.
486
- *
487
- * hq-pro response contract v1 uses the established ingest fields (`ok`,
488
- * `written`, `skipped`). The decoder also accepts the briefly deployed
489
- * `{ accepted, deduped }` shape so clients remain compatible while the server
490
- * rollout catches up.
491
- */
492
- export type TelemetryEventsIngestResult = UsageIngestResult;
493
-
494
- // ---------------------------------------------------------------------------
495
- // Skill-invocation telemetry (hq-pro `/v1/skill-invocations`)
496
- // ---------------------------------------------------------------------------
497
-
498
- export interface SkillInvocationBatch {
499
- machineId: string;
500
- installerVersion: string;
501
- /**
502
- * Skill-invocation event rows. Each row contains only the fields in the
503
- * server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
504
- * hasArgs, the optional companyUid edge-attribution field — US-002, and the
505
- * optional skillVersion content-hash marker — US-015). Raw argument text is
506
- * never included — see the privacy note in `./skill-telemetry.ts`. Any extra
507
- * field is rejected by hq-pro with `unexpected-event-field`, so the extractor
508
- * in `./skill-telemetry.ts` is the only thing allowed to produce these.
509
- */
510
- events: Array<Record<string, unknown>>;
511
- }
512
-
513
- /** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
514
- export type SkillInvocationIngestResult = UsageIngestResult;
515
-
516
- // ---------------------------------------------------------------------------
517
- // Outcome events (hq-pro `/v1/outcome-events`)
518
- // ---------------------------------------------------------------------------
519
-
520
- export interface OutcomeEventsBatch {
521
- /**
522
- * Outcome-event rows. Each row is a plain object containing only the fields
523
- * in the server's KEEP allowlist (type, occurredAt, companyUid, repo, branch,
524
- * dedupeKey, and the type-specific refs projectName / storyId). Unlike the
525
- * usage/skill batch, this batch carries NO machineId/installerVersion — the
526
- * ingest handler (`apps/hq-pro/src/vault-service/handlers/outcome-events.ts`)
527
- * accepts only `{ events }`. `personUid` MUST NOT appear on the batch or any
528
- * event — hq-pro resolves the caller server-side from the Cognito JWT and
529
- * REJECTS a body-supplied person identifier. Any field outside the allowlist
530
- * is rejected with a 4xx `invalid-event`, so the emitter in
531
- * `./outcome-telemetry.ts` is the only thing allowed to produce these.
532
- */
533
- events: Array<Record<string, unknown>>;
534
- }
535
-
536
- export interface OutcomeEventsIngestResult {
537
- ok: boolean;
538
- /** Rows newly written this request. */
539
- written: number;
540
- /** Rows collapsed by the server-side conditional put (idempotent replay). */
541
- deduped: number;
542
- }
543
-
544
- // ---------------------------------------------------------------------------
545
- // Retry config
546
- // ---------------------------------------------------------------------------
547
-
548
- const MAX_RETRIES = 3;
549
- const BASE_DELAY_MS = 500;
550
-
551
- interface VaultRequestOptions {
552
- timeoutMs?: number;
553
- maxRetries?: number;
554
- }
555
-
556
- function isTransient(status: number): boolean {
557
- return status === 429 || status >= 500;
558
- }
559
-
560
- async function sleep(ms: number): Promise<void> {
561
- return new Promise((resolve) => setTimeout(resolve, ms));
562
- }
563
-
564
- // ---------------------------------------------------------------------------
565
- // Response schemas
566
- // ---------------------------------------------------------------------------
567
-
568
- type VaultResponseSchema<T> = z.ZodType<T>;
569
-
570
- const membershipRoleSchema = z.enum(["owner", "admin", "member", "guest"]);
571
- const membershipStatusSchema = z.enum(["pending", "active", "revoked"]);
572
- const grantPermissionSchema = z.enum(["read", "write", "admin"]);
573
- const grantSourceSchema = z.enum(["creator", "person", "email", "group", "open"]);
574
- const presignOpSchema = z.enum(["get", "put", "delete"]);
575
- const syncModeSchema = z.enum(["shared", "all", "custom"]);
576
- const vendPurposeSchema = z.enum(["sync", "browse"]);
577
- const vaultOperationSchema = z.enum(["read-only", "read-write", "staged-write"]);
578
-
579
- // Membership/entity rows are intentionally permissive on field optionality:
580
- // current command tests and server flows use short rows outside sync decisions.
581
- const membershipSchema = z
582
- .object({
583
- membershipKey: z.string().optional(),
584
- personUid: z.string().optional(),
585
- companyUid: z.string().optional(),
586
- role: membershipRoleSchema.optional(),
587
- status: membershipStatusSchema.optional(),
588
- allowedPrefixes: z.array(z.string()).optional(),
589
- inviteToken: z.string().optional(),
590
- invitedBy: z.string().optional(),
591
- invitedAt: z.string().optional(),
592
- acceptedAt: z.string().optional(),
593
- revokedAt: z.string().optional(),
594
- createdAt: z.string().optional(),
595
- updatedAt: z.string().optional(),
596
- })
597
- .strip() as unknown as VaultResponseSchema<Membership>;
598
-
599
- const entityInfoSchema = z
600
- .object({
601
- uid: z.string(),
602
- slug: z.string(),
603
- type: z.string(),
604
- name: z.string().optional(),
605
- bucketName: z.string().optional(),
606
- status: z.string(),
607
- createdAt: z.string().optional(),
608
- })
609
- .strip() as unknown as VaultResponseSchema<EntityInfo>;
610
-
611
- const pendingInviteByEmailSchema: VaultResponseSchema<PendingInviteByEmail> = z
612
- .object({
613
- membershipKey: z.string(),
614
- companyUid: z.string(),
615
- role: membershipRoleSchema,
616
- inviteToken: z.string().optional(),
617
- invitedBy: z.string(),
618
- invitedAt: z.string(),
619
- })
620
- .strip();
621
-
622
- const explicitGrantSchema: VaultResponseSchema<ExplicitGrant> = z
623
- .object({
624
- companyUid: z.string(),
625
- path: z.string(),
626
- permission: grantPermissionSchema,
627
- source: grantSourceSchema,
628
- })
629
- .strip();
630
-
631
- const vaultListedObjectSchema: VaultResponseSchema<VaultListedObject> = z
632
- .object({
633
- key: z.string(),
634
- size: z.number(),
635
- lastModified: z.string().nullable(),
636
- etag: z.string().nullable(),
637
- permission: grantPermissionSchema,
638
- })
639
- .strip();
640
-
641
- const presignResultRowSchema: VaultResponseSchema<PresignResultRow> = z
642
- .object({
643
- key: z.string(),
644
- op: presignOpSchema,
645
- url: z.string().optional(),
646
- headers: z.record(z.string(), z.string()).optional(),
647
- expiresIn: z.number().optional(),
648
- expiresAt: z.string().optional(),
649
- error: z.string().optional(),
650
- code: z.string().optional(),
651
- })
652
- .strip();
653
-
654
- const membershipSyncConfigSchema: VaultResponseSchema<MembershipSyncConfig> = z
655
- .object({
656
- membershipId: z.string(),
657
- syncMode: syncModeSchema,
658
- customPaths: z.array(z.string()).optional(),
659
- isDefault: z.boolean(),
660
- updatedAt: z.string().optional(),
661
- updatedBy: z.string().optional(),
662
- })
663
- .strip();
664
-
665
- const vendCredentialsSchema: VaultResponseSchema<VendCredentials> = z
666
- .object({
667
- accessKeyId: z.string(),
668
- secretAccessKey: z.string(),
669
- sessionToken: z.string(),
670
- expiration: z.string(),
671
- })
672
- .strip();
673
-
674
- const stsCredentialsSchema: VaultResponseSchema<StsChildCredentials> = z
675
- .object({
676
- accessKeyId: z.string(),
677
- secretAccessKey: z.string(),
678
- sessionToken: z.string(),
679
- })
680
- .strip();
681
-
682
- const telemetryOptInResponseSchema: VaultResponseSchema<TelemetryOptInResponse> =
683
- z
684
- .object({
685
- enabled: z.boolean().default(false),
686
- updatedAt: z.string().nullable().default(null),
687
- personUid: z.string().optional(),
688
- // `.optional()`, NOT `.default(false)`: an older server omits this field,
689
- // and materializing it would change the parsed response shape for every
690
- // legacy caller. Callers test `unset === true`, so absent reads as
691
- // "not unset" and the self-heal path is skipped, which is the pre-existing
692
- // behaviour exactly.
693
- unset: z.boolean().optional(),
694
- })
695
- .strip();
696
-
697
- /**
698
- * `POST /v1/usage/opt-in` acknowledgement.
699
- *
700
- * `applied` is absent on older servers (which always wrote unconditionally), so
701
- * it stays optional and callers treat absence as "the write landed".
702
- */
703
- const telemetryOptInAckSchema: VaultResponseSchema<{ ok: boolean; applied?: boolean }> = z
704
- .object({ ok: z.boolean().default(true), applied: z.boolean().optional() })
705
- .strip();
706
-
707
- const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
708
- .object({
709
- ok: z.boolean(),
710
- written: z.number(),
711
- skipped: z.array(
712
- z
713
- .object({
714
- index: z.number(),
715
- code: z.string(),
716
- error: z.string(),
717
- })
718
- .strip(),
719
- ),
720
- })
721
- .strip();
722
-
723
- const outcomeEventsIngestResultSchema: VaultResponseSchema<OutcomeEventsIngestResult> = z
724
- .object({
725
- ok: z.boolean(),
726
- written: z.number().nonnegative(),
727
- // Older/partial servers may omit `deduped`; default to 0 so a 2xx that
728
- // reports only `written` still decodes (best-effort, matches usage rollout).
729
- deduped: z.number().nonnegative().optional().transform((v) => v ?? 0),
730
- })
731
- .strip() as unknown as VaultResponseSchema<OutcomeEventsIngestResult>;
732
-
733
- /**
734
- * Accept the canonical v1 response and the previous raw-event aliases, then
735
- * normalize at this boundary so telemetry remains best-effort during rollout.
736
- */
737
- const telemetryEventsIngestResultSchema: VaultResponseSchema<TelemetryEventsIngestResult> = z
738
- .object({
739
- contractVersion: z.literal(1).optional(),
740
- ok: z.boolean().optional(),
741
- written: z.number().nonnegative().optional(),
742
- skipped: z
743
- .array(
744
- z
745
- .object({
746
- index: z.number(),
747
- code: z.string(),
748
- error: z.string(),
749
- })
750
- .strip(),
751
- )
752
- .optional(),
753
- // Compatibility aliases emitted by hq-pro before response contract v1.
754
- accepted: z.number().nonnegative().optional(),
755
- deduped: z.number().nonnegative().optional(),
756
- })
757
- .refine(
758
- (value) =>
759
- value.ok !== undefined || value.written !== undefined || value.accepted !== undefined,
760
- { message: "telemetry ingest response has no recognized result fields" },
761
- )
762
- .transform((value) => ({
763
- ok: value.ok ?? true,
764
- written: value.written ?? value.accepted ?? 0,
765
- skipped: value.skipped ?? [],
766
- }));
767
-
768
- const createInviteResponseSchema: VaultResponseSchema<CreateInviteResult> = z
769
- .object({
770
- membership: membershipSchema,
771
- inviteToken: z.string(),
772
- })
773
- .strip();
774
-
775
- const acceptInviteResponseSchema: VaultResponseSchema<AcceptInviteResult> = z
776
- .object({
777
- membership: membershipSchema,
778
- })
779
- .strip();
780
-
781
- const membershipsResponseSchema: VaultResponseSchema<{ memberships: Membership[] }> =
782
- z
783
- .object({
784
- memberships: z.array(membershipSchema),
785
- })
786
- .strip();
787
-
788
- const pendingInvitesByEmailResponseSchema: VaultResponseSchema<{
789
- invites?: PendingInviteByEmail[];
790
- }> = z
791
- .object({
792
- invites: z.array(pendingInviteByEmailSchema).optional(),
793
- })
794
- .strip();
795
-
796
- const membersResponseSchema: VaultResponseSchema<{ members: Membership[] }> = z
797
- .object({
798
- members: z.array(membershipSchema),
799
- })
800
- .strip();
801
-
802
- const membershipResponseSchema: VaultResponseSchema<{ membership: Membership }> =
803
- z
804
- .object({
805
- membership: membershipSchema,
806
- })
807
- .strip();
808
-
809
- const invitesResponseSchema: VaultResponseSchema<{ invites: Membership[] }> = z
810
- .object({
811
- invites: z.array(membershipSchema),
812
- })
813
- .strip();
814
-
815
- const explicitGrantsResponseSchema: VaultResponseSchema<{
816
- grants?: ExplicitGrant[];
817
- computedAt: string;
818
- }> = z
819
- .object({
820
- grants: z.array(explicitGrantSchema).optional(),
821
- computedAt: z.string(),
822
- })
823
- .strip();
824
-
825
- const listFilesResponseSchema: VaultResponseSchema<{
826
- objects: VaultListedObject[];
827
- cursor: string | null;
828
- truncated: boolean;
829
- }> = z
830
- .object({
831
- objects: z.array(vaultListedObjectSchema).default([]),
832
- cursor: z.string().nullable().default(null),
833
- truncated: z.boolean().default(false),
834
- })
835
- .strip();
836
-
837
- const presignResponseSchema: VaultResponseSchema<{
838
- results: PresignResultRow[];
839
- expiresAt: string;
840
- }> = z
841
- .object({
842
- results: z.array(presignResultRowSchema),
843
- expiresAt: z.string(),
844
- })
845
- .strip();
846
-
847
- const entityResponseSchema: VaultResponseSchema<{ entity: EntityInfo }> = z
848
- .object({
849
- entity: entityInfoSchema,
850
- })
851
- .strip();
852
-
853
- const checkSlugMeResponseSchema: VaultResponseSchema<{
854
- available: boolean;
855
- conflictingCompanyUid?: string;
856
- }> = z
857
- .object({
858
- available: z.boolean(),
859
- conflictingCompanyUid: z.string().optional(),
860
- })
861
- .strip();
862
-
863
- const createEntityResponseSchema: VaultResponseSchema<CreateEntityResult> = z
864
- .object({
865
- entity: entityInfoSchema,
866
- })
867
- .strip();
868
-
869
- const entitiesResponseSchema: VaultResponseSchema<{ entities?: EntityInfo[] }> =
870
- z
871
- .object({
872
- entities: z.array(entityInfoSchema).optional(),
873
- })
874
- .strip();
875
-
876
- const provisionBucketResponseSchema: VaultResponseSchema<{
877
- bucketName: string;
878
- kmsKeyId: string;
879
- }> = z
880
- .object({
881
- bucketName: z.string(),
882
- kmsKeyId: z.string(),
883
- })
884
- .strip();
885
-
886
- const vendResponseSchema: VaultResponseSchema<VendResult> = z
887
- .object({
888
- credentials: vendCredentialsSchema,
889
- paths: z.array(z.string()),
890
- operations: vaultOperationSchema,
891
- purpose: vendPurposeSchema,
892
- policySize: z.number(),
893
- requestId: z.string().optional(),
894
- })
895
- .strip();
896
-
897
- const stsVendResponseSchema: VaultResponseSchema<{
898
- credentials: StsChildCredentials;
899
- expiresAt: string;
900
- }> = z
901
- .object({
902
- credentials: stsCredentialsSchema,
903
- expiresAt: z.string(),
904
- })
905
- .strip();
906
-
907
- const vendChildResponseSchema: VaultResponseSchema<VendChildResult> = z
908
- .object({
909
- credentials: stsCredentialsSchema,
910
- sessionName: z.string(),
911
- expiresAt: z.string(),
912
- })
913
- .strip();
914
-
915
- const emptyObjectResponseSchema = z.object({}).strip();
916
-
917
- function summarizeZodIssues(issues: readonly z.core.$ZodIssue[]): string {
918
- return issues
919
- .map((issue) => {
920
- const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
921
- return `${path}: ${issue.message}`;
922
- })
923
- .join("; ");
924
- }
925
-
926
- function companyUidFromMembershipId(membershipId: string): string | undefined {
927
- const parts = membershipId.split("#");
928
- const candidate = parts.length > 1 ? parts[parts.length - 1] : undefined;
929
- return candidate?.startsWith("cmp_") ? candidate : undefined;
930
- }
931
-
932
- // ---------------------------------------------------------------------------
933
- // VaultClient
934
- // ---------------------------------------------------------------------------
935
-
936
- export class VaultClient {
937
- private readonly apiUrl: string;
938
- private readonly getAuthToken: () => Promise<string>;
939
- private readonly clientInfo: ClientInfo | undefined;
940
-
941
- constructor(config: VaultServiceConfig) {
942
- this.apiUrl = config.apiUrl.replace(/\/+$/, "");
943
- // Normalize string|getter into a single async getter so the request path
944
- // doesn't have to branch. Static strings still work — they just produce a
945
- // getter that returns the same value forever (suitable for short-lived
946
- // tools and tests). Long-running callers pass a getter that re-reads
947
- // `~/.hq/cognito-tokens.json` via `getValidAccessToken`, which is what
948
- // makes the request layer self-heal across token refreshes.
949
- const tok = config.authToken;
950
- this.getAuthToken =
951
- typeof tok === "function"
952
- ? async () => tok()
953
- : async () => tok;
954
- this.clientInfo = config.clientInfo;
955
- }
956
-
957
- // -- Membership operations ------------------------------------------------
958
-
959
- async createInvite(input: CreateInviteInput): Promise<CreateInviteResult> {
960
- const data = await this.post(
961
- "/membership/invite",
962
- input,
963
- createInviteResponseSchema,
964
- );
965
- return data;
966
- }
967
-
968
- async acceptInvite(token: string, personUid: string): Promise<AcceptInviteResult> {
969
- const data = await this.post(
970
- "/membership/accept",
971
- { token, personUid },
972
- acceptInviteResponseSchema,
973
- );
974
- return data;
975
- }
976
-
977
- /**
978
- * Revoke a membership. The handler needs both the membershipKey AND the
979
- * companyUid so it can authorize the caller as admin/owner of the company
980
- * before performing the revoke. (We can't infer companyUid from the key
981
- * alone without an extra DDB read, and the caller already knows it.)
982
- */
983
- async revokeMembership(membershipKey: string, companyUid: string): Promise<void> {
984
- await this.post(
985
- "/membership/revoke",
986
- { membershipKey, companyUid },
987
- emptyObjectResponseSchema,
988
- );
989
- }
990
-
991
- /**
992
- * List the caller's own active memberships.
993
- *
994
- * Server infers the caller's identity from the Cognito JWT `sub` claim and
995
- * returns the union of active memberships across every person entity owned
996
- * by that sub (orphan-tolerant — prior failed provisioning runs can leave
997
- * multiple `prs_*` rows for the same Cognito identity).
998
- *
999
- * Returns `[]` — NOT a 404 — when the caller has no person entity yet.
1000
- * This lets `hq-sync-runner` distinguish "signed in but not bootstrapped"
1001
- * (empty array → emit `setup-needed`) from "auth broken" (throws
1002
- * VaultAuthError) without catching HTTP errors for flow control.
1003
- *
1004
- * Backed by `GET /membership/me` (see hq-pro ADR-0002).
1005
- */
1006
- async listMyMemberships(): Promise<Membership[]> {
1007
- const data = await this.get("/membership/me", membershipsResponseSchema);
1008
- return data.memberships;
1009
- }
1010
-
1011
- /**
1012
- * List the caller's email-keyed pending invites. Server reads the email
1013
- * from the Cognito JWT, so no parameters are needed client-side.
1014
- *
1015
- * Used on first sign-in (installer + sync-runner) to detect invites that
1016
- * were sent to the caller's email before they had a person entity. Pair
1017
- * with {@link claimPendingInvitesByEmail} to rewrite those rows once the
1018
- * person exists.
1019
- */
1020
- async listMyPendingInvitesByEmail(): Promise<PendingInviteByEmail[]> {
1021
- const data = await this.get(
1022
- "/membership/pending-by-email",
1023
- pendingInvitesByEmailResponseSchema,
1024
- );
1025
- return data.invites ?? [];
1026
- }
1027
-
1028
- /**
1029
- * Rewrite every email-keyed pending invite for the caller's email so it
1030
- * becomes personUid-keyed. Idempotent — zero-cost for returning users who
1031
- * have no pending invites. The caller's email is inferred from the JWT.
1032
- */
1033
- async claimPendingInvitesByEmail(personUid: string): Promise<void> {
1034
- await this.post(
1035
- "/membership/claim-by-email",
1036
- { personUid },
1037
- emptyObjectResponseSchema,
1038
- );
1039
- }
1040
-
1041
- async listMembersOfCompany(companyUid: string): Promise<Membership[]> {
1042
- const data = await this.get(
1043
- `/membership/company/${encodeURIComponent(companyUid)}`,
1044
- membersResponseSchema,
1045
- );
1046
- return data.members;
1047
- }
1048
-
1049
- async updateRole(input: UpdateRoleInput): Promise<Membership> {
1050
- const data = await this.post(
1051
- "/membership/role",
1052
- input,
1053
- membershipResponseSchema,
1054
- );
1055
- return data.membership;
1056
- }
1057
-
1058
- async listPendingInvites(companyUid: string): Promise<Membership[]> {
1059
- const data = await this.get(
1060
- `/membership/company/${encodeURIComponent(companyUid)}/pending`,
1061
- invitesResponseSchema,
1062
- );
1063
- return data.invites;
1064
- }
1065
-
1066
- // -- Browse-vs-sync (US-002, US-003, US-004) -----------------------------
1067
-
1068
- /**
1069
- * List the caller's EXPLICIT per-company file-ACL grants. Backed by
1070
- * `GET /v1/files/grants?company={companyUid}` (hq-pro US-002).
1071
- *
1072
- * Role-bypass (owner/admin) entries are excluded server-side — the
1073
- * response is the caller's actual grant graph, not the full set of
1074
- * prefixes they can touch by virtue of role. Used by the
1075
- * browse-vs-sync UI to render an honest grant graph and by the
1076
- * sync engine to narrow what it pulls.
1077
- *
1078
- * Returns `[]` (NOT a 404) when the caller has no explicit grants in
1079
- * this company, so call sites can treat "empty graph" as a normal
1080
- * state without catching errors.
1081
- */
1082
- async listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]> {
1083
- const data = await this.get(
1084
- `/v1/files/grants?company=${encodeURIComponent(companyUid)}`,
1085
- explicitGrantsResponseSchema,
1086
- );
1087
- return data.grants ?? [];
1088
- }
1089
-
1090
- // -- Presigned-URL transport (vault list + presign) ----------------------
1091
-
1092
- /**
1093
- * ACL-filtered list of objects under `prefix`. Backed by
1094
- * `GET /v1/files/list?company=&prefix=&cursor=`. Returns only the keys the
1095
- * caller can read, each with metadata (size, lastModified, permission), plus
1096
- * an opaque `cursor` for the next page (null when exhausted). Page the cursor
1097
- * until it is null.
1098
- */
1099
- async listFiles(
1100
- companyUid: string,
1101
- prefix?: string,
1102
- cursor?: string,
1103
- ): Promise<{
1104
- objects: VaultListedObject[];
1105
- cursor: string | null;
1106
- truncated: boolean;
1107
- }> {
1108
- const qs = new URLSearchParams({ company: companyUid });
1109
- if (prefix) qs.set("prefix", prefix);
1110
- if (cursor) qs.set("cursor", cursor);
1111
- const data = await this.get(
1112
- `/v1/files/list?${qs.toString()}`,
1113
- listFilesResponseSchema,
1114
- );
1115
- return {
1116
- objects: data.objects,
1117
- cursor: data.cursor,
1118
- truncated: data.truncated,
1119
- };
1120
- }
1121
-
1122
- /**
1123
- * Batch-mint presigned get/put/delete URLs. Backed by
1124
- * `POST /v1/files/presign`. Authorization is PER KEY — denied/invalid keys
1125
- * come back as `results[i].error` (the call itself succeeds), so callers must
1126
- * inspect each row. PUT rows carry `headers` the client must replay verbatim.
1127
- */
1128
- async presign(input: {
1129
- companyUid: string;
1130
- op?: PresignOp;
1131
- expiresIn?: number;
1132
- keys: PresignKeyInput[];
1133
- }): Promise<{ results: PresignResultRow[]; expiresAt: string }> {
1134
- return this.post(
1135
- `/v1/files/presign`,
1136
- {
1137
- company: input.companyUid,
1138
- ...(input.op ? { op: input.op } : {}),
1139
- ...(input.expiresIn ? { expiresIn: input.expiresIn } : {}),
1140
- keys: input.keys,
1141
- },
1142
- presignResponseSchema,
1143
- );
1144
- }
1145
-
1146
- /**
1147
- * Read the effective sync-mode for a single membership. Backed by
1148
- * `GET /v1/memberships/{id}/sync-config` (hq-pro US-003).
1149
- *
1150
- * The server resolves the effective view — when no row exists for the
1151
- * membership it returns the built-in default with `isDefault: true`
1152
- * and omits `updatedAt`/`updatedBy`. Callers should treat `isDefault:
1153
- * true` as "no explicit config yet" rather than special-casing 404.
1154
- *
1155
- * Authorization: caller must own the membership OR hold admin/owner
1156
- * on the company that the membership belongs to. The server 404s
1157
- * tombstoned/revoked memberships.
1158
- */
1159
- async getMembershipSyncConfig(
1160
- membershipId: string,
1161
- ): Promise<MembershipSyncConfig> {
1162
- return this.get(
1163
- `/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
1164
- membershipSyncConfigSchema,
1165
- );
1166
- }
1167
-
1168
- /**
1169
- * Write the sync-mode for a single membership. Backed by
1170
- * `PUT /v1/memberships/{id}/sync-config` (hq-pro US-003).
1171
- *
1172
- * Server validates: `customPaths` is required when `syncMode` is
1173
- * `'custom'` and rejected otherwise. The returned row reflects the
1174
- * persisted state with `isDefault: false` (writing the row is what
1175
- * makes it non-default) and the server-assigned `updatedAt` +
1176
- * `updatedBy`.
1177
- */
1178
- async setMembershipSyncConfig(
1179
- membershipId: string,
1180
- partial: SetMembershipSyncConfigInput,
1181
- ): Promise<MembershipSyncConfig> {
1182
- const companyUid = companyUidFromMembershipId(membershipId);
1183
- try {
1184
- const result = await this.request(
1185
- "PUT",
1186
- `/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
1187
- partial,
1188
- membershipSyncConfigSchema,
1189
- );
1190
- void emitCloudTelemetry(this, {
1191
- eventName: "membership_sync_config_changed",
1192
- source: "vault-client",
1193
- ...(companyUid ? { companyUid } : {}),
1194
- properties: {
1195
- syncMode: result.syncMode,
1196
- customPathCount: result.customPaths?.length ?? 0,
1197
- result: "success",
1198
- defaultState: result.isDefault ? "default" : "configured",
1199
- },
1200
- });
1201
- return result;
1202
- } catch (err) {
1203
- void emitCloudTelemetry(this, {
1204
- eventName: "membership_sync_config_changed",
1205
- source: "vault-client",
1206
- ...(companyUid ? { companyUid } : {}),
1207
- properties: {
1208
- syncMode: partial.syncMode,
1209
- customPathCount: partial.customPaths?.length ?? 0,
1210
- result: "failure",
1211
- },
1212
- });
1213
- throw err;
1214
- }
1215
- }
1216
-
1217
- // -- Entity operations ----------------------------------------------------
1218
-
1219
- readonly entity = {
1220
- get: async (uid: string): Promise<EntityInfo> => {
1221
- const data = await this.get(
1222
- `/entity/${encodeURIComponent(uid)}`,
1223
- entityResponseSchema,
1224
- );
1225
- return data.entity;
1226
- },
1227
-
1228
- /**
1229
- * Legacy global slug lookup. Under the per-user-namespace model on
1230
- * hq-pro (PR indigoai-us/hq-pro#67, live in prod 2026-05-15) the
1231
- * server-side handler now uses `requireUnique: true` — this method
1232
- * returns a single entity when only one tenant holds the slug, 404s
1233
- * when nobody does, or 409s with `SlugNotUniqueError` and a list of
1234
- * colliding `uids` when more than one tenant holds it. Most CLI
1235
- * call sites have moved to `findInMyNamespace` (which respects the
1236
- * caller's effective namespace); only flows that genuinely want a
1237
- * global lookup (admin tooling) should still use this method.
1238
- */
1239
- findBySlug: async (type: string, slug: string): Promise<EntityInfo> => {
1240
- const data = await this.get(
1241
- `/entity/by-slug/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`,
1242
- entityResponseSchema,
1243
- );
1244
- return data.entity;
1245
- },
1246
-
1247
- /**
1248
- * Resolve an entity by slug within the CALLER's namespace
1249
- * (owned ∪ active-member-of, soft-deleted excluded). Hits the new
1250
- * `GET /entity/check-slug/me?type=&slug=` endpoint added in PR 67.
1251
- *
1252
- * Returns the full entity when present in the caller's namespace,
1253
- * or `null` when the slug isn't theirs — even if some OTHER user
1254
- * happens to own a company with the same slug. This is what every
1255
- * "find my-company by slug" flow wants under the per-user model;
1256
- * `findBySlug`'s global semantic would over-match (return a
1257
- * stranger's entity) or 409 (multi-tenant slug) in those cases.
1258
- */
1259
- findInMyNamespace: async (
1260
- type: string,
1261
- slug: string,
1262
- ): Promise<EntityInfo | null> => {
1263
- const check = await this.get(
1264
- `/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`,
1265
- checkSlugMeResponseSchema,
1266
- );
1267
- if (check.available || !check.conflictingCompanyUid) return null;
1268
- return this.entity.get(check.conflictingCompanyUid);
1269
- },
1270
-
1271
- create: async (input: CreateEntityInput): Promise<EntityInfo> => {
1272
- const data = await this.post("/entity", input, createEntityResponseSchema);
1273
- return data.entity;
1274
- },
1275
-
1276
- /** Return every entity of `type` owned by the caller (scoped by JWT). */
1277
- listByType: async (type: string): Promise<EntityInfo[]> => {
1278
- const data = await this.get(
1279
- `/entity/by-type/${encodeURIComponent(type)}`,
1280
- entitiesResponseSchema,
1281
- );
1282
- return data.entities ?? [];
1283
- },
1284
- };
1285
-
1286
- // -- Identity bootstrap ---------------------------------------------------
1287
-
1288
- /**
1289
- * Return the caller's person entity, creating it if one does not exist.
1290
- *
1291
- * Mirrors the installer's `ensurePersonEntity` bootstrap (`vault-handoff.ts`):
1292
- * pre-condition for {@link claimPendingInvitesByEmail}, which needs a
1293
- * concrete `personUid` to rewrite the email-keyed rows against.
1294
- *
1295
- * The slug is derived from `displayName`; if slugification yields an empty
1296
- * string, falls back to `user-<last-8-of-ownerSub>` so the POST always has
1297
- * a non-empty slug.
1298
- */
1299
- async ensureMyPersonEntity(hints: {
1300
- ownerSub: string;
1301
- displayName: string;
1302
- }): Promise<EntityInfo> {
1303
- const existing = await this.entity.listByType("person");
1304
- const pick = pickCanonicalPersonEntity(existing);
1305
- if (pick !== null) return pick;
1306
-
1307
- const slug =
1308
- hints.displayName
1309
- .toLowerCase()
1310
- .replace(/[^a-z0-9]+/g, "-")
1311
- .replace(/^-+|-+$/g, "")
1312
- .slice(0, 63) || `user-${hints.ownerSub.slice(-8).toLowerCase()}`;
1313
-
1314
- return this.entity.create({
1315
- type: "person",
1316
- name: hints.displayName,
1317
- slug,
1318
- });
1319
- }
1320
-
1321
- // -- Provisioning operations (VLT-2) -----------------------------------------
1322
-
1323
- async provisionBucket(companyUid: string): Promise<{ bucketName: string; kmsKeyId: string }> {
1324
- const data = await this.post(
1325
- "/provision/bucket",
1326
- { companyUid },
1327
- provisionBucketResponseSchema,
1328
- );
1329
- return data;
1330
- }
1331
-
1332
- // -- Raw vend (POST /vend) ------------------------------------------------
1333
-
1334
- /**
1335
- * POST `/vend` — vend STS-scoped credentials for an explicit path list.
1336
- *
1337
- * This is the legacy raw-vend endpoint (distinct from `/sts/vend`,
1338
- * `/sts/vend-self`, and `/sts/vend-child`). Per US-009 it accepts a
1339
- * `purpose` discriminator that controls whether admin/owner
1340
- * role-bypass widens the resulting session policy beyond the
1341
- * caller's explicit ACL grants:
1342
- *
1343
- * - `purpose: 'browse'` — role-bypass APPLIES (interactive
1344
- * `hq files browse`, admin spelunking).
1345
- * - `purpose: 'sync'` — role-bypass SUPPRESSED (background sync;
1346
- * credentials are scoped to exactly what the caller has explicitly
1347
- * been granted, regardless of role).
1348
- *
1349
- * The server defaults missing/empty to `'sync'` but every first-party
1350
- * caller should be explicit so audit attribution is correct.
1351
- *
1352
- * Used by `hq files browse`/`hq files cat` (US-008) to peek at vault
1353
- * objects without ever materialising them under `companies/{co}/`.
1354
- */
1355
- async vend(input: VendInput): Promise<VendResult> {
1356
- return this.post("/vend", input, vendResponseSchema);
1357
- }
1358
-
1359
- // -- STS operations (VLT-8) -----------------------------------------------
1360
-
1361
- readonly sts = {
1362
- /**
1363
- * Vend membership-scoped credentials for a company the caller belongs to.
1364
- * Backed by the vault-service `POST /sts/vend` route — the multi-tenant
1365
- * path that resolves the company's per-entity bucket and builds the
1366
- * session policy from the caller's role + ACL grants server-side
1367
- * (owner/admin get full-access, member/guest get per-prefix scoping).
1368
- *
1369
- * This is the correct path for interactive reads (`hq files browse`/`cat`):
1370
- * the legacy `POST /vend` ({@link VaultClient.vend}) assumes a single
1371
- * static bucket and is non-functional in multi-tenant production.
1372
- */
1373
- vend: async (input: {
1374
- companyUid: string;
1375
- durationSeconds?: number;
1376
- }): Promise<{
1377
- credentials: {
1378
- accessKeyId: string;
1379
- secretAccessKey: string;
1380
- sessionToken: string;
1381
- };
1382
- expiresAt: string;
1383
- }> => {
1384
- return this.post("/sts/vend", input, stsVendResponseSchema);
1385
- },
1386
- /**
1387
- * Vend task-scoped child credentials strictly narrower than the caller's
1388
- * own membership. Backed by the vault-service `POST /sts/vend-child`
1389
- * route (kebab-case to match the rest of the vault-service API).
1390
- *
1391
- * The child policy is intersected with the caller's membership on the
1392
- * server — if `taskScope.allowedPrefixes` requests anything the parent
1393
- * can't see, the server throws ScopeExceedsParentError before calling STS.
1394
- *
1395
- * Session name format: `${parentPersonUid}--task--${taskId}` — this lands
1396
- * in CloudTrail verbatim, so every child S3 action can be traced back to
1397
- * the parent task for incident response.
1398
- */
1399
- vendChild: async (input: VendChildInput): Promise<VendChildResult> => {
1400
- const data = await this.post("/sts/vend-child", input, vendChildResponseSchema);
1401
- return data;
1402
- },
1403
- vendSelf: async (input: { personUid: string; durationSeconds?: number }): Promise<{
1404
- credentials: { accessKeyId: string; secretAccessKey: string; sessionToken: string };
1405
- expiresAt: string;
1406
- }> => {
1407
- return this.post("/sts/vend-self", input, stsVendResponseSchema);
1408
- },
1409
- };
1410
-
1411
- // -- Usage telemetry ------------------------------------------------------
1412
- //
1413
- // The server resolves `personUid` from the JWT (`extractCallerSub` →
1414
- // `resolveCallerPersonUid` in hq-pro `src/vault-service/handlers/_shared.ts`)
1415
- // and explicitly rejects any request body that carries a top-level
1416
- // `personUid`. So clients only send `{ machineId, installerVersion, events }`
1417
- // — tenant isolation is preserved no matter how this client is wrapped.
1418
-
1419
- /**
1420
- * `GET /v1/usage/opt-in` — read whether the authenticated caller has opted
1421
- * in to per-event usage telemetry. Defaults to `false` server-side when the
1422
- * person row carries no `telemetryOptIn` field. Callers should treat any
1423
- * thrown error as "unknown — fall back to the local gate" rather than
1424
- * either yes or no; see `./telemetry.ts::collectAndSendTelemetry`.
1425
- */
1426
- async getTelemetryOptIn(): Promise<TelemetryOptInResponse> {
1427
- return this.get("/v1/usage/opt-in", telemetryOptInResponseSchema);
1428
- }
1429
-
1430
- /**
1431
- * `POST /v1/usage/opt-in` — record the authenticated caller's consent.
1432
- *
1433
- * The installer is the primary writer (it owns the consent prompt). This
1434
- * client-side setter exists so the sync runner can RE-ASSERT a consent the
1435
- * user already gave locally but which never reached the server: the
1436
- * installer's write fires before the person entity exists and 404s on
1437
- * `no-person-entity`, so the answer survives only in `~/.hq/menubar.json`.
1438
- * See `./telemetry.ts::collectAndSendTelemetry`, which calls this ONLY when
1439
- * the server reports `unset` — never over an explicit opt-out.
1440
- *
1441
- * `onlyIfUnset` makes the server write conditional on the consent still never
1442
- * having been recorded. The self-heal MUST pass it: reading `unset` and
1443
- * replaying the answer are two separate requests, so without the condition
1444
- * another device could record a real opt-out in between and this replay would
1445
- * silently overwrite it. A deliberate user choice omits the flag so it always
1446
- * wins. The response's `applied` reports whether the write landed.
1447
- */
1448
- async setTelemetryOptIn(
1449
- enabled: boolean,
1450
- opts?: { onlyIfUnset?: boolean },
1451
- ): Promise<{ applied: boolean }> {
1452
- const ack = await this.post(
1453
- "/v1/usage/opt-in",
1454
- opts?.onlyIfUnset ? { enabled, onlyIfUnset: true } : { enabled },
1455
- telemetryOptInAckSchema,
1456
- );
1457
- // Older servers answer `{ ok: true }` with no `applied`; they also write
1458
- // unconditionally, so a 2xx there means the write did land.
1459
- return { applied: ack.applied ?? true };
1460
- }
1461
-
1462
- /**
1463
- * `POST /v1/usage` — upload a batch of sanitized telemetry events.
1464
- *
1465
- * `personUid` MUST NOT appear in the batch — server-side resolution from
1466
- * the JWT is the only path. The server caps the body at 256 KiB and the
1467
- * event list at 100 rows; the collector in `./telemetry.ts` enforces a
1468
- * 1 MiB pre-flush cap which is the binding limit in practice.
1469
- */
1470
- async postUsage(batch: UsageBatch): Promise<UsageIngestResult> {
1471
- return this.post("/v1/usage", batch, usageIngestResultSchema);
1472
- }
1473
-
1474
- /**
1475
- * `POST /v1/skill-invocations` — upload a batch of skill-invocation events.
1476
- *
1477
- * Same trust + size model as `postUsage`: `personUid` MUST NOT appear in the
1478
- * batch (server resolves it from the JWT). Gated by the same telemetry
1479
- * opt-in as `/v1/usage`.
1480
- */
1481
- async postSkillInvocations(
1482
- batch: SkillInvocationBatch,
1483
- ): Promise<SkillInvocationIngestResult> {
1484
- return this.post("/v1/skill-invocations", batch, usageIngestResultSchema);
1485
- }
1486
-
1487
- /**
1488
- * `POST /v1/outcome-events` — upload a batch of delivery-outcome events
1489
- * (story-completed / project-shipped, per outcome-leaderboard US-004).
1490
- *
1491
- * Same trust model as `postUsage`: `personUid` MUST NOT appear on the batch
1492
- * or any event — hq-pro resolves the caller from the JWT and rejects a
1493
- * body-supplied person identifier. Gated by the same telemetry opt-in as
1494
- * `/v1/usage`. Server-side conditional PutItem on the composite dedupeKey
1495
- * makes re-syncs and multi-machine replays idempotent (they count as
1496
- * `deduped`, never a double-write).
1497
- */
1498
- async postOutcomeEvents(
1499
- batch: OutcomeEventsBatch,
1500
- ): Promise<OutcomeEventsIngestResult> {
1501
- return this.post("/v1/outcome-events", batch, outcomeEventsIngestResultSchema);
1502
- }
1503
-
1504
- /**
1505
- * `POST /v1/telemetry/events` — upload a capped batch of hq-cloud ACTION
1506
- * events.
1507
- *
1508
- * Same trust model as `postUsage`: `personUid` MUST NOT appear in the body;
1509
- * hq-pro resolves the caller from the JWT and validates any `companyUid`
1510
- * against the caller's memberships. This endpoint is for server/CLI action
1511
- * telemetry and is not gated by the personal `/v1/usage` opt-in flag.
1512
- */
1513
- async postTelemetryEvents(
1514
- batch: TelemetryEventsBatch,
1515
- options: { timeoutMs?: number } = {},
1516
- ): Promise<TelemetryEventsIngestResult> {
1517
- return this.post("/v1/telemetry/events", batch, telemetryEventsIngestResultSchema, {
1518
- timeoutMs: options.timeoutMs ?? 1500,
1519
- maxRetries: 0,
1520
- });
1521
- }
1522
-
1523
- // -- HTTP primitives with retry -------------------------------------------
1524
-
1525
- private async get<T>(path: string, schema: VaultResponseSchema<T>): Promise<T> {
1526
- return this.request("GET", path, undefined, schema);
1527
- }
1528
-
1529
- private async post<T>(
1530
- path: string,
1531
- body: unknown | undefined,
1532
- schema: VaultResponseSchema<T>,
1533
- options?: VaultRequestOptions,
1534
- ): Promise<T> {
1535
- return this.request("POST", path, body, schema, options);
1536
- }
1537
-
1538
- private async request<T>(
1539
- method: string,
1540
- path: string,
1541
- body: unknown,
1542
- schema: VaultResponseSchema<T>,
1543
- options: VaultRequestOptions = {},
1544
- ): Promise<T> {
1545
- let lastError: Error | undefined;
1546
- const maxRetries = options.maxRetries ?? MAX_RETRIES;
1547
- // A 401 is not in isTransient() because most 401s are genuinely terminal.
1548
- // But one shape of 401 is fully recoverable: the access token expired
1549
- // between the caller resolving it and this request landing. Callers that
1550
- // pass a getter (getValidAccessToken) can mint a fresh token on demand, so
1551
- // we re-resolve once and retry IF the token actually changed. Static-string
1552
- // callers get the same value back and fall through to the throw, so this
1553
- // costs them nothing. Bounded to one attempt: a genuinely bad credential
1554
- // must not turn into a retry storm against the authorizer.
1555
- let authRetried = false;
1556
- // The auth retry gets its own budget rather than borrowing from the
1557
- // transient one. Sharing had two defects: a 401 early on left fewer
1558
- // retries for later 5xx, and a 401 on the FINAL attempt set the flag,
1559
- // hit `continue`, and fell out of the loop having never actually retried.
1560
- let maxAttempts = maxRetries;
1561
- // Backoff exists to let a rate limit or a struggling server recover.
1562
- // Neither applies to a token swap, so the auth retry goes out immediately.
1563
- let noBackoffAt = -1;
1564
-
1565
- for (let attempt = 0; attempt <= maxAttempts; attempt++) {
1566
- if (attempt > 0 && attempt !== noBackoffAt) {
1567
- const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
1568
- await sleep(delay);
1569
- }
1570
-
1571
- const authToken = await this.getAuthToken();
1572
- const headers: Record<string, string> = {
1573
- Authorization: `Bearer ${authToken}`,
1574
- Accept: "application/json",
1575
- ...buildClientHeaders(this.clientInfo),
1576
- };
1577
-
1578
- const init: RequestInit = { method, headers };
1579
- const controller =
1580
- options.timeoutMs !== undefined && options.timeoutMs > 0
1581
- ? new AbortController()
1582
- : undefined;
1583
- let timeout: ReturnType<typeof setTimeout> | undefined;
1584
- if (controller) {
1585
- timeout = setTimeout(() => controller.abort(), options.timeoutMs);
1586
- timeout.unref?.();
1587
- init.signal = controller.signal;
1588
- }
1589
-
1590
- if (body !== undefined) {
1591
- headers["Content-Type"] = "application/json";
1592
- init.body = JSON.stringify(body);
1593
- }
1594
-
1595
- let res: Response;
1596
- try {
1597
- res = await fetch(`${this.apiUrl}${path}`, init);
1598
- } catch (err) {
1599
- lastError = err instanceof Error ? err : new Error(String(err));
1600
- if (attempt < maxAttempts) continue;
1601
- throw lastError;
1602
- } finally {
1603
- if (timeout !== undefined) clearTimeout(timeout);
1604
- }
1605
-
1606
- if (res.ok) {
1607
- if (res.status === 204) return undefined as T;
1608
- const responseBody = await res.text();
1609
- let decoded: unknown;
1610
- try {
1611
- decoded = JSON.parse(responseBody);
1612
- } catch (err) {
1613
- throw new VaultClientError(
1614
- `Invalid JSON response from vault-service for ${method} ${path}`,
1615
- 502,
1616
- err instanceof Error ? err.message : responseBody,
1617
- );
1618
- }
1619
-
1620
- const parsed = schema.safeParse(decoded);
1621
- if (!parsed.success) {
1622
- throw new VaultClientError(
1623
- `Invalid response from vault-service for ${method} ${path}: ${summarizeZodIssues(parsed.error.issues)}`,
1624
- 502,
1625
- responseBody,
1626
- );
1627
- }
1628
-
1629
- return parsed.data;
1630
- }
1631
-
1632
- const responseBody = await res.text();
1633
-
1634
- // Expired-token 401 → re-resolve once and retry with the fresh token.
1635
- // Residual gap (deliberate): if the local cache still considers the token
1636
- // valid — clock skew, or a server-side revocation the client can't see —
1637
- // the getter returns the same string and we fall through to the throw.
1638
- // Fixing that needs a force-refresh hook on the token getter contract.
1639
- if (res.status === 401 && !authRetried) {
1640
- authRetried = true;
1641
- const refreshed = await this.getAuthToken().catch(() => undefined);
1642
- if (refreshed && refreshed !== authToken) {
1643
- lastError = this.mapError(res.status, responseBody, authToken);
1644
- maxAttempts++; // free retry — must not eat the transient budget
1645
- noBackoffAt = attempt + 1;
1646
- continue;
1647
- }
1648
- }
1649
-
1650
- // Non-retryable errors → throw immediately
1651
- if (!isTransient(res.status)) {
1652
- throw this.mapError(res.status, responseBody, authToken);
1653
- }
1654
-
1655
- // Retryable — store and loop
1656
- lastError = this.mapError(res.status, responseBody, authToken);
1657
- }
1658
-
1659
- throw lastError ?? new VaultClientError("Request failed after retries", 500);
1660
- }
1661
-
1662
- private mapError(
1663
- status: number,
1664
- body: string,
1665
- authToken?: string,
1666
- ): VaultClientError {
1667
- const message = this.extractMessage(body);
1668
-
1669
- switch (status) {
1670
- case 401:
1671
- return new VaultAuthError(
1672
- message,
1673
- authToken ? accessTokenFingerprint(authToken) : undefined,
1674
- );
1675
- case 403:
1676
- return new VaultPermissionDeniedError(message);
1677
- case 404:
1678
- return new VaultNotFoundError(message);
1679
- case 409:
1680
- return new VaultConflictError(message);
1681
- default:
1682
- return new VaultClientError(message || `Request failed with status ${status}`, status, body);
1683
- }
1684
- }
1685
-
1686
- private extractMessage(body: string): string {
1687
- try {
1688
- const parsed = JSON.parse(body) as { message?: string; error?: string };
1689
- return parsed.message ?? parsed.error ?? body;
1690
- } catch {
1691
- return body;
1692
- }
1693
- }
1694
- }