@indigoai-us/hq-cloud 5.22.0 → 5.24.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/bin/sync-runner.d.ts +20 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +18 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +46 -2
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +77 -20
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +278 -61
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +484 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- 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/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/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/bin/sync-runner.test.ts +56 -2
- package/src/bin/sync-runner.ts +39 -0
- package/src/cli/share.test.ts +577 -3
- package/src/cli/share.ts +395 -85
- package/src/cli/sync.ts +28 -0
- package/src/index.ts +67 -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/scope-shrink.test.ts +402 -0
- package/src/scope-shrink.ts +264 -0
- package/src/types.ts +49 -1
- package/src/vault-client.test.ts +335 -0
- package/src/vault-client.ts +223 -0
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
|
+
});
|
package/src/vault-client.ts
CHANGED
|
@@ -151,6 +151,137 @@ export interface CreateEntityResult {
|
|
|
151
151
|
entity: EntityInfo;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// -- Browse-vs-sync types (US-002, US-003, US-004) -----------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Source kind for an explicit per-company file-ACL grant. Mirrors the
|
|
158
|
+
* server enum in hq-pro `vault-service/handlers/files-grants.ts`.
|
|
159
|
+
*
|
|
160
|
+
* `'open'` collapses two server-side shapes that are indistinguishable to
|
|
161
|
+
* the caller — the legacy `acl.open === true` floor and an explicit
|
|
162
|
+
* `granteeType: 'company-wide'` row. Both mean "every active member of
|
|
163
|
+
* this company sees this prefix".
|
|
164
|
+
*/
|
|
165
|
+
export type GrantSource = "person" | "email" | "group" | "open";
|
|
166
|
+
|
|
167
|
+
/** Permission level surfaced on a grant row. Matches `AclPermission`. */
|
|
168
|
+
export type GrantPermission = "read" | "write" | "admin";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One row in the response of `GET /v1/files/grants?company={uid}`.
|
|
172
|
+
*
|
|
173
|
+
* Role-bypass (owner/admin) entries are intentionally excluded by the
|
|
174
|
+
* server — this is the caller's EXPLICIT grant graph, not the full set
|
|
175
|
+
* of prefixes they can touch by virtue of role.
|
|
176
|
+
*/
|
|
177
|
+
export interface ExplicitGrant {
|
|
178
|
+
companyUid: string;
|
|
179
|
+
path: string;
|
|
180
|
+
permission: GrantPermission;
|
|
181
|
+
source: GrantSource;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Effective sync mode for a single membership. Mirrors the server's
|
|
186
|
+
* resolved view from `GET /v1/memberships/{id}/sync-config`:
|
|
187
|
+
*
|
|
188
|
+
* - `shared` — sync only `shared/` and the caller's `personal/` prefix
|
|
189
|
+
* - `all` — sync every prefix the caller has read access to
|
|
190
|
+
* - `custom` — sync the explicit `customPaths` list (server validates)
|
|
191
|
+
*
|
|
192
|
+
* `isDefault: true` means no row exists in DDB and the server is
|
|
193
|
+
* falling back to its built-in default (currently `'all'` for legacy
|
|
194
|
+
* memberships created pre-US-003). When `true`, `updatedAt`/`updatedBy`
|
|
195
|
+
* are absent because there's no row to attribute.
|
|
196
|
+
*/
|
|
197
|
+
export type SyncMode = "shared" | "all" | "custom";
|
|
198
|
+
|
|
199
|
+
export interface MembershipSyncConfig {
|
|
200
|
+
membershipId: string;
|
|
201
|
+
syncMode: SyncMode;
|
|
202
|
+
customPaths?: string[];
|
|
203
|
+
/**
|
|
204
|
+
* `true` when the server returned the built-in default because no
|
|
205
|
+
* sync-config row exists for this membership. PUT always returns
|
|
206
|
+
* `false` — writing the row is what makes it non-default.
|
|
207
|
+
*/
|
|
208
|
+
isDefault: boolean;
|
|
209
|
+
/** Present only when a sync-config row exists (i.e. `isDefault: false`). */
|
|
210
|
+
updatedAt?: string;
|
|
211
|
+
/** Present only when a sync-config row exists. PersonUid of the writer. */
|
|
212
|
+
updatedBy?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Input shape for {@link VaultClient.setMembershipSyncConfig}. The server
|
|
217
|
+
* validates the combination — `customPaths` is required when `syncMode`
|
|
218
|
+
* is `'custom'` and rejected otherwise.
|
|
219
|
+
*/
|
|
220
|
+
export interface SetMembershipSyncConfigInput {
|
|
221
|
+
syncMode: SyncMode;
|
|
222
|
+
customPaths?: string[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// -- Raw vend (legacy POST /vend, purpose-aware after US-009) -------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Why the caller is requesting STS-scoped credentials. Mirrors the
|
|
229
|
+
* hq-pro vault-service enum (`src/vault-service/policy-builder.ts`).
|
|
230
|
+
*
|
|
231
|
+
* - `'sync'` — background machine sync. Role-bypass MUST NOT widen
|
|
232
|
+
* the path set: credentials are scoped to exactly the requested
|
|
233
|
+
* paths (which the sync engine has already narrowed via US-005).
|
|
234
|
+
* - `'browse'` — interactive exploration (hq-console Explore,
|
|
235
|
+
* `hq files browse`, admin spelunking). Admin/owner role-bypass
|
|
236
|
+
* APPLIES — the caller may receive credentials covering paths
|
|
237
|
+
* beyond their explicit ACL grants.
|
|
238
|
+
*
|
|
239
|
+
* The server defaults missing/empty to `'sync'` (the safer choice).
|
|
240
|
+
* The client doesn't mirror that default — every caller should be
|
|
241
|
+
* explicit about its intent so audit rows are accurate.
|
|
242
|
+
*/
|
|
243
|
+
export type VendPurpose = "sync" | "browse";
|
|
244
|
+
|
|
245
|
+
export type VaultOperation = "read-only" | "read-write" | "staged-write";
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Input shape for {@link VaultClient.vend}. The server validates
|
|
249
|
+
* combinations — e.g. `purpose: 'sync'` rejects bucket-wide `'*'` paths
|
|
250
|
+
* as defense in depth against role-bypass widening on the sync path.
|
|
251
|
+
*/
|
|
252
|
+
export interface VendInput {
|
|
253
|
+
paths: string[];
|
|
254
|
+
operations: VaultOperation;
|
|
255
|
+
/** Why these credentials are being vended. See {@link VendPurpose}. */
|
|
256
|
+
purpose: VendPurpose;
|
|
257
|
+
/** STS session lifetime in seconds. Server default is 900 (15m). */
|
|
258
|
+
duration?: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface VendCredentials {
|
|
262
|
+
accessKeyId: string;
|
|
263
|
+
secretAccessKey: string;
|
|
264
|
+
sessionToken: string;
|
|
265
|
+
/** ISO-8601 STS-native expiration string. */
|
|
266
|
+
expiration: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface VendResult {
|
|
270
|
+
credentials: VendCredentials;
|
|
271
|
+
/** Echo of the server-resolved paths after ACL intersection. */
|
|
272
|
+
paths: string[];
|
|
273
|
+
operations: VaultOperation;
|
|
274
|
+
/** Echo of the effective purpose (server-defaulted to 'sync' if absent). */
|
|
275
|
+
purpose: VendPurpose;
|
|
276
|
+
/**
|
|
277
|
+
* Size of the rendered IAM session policy in characters. Lets the
|
|
278
|
+
* caller detect when it's nearing the 2048-char IAM ceiling so it can
|
|
279
|
+
* fan out across multiple vends or shrink its path set.
|
|
280
|
+
*/
|
|
281
|
+
policySize: number;
|
|
282
|
+
requestId?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
154
285
|
// -- STS child vending (VLT-8) --------------------------------------------
|
|
155
286
|
|
|
156
287
|
export type TaskAction = "read" | "write";
|
|
@@ -356,6 +487,71 @@ export class VaultClient {
|
|
|
356
487
|
return data.invites;
|
|
357
488
|
}
|
|
358
489
|
|
|
490
|
+
// -- Browse-vs-sync (US-002, US-003, US-004) -----------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* List the caller's EXPLICIT per-company file-ACL grants. Backed by
|
|
494
|
+
* `GET /v1/files/grants?company={companyUid}` (hq-pro US-002).
|
|
495
|
+
*
|
|
496
|
+
* Role-bypass (owner/admin) entries are excluded server-side — the
|
|
497
|
+
* response is the caller's actual grant graph, not the full set of
|
|
498
|
+
* prefixes they can touch by virtue of role. Used by the
|
|
499
|
+
* browse-vs-sync UI to render an honest grant graph and by the
|
|
500
|
+
* sync engine to narrow what it pulls.
|
|
501
|
+
*
|
|
502
|
+
* Returns `[]` (NOT a 404) when the caller has no explicit grants in
|
|
503
|
+
* this company, so call sites can treat "empty graph" as a normal
|
|
504
|
+
* state without catching errors.
|
|
505
|
+
*/
|
|
506
|
+
async listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]> {
|
|
507
|
+
const data = await this.get<{ grants: ExplicitGrant[]; computedAt: string }>(
|
|
508
|
+
`/v1/files/grants?company=${encodeURIComponent(companyUid)}`,
|
|
509
|
+
);
|
|
510
|
+
return data.grants ?? [];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read the effective sync-mode for a single membership. Backed by
|
|
515
|
+
* `GET /v1/memberships/{id}/sync-config` (hq-pro US-003).
|
|
516
|
+
*
|
|
517
|
+
* The server resolves the effective view — when no row exists for the
|
|
518
|
+
* membership it returns the built-in default with `isDefault: true`
|
|
519
|
+
* and omits `updatedAt`/`updatedBy`. Callers should treat `isDefault:
|
|
520
|
+
* true` as "no explicit config yet" rather than special-casing 404.
|
|
521
|
+
*
|
|
522
|
+
* Authorization: caller must own the membership OR hold admin/owner
|
|
523
|
+
* on the company that the membership belongs to. The server 404s
|
|
524
|
+
* tombstoned/revoked memberships.
|
|
525
|
+
*/
|
|
526
|
+
async getMembershipSyncConfig(
|
|
527
|
+
membershipId: string,
|
|
528
|
+
): Promise<MembershipSyncConfig> {
|
|
529
|
+
return this.get<MembershipSyncConfig>(
|
|
530
|
+
`/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Write the sync-mode for a single membership. Backed by
|
|
536
|
+
* `PUT /v1/memberships/{id}/sync-config` (hq-pro US-003).
|
|
537
|
+
*
|
|
538
|
+
* Server validates: `customPaths` is required when `syncMode` is
|
|
539
|
+
* `'custom'` and rejected otherwise. The returned row reflects the
|
|
540
|
+
* persisted state with `isDefault: false` (writing the row is what
|
|
541
|
+
* makes it non-default) and the server-assigned `updatedAt` +
|
|
542
|
+
* `updatedBy`.
|
|
543
|
+
*/
|
|
544
|
+
async setMembershipSyncConfig(
|
|
545
|
+
membershipId: string,
|
|
546
|
+
partial: SetMembershipSyncConfigInput,
|
|
547
|
+
): Promise<MembershipSyncConfig> {
|
|
548
|
+
return this.request<MembershipSyncConfig>(
|
|
549
|
+
"PUT",
|
|
550
|
+
`/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
|
|
551
|
+
partial,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
359
555
|
// -- Entity operations ----------------------------------------------------
|
|
360
556
|
|
|
361
557
|
readonly entity = {
|
|
@@ -467,6 +663,33 @@ export class VaultClient {
|
|
|
467
663
|
return data;
|
|
468
664
|
}
|
|
469
665
|
|
|
666
|
+
// -- Raw vend (POST /vend) ------------------------------------------------
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* POST `/vend` — vend STS-scoped credentials for an explicit path list.
|
|
670
|
+
*
|
|
671
|
+
* This is the legacy raw-vend endpoint (distinct from `/sts/vend`,
|
|
672
|
+
* `/sts/vend-self`, and `/sts/vend-child`). Per US-009 it accepts a
|
|
673
|
+
* `purpose` discriminator that controls whether admin/owner
|
|
674
|
+
* role-bypass widens the resulting session policy beyond the
|
|
675
|
+
* caller's explicit ACL grants:
|
|
676
|
+
*
|
|
677
|
+
* - `purpose: 'browse'` — role-bypass APPLIES (interactive
|
|
678
|
+
* `hq files browse`, admin spelunking).
|
|
679
|
+
* - `purpose: 'sync'` — role-bypass SUPPRESSED (background sync;
|
|
680
|
+
* credentials are scoped to exactly what the caller has explicitly
|
|
681
|
+
* been granted, regardless of role).
|
|
682
|
+
*
|
|
683
|
+
* The server defaults missing/empty to `'sync'` but every first-party
|
|
684
|
+
* caller should be explicit so audit attribution is correct.
|
|
685
|
+
*
|
|
686
|
+
* Used by `hq files browse`/`hq files cat` (US-008) to peek at vault
|
|
687
|
+
* objects without ever materialising them under `companies/{co}/`.
|
|
688
|
+
*/
|
|
689
|
+
async vend(input: VendInput): Promise<VendResult> {
|
|
690
|
+
return this.post<VendResult>("/vend", input);
|
|
691
|
+
}
|
|
692
|
+
|
|
470
693
|
// -- STS operations (VLT-8) -----------------------------------------------
|
|
471
694
|
|
|
472
695
|
readonly sts = {
|