@indigoai-us/hq-cloud 5.21.0 → 5.23.0
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/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +76 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +148 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +251 -5
- package/dist/journal.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +38 -0
- package/dist/prefix-coalesce.d.ts.map +1 -0
- package/dist/prefix-coalesce.js +69 -0
- package/dist/prefix-coalesce.js.map +1 -0
- package/dist/prefix-coalesce.test.d.ts +2 -0
- package/dist/prefix-coalesce.test.d.ts.map +1 -0
- package/dist/prefix-coalesce.test.js +77 -0
- package/dist/prefix-coalesce.test.js.map +1 -0
- package/dist/public-surface.test.d.ts +15 -0
- package/dist/public-surface.test.d.ts.map +1 -0
- package/dist/public-surface.test.js +105 -0
- package/dist/public-surface.test.js.map +1 -0
- package/dist/remote-pull.d.ts +145 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +258 -1
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +470 -2
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/schemas/source-channels.d.ts +14 -0
- package/dist/schemas/source-channels.d.ts.map +1 -1
- package/dist/schemas/source-channels.js +16 -0
- package/dist/schemas/source-channels.js.map +1 -1
- package/dist/scope-shrink.d.ts +109 -0
- package/dist/scope-shrink.d.ts.map +1 -0
- package/dist/scope-shrink.js +196 -0
- package/dist/scope-shrink.js.map +1 -0
- package/dist/scope-shrink.test.d.ts +13 -0
- package/dist/scope-shrink.test.d.ts.map +1 -0
- package/dist/scope-shrink.test.js +342 -0
- package/dist/scope-shrink.test.js.map +1 -0
- package/dist/sources/get.d.ts.map +1 -1
- package/dist/sources/get.js +6 -3
- package/dist/sources/get.js.map +1 -1
- package/dist/sources/get.test.js +7 -7
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.d.ts.map +1 -1
- package/dist/sources/list.js +4 -2
- package/dist/sources/list.js.map +1 -1
- package/dist/sources/list.test.js +6 -6
- package/dist/sources/list.test.js.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +178 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +73 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +226 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +68 -0
- package/src/journal.test.ts +284 -5
- package/src/journal.ts +167 -2
- package/src/prefix-coalesce.test.ts +95 -0
- package/src/prefix-coalesce.ts +72 -0
- package/src/public-surface.test.ts +112 -0
- package/src/remote-pull.test.ts +540 -3
- package/src/remote-pull.ts +419 -2
- package/src/schemas/source-channels.ts +17 -0
- package/src/scope-shrink.test.ts +402 -0
- package/src/scope-shrink.ts +264 -0
- package/src/sources/get.test.ts +7 -7
- package/src/sources/get.ts +6 -3
- package/src/sources/list.test.ts +6 -6
- package/src/sources/list.ts +4 -2
- package/src/types.ts +49 -1
- package/src/vault-client.test.ts +335 -0
- package/src/vault-client.ts +223 -0
package/src/sources/get.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ afterEach(() => _resetSourcesS3Factory());
|
|
|
70
70
|
|
|
71
71
|
describe("getSource", () => {
|
|
72
72
|
it("happy path: parses frontmatter and body", async () => {
|
|
73
|
-
installStub({ "sources/
|
|
73
|
+
installStub({ "sources/meetings/abc.md": MEETING_MD });
|
|
74
74
|
|
|
75
75
|
const doc = await getSource({
|
|
76
76
|
entity: ENTITY,
|
|
@@ -78,7 +78,7 @@ describe("getSource", () => {
|
|
|
78
78
|
sourceId: "abc",
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
expect(doc.key).toBe("sources/
|
|
81
|
+
expect(doc.key).toBe("sources/meetings/abc.md");
|
|
82
82
|
expect(doc.frontmatter).toEqual({
|
|
83
83
|
source_id: "abc",
|
|
84
84
|
source_type: "meeting",
|
|
@@ -98,13 +98,13 @@ describe("getSource", () => {
|
|
|
98
98
|
|
|
99
99
|
await expect(
|
|
100
100
|
getSource({ entity: ENTITY, channel: "meeting", sourceId: "missing" }),
|
|
101
|
-
).rejects.toThrow(/sources\/
|
|
101
|
+
).rejects.toThrow(/sources\/meetings\/missing\.md/);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it("includeRaw:true fetches the .raw.json sibling", async () => {
|
|
105
105
|
installStub({
|
|
106
|
-
"sources/
|
|
107
|
-
"sources/
|
|
106
|
+
"sources/meetings/abc.md": MEETING_MD,
|
|
107
|
+
"sources/meetings/abc.raw.json": JSON.stringify(RAW_PAYLOAD),
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
const doc = await getSource({
|
|
@@ -118,7 +118,7 @@ describe("getSource", () => {
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
it("includeRaw:true tolerates missing .raw.json (raw stays undefined)", async () => {
|
|
121
|
-
installStub({ "sources/
|
|
121
|
+
installStub({ "sources/meetings/abc.md": MEETING_MD });
|
|
122
122
|
|
|
123
123
|
const doc = await getSource({
|
|
124
124
|
entity: ENTITY,
|
|
@@ -152,7 +152,7 @@ describe("getSource", () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it("returns frontmatter:null for malformed (no frontmatter block) documents", async () => {
|
|
155
|
-
installStub({ "sources/
|
|
155
|
+
installStub({ "sources/meetings/plain.md": "Just a body, no frontmatter." });
|
|
156
156
|
|
|
157
157
|
const doc = await getSource({
|
|
158
158
|
entity: ENTITY,
|
package/src/sources/get.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
7
|
-
import { assertSourceChannel } from "../schemas/source-channels.js";
|
|
7
|
+
import { assertSourceChannel, sourcePathSegment } from "../schemas/source-channels.js";
|
|
8
8
|
import { getS3Client, streamToString } from "./internals.js";
|
|
9
9
|
import { parseMarkdown } from "./parse.js";
|
|
10
10
|
import type { GetSourceOptions, SourceDocument } from "./types.js";
|
|
@@ -28,7 +28,10 @@ export async function getSource(opts: GetSourceOptions): Promise<SourceDocument>
|
|
|
28
28
|
assertSourceChannel(opts.channel);
|
|
29
29
|
|
|
30
30
|
const client = getS3Client(opts.entity);
|
|
31
|
-
|
|
31
|
+
// sources-pipeline writes "meeting" channel to sources/meetings/...; other
|
|
32
|
+
// channels pass through. See schemas/source-channels.ts:sourcePathSegment.
|
|
33
|
+
const segment = sourcePathSegment(opts.channel);
|
|
34
|
+
const key = `sources/${segment}/${opts.sourceId}.md`;
|
|
32
35
|
|
|
33
36
|
let body: string;
|
|
34
37
|
try {
|
|
@@ -55,7 +58,7 @@ export async function getSource(opts: GetSourceOptions): Promise<SourceDocument>
|
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
if (opts.includeRaw) {
|
|
58
|
-
const rawKey = `sources/${
|
|
61
|
+
const rawKey = `sources/${segment}/${opts.sourceId}.raw.json`;
|
|
59
62
|
try {
|
|
60
63
|
const rawResponse = await client.send(
|
|
61
64
|
new GetObjectCommand({
|
package/src/sources/list.test.ts
CHANGED
|
@@ -128,12 +128,12 @@ describe("listSources", () => {
|
|
|
128
128
|
it("happy path: lists one source and skips its .raw.json sibling", async () => {
|
|
129
129
|
installStub([
|
|
130
130
|
{
|
|
131
|
-
key: "sources/
|
|
131
|
+
key: "sources/meetings/abc.md",
|
|
132
132
|
content: MEETING_MD,
|
|
133
133
|
lastModified: new Date("2026-03-15T14:00:00Z"),
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
key: "sources/
|
|
136
|
+
key: "sources/meetings/abc.raw.json",
|
|
137
137
|
content: '{"raw": true}',
|
|
138
138
|
lastModified: new Date("2026-03-15T14:00:00Z"),
|
|
139
139
|
},
|
|
@@ -144,7 +144,7 @@ describe("listSources", () => {
|
|
|
144
144
|
expect(result.entries).toHaveLength(1);
|
|
145
145
|
expect(result.entries[0].sourceId).toBe("abc");
|
|
146
146
|
expect(result.entries[0].channel).toBe("meeting");
|
|
147
|
-
expect(result.entries[0].key).toBe("sources/
|
|
147
|
+
expect(result.entries[0].key).toBe("sources/meetings/abc.md");
|
|
148
148
|
expect(result.entries[0].size).toBeGreaterThan(0);
|
|
149
149
|
expect(result.entries[0].frontmatter).toBeUndefined();
|
|
150
150
|
expect(result.nextToken).toBeUndefined();
|
|
@@ -152,7 +152,7 @@ describe("listSources", () => {
|
|
|
152
152
|
|
|
153
153
|
it("pagination: returns nextToken and accepts it on the next call", async () => {
|
|
154
154
|
const objects: StoredObject[] = Array.from({ length: 5 }, (_, i) => ({
|
|
155
|
-
key: `sources/
|
|
155
|
+
key: `sources/meetings/m${i}.md`,
|
|
156
156
|
content: MEETING_MD,
|
|
157
157
|
lastModified: new Date("2026-03-15T14:00:00Z"),
|
|
158
158
|
}));
|
|
@@ -189,7 +189,7 @@ describe("listSources", () => {
|
|
|
189
189
|
it("includeFrontmatter:true fetches each object and parses YAML", async () => {
|
|
190
190
|
const observed = installStub([
|
|
191
191
|
{
|
|
192
|
-
key: "sources/
|
|
192
|
+
key: "sources/meetings/abc.md",
|
|
193
193
|
content: MEETING_MD,
|
|
194
194
|
lastModified: new Date("2026-03-15T14:00:00Z"),
|
|
195
195
|
},
|
|
@@ -215,7 +215,7 @@ describe("listSources", () => {
|
|
|
215
215
|
it("includeFrontmatter:false (default) does not perform GETs", async () => {
|
|
216
216
|
const observed = installStub([
|
|
217
217
|
{
|
|
218
|
-
key: "sources/
|
|
218
|
+
key: "sources/meetings/abc.md",
|
|
219
219
|
content: MEETING_MD,
|
|
220
220
|
lastModified: new Date(),
|
|
221
221
|
},
|
package/src/sources/list.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
GetObjectCommand,
|
|
11
11
|
ListObjectsV2Command,
|
|
12
12
|
} from "@aws-sdk/client-s3";
|
|
13
|
-
import { assertSourceChannel } from "../schemas/source-channels.js";
|
|
13
|
+
import { assertSourceChannel, sourcePathSegment } from "../schemas/source-channels.js";
|
|
14
14
|
import { getS3Client, streamToString } from "./internals.js";
|
|
15
15
|
import { parseFrontmatter } from "./parse.js";
|
|
16
16
|
import type {
|
|
@@ -44,7 +44,9 @@ export async function listSources(opts: ListSourcesOptions): Promise<ListSources
|
|
|
44
44
|
assertSourceChannel(opts.channel);
|
|
45
45
|
|
|
46
46
|
const client = getS3Client(opts.entity);
|
|
47
|
-
|
|
47
|
+
// Use the canonical path segment from the schema; channel "meeting" maps
|
|
48
|
+
// to the plural "meetings/" prefix the sources-pipeline writes to.
|
|
49
|
+
const prefix = `sources/${sourcePathSegment(opts.channel)}/`;
|
|
48
50
|
|
|
49
51
|
const response = await client.send(
|
|
50
52
|
new ListObjectsV2Command({
|
package/src/types.ts
CHANGED
|
@@ -34,12 +34,60 @@ export interface JournalEntry {
|
|
|
34
34
|
* against `syncedAt`.
|
|
35
35
|
*/
|
|
36
36
|
remoteEtag?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Tombstone marker (Journal v2, US-005). When set, this entry represents
|
|
39
|
+
* a file that was pruned by a scope shrink — either implicitly (next pull
|
|
40
|
+
* after the membership's effective scope narrowed) or explicitly (via
|
|
41
|
+
* `hq sync narrow --apply`). Tombstones are kept for `TOMBSTONE_TTL_MS`
|
|
42
|
+
* (30d) so subsequent pulls under the same shrunk scope don't re-flag
|
|
43
|
+
* the same paths as orphans, then garbage-collected.
|
|
44
|
+
*/
|
|
45
|
+
removedAt?: string;
|
|
46
|
+
removedReason?: "scope_shrink" | "narrow_apply" | "manual";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-pull boundary record (Journal v2, US-005).
|
|
51
|
+
*
|
|
52
|
+
* Recorded at the end of every per-company sync leg so the next pull can
|
|
53
|
+
* detect "scope changed" against `prefixSet` and compute orphans
|
|
54
|
+
* deterministically. `pulls[]` is append-only; old records are not pruned
|
|
55
|
+
* (cheap, useful for incident forensics — one row per pull per company).
|
|
56
|
+
*/
|
|
57
|
+
export interface PullRecord {
|
|
58
|
+
/** ULID-shaped id (crockford base32, lexically sortable). */
|
|
59
|
+
pullId: string;
|
|
60
|
+
companyUid: string;
|
|
61
|
+
startedAt: string;
|
|
62
|
+
completedAt: string;
|
|
63
|
+
/** Effective sync-mode at pull start. */
|
|
64
|
+
syncMode: "all" | "shared" | "custom";
|
|
65
|
+
/**
|
|
66
|
+
* Coalesced prefixes used to drive ListObjectsV2 for this pull. Empty for
|
|
67
|
+
* v1-migrated records (no recorded scope; treated as full-bucket "all").
|
|
68
|
+
*/
|
|
69
|
+
prefixSet: string[];
|
|
70
|
+
scopeChangeDetected: boolean;
|
|
71
|
+
orphansRemoved: number;
|
|
72
|
+
orphansBlocked: number;
|
|
37
73
|
}
|
|
38
74
|
|
|
39
75
|
export interface SyncJournal {
|
|
40
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Schema version. `"1"` is the pre-US-005 shape (no `pulls`, no
|
|
78
|
+
* tombstone fields on entries). `"2"` adds per-pull records and
|
|
79
|
+
* tombstone markers; readers MUST tolerate either and migrate v1 → v2
|
|
80
|
+
* in place on the first v2 write.
|
|
81
|
+
*/
|
|
82
|
+
version: "1" | "2";
|
|
41
83
|
lastSync: string;
|
|
42
84
|
files: Record<string, JournalEntry>;
|
|
85
|
+
/**
|
|
86
|
+
* Per-pull boundary records (v2 only). Always present on v2 journals.
|
|
87
|
+
* v1 journals do not carry this field — readers should treat absence as
|
|
88
|
+
* "no recorded history; treat last scope as 'all'/empty".
|
|
89
|
+
*/
|
|
90
|
+
pulls?: PullRecord[];
|
|
43
91
|
}
|
|
44
92
|
|
|
45
93
|
export interface SyncStatus {
|
package/src/vault-client.test.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
VaultClientError,
|
|
15
15
|
pickCanonicalPersonEntity,
|
|
16
16
|
type EntityInfo,
|
|
17
|
+
type ExplicitGrant,
|
|
18
|
+
type MembershipSyncConfig,
|
|
17
19
|
} from "./vault-client.js";
|
|
18
20
|
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
@@ -694,6 +696,263 @@ describe("VaultClient identity bootstrap", () => {
|
|
|
694
696
|
});
|
|
695
697
|
});
|
|
696
698
|
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// Browse-vs-sync SDK methods (US-004)
|
|
701
|
+
//
|
|
702
|
+
// listMyExplicitGrants, getMembershipSyncConfig, setMembershipSyncConfig
|
|
703
|
+
// wrap the US-002/US-003 hq-pro endpoints. Tests assert URL shape, payload
|
|
704
|
+
// shape, error mapping (401/404/network), and the empty-grants fallback
|
|
705
|
+
// that lets call sites treat "no grants" as a normal state.
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
describe("listMyExplicitGrants (US-004)", () => {
|
|
709
|
+
it("GETs /v1/files/grants with the company query param and returns grants[]", async () => {
|
|
710
|
+
const grant: ExplicitGrant = {
|
|
711
|
+
companyUid: "cmp_abc",
|
|
712
|
+
path: "shared/docs",
|
|
713
|
+
permission: "read",
|
|
714
|
+
source: "person",
|
|
715
|
+
};
|
|
716
|
+
fetchSpy.mockResolvedValueOnce(
|
|
717
|
+
jsonResponse(200, {
|
|
718
|
+
grants: [grant],
|
|
719
|
+
computedAt: "2026-05-20T12:00:00.000Z",
|
|
720
|
+
}),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const grants = await client.listMyExplicitGrants("cmp_abc");
|
|
724
|
+
|
|
725
|
+
expect(grants).toEqual([grant]);
|
|
726
|
+
|
|
727
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
728
|
+
expect(url).toBe(
|
|
729
|
+
"https://vault.test.example.com/v1/files/grants?company=cmp_abc",
|
|
730
|
+
);
|
|
731
|
+
expect(init.method).toBe("GET");
|
|
732
|
+
expect(init.body).toBeUndefined();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("URL-encodes the companyUid query parameter", async () => {
|
|
736
|
+
fetchSpy.mockResolvedValueOnce(
|
|
737
|
+
jsonResponse(200, { grants: [], computedAt: "2026-05-20T12:00:00.000Z" }),
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
await client.listMyExplicitGrants("cmp/with spaces&weird=chars");
|
|
741
|
+
|
|
742
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
743
|
+
expect(url).toBe(
|
|
744
|
+
"https://vault.test.example.com/v1/files/grants?company=cmp%2Fwith%20spaces%26weird%3Dchars",
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("returns [] when the server omits the grants key (empty graph)", async () => {
|
|
749
|
+
fetchSpy.mockResolvedValueOnce(
|
|
750
|
+
jsonResponse(200, { computedAt: "2026-05-20T12:00:00.000Z" }),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const grants = await client.listMyExplicitGrants("cmp_abc");
|
|
754
|
+
expect(grants).toEqual([]);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("maps 401 to VaultAuthError", async () => {
|
|
758
|
+
fetchSpy.mockResolvedValueOnce(
|
|
759
|
+
jsonResponse(401, { error: "Missing or invalid authorization token" }),
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
763
|
+
VaultAuthError,
|
|
764
|
+
);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("maps 404 to VaultNotFoundError", async () => {
|
|
768
|
+
fetchSpy.mockResolvedValueOnce(
|
|
769
|
+
jsonResponse(404, { error: "Unknown route" }),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
773
|
+
VaultNotFoundError,
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("surfaces transport errors after exhausting retries", async () => {
|
|
778
|
+
fetchSpy.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
779
|
+
|
|
780
|
+
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
781
|
+
/ECONNREFUSED/,
|
|
782
|
+
);
|
|
783
|
+
// 1 initial + 3 retries = 4 attempts on a persistent network error.
|
|
784
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
describe("getMembershipSyncConfig (US-004)", () => {
|
|
789
|
+
it("GETs /v1/memberships/{id}/sync-config and returns the row verbatim", async () => {
|
|
790
|
+
const row: MembershipSyncConfig = {
|
|
791
|
+
membershipId: "psn_1#cmp_abc",
|
|
792
|
+
syncMode: "custom",
|
|
793
|
+
customPaths: ["shared/docs", "personal/psn_1"],
|
|
794
|
+
isDefault: false,
|
|
795
|
+
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
796
|
+
updatedBy: "psn_admin",
|
|
797
|
+
};
|
|
798
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, row));
|
|
799
|
+
|
|
800
|
+
const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
|
|
801
|
+
|
|
802
|
+
expect(result).toEqual(row);
|
|
803
|
+
|
|
804
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
805
|
+
expect(url).toBe(
|
|
806
|
+
// `#` in the membershipKey MUST be URL-encoded — otherwise the
|
|
807
|
+
// server sees a fragment, not a path segment.
|
|
808
|
+
"https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
|
|
809
|
+
);
|
|
810
|
+
expect(init.method).toBe("GET");
|
|
811
|
+
expect(init.body).toBeUndefined();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("returns the default row (isDefault: true) without updatedAt/updatedBy", async () => {
|
|
815
|
+
fetchSpy.mockResolvedValueOnce(
|
|
816
|
+
jsonResponse(200, {
|
|
817
|
+
membershipId: "psn_1#cmp_abc",
|
|
818
|
+
syncMode: "all",
|
|
819
|
+
isDefault: true,
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
|
|
824
|
+
expect(result.isDefault).toBe(true);
|
|
825
|
+
expect(result.updatedAt).toBeUndefined();
|
|
826
|
+
expect(result.updatedBy).toBeUndefined();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("maps 401 to VaultAuthError", async () => {
|
|
830
|
+
fetchSpy.mockResolvedValueOnce(
|
|
831
|
+
jsonResponse(401, { error: "Token expired" }),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
await expect(
|
|
835
|
+
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
836
|
+
).rejects.toThrow(VaultAuthError);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("maps 404 to VaultNotFoundError (membership tombstoned or missing)", async () => {
|
|
840
|
+
fetchSpy.mockResolvedValueOnce(
|
|
841
|
+
jsonResponse(404, {
|
|
842
|
+
error: "Membership not found or not active: psn_1#cmp_abc",
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
await expect(
|
|
847
|
+
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
848
|
+
).rejects.toThrow(VaultNotFoundError);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("surfaces network errors after retry exhaustion", async () => {
|
|
852
|
+
fetchSpy.mockRejectedValue(new Error("socket hang up"));
|
|
853
|
+
|
|
854
|
+
await expect(
|
|
855
|
+
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
856
|
+
).rejects.toThrow(/socket hang up/);
|
|
857
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
describe("setMembershipSyncConfig (US-004)", () => {
|
|
862
|
+
it("PUTs the partial body to /v1/memberships/{id}/sync-config", async () => {
|
|
863
|
+
const persisted: MembershipSyncConfig = {
|
|
864
|
+
membershipId: "psn_1#cmp_abc",
|
|
865
|
+
syncMode: "custom",
|
|
866
|
+
customPaths: ["shared/docs"],
|
|
867
|
+
isDefault: false,
|
|
868
|
+
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
869
|
+
updatedBy: "psn_1",
|
|
870
|
+
};
|
|
871
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, persisted));
|
|
872
|
+
|
|
873
|
+
const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
|
|
874
|
+
syncMode: "custom",
|
|
875
|
+
customPaths: ["shared/docs"],
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
expect(result).toEqual(persisted);
|
|
879
|
+
|
|
880
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
881
|
+
expect(url).toBe(
|
|
882
|
+
"https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
|
|
883
|
+
);
|
|
884
|
+
expect(init.method).toBe("PUT");
|
|
885
|
+
const headers = init.headers as Record<string, string>;
|
|
886
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
887
|
+
expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
|
|
888
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
889
|
+
syncMode: "custom",
|
|
890
|
+
customPaths: ["shared/docs"],
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("supports the shared mode without customPaths", async () => {
|
|
895
|
+
fetchSpy.mockResolvedValueOnce(
|
|
896
|
+
jsonResponse(200, {
|
|
897
|
+
membershipId: "psn_1#cmp_abc",
|
|
898
|
+
syncMode: "shared",
|
|
899
|
+
isDefault: false,
|
|
900
|
+
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
901
|
+
updatedBy: "psn_1",
|
|
902
|
+
}),
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
|
|
906
|
+
syncMode: "shared",
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
expect(result.syncMode).toBe("shared");
|
|
910
|
+
expect(result.customPaths).toBeUndefined();
|
|
911
|
+
|
|
912
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
913
|
+
expect(JSON.parse(init.body as string)).toEqual({ syncMode: "shared" });
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("maps 401 to VaultAuthError", async () => {
|
|
917
|
+
fetchSpy.mockResolvedValueOnce(
|
|
918
|
+
jsonResponse(401, { error: "Token expired" }),
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
await expect(
|
|
922
|
+
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
923
|
+
).rejects.toThrow(VaultAuthError);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("maps 404 to VaultNotFoundError when the membership is gone", async () => {
|
|
927
|
+
fetchSpy.mockResolvedValueOnce(
|
|
928
|
+
jsonResponse(404, { error: "Membership not found" }),
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
await expect(
|
|
932
|
+
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
933
|
+
).rejects.toThrow(VaultNotFoundError);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("maps 409 to VaultConflictError", async () => {
|
|
937
|
+
fetchSpy.mockResolvedValueOnce(
|
|
938
|
+
jsonResponse(409, { error: "Concurrent write conflict" }),
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
await expect(
|
|
942
|
+
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
943
|
+
).rejects.toThrow(VaultConflictError);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("surfaces network errors after retry exhaustion", async () => {
|
|
947
|
+
fetchSpy.mockRejectedValue(new Error("ETIMEDOUT"));
|
|
948
|
+
|
|
949
|
+
await expect(
|
|
950
|
+
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
951
|
+
).rejects.toThrow(/ETIMEDOUT/);
|
|
952
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
697
956
|
// ---------------------------------------------------------------------------
|
|
698
957
|
// Refreshable authToken getter
|
|
699
958
|
//
|
|
@@ -787,3 +1046,79 @@ describe("authToken getter (refreshable token)", () => {
|
|
|
787
1046
|
}
|
|
788
1047
|
});
|
|
789
1048
|
});
|
|
1049
|
+
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
// Raw vend (POST /vend, purpose-aware after US-009)
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
describe("vend (POST /vend, purpose-aware)", () => {
|
|
1055
|
+
it("POSTs to /vend with paths/operations/purpose and returns credentials + policySize", async () => {
|
|
1056
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1057
|
+
jsonResponse(200, {
|
|
1058
|
+
credentials: {
|
|
1059
|
+
accessKeyId: "AKIA-BROWSE",
|
|
1060
|
+
secretAccessKey: "secret",
|
|
1061
|
+
sessionToken: "token",
|
|
1062
|
+
expiration: "2026-05-20T13:00:00.000Z",
|
|
1063
|
+
},
|
|
1064
|
+
paths: ["shared/docs/"],
|
|
1065
|
+
operations: "read-only",
|
|
1066
|
+
purpose: "browse",
|
|
1067
|
+
policySize: 412,
|
|
1068
|
+
requestId: "req_xyz",
|
|
1069
|
+
}),
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
const out = await client.vend({
|
|
1073
|
+
paths: ["shared/docs/"],
|
|
1074
|
+
operations: "read-only",
|
|
1075
|
+
purpose: "browse",
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
expect(out.purpose).toBe("browse");
|
|
1079
|
+
expect(out.policySize).toBe(412);
|
|
1080
|
+
expect(out.credentials.accessKeyId).toBe("AKIA-BROWSE");
|
|
1081
|
+
expect(out.credentials.sessionToken).toBe("token");
|
|
1082
|
+
|
|
1083
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1084
|
+
expect(url).toBe("https://vault.test.example.com/vend");
|
|
1085
|
+
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
1086
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
1087
|
+
paths: ["shared/docs/"],
|
|
1088
|
+
operations: "read-only",
|
|
1089
|
+
purpose: "browse",
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("forwards purpose='sync' verbatim (no client-side defaulting)", async () => {
|
|
1094
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1095
|
+
jsonResponse(200, {
|
|
1096
|
+
credentials: {
|
|
1097
|
+
accessKeyId: "k",
|
|
1098
|
+
secretAccessKey: "s",
|
|
1099
|
+
sessionToken: "t",
|
|
1100
|
+
expiration: "2026-05-20T13:00:00.000Z",
|
|
1101
|
+
},
|
|
1102
|
+
paths: ["shared/"],
|
|
1103
|
+
operations: "read-only",
|
|
1104
|
+
purpose: "sync",
|
|
1105
|
+
policySize: 200,
|
|
1106
|
+
}),
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
await client.vend({
|
|
1110
|
+
paths: ["shared/"],
|
|
1111
|
+
operations: "read-only",
|
|
1112
|
+
purpose: "sync",
|
|
1113
|
+
duration: 1800,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1117
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
1118
|
+
paths: ["shared/"],
|
|
1119
|
+
operations: "read-only",
|
|
1120
|
+
purpose: "sync",
|
|
1121
|
+
duration: 1800,
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
});
|