@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.
Files changed (77) hide show
  1. package/dist/index.d.ts +10 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/journal.d.ts +76 -1
  6. package/dist/journal.d.ts.map +1 -1
  7. package/dist/journal.js +148 -1
  8. package/dist/journal.js.map +1 -1
  9. package/dist/journal.test.js +251 -5
  10. package/dist/journal.test.js.map +1 -1
  11. package/dist/prefix-coalesce.d.ts +38 -0
  12. package/dist/prefix-coalesce.d.ts.map +1 -0
  13. package/dist/prefix-coalesce.js +69 -0
  14. package/dist/prefix-coalesce.js.map +1 -0
  15. package/dist/prefix-coalesce.test.d.ts +2 -0
  16. package/dist/prefix-coalesce.test.d.ts.map +1 -0
  17. package/dist/prefix-coalesce.test.js +77 -0
  18. package/dist/prefix-coalesce.test.js.map +1 -0
  19. package/dist/public-surface.test.d.ts +15 -0
  20. package/dist/public-surface.test.d.ts.map +1 -0
  21. package/dist/public-surface.test.js +105 -0
  22. package/dist/public-surface.test.js.map +1 -0
  23. package/dist/remote-pull.d.ts +145 -1
  24. package/dist/remote-pull.d.ts.map +1 -1
  25. package/dist/remote-pull.js +258 -1
  26. package/dist/remote-pull.js.map +1 -1
  27. package/dist/remote-pull.test.js +470 -2
  28. package/dist/remote-pull.test.js.map +1 -1
  29. package/dist/schemas/source-channels.d.ts +14 -0
  30. package/dist/schemas/source-channels.d.ts.map +1 -1
  31. package/dist/schemas/source-channels.js +16 -0
  32. package/dist/schemas/source-channels.js.map +1 -1
  33. package/dist/scope-shrink.d.ts +109 -0
  34. package/dist/scope-shrink.d.ts.map +1 -0
  35. package/dist/scope-shrink.js +196 -0
  36. package/dist/scope-shrink.js.map +1 -0
  37. package/dist/scope-shrink.test.d.ts +13 -0
  38. package/dist/scope-shrink.test.d.ts.map +1 -0
  39. package/dist/scope-shrink.test.js +342 -0
  40. package/dist/scope-shrink.test.js.map +1 -0
  41. package/dist/sources/get.d.ts.map +1 -1
  42. package/dist/sources/get.js +6 -3
  43. package/dist/sources/get.js.map +1 -1
  44. package/dist/sources/get.test.js +7 -7
  45. package/dist/sources/get.test.js.map +1 -1
  46. package/dist/sources/list.d.ts.map +1 -1
  47. package/dist/sources/list.js +4 -2
  48. package/dist/sources/list.js.map +1 -1
  49. package/dist/sources/list.test.js +6 -6
  50. package/dist/sources/list.test.js.map +1 -1
  51. package/dist/types.d.ts +48 -1
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/vault-client.d.ts +178 -0
  54. package/dist/vault-client.d.ts.map +1 -1
  55. package/dist/vault-client.js +73 -0
  56. package/dist/vault-client.js.map +1 -1
  57. package/dist/vault-client.test.js +226 -0
  58. package/dist/vault-client.test.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/index.ts +68 -0
  61. package/src/journal.test.ts +284 -5
  62. package/src/journal.ts +167 -2
  63. package/src/prefix-coalesce.test.ts +95 -0
  64. package/src/prefix-coalesce.ts +72 -0
  65. package/src/public-surface.test.ts +112 -0
  66. package/src/remote-pull.test.ts +540 -3
  67. package/src/remote-pull.ts +419 -2
  68. package/src/schemas/source-channels.ts +17 -0
  69. package/src/scope-shrink.test.ts +402 -0
  70. package/src/scope-shrink.ts +264 -0
  71. package/src/sources/get.test.ts +7 -7
  72. package/src/sources/get.ts +6 -3
  73. package/src/sources/list.test.ts +6 -6
  74. package/src/sources/list.ts +4 -2
  75. package/src/types.ts +49 -1
  76. package/src/vault-client.test.ts +335 -0
  77. package/src/vault-client.ts +223 -0
@@ -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/meeting/abc.md": MEETING_MD });
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/meeting/abc.md");
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\/meeting\/missing\.md/);
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/meeting/abc.md": MEETING_MD,
107
- "sources/meeting/abc.raw.json": JSON.stringify(RAW_PAYLOAD),
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/meeting/abc.md": MEETING_MD });
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/meeting/plain.md": "Just a body, no frontmatter." });
155
+ installStub({ "sources/meetings/plain.md": "Just a body, no frontmatter." });
156
156
 
157
157
  const doc = await getSource({
158
158
  entity: ENTITY,
@@ -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
- const key = `sources/${opts.channel}/${opts.sourceId}.md`;
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/${opts.channel}/${opts.sourceId}.raw.json`;
61
+ const rawKey = `sources/${segment}/${opts.sourceId}.raw.json`;
59
62
  try {
60
63
  const rawResponse = await client.send(
61
64
  new GetObjectCommand({
@@ -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/meeting/abc.md",
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/meeting/abc.raw.json",
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/meeting/abc.md");
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/meeting/m${i}.md`,
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/meeting/abc.md",
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/meeting/abc.md",
218
+ key: "sources/meetings/abc.md",
219
219
  content: MEETING_MD,
220
220
  lastModified: new Date(),
221
221
  },
@@ -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
- const prefix = `sources/${opts.channel}/`;
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
- version: "1";
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 {
@@ -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
+ });