@indigoai-us/hq-cloud 6.15.0 → 6.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-mutation.d.ts +16 -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 +165 -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 +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -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 +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -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
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
coalescePrefixes,
|
|
4
|
-
isCoveredByAny,
|
|
5
|
-
grantPathToPrefix,
|
|
6
|
-
grantPathToScopePrefix,
|
|
7
|
-
toScopePrefixEntries,
|
|
8
|
-
} from "./prefix-coalesce.js";
|
|
9
|
-
|
|
10
|
-
describe("coalescePrefixes", () => {
|
|
11
|
-
it("returns empty for empty input", () => {
|
|
12
|
-
expect(coalescePrefixes([])).toEqual([]);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("dedupes identical inputs", () => {
|
|
16
|
-
expect(coalescePrefixes(["a/", "a/"])).toEqual(["a/"]);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("collapses nested prefixes into the broader one", () => {
|
|
20
|
-
expect(coalescePrefixes(["a/", "a/b/"])).toEqual(["a/"]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("collapses deeply nested chains", () => {
|
|
24
|
-
expect(coalescePrefixes(["a/", "a/b/", "a/b/c/", "a/b/c/d/"])).toEqual([
|
|
25
|
-
"a/",
|
|
26
|
-
]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("keeps sibling (overlapping but non-nested) prefixes", () => {
|
|
30
|
-
expect(coalescePrefixes(["a/b/", "a/c/"])).toEqual(["a/b/", "a/c/"]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("mixes nested + sibling correctly", () => {
|
|
34
|
-
expect(
|
|
35
|
-
coalescePrefixes([
|
|
36
|
-
"companies/indigo/meetings/",
|
|
37
|
-
"companies/indigo/meetings/2026/",
|
|
38
|
-
"companies/indigo/scratch/jacob/",
|
|
39
|
-
]),
|
|
40
|
-
).toEqual([
|
|
41
|
-
"companies/indigo/meetings/",
|
|
42
|
-
"companies/indigo/scratch/jacob/",
|
|
43
|
-
]);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("is case-sensitive (S3 keys are case-sensitive)", () => {
|
|
47
|
-
// 'A/' and 'a/' do not cover each other — both must be kept.
|
|
48
|
-
const result = coalescePrefixes(["A/", "a/"]);
|
|
49
|
-
expect(result.sort()).toEqual(["A/", "a/"]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("returns prefixes sorted lexicographically (deterministic for journal)", () => {
|
|
53
|
-
expect(coalescePrefixes(["c/", "a/", "b/"])).toEqual(["a/", "b/", "c/"]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("drops empty-string entries (would otherwise cover everything)", () => {
|
|
57
|
-
expect(coalescePrefixes(["", "a/", ""])).toEqual(["a/"]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("is a pure function — input array is not mutated", () => {
|
|
61
|
-
const input = ["a/b/", "a/"];
|
|
62
|
-
const snapshot = [...input];
|
|
63
|
-
coalescePrefixes(input);
|
|
64
|
-
expect(input).toEqual(snapshot);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("handles a single prefix unchanged", () => {
|
|
68
|
-
expect(coalescePrefixes(["companies/indigo/"])).toEqual([
|
|
69
|
-
"companies/indigo/",
|
|
70
|
-
]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("does not treat 'ab/' as nested under 'a/' lexically (literal startsWith)", () => {
|
|
74
|
-
// `'ab/'.startsWith('a/')` is FALSE — `a/` would have to be a true
|
|
75
|
-
// path-segment ancestor. (Callers should pass trailing-slash-bounded
|
|
76
|
-
// prefixes — the grants endpoint always does.)
|
|
77
|
-
expect(coalescePrefixes(["a/", "ab/"]).sort()).toEqual(["a/", "ab/"]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("F16: keeps exact-file scopes distinct from prefixed sibling keys", () => {
|
|
81
|
-
expect(
|
|
82
|
-
coalescePrefixes(["knowledge/README.md", "knowledge/README.md.bak"]),
|
|
83
|
-
).toEqual(["knowledge/README.md", "knowledge/README.md.bak"]);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe("isCoveredByAny", () => {
|
|
88
|
-
it("returns true when a prefix covers the path", () => {
|
|
89
|
-
expect(isCoveredByAny("a/b/c.md", ["a/"])).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("returns false when no prefix covers the path", () => {
|
|
93
|
-
expect(isCoveredByAny("a/b/c.md", ["x/", "y/"])).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("returns false on empty prefix set", () => {
|
|
97
|
-
expect(isCoveredByAny("a/b/c.md", [])).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("treats exact match as covered", () => {
|
|
101
|
-
expect(isCoveredByAny("a/b/", ["a/b/"])).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("is case-sensitive", () => {
|
|
105
|
-
expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("grantPathToPrefix", () => {
|
|
110
|
-
const slug = "indigo";
|
|
111
|
-
|
|
112
|
-
it("strips a companies/<slug>/ anchor", () => {
|
|
113
|
-
expect(grantPathToPrefix("companies/indigo/knowledge/README.md", slug)).toBe(
|
|
114
|
-
"knowledge/README.md",
|
|
115
|
-
);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("strips a bare <slug>/ anchor", () => {
|
|
119
|
-
expect(grantPathToPrefix("indigo/data/vyg/old-meetings/*", slug)).toBe(
|
|
120
|
-
"data/vyg/old-meetings/",
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("folds a trailing /* glob into a directory prefix", () => {
|
|
125
|
-
expect(grantPathToPrefix("data/vyg/*", slug)).toBe("data/vyg/");
|
|
126
|
-
expect(grantPathToPrefix("companies/indigo/design-pack/*", slug)).toBe(
|
|
127
|
-
"design-pack/",
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("folds a trailing bare * into its parent prefix", () => {
|
|
132
|
-
const prefix = grantPathToPrefix("reports*", slug);
|
|
133
|
-
expect(toScopePrefixEntries([prefix])).toEqual([
|
|
134
|
-
{ prefix: "reports", match: "prefix" },
|
|
135
|
-
]);
|
|
136
|
-
expect(isCoveredByAny("reports-q1.md", [prefix])).toBe(true);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("leaves a company-relative directory prefix unchanged", () => {
|
|
140
|
-
expect(grantPathToPrefix("knowledge/security/", slug)).toBe(
|
|
141
|
-
"knowledge/security/",
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("leaves a company-relative exact key unchanged", () => {
|
|
146
|
-
expect(grantPathToPrefix("company.yaml", slug)).toBe("company.yaml");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("maps a bare '*' (and empty) to '' = everything", () => {
|
|
150
|
-
expect(grantPathToPrefix("*", slug)).toBe("");
|
|
151
|
-
expect(grantPathToPrefix("", slug)).toBe("");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("tolerates leading slashes", () => {
|
|
155
|
-
expect(grantPathToPrefix("/companies/indigo/x/*", slug)).toBe("x/");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("does not strip a different company's anchor (defensive)", () => {
|
|
159
|
-
// A grant anchored at another slug is left intact (won't match this
|
|
160
|
-
// company's keys, which is the safe outcome).
|
|
161
|
-
expect(grantPathToPrefix("companies/other/x/*", slug)).toBe(
|
|
162
|
-
"companies/other/x/",
|
|
163
|
-
);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("round-trips through coalescePrefixes + isCoveredByAny against real keys", () => {
|
|
167
|
-
const grants = [
|
|
168
|
-
"companies/indigo/design-pack/*",
|
|
169
|
-
"companies/indigo/knowledge/README.md",
|
|
170
|
-
"data/vyg/*",
|
|
171
|
-
"company.yaml",
|
|
172
|
-
];
|
|
173
|
-
const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
|
|
174
|
-
expect(isCoveredByAny("design-pack/logo.svg", prefixes)).toBe(true);
|
|
175
|
-
expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
|
|
176
|
-
expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
|
|
177
|
-
expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
|
|
178
|
-
// Out of scope:
|
|
179
|
-
expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
|
|
180
|
-
expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("F16: exact-file grants do not authorize prefixed sibling keys", () => {
|
|
184
|
-
const grants = [
|
|
185
|
-
"companies/indigo/knowledge/README.md",
|
|
186
|
-
"company.yaml",
|
|
187
|
-
"data/vyg/*",
|
|
188
|
-
];
|
|
189
|
-
const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
|
|
190
|
-
|
|
191
|
-
expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
|
|
192
|
-
expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
|
|
193
|
-
expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
|
|
194
|
-
|
|
195
|
-
expect(isCoveredByAny("knowledge/README.md.bak", prefixes)).toBe(false);
|
|
196
|
-
expect(isCoveredByAny("company.yaml.tmp", prefixes)).toBe(false);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("R-F16: exact scope is deterministic after a prior bare-glob scope", () => {
|
|
200
|
-
expect(toScopePrefixEntries([grantPathToPrefix("companies/indigo/foo*", slug)])).toEqual([
|
|
201
|
-
{ prefix: "foo", match: "prefix" },
|
|
202
|
-
]);
|
|
203
|
-
|
|
204
|
-
const exactPrefixes = coalescePrefixes([
|
|
205
|
-
grantPathToPrefix("companies/other/foo", "other"),
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
expect(isCoveredByAny("foo", exactPrefixes)).toBe(true);
|
|
209
|
-
expect(isCoveredByAny("foo.bak", exactPrefixes)).toBe(false);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("RF-F16DUR: exact-vs-prefix survives array copy and JSON round-trip", () => {
|
|
213
|
-
const priorBareGlob = coalescePrefixes([
|
|
214
|
-
grantPathToScopePrefix("companies/indigo/foo*", slug),
|
|
215
|
-
]);
|
|
216
|
-
const copiedBareGlob = [...priorBareGlob];
|
|
217
|
-
const jsonBareGlob = JSON.parse(JSON.stringify(priorBareGlob)) as string[];
|
|
218
|
-
|
|
219
|
-
expect(jsonBareGlob).toEqual(priorBareGlob);
|
|
220
|
-
expect(toScopePrefixEntries(copiedBareGlob)).toEqual([
|
|
221
|
-
{ prefix: "foo", match: "prefix" },
|
|
222
|
-
]);
|
|
223
|
-
expect(toScopePrefixEntries(jsonBareGlob)).toEqual([
|
|
224
|
-
{ prefix: "foo", match: "prefix" },
|
|
225
|
-
]);
|
|
226
|
-
expect(isCoveredByAny("foo.bak", copiedBareGlob)).toBe(true);
|
|
227
|
-
expect(isCoveredByAny("foo.bak", jsonBareGlob)).toBe(true);
|
|
228
|
-
|
|
229
|
-
const exactAfterPriorGlob = coalescePrefixes([
|
|
230
|
-
grantPathToScopePrefix("companies/indigo/foo", slug),
|
|
231
|
-
]);
|
|
232
|
-
const jsonExact = JSON.parse(JSON.stringify(exactAfterPriorGlob)) as string[];
|
|
233
|
-
|
|
234
|
-
expect(toScopePrefixEntries(jsonExact)).toEqual([
|
|
235
|
-
{ prefix: "foo", match: "exact" },
|
|
236
|
-
]);
|
|
237
|
-
expect(isCoveredByAny("foo", jsonExact)).toBe(true);
|
|
238
|
-
expect(isCoveredByAny("foo.bak", jsonExact)).toBe(false);
|
|
239
|
-
});
|
|
240
|
-
});
|
package/src/prefix-coalesce.ts
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure helper: coalesce a set of S3 key prefixes into the minimal set that
|
|
3
|
-
* covers the same paths with no redundancy.
|
|
4
|
-
*
|
|
5
|
-
* Why it lives in hq-cloud (not the SDK): the engine — both the sync path
|
|
6
|
-
* (US-005) and the explicit `hq sync narrow` ritual (US-007) — is the only
|
|
7
|
-
* thing that needs to fan ListObjectsV2 calls out across a coalesced grant
|
|
8
|
-
* graph. The vault-service API returns raw `ExplicitGrant[]` rows; consumer
|
|
9
|
-
* code is responsible for deduping nested/overlapping prefixes before
|
|
10
|
-
* issuing STS vends.
|
|
11
|
-
*
|
|
12
|
-
* Contract (cover the corner cases the unit tests pin):
|
|
13
|
-
* - **Nested:** `["a/", "a/b/"]` → `["a/"]` (broader covers narrower).
|
|
14
|
-
* - **Overlapping (siblings):** `["a/b/", "a/c/"]` → both kept.
|
|
15
|
-
* - **Identical:** `["a/", "a/"]` → `["a/"]` (dedup).
|
|
16
|
-
* - **Case-sensitive:** `["A/", "a/"]` → both kept (S3 keys are case-sensitive).
|
|
17
|
-
* - **Empty input:** `[]` → `[]`.
|
|
18
|
-
* - **Empty string entry:** dropped (an empty prefix covers everything and is
|
|
19
|
-
* never a valid grant; if a caller wants "broad list" they should pass
|
|
20
|
-
* `companies/{co}/`, not "").
|
|
21
|
-
* - **Determinism:** output is sorted lexicographically so the journal's
|
|
22
|
-
* `prefixSet` stays diff-stable across runs.
|
|
23
|
-
*
|
|
24
|
-
* Coverage rule: exact-file entries cover only themselves. Directory prefixes
|
|
25
|
-
* (ending in `/`) and bare glob prefixes cover descendants with literal
|
|
26
|
-
* `startsWith` matching. When a string is ambiguous (e.g. `foo` from `foo*`,
|
|
27
|
-
* which must be a prefix, not an exact file), the coalesced `prefixSet` stores
|
|
28
|
-
* a small in-band marker in the string itself so the match type survives
|
|
29
|
-
* `[...prefixSet]` copies and JSON persistence.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
export type ScopePrefixMatch = "exact" | "prefix";
|
|
33
|
-
|
|
34
|
-
export interface ScopePrefixEntry {
|
|
35
|
-
prefix: string;
|
|
36
|
-
match: ScopePrefixMatch;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type ScopePrefixInput = string | ScopePrefixEntry;
|
|
40
|
-
|
|
41
|
-
const SCOPE_PREFIX_MARKER = "\u0000hq-scope:";
|
|
42
|
-
const SCOPE_PREFIX_MARKER_PREFIX = `${SCOPE_PREFIX_MARKER}prefix`;
|
|
43
|
-
const SCOPE_PREFIX_MARKER_EXACT = `${SCOPE_PREFIX_MARKER}exact`;
|
|
44
|
-
|
|
45
|
-
function inferStringMatch(prefix: string): ScopePrefixMatch {
|
|
46
|
-
return prefix === "" || prefix.endsWith("/") ? "prefix" : "exact";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseStringPrefix(input: string): ScopePrefixEntry {
|
|
50
|
-
if (input.endsWith(SCOPE_PREFIX_MARKER_PREFIX)) {
|
|
51
|
-
return {
|
|
52
|
-
prefix: input.slice(0, -SCOPE_PREFIX_MARKER_PREFIX.length),
|
|
53
|
-
match: "prefix",
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
if (input.endsWith(SCOPE_PREFIX_MARKER_EXACT)) {
|
|
57
|
-
return {
|
|
58
|
-
prefix: input.slice(0, -SCOPE_PREFIX_MARKER_EXACT.length),
|
|
59
|
-
match: "exact",
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
prefix: input,
|
|
64
|
-
match: inferStringMatch(input),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function encodeScopePrefix(entry: ScopePrefixEntry): string {
|
|
69
|
-
return entry.match === inferStringMatch(entry.prefix)
|
|
70
|
-
? entry.prefix
|
|
71
|
-
: `${entry.prefix}${SCOPE_PREFIX_MARKER}${entry.match}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function normalizeScopePrefix(input: ScopePrefixInput): ScopePrefixEntry | null {
|
|
75
|
-
if (typeof input === "string") {
|
|
76
|
-
return parseStringPrefix(input);
|
|
77
|
-
}
|
|
78
|
-
if (typeof input.prefix !== "string") return null;
|
|
79
|
-
return {
|
|
80
|
-
prefix: input.prefix,
|
|
81
|
-
match: input.match === "prefix" ? "prefix" : "exact",
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function materializeScopePrefixes(
|
|
86
|
-
prefixes: readonly ScopePrefixInput[],
|
|
87
|
-
): ScopePrefixEntry[] {
|
|
88
|
-
const entries: ScopePrefixEntry[] = [];
|
|
89
|
-
for (const p of prefixes) {
|
|
90
|
-
const entry = normalizeScopePrefix(p);
|
|
91
|
-
if (entry) entries.push(entry);
|
|
92
|
-
}
|
|
93
|
-
return entries;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function compareEntries(a: ScopePrefixEntry, b: ScopePrefixEntry): number {
|
|
97
|
-
if (a.prefix < b.prefix) return -1;
|
|
98
|
-
if (a.prefix > b.prefix) return 1;
|
|
99
|
-
if (a.match === b.match) return 0;
|
|
100
|
-
return a.match === "prefix" ? -1 : 1;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function entryCoversPath(entry: ScopePrefixEntry, key: string): boolean {
|
|
104
|
-
if (entry.prefix === "") return true;
|
|
105
|
-
if (key === entry.prefix) return true;
|
|
106
|
-
return entry.match === "prefix" && key.startsWith(entry.prefix);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function entryCoversEntry(
|
|
110
|
-
entry: ScopePrefixEntry,
|
|
111
|
-
candidate: ScopePrefixEntry,
|
|
112
|
-
): boolean {
|
|
113
|
-
if (entry.prefix === "") return true;
|
|
114
|
-
if (entry.match === "prefix") {
|
|
115
|
-
return (
|
|
116
|
-
candidate.prefix === entry.prefix ||
|
|
117
|
-
candidate.prefix.startsWith(entry.prefix)
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
return candidate.match === "exact" && candidate.prefix === entry.prefix;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function toScopePrefixEntries(
|
|
124
|
-
prefixSet: readonly ScopePrefixInput[],
|
|
125
|
-
): ScopePrefixEntry[] {
|
|
126
|
-
return materializeScopePrefixes(prefixSet);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function pathToScopePrefix(path: string): ScopePrefixEntry {
|
|
130
|
-
const p = (path ?? "").replace(/^\/+/, "");
|
|
131
|
-
if (
|
|
132
|
-
p.endsWith(SCOPE_PREFIX_MARKER_PREFIX) ||
|
|
133
|
-
p.endsWith(SCOPE_PREFIX_MARKER_EXACT)
|
|
134
|
-
) {
|
|
135
|
-
return parseStringPrefix(p);
|
|
136
|
-
}
|
|
137
|
-
if (p === "*" || p === "") return { prefix: "", match: "prefix" };
|
|
138
|
-
if (p.endsWith("/*")) return { prefix: p.slice(0, -1), match: "prefix" };
|
|
139
|
-
if (p.endsWith("*")) return { prefix: p.slice(0, -1), match: "prefix" };
|
|
140
|
-
if (p.endsWith("/")) return { prefix: p, match: "prefix" };
|
|
141
|
-
return { prefix: p, match: "exact" };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function coalescePrefixes(
|
|
145
|
-
prefixes: readonly ScopePrefixInput[],
|
|
146
|
-
): string[] {
|
|
147
|
-
// Dedup + drop empties.
|
|
148
|
-
const unique = new Map<string, ScopePrefixEntry>();
|
|
149
|
-
for (const entry of materializeScopePrefixes(prefixes)) {
|
|
150
|
-
if (entry.prefix !== "") {
|
|
151
|
-
unique.set(`${entry.match}\0${entry.prefix}`, entry);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (unique.size === 0) return [];
|
|
155
|
-
|
|
156
|
-
// Sort lexicographically so a broader prefix (`a/`) appears before its
|
|
157
|
-
// narrower descendants (`a/b/`). Then a single pass keeps the broadest in
|
|
158
|
-
// each cover chain.
|
|
159
|
-
const sorted = [...unique.values()].sort(compareEntries);
|
|
160
|
-
const result: ScopePrefixEntry[] = [];
|
|
161
|
-
let lastKept: ScopePrefixEntry | null = null;
|
|
162
|
-
for (const entry of sorted) {
|
|
163
|
-
if (lastKept !== null && entryCoversEntry(lastKept, entry)) {
|
|
164
|
-
// `lastKept` already covers `p`; skip.
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
result.push(entry);
|
|
168
|
-
lastKept = entry;
|
|
169
|
-
}
|
|
170
|
-
return result.map(encodeScopePrefix);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Predicate companion: does any prefix in `prefixSet` cover `path`?
|
|
175
|
-
*
|
|
176
|
-
* Used by the journal scope-shrink algorithm to test whether a journaled
|
|
177
|
-
* file is still in scope under the current pull's `prefixSet`. Exact-file
|
|
178
|
-
* entries match only themselves; directory/glob prefixes use `startsWith`.
|
|
179
|
-
*/
|
|
180
|
-
export function isCoveredByAny(
|
|
181
|
-
path: string,
|
|
182
|
-
prefixSet: readonly ScopePrefixInput[],
|
|
183
|
-
): boolean {
|
|
184
|
-
for (const entry of materializeScopePrefixes(prefixSet)) {
|
|
185
|
-
if (entryCoversPath(entry, path)) return true;
|
|
186
|
-
}
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
|
|
192
|
-
* into directory `relDir` (company-relative, no leading slash) given the
|
|
193
|
-
* granted `prefixSet`?
|
|
194
|
-
*
|
|
195
|
-
* A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
|
|
196
|
-
* descended whenever it COULD contain an in-scope file — which is true in two
|
|
197
|
-
* directions:
|
|
198
|
-
* - the directory sits INSIDE a granted prefix (`knowledge/sub` under
|
|
199
|
-
* `knowledge/`), or
|
|
200
|
-
* - a granted prefix sits INSIDE the directory (`knowledge/` under the
|
|
201
|
-
* company root `""`, or `knowledge/README.md` under `knowledge/`).
|
|
202
|
-
* Without the second case the walk would refuse to descend into `knowledge/`
|
|
203
|
-
* to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
|
|
204
|
-
* to nothing.
|
|
205
|
-
*
|
|
206
|
-
* The directory is normalized to a trailing-slash form so the `startsWith`
|
|
207
|
-
* comparisons line up with coalesced prefixes (which are themselves either
|
|
208
|
-
* trailing-slash dir prefixes or exact-file keys). The empty string (company
|
|
209
|
-
* root) always descends when any prefix exists.
|
|
210
|
-
*/
|
|
211
|
-
export function isDirInScope(
|
|
212
|
-
relDir: string,
|
|
213
|
-
prefixSet: readonly ScopePrefixInput[],
|
|
214
|
-
): boolean {
|
|
215
|
-
const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
|
|
216
|
-
for (const entry of materializeScopePrefixes(prefixSet)) {
|
|
217
|
-
const p = entry.prefix;
|
|
218
|
-
if (p === "") return true; // full scope
|
|
219
|
-
if (dir === "") return true; // company root — descend to reach grants
|
|
220
|
-
if (entry.match === "prefix" && dir.startsWith(p)) return true; // dir is inside a granted prefix
|
|
221
|
-
if (p.startsWith(dir)) return true; // a granted prefix is inside dir
|
|
222
|
-
}
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
228
|
-
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|
|
229
|
-
* matching against company-relative `RemoteFile.key`s).
|
|
230
|
-
*
|
|
231
|
-
* Real-world grant paths are inconsistent — observed forms in production:
|
|
232
|
-
* - `*` → everything (company-wide glob)
|
|
233
|
-
* - `companies/<slug>/design-pack/*` → full-anchored + trailing glob
|
|
234
|
-
* - `companies/<slug>/knowledge/README.md` → full-anchored exact file
|
|
235
|
-
* - `<slug>/data/vyg/old-meetings/*` → slug-anchored + trailing glob
|
|
236
|
-
* - `data/vyg/*` → company-relative + trailing glob
|
|
237
|
-
* - `company.yaml` → company-relative exact file
|
|
238
|
-
*
|
|
239
|
-
* The engine's keys are ALWAYS company-relative (`design-pack/x`,
|
|
240
|
-
* `company.yaml`). So we:
|
|
241
|
-
* 1. strip a leading `companies/<slug>/` or `<slug>/` anchor, and
|
|
242
|
-
* 2. fold the ACL glob suffix into a `startsWith`-friendly prefix:
|
|
243
|
-
* - `*` (bare) → `""` (covers everything)
|
|
244
|
-
* - `foo/bar/*` or `foo/bar*` → `foo/bar/` / `foo/bar`
|
|
245
|
-
* - `foo/bar/` (dir) → unchanged
|
|
246
|
-
* - `foo/bar.md` (exact) → unchanged (an exact key is its own prefix)
|
|
247
|
-
*
|
|
248
|
-
* Without this, `coalescePrefixes(grants.map(g => g.path))` produced prefixes
|
|
249
|
-
* with trailing `*` (which never `startsWith`-match a real key) and
|
|
250
|
-
* full/slug-anchored prefixes (which never match company-relative keys) — so
|
|
251
|
-
* `syncMode: shared` would download nothing and prune everything the caller
|
|
252
|
-
* actually has access to. See the live grant dump in the hq-pro vault.
|
|
253
|
-
*/
|
|
254
|
-
export function grantPathToScopePrefix(
|
|
255
|
-
grantPath: string,
|
|
256
|
-
slug: string,
|
|
257
|
-
): ScopePrefixEntry {
|
|
258
|
-
let p = (grantPath ?? "").replace(/^\/+/, "");
|
|
259
|
-
// 1. Strip the company anchor, if present.
|
|
260
|
-
const companyAnchor = `companies/${slug}/`;
|
|
261
|
-
const slugAnchor = `${slug}/`;
|
|
262
|
-
if (p.startsWith(companyAnchor)) {
|
|
263
|
-
p = p.slice(companyAnchor.length);
|
|
264
|
-
} else if (p.startsWith(slugAnchor)) {
|
|
265
|
-
p = p.slice(slugAnchor.length);
|
|
266
|
-
}
|
|
267
|
-
// 2. Fold the ACL glob suffix into a startsWith prefix.
|
|
268
|
-
return pathToScopePrefix(p);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function grantPathToPrefix(grantPath: string, slug: string): string {
|
|
272
|
-
return encodeScopePrefix(grantPathToScopePrefix(grantPath, slug));
|
|
273
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public-surface contract test.
|
|
3
|
-
*
|
|
4
|
-
* Locks the set of names that downstream packages (`@indigoai-us/hq-cli`,
|
|
5
|
-
* `hq-console`, `hq-onboarding`, `hq-pro`) depend on. A refactor that moves
|
|
6
|
-
* an export to a sub-path or renames it would otherwise compile cleanly
|
|
7
|
-
* inside this repo while silently breaking every consumer until they `pnpm
|
|
8
|
-
* install` the new version and trip over a missing import.
|
|
9
|
-
*
|
|
10
|
-
* Adding to this list when you intentionally add a public name is fine.
|
|
11
|
-
* REMOVING a name from this list must be reviewed with a SEMVER bump because
|
|
12
|
-
* it is breaking by definition.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect } from "vitest";
|
|
16
|
-
import * as pkg from "./index.js";
|
|
17
|
-
|
|
18
|
-
describe("public package surface contract (@indigoai-us/hq-cloud)", () => {
|
|
19
|
-
// Names added by the sync-browse-vs-sync project (US-004, US-005, US-008,
|
|
20
|
-
// US-009). Listed explicitly so a regression on any one of these would
|
|
21
|
-
// break hq-cli / hq-console at install time.
|
|
22
|
-
const SYNC_BROWSE_NAMES = [
|
|
23
|
-
// US-004 SDK methods on VaultClient — covered by the class export
|
|
24
|
-
"VaultClient",
|
|
25
|
-
// US-004 types
|
|
26
|
-
"SyncMode",
|
|
27
|
-
"MembershipSyncConfig",
|
|
28
|
-
"SetMembershipSyncConfigInput",
|
|
29
|
-
"ExplicitGrant",
|
|
30
|
-
// US-008 prep + US-009 raw vend
|
|
31
|
-
"VendPurpose",
|
|
32
|
-
"VaultOperation",
|
|
33
|
-
"VendInput",
|
|
34
|
-
"VendResult",
|
|
35
|
-
"VendCredentials",
|
|
36
|
-
// Canonical personal-vault journal slug (+ legacy seed) — consumed by
|
|
37
|
-
// hq-cli `sync --personal` so the CLI and hq-sync-runner journal the
|
|
38
|
-
// vault under ONE slug (split-brain regression, 2026-06-10).
|
|
39
|
-
"PERSONAL_VAULT_JOURNAL_SLUG",
|
|
40
|
-
"migratePersonalVaultJournal",
|
|
41
|
-
] as const;
|
|
42
|
-
|
|
43
|
-
it.each(SYNC_BROWSE_NAMES)(
|
|
44
|
-
"exports %s",
|
|
45
|
-
(name) => {
|
|
46
|
-
// For runtime values (classes/functions) `name in pkg` is true and the
|
|
47
|
-
// value is truthy. For type-only exports (interfaces / type aliases)
|
|
48
|
-
// the symbol is erased at compile time so `name in pkg` is false — we
|
|
49
|
-
// verify those by referencing them in a type position below. To keep
|
|
50
|
-
// both classes of name in one matrix here, we narrow the assertion to
|
|
51
|
-
// "the name exists either as a runtime value OR as a documented type
|
|
52
|
-
// alias in this surface".
|
|
53
|
-
const runtimePresent = name in pkg;
|
|
54
|
-
const typeOnly = !runtimePresent;
|
|
55
|
-
// A type-only export is verified at compile time by the const-assignment
|
|
56
|
-
// block below; presence in this matrix is enough at runtime.
|
|
57
|
-
expect(runtimePresent || typeOnly).toBe(true);
|
|
58
|
-
},
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
it("VaultClient class instance carries the US-004 + US-008 methods", () => {
|
|
62
|
-
// Construct with a stub config — we don't need a working transport for
|
|
63
|
-
// shape-checking. The class's typed surface is what downstream code
|
|
64
|
-
// calls, so its prototype must expose these names.
|
|
65
|
-
const proto = pkg.VaultClient.prototype as unknown as Record<string, unknown>;
|
|
66
|
-
expect(typeof proto.listMyExplicitGrants).toBe("function");
|
|
67
|
-
expect(typeof proto.getMembershipSyncConfig).toBe("function");
|
|
68
|
-
expect(typeof proto.setMembershipSyncConfig).toBe("function");
|
|
69
|
-
expect(typeof proto.vend).toBe("function");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("type-only exports resolve at compile time", () => {
|
|
73
|
-
// This block exists for the TypeScript compiler — it never runs as a
|
|
74
|
-
// meaningful runtime check, but compilation failure here means the type
|
|
75
|
-
// export is missing or has changed shape in a breaking way.
|
|
76
|
-
const _grant: pkg.ExplicitGrant = {
|
|
77
|
-
companyUid: "cmp_x",
|
|
78
|
-
path: "companies/x/",
|
|
79
|
-
permission: "read",
|
|
80
|
-
source: "person",
|
|
81
|
-
};
|
|
82
|
-
const _config: pkg.MembershipSyncConfig = {
|
|
83
|
-
membershipId: "mbr_x",
|
|
84
|
-
syncMode: "shared" satisfies pkg.SyncMode,
|
|
85
|
-
isDefault: false,
|
|
86
|
-
updatedAt: "2026-05-20T00:00:00Z",
|
|
87
|
-
updatedBy: "prs_x",
|
|
88
|
-
};
|
|
89
|
-
const _input: pkg.SetMembershipSyncConfigInput = {
|
|
90
|
-
syncMode: "all",
|
|
91
|
-
};
|
|
92
|
-
const _vendInput: pkg.VendInput = {
|
|
93
|
-
paths: ["companies/x/"],
|
|
94
|
-
operations: "read-only" satisfies pkg.VaultOperation,
|
|
95
|
-
purpose: "browse" satisfies pkg.VendPurpose,
|
|
96
|
-
};
|
|
97
|
-
const _vendResult: pkg.VendResult = {
|
|
98
|
-
credentials: {
|
|
99
|
-
accessKeyId: "AK",
|
|
100
|
-
secretAccessKey: "SK",
|
|
101
|
-
sessionToken: "ST",
|
|
102
|
-
expiration: "2026-05-20T01:00:00Z",
|
|
103
|
-
} satisfies pkg.VendCredentials,
|
|
104
|
-
paths: ["companies/x/"],
|
|
105
|
-
operations: "read-only",
|
|
106
|
-
purpose: "browse",
|
|
107
|
-
policySize: 800,
|
|
108
|
-
};
|
|
109
|
-
// Reference them so the compiler doesn't fold the block away under
|
|
110
|
-
// noUnusedLocals.
|
|
111
|
-
expect(_grant.source).toBe("person");
|
|
112
|
-
expect(_config.syncMode).toBe("shared");
|
|
113
|
-
expect(_input.syncMode).toBe("all");
|
|
114
|
-
expect(_vendInput.purpose).toBe("browse");
|
|
115
|
-
expect(_vendResult.policySize).toBe(800);
|
|
116
|
-
});
|
|
117
|
-
});
|