@indigoai-us/hq-cloud 5.22.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 +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/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/journal.ts
CHANGED
|
@@ -17,7 +17,13 @@ import * as fs from "fs";
|
|
|
17
17
|
import * as os from "os";
|
|
18
18
|
import * as path from "path";
|
|
19
19
|
import * as crypto from "crypto";
|
|
20
|
-
import type { SyncJournal, JournalEntry } from "./types.js";
|
|
20
|
+
import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
|
|
23
|
+
export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** Current journal schema version written by all v2-aware writers. */
|
|
26
|
+
export const JOURNAL_VERSION_CURRENT = "2" as const;
|
|
21
27
|
|
|
22
28
|
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
23
29
|
const JOURNAL_FILE_SUFFIX = ".json";
|
|
@@ -54,16 +60,54 @@ export function getJournalPath(slug: string): string {
|
|
|
54
60
|
);
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Read a per-company journal from disk.
|
|
65
|
+
*
|
|
66
|
+
* Back-compat (US-005, v1 → v2): a v1 file on disk is returned as-is with
|
|
67
|
+
* `version: "1"` and no `pulls` field. The in-place migration to v2 happens
|
|
68
|
+
* the first time `writeJournal` runs — `migrateToV2` ensures any journal
|
|
69
|
+
* passed to the writer carries `version: "2"`, `pulls: []`, and the rest of
|
|
70
|
+
* the v2 shape. This keeps `readJournal` deterministic + cheap and confines
|
|
71
|
+
* the side effect (schema bump on disk) to writes.
|
|
72
|
+
*
|
|
73
|
+
* When the file doesn't exist, we return a fresh v2 journal directly — new
|
|
74
|
+
* installs never pass through v1 on disk.
|
|
75
|
+
*/
|
|
57
76
|
export function readJournal(slug: string): SyncJournal {
|
|
58
77
|
const journalPath = getJournalPath(slug);
|
|
59
78
|
if (fs.existsSync(journalPath)) {
|
|
60
79
|
const content = fs.readFileSync(journalPath, "utf-8");
|
|
61
80
|
return JSON.parse(content) as SyncJournal;
|
|
62
81
|
}
|
|
63
|
-
return { version:
|
|
82
|
+
return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
|
|
87
|
+
* Mutates the input and returns it for chainable use. Call this immediately
|
|
88
|
+
* after `readJournal` if your code-path needs the v2 fields.
|
|
89
|
+
*
|
|
90
|
+
* v1 → v2 contract: every existing `files[]` entry is preserved as-is; no
|
|
91
|
+
* tombstone fields are inserted (legacy entries are NOT scope-shrink
|
|
92
|
+
* tombstones). `pulls` becomes `[]` (empty history → treat last scope as
|
|
93
|
+
* "all" in the scope-shrink algorithm).
|
|
94
|
+
*/
|
|
95
|
+
export function migrateToV2(journal: SyncJournal): SyncJournal {
|
|
96
|
+
if (journal.version === "2" && Array.isArray(journal.pulls)) {
|
|
97
|
+
return journal;
|
|
98
|
+
}
|
|
99
|
+
journal.version = JOURNAL_VERSION_CURRENT;
|
|
100
|
+
if (!Array.isArray(journal.pulls)) journal.pulls = [];
|
|
101
|
+
return journal;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Write a journal to disk, migrating to the current schema version in-place.
|
|
106
|
+
* `migrateToV2` mutates the passed-in object — callers that hold a reference
|
|
107
|
+
* after the write will see the v2 shape.
|
|
108
|
+
*/
|
|
66
109
|
export function writeJournal(slug: string, journal: SyncJournal): void {
|
|
110
|
+
migrateToV2(journal);
|
|
67
111
|
const journalPath = getJournalPath(slug);
|
|
68
112
|
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
69
113
|
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
|
|
@@ -151,3 +195,124 @@ export function removeEntry(
|
|
|
151
195
|
): void {
|
|
152
196
|
delete journal.files[relativePath];
|
|
153
197
|
}
|
|
198
|
+
|
|
199
|
+
// ─── Journal v2 (US-005): pulls, tombstones, GC ─────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Crockford base32 alphabet (ULID-compatible). */
|
|
202
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate a ULID-shaped 26-char identifier without adding a runtime dep.
|
|
206
|
+
* Format: 10-char base32 of the current millisecond timestamp + 16-char
|
|
207
|
+
* base32 of random bytes. Lexically sortable, time-prefixed — same property
|
|
208
|
+
* that makes ULIDs useful for `pulls[]` ordering.
|
|
209
|
+
*
|
|
210
|
+
* We don't need full ULID spec compliance (monotonic counter, randomness
|
|
211
|
+
* spec) — just sortable + collision-resistant enough that two pulls
|
|
212
|
+
* issued in the same millisecond by different processes don't clash.
|
|
213
|
+
* 80 bits of randomness is plenty.
|
|
214
|
+
*/
|
|
215
|
+
export function generatePullId(now: number = Date.now()): string {
|
|
216
|
+
let time = now;
|
|
217
|
+
const timeChars: string[] = [];
|
|
218
|
+
for (let i = 0; i < 10; i++) {
|
|
219
|
+
timeChars.unshift(CROCKFORD[time % 32]!);
|
|
220
|
+
time = Math.floor(time / 32);
|
|
221
|
+
}
|
|
222
|
+
const randBytes = crypto.randomBytes(10); // 80 bits
|
|
223
|
+
const randChars: string[] = [];
|
|
224
|
+
// Encode 10 random bytes (80 bits) into 16 base32 chars.
|
|
225
|
+
let buf = 0;
|
|
226
|
+
let bits = 0;
|
|
227
|
+
for (let i = 0; i < randBytes.length; i++) {
|
|
228
|
+
buf = (buf << 8) | randBytes[i]!;
|
|
229
|
+
bits += 8;
|
|
230
|
+
while (bits >= 5) {
|
|
231
|
+
bits -= 5;
|
|
232
|
+
randChars.push(CROCKFORD[(buf >> bits) & 0x1f]!);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (bits > 0) {
|
|
236
|
+
randChars.push(CROCKFORD[(buf << (5 - bits)) & 0x1f]!);
|
|
237
|
+
}
|
|
238
|
+
return timeChars.join("") + randChars.slice(0, 16).join("");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Find the most-recent `PullRecord` for a company in the journal. Returns
|
|
243
|
+
* `undefined` when no record exists — scope-shrink callers treat that as
|
|
244
|
+
* "no prior scope; nothing to shrink".
|
|
245
|
+
*
|
|
246
|
+
* Order by `completedAt` descending — `pullId` is lexically sortable but
|
|
247
|
+
* `completedAt` is what semantically represents "most recent successful
|
|
248
|
+
* pull state at last close".
|
|
249
|
+
*/
|
|
250
|
+
export function lastPullRecord(
|
|
251
|
+
journal: SyncJournal,
|
|
252
|
+
companyUid: string,
|
|
253
|
+
): PullRecord | undefined {
|
|
254
|
+
if (!journal.pulls || journal.pulls.length === 0) return undefined;
|
|
255
|
+
let best: PullRecord | undefined;
|
|
256
|
+
for (const p of journal.pulls) {
|
|
257
|
+
if (p.companyUid !== companyUid) continue;
|
|
258
|
+
if (!best || p.completedAt > best.completedAt) best = p;
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Append a `PullRecord` (mutates `journal.pulls`). */
|
|
264
|
+
export function appendPullRecord(
|
|
265
|
+
journal: SyncJournal,
|
|
266
|
+
record: PullRecord,
|
|
267
|
+
): void {
|
|
268
|
+
migrateToV2(journal);
|
|
269
|
+
journal.pulls!.push(record);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Write a journal tombstone entry for `relativePath`. Used by the scope-
|
|
274
|
+
* shrink algorithm (US-005) and by `hq sync narrow --apply` (US-007).
|
|
275
|
+
*
|
|
276
|
+
* Tombstones intentionally keep the old `hash` / `size` / `syncedAt` /
|
|
277
|
+
* `direction` so a recovery flow could see what was there before pruning.
|
|
278
|
+
* They are GC'd after `TOMBSTONE_TTL_MS` via `gcTombstones`.
|
|
279
|
+
*/
|
|
280
|
+
export function tombstoneEntry(
|
|
281
|
+
journal: SyncJournal,
|
|
282
|
+
relativePath: string,
|
|
283
|
+
reason: "scope_shrink" | "narrow_apply" | "manual",
|
|
284
|
+
now: string = new Date().toISOString(),
|
|
285
|
+
): void {
|
|
286
|
+
const entry = journal.files[relativePath];
|
|
287
|
+
if (!entry) return;
|
|
288
|
+
entry.removedAt = now;
|
|
289
|
+
entry.removedReason = reason;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** True if the entry is a tombstone (set by `tombstoneEntry`). */
|
|
293
|
+
export function isTombstone(entry: JournalEntry | undefined): boolean {
|
|
294
|
+
return !!entry && typeof entry.removedAt === "string";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Garbage-collect tombstones older than `TOMBSTONE_TTL_MS` from
|
|
299
|
+
* `journal.files`. Returns the number removed. Cheap — single pass over
|
|
300
|
+
* the files map, no I/O. Safe to call at the start AND end of every
|
|
301
|
+
* `pullAll` per-company leg; both runs are idempotent.
|
|
302
|
+
*/
|
|
303
|
+
export function gcTombstones(
|
|
304
|
+
journal: SyncJournal,
|
|
305
|
+
now: number = Date.now(),
|
|
306
|
+
): number {
|
|
307
|
+
let removed = 0;
|
|
308
|
+
for (const [path, entry] of Object.entries(journal.files)) {
|
|
309
|
+
if (!entry.removedAt) continue;
|
|
310
|
+
const removedTime = Date.parse(entry.removedAt);
|
|
311
|
+
if (Number.isNaN(removedTime)) continue;
|
|
312
|
+
if (now - removedTime > TOMBSTONE_TTL_MS) {
|
|
313
|
+
delete journal.files[path];
|
|
314
|
+
removed++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return removed;
|
|
318
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { coalescePrefixes, isCoveredByAny } from "./prefix-coalesce.js";
|
|
3
|
+
|
|
4
|
+
describe("coalescePrefixes", () => {
|
|
5
|
+
it("returns empty for empty input", () => {
|
|
6
|
+
expect(coalescePrefixes([])).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("dedupes identical inputs", () => {
|
|
10
|
+
expect(coalescePrefixes(["a/", "a/"])).toEqual(["a/"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("collapses nested prefixes into the broader one", () => {
|
|
14
|
+
expect(coalescePrefixes(["a/", "a/b/"])).toEqual(["a/"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("collapses deeply nested chains", () => {
|
|
18
|
+
expect(coalescePrefixes(["a/", "a/b/", "a/b/c/", "a/b/c/d/"])).toEqual([
|
|
19
|
+
"a/",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps sibling (overlapping but non-nested) prefixes", () => {
|
|
24
|
+
expect(coalescePrefixes(["a/b/", "a/c/"])).toEqual(["a/b/", "a/c/"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("mixes nested + sibling correctly", () => {
|
|
28
|
+
expect(
|
|
29
|
+
coalescePrefixes([
|
|
30
|
+
"companies/indigo/meetings/",
|
|
31
|
+
"companies/indigo/meetings/2026/",
|
|
32
|
+
"companies/indigo/scratch/jacob/",
|
|
33
|
+
]),
|
|
34
|
+
).toEqual([
|
|
35
|
+
"companies/indigo/meetings/",
|
|
36
|
+
"companies/indigo/scratch/jacob/",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("is case-sensitive (S3 keys are case-sensitive)", () => {
|
|
41
|
+
// 'A/' and 'a/' do not cover each other — both must be kept.
|
|
42
|
+
const result = coalescePrefixes(["A/", "a/"]);
|
|
43
|
+
expect(result.sort()).toEqual(["A/", "a/"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns prefixes sorted lexicographically (deterministic for journal)", () => {
|
|
47
|
+
expect(coalescePrefixes(["c/", "a/", "b/"])).toEqual(["a/", "b/", "c/"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("drops empty-string entries (would otherwise cover everything)", () => {
|
|
51
|
+
expect(coalescePrefixes(["", "a/", ""])).toEqual(["a/"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is a pure function — input array is not mutated", () => {
|
|
55
|
+
const input = ["a/b/", "a/"];
|
|
56
|
+
const snapshot = [...input];
|
|
57
|
+
coalescePrefixes(input);
|
|
58
|
+
expect(input).toEqual(snapshot);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("handles a single prefix unchanged", () => {
|
|
62
|
+
expect(coalescePrefixes(["companies/indigo/"])).toEqual([
|
|
63
|
+
"companies/indigo/",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not treat 'ab/' as nested under 'a/' lexically (literal startsWith)", () => {
|
|
68
|
+
// `'ab/'.startsWith('a/')` is FALSE — `a/` would have to be a true
|
|
69
|
+
// path-segment ancestor. (Callers should pass trailing-slash-bounded
|
|
70
|
+
// prefixes — the grants endpoint always does.)
|
|
71
|
+
expect(coalescePrefixes(["a/", "ab/"]).sort()).toEqual(["a/", "ab/"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isCoveredByAny", () => {
|
|
76
|
+
it("returns true when a prefix covers the path", () => {
|
|
77
|
+
expect(isCoveredByAny("a/b/c.md", ["a/"])).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false when no prefix covers the path", () => {
|
|
81
|
+
expect(isCoveredByAny("a/b/c.md", ["x/", "y/"])).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns false on empty prefix set", () => {
|
|
85
|
+
expect(isCoveredByAny("a/b/c.md", [])).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("treats exact match as covered", () => {
|
|
89
|
+
expect(isCoveredByAny("a/b/", ["a/b/"])).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is case-sensitive", () => {
|
|
93
|
+
expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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: `a` covers `b` iff `a === b` OR `b.startsWith(a)`. This is a
|
|
25
|
+
* literal string-prefix relation — it does NOT understand S3 "folders". A
|
|
26
|
+
* caller that wants `a/` to NOT cover `ab/` must pass trailing-slash-bounded
|
|
27
|
+
* prefixes (the grants endpoint already does — every ACL row ends in `/`).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export function coalescePrefixes(prefixes: readonly string[]): string[] {
|
|
31
|
+
// Dedup + drop empties.
|
|
32
|
+
const unique = new Set<string>();
|
|
33
|
+
for (const p of prefixes) {
|
|
34
|
+
if (typeof p === "string" && p !== "") {
|
|
35
|
+
unique.add(p);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (unique.size === 0) return [];
|
|
39
|
+
|
|
40
|
+
// Sort lexicographically so a broader prefix (`a/`) appears before its
|
|
41
|
+
// narrower descendants (`a/b/`). Then a single pass keeps the broadest in
|
|
42
|
+
// each cover chain.
|
|
43
|
+
const sorted = [...unique].sort();
|
|
44
|
+
const result: string[] = [];
|
|
45
|
+
let lastKept: string | null = null;
|
|
46
|
+
for (const p of sorted) {
|
|
47
|
+
if (lastKept !== null && p.startsWith(lastKept)) {
|
|
48
|
+
// `lastKept` already covers `p`; skip.
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
result.push(p);
|
|
52
|
+
lastKept = p;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Predicate companion: does any prefix in `prefixSet` cover `path`?
|
|
59
|
+
*
|
|
60
|
+
* Used by the journal scope-shrink algorithm to test whether a journaled
|
|
61
|
+
* file is still in scope under the current pull's `prefixSet`. Same
|
|
62
|
+
* `startsWith` semantics as `coalescePrefixes`.
|
|
63
|
+
*/
|
|
64
|
+
export function isCoveredByAny(
|
|
65
|
+
path: string,
|
|
66
|
+
prefixSet: readonly string[],
|
|
67
|
+
): boolean {
|
|
68
|
+
for (const p of prefixSet) {
|
|
69
|
+
if (p === "" || path.startsWith(p)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
it.each(SYNC_BROWSE_NAMES)(
|
|
39
|
+
"exports %s",
|
|
40
|
+
(name) => {
|
|
41
|
+
// For runtime values (classes/functions) `name in pkg` is true and the
|
|
42
|
+
// value is truthy. For type-only exports (interfaces / type aliases)
|
|
43
|
+
// the symbol is erased at compile time so `name in pkg` is false — we
|
|
44
|
+
// verify those by referencing them in a type position below. To keep
|
|
45
|
+
// both classes of name in one matrix here, we narrow the assertion to
|
|
46
|
+
// "the name exists either as a runtime value OR as a documented type
|
|
47
|
+
// alias in this surface".
|
|
48
|
+
const runtimePresent = name in pkg;
|
|
49
|
+
const typeOnly = !runtimePresent;
|
|
50
|
+
// A type-only export is verified at compile time by the const-assignment
|
|
51
|
+
// block below; presence in this matrix is enough at runtime.
|
|
52
|
+
expect(runtimePresent || typeOnly).toBe(true);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it("VaultClient class instance carries the US-004 + US-008 methods", () => {
|
|
57
|
+
// Construct with a stub config — we don't need a working transport for
|
|
58
|
+
// shape-checking. The class's typed surface is what downstream code
|
|
59
|
+
// calls, so its prototype must expose these names.
|
|
60
|
+
const proto = pkg.VaultClient.prototype as unknown as Record<string, unknown>;
|
|
61
|
+
expect(typeof proto.listMyExplicitGrants).toBe("function");
|
|
62
|
+
expect(typeof proto.getMembershipSyncConfig).toBe("function");
|
|
63
|
+
expect(typeof proto.setMembershipSyncConfig).toBe("function");
|
|
64
|
+
expect(typeof proto.vend).toBe("function");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("type-only exports resolve at compile time", () => {
|
|
68
|
+
// This block exists for the TypeScript compiler — it never runs as a
|
|
69
|
+
// meaningful runtime check, but compilation failure here means the type
|
|
70
|
+
// export is missing or has changed shape in a breaking way.
|
|
71
|
+
const _grant: pkg.ExplicitGrant = {
|
|
72
|
+
companyUid: "cmp_x",
|
|
73
|
+
path: "companies/x/",
|
|
74
|
+
permission: "read",
|
|
75
|
+
source: "person",
|
|
76
|
+
};
|
|
77
|
+
const _config: pkg.MembershipSyncConfig = {
|
|
78
|
+
membershipId: "mbr_x",
|
|
79
|
+
syncMode: "shared" satisfies pkg.SyncMode,
|
|
80
|
+
isDefault: false,
|
|
81
|
+
updatedAt: "2026-05-20T00:00:00Z",
|
|
82
|
+
updatedBy: "prs_x",
|
|
83
|
+
};
|
|
84
|
+
const _input: pkg.SetMembershipSyncConfigInput = {
|
|
85
|
+
syncMode: "all",
|
|
86
|
+
};
|
|
87
|
+
const _vendInput: pkg.VendInput = {
|
|
88
|
+
paths: ["companies/x/"],
|
|
89
|
+
operations: "read-only" satisfies pkg.VaultOperation,
|
|
90
|
+
purpose: "browse" satisfies pkg.VendPurpose,
|
|
91
|
+
};
|
|
92
|
+
const _vendResult: pkg.VendResult = {
|
|
93
|
+
credentials: {
|
|
94
|
+
accessKeyId: "AK",
|
|
95
|
+
secretAccessKey: "SK",
|
|
96
|
+
sessionToken: "ST",
|
|
97
|
+
expiration: "2026-05-20T01:00:00Z",
|
|
98
|
+
} satisfies pkg.VendCredentials,
|
|
99
|
+
paths: ["companies/x/"],
|
|
100
|
+
operations: "read-only",
|
|
101
|
+
purpose: "browse",
|
|
102
|
+
policySize: 800,
|
|
103
|
+
};
|
|
104
|
+
// Reference them so the compiler doesn't fold the block away under
|
|
105
|
+
// noUnusedLocals.
|
|
106
|
+
expect(_grant.source).toBe("person");
|
|
107
|
+
expect(_config.syncMode).toBe("shared");
|
|
108
|
+
expect(_input.syncMode).toBe("all");
|
|
109
|
+
expect(_vendInput.purpose).toBe("browse");
|
|
110
|
+
expect(_vendResult.policySize).toBe(800);
|
|
111
|
+
});
|
|
112
|
+
});
|