@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.
- package/dist/bin/sync-mutation.d.ts +14 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +64 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +52 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +31 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +82 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +221 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/vault-client.ts
DELETED
|
@@ -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
|
-
}
|