@indigoai-us/hq-cloud 5.41.0 → 5.43.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 +26 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +90 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +168 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.d.ts +22 -0
- package/dist/cli/sync-scope.test.d.ts.map +1 -0
- package/dist/cli/sync-scope.test.js +273 -0
- package/dist/cli/sync-scope.test.js.map +1 -0
- package/dist/cli/sync.d.ts +64 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +152 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +29 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +48 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +51 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +18 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +28 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/vault-client.d.ts +22 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +14 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +18 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -0
- package/src/bin/sync-runner.ts +108 -0
- package/src/cli/sync-scope.test.ts +307 -0
- package/src/cli/sync.ts +240 -1
- package/src/index.ts +1 -0
- package/src/prefix-coalesce.test.ts +76 -1
- package/src/prefix-coalesce.ts +45 -0
- package/src/scope-shrink.ts +28 -0
- package/src/vault-client.test.ts +24 -0
- package/src/vault-client.ts +24 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
coalescePrefixes,
|
|
4
|
+
isCoveredByAny,
|
|
5
|
+
grantPathToPrefix,
|
|
6
|
+
} from "./prefix-coalesce.js";
|
|
3
7
|
|
|
4
8
|
describe("coalescePrefixes", () => {
|
|
5
9
|
it("returns empty for empty input", () => {
|
|
@@ -93,3 +97,74 @@ describe("isCoveredByAny", () => {
|
|
|
93
97
|
expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
|
|
94
98
|
});
|
|
95
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("grantPathToPrefix", () => {
|
|
102
|
+
const slug = "indigo";
|
|
103
|
+
|
|
104
|
+
it("strips a companies/<slug>/ anchor", () => {
|
|
105
|
+
expect(grantPathToPrefix("companies/indigo/knowledge/README.md", slug)).toBe(
|
|
106
|
+
"knowledge/README.md",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("strips a bare <slug>/ anchor", () => {
|
|
111
|
+
expect(grantPathToPrefix("indigo/data/vyg/old-meetings/*", slug)).toBe(
|
|
112
|
+
"data/vyg/old-meetings/",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("folds a trailing /* glob into a directory prefix", () => {
|
|
117
|
+
expect(grantPathToPrefix("data/vyg/*", slug)).toBe("data/vyg/");
|
|
118
|
+
expect(grantPathToPrefix("companies/indigo/design-pack/*", slug)).toBe(
|
|
119
|
+
"design-pack/",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("folds a trailing bare * into its parent prefix", () => {
|
|
124
|
+
expect(grantPathToPrefix("reports*", slug)).toBe("reports");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("leaves a company-relative directory prefix unchanged", () => {
|
|
128
|
+
expect(grantPathToPrefix("knowledge/security/", slug)).toBe(
|
|
129
|
+
"knowledge/security/",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves a company-relative exact key unchanged", () => {
|
|
134
|
+
expect(grantPathToPrefix("company.yaml", slug)).toBe("company.yaml");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("maps a bare '*' (and empty) to '' = everything", () => {
|
|
138
|
+
expect(grantPathToPrefix("*", slug)).toBe("");
|
|
139
|
+
expect(grantPathToPrefix("", slug)).toBe("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("tolerates leading slashes", () => {
|
|
143
|
+
expect(grantPathToPrefix("/companies/indigo/x/*", slug)).toBe("x/");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not strip a different company's anchor (defensive)", () => {
|
|
147
|
+
// A grant anchored at another slug is left intact (won't match this
|
|
148
|
+
// company's keys, which is the safe outcome).
|
|
149
|
+
expect(grantPathToPrefix("companies/other/x/*", slug)).toBe(
|
|
150
|
+
"companies/other/x/",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("round-trips through coalescePrefixes + isCoveredByAny against real keys", () => {
|
|
155
|
+
const grants = [
|
|
156
|
+
"companies/indigo/design-pack/*",
|
|
157
|
+
"companies/indigo/knowledge/README.md",
|
|
158
|
+
"data/vyg/*",
|
|
159
|
+
"company.yaml",
|
|
160
|
+
];
|
|
161
|
+
const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
|
|
162
|
+
expect(isCoveredByAny("design-pack/logo.svg", prefixes)).toBe(true);
|
|
163
|
+
expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
|
|
164
|
+
expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
|
|
165
|
+
expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
|
|
166
|
+
// Out of scope:
|
|
167
|
+
expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
|
|
168
|
+
expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/prefix-coalesce.ts
CHANGED
|
@@ -70,3 +70,48 @@ export function isCoveredByAny(
|
|
|
70
70
|
}
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
76
|
+
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|
|
77
|
+
* matching against company-relative `RemoteFile.key`s).
|
|
78
|
+
*
|
|
79
|
+
* Real-world grant paths are inconsistent — observed forms in production:
|
|
80
|
+
* - `*` → everything (company-wide glob)
|
|
81
|
+
* - `companies/<slug>/design-pack/*` → full-anchored + trailing glob
|
|
82
|
+
* - `companies/<slug>/knowledge/README.md` → full-anchored exact file
|
|
83
|
+
* - `<slug>/data/vyg/old-meetings/*` → slug-anchored + trailing glob
|
|
84
|
+
* - `data/vyg/*` → company-relative + trailing glob
|
|
85
|
+
* - `company.yaml` → company-relative exact file
|
|
86
|
+
*
|
|
87
|
+
* The engine's keys are ALWAYS company-relative (`design-pack/x`,
|
|
88
|
+
* `company.yaml`). So we:
|
|
89
|
+
* 1. strip a leading `companies/<slug>/` or `<slug>/` anchor, and
|
|
90
|
+
* 2. fold the ACL glob suffix into a `startsWith`-friendly prefix:
|
|
91
|
+
* - `*` (bare) → `""` (covers everything)
|
|
92
|
+
* - `foo/bar/*` or `foo/bar*` → `foo/bar/` / `foo/bar`
|
|
93
|
+
* - `foo/bar/` (dir) → unchanged
|
|
94
|
+
* - `foo/bar.md` (exact) → unchanged (an exact key is its own prefix)
|
|
95
|
+
*
|
|
96
|
+
* Without this, `coalescePrefixes(grants.map(g => g.path))` produced prefixes
|
|
97
|
+
* with trailing `*` (which never `startsWith`-match a real key) and
|
|
98
|
+
* full/slug-anchored prefixes (which never match company-relative keys) — so
|
|
99
|
+
* `syncMode: shared` would download nothing and prune everything the caller
|
|
100
|
+
* actually has access to. See the live grant dump in the hq-pro vault.
|
|
101
|
+
*/
|
|
102
|
+
export function grantPathToPrefix(grantPath: string, slug: string): string {
|
|
103
|
+
let p = (grantPath ?? "").replace(/^\/+/, "");
|
|
104
|
+
// 1. Strip the company anchor, if present.
|
|
105
|
+
const companyAnchor = `companies/${slug}/`;
|
|
106
|
+
const slugAnchor = `${slug}/`;
|
|
107
|
+
if (p.startsWith(companyAnchor)) {
|
|
108
|
+
p = p.slice(companyAnchor.length);
|
|
109
|
+
} else if (p.startsWith(slugAnchor)) {
|
|
110
|
+
p = p.slice(slugAnchor.length);
|
|
111
|
+
}
|
|
112
|
+
// 2. Fold the ACL glob suffix into a startsWith prefix.
|
|
113
|
+
if (p === "*" || p === "") return ""; // company-wide / empty → everything
|
|
114
|
+
if (p.endsWith("/*")) return p.slice(0, -1); // "a/b/*" → "a/b/"
|
|
115
|
+
if (p.endsWith("*")) return p.slice(0, -1); // "a/b*" → "a/b"
|
|
116
|
+
return p; // dir prefix ("a/b/") or exact key ("a/b.md") — already a prefix
|
|
117
|
+
}
|
package/src/scope-shrink.ts
CHANGED
|
@@ -203,6 +203,34 @@ export class ScopeShrinkBlockedError extends Error {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Structured error thrown when an AUTOMATIC scope shrink would prune more
|
|
208
|
+
* CLEAN local files than the configured safety cap in a single pull. This is
|
|
209
|
+
* the bulk-delete guard: a routine background sync should never silently
|
|
210
|
+
* delete a large local tree — whether from a deliberate-but-abrupt first
|
|
211
|
+
* narrow, a server bug returning an unexpectedly small grant set, or a
|
|
212
|
+
* mis-resolved scope. The operator runs the explicit `hq sync narrow` ritual
|
|
213
|
+
* (which has its own confirmation + dirty gate) or passes `--force-scope-shrink`
|
|
214
|
+
* to proceed. The engine never deletes anything when it throws this.
|
|
215
|
+
*/
|
|
216
|
+
export class ScopeShrinkLargePruneError extends Error {
|
|
217
|
+
readonly code = "SCOPE_SHRINK_LARGE_PRUNE";
|
|
218
|
+
constructor(
|
|
219
|
+
public readonly companyUid: string,
|
|
220
|
+
public readonly toMode: PullRecord["syncMode"],
|
|
221
|
+
public readonly cleanCount: number,
|
|
222
|
+
public readonly cap: number,
|
|
223
|
+
) {
|
|
224
|
+
super(
|
|
225
|
+
`Refusing to auto-prune ${cleanCount} local file(s) for ${companyUid} ` +
|
|
226
|
+
`(${toMode} scope) in one sync — exceeds the safety cap of ${cap}. ` +
|
|
227
|
+
`Run \`hq sync narrow --apply\` to migrate with confirmation, raise ` +
|
|
228
|
+
`HQ_SYNC_MAX_AUTO_PRUNE, or pass --force-scope-shrink.`,
|
|
229
|
+
);
|
|
230
|
+
this.name = "ScopeShrinkLargePruneError";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
export interface ApplyScopeShrinkInput {
|
|
207
235
|
journal: SyncJournal;
|
|
208
236
|
plan: ScopeShrinkPlan;
|
package/src/vault-client.test.ts
CHANGED
|
@@ -670,6 +670,30 @@ describe("VaultClient identity bootstrap", () => {
|
|
|
670
670
|
expect(result?.type).toBe("person");
|
|
671
671
|
});
|
|
672
672
|
|
|
673
|
+
it("sts.vend roundtrips POST /sts/vend with companyUid", async () => {
|
|
674
|
+
fetchSpy.mockResolvedValueOnce(
|
|
675
|
+
jsonResponse(200, {
|
|
676
|
+
credentials: {
|
|
677
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
678
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
679
|
+
sessionToken: "FwoGZXIvYXdzEBY...",
|
|
680
|
+
},
|
|
681
|
+
expiresAt: "2026-01-01T01:00:00.000Z",
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const result = await client.sts.vend({ companyUid: "cmp_x" });
|
|
686
|
+
|
|
687
|
+
expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
|
|
688
|
+
expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
|
|
689
|
+
expect(typeof result.expiresAt).toBe("string");
|
|
690
|
+
|
|
691
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
692
|
+
expect(url).toBe("https://vault.test.example.com/sts/vend");
|
|
693
|
+
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
694
|
+
expect(JSON.parse(init.body as string)).toEqual({ companyUid: "cmp_x" });
|
|
695
|
+
});
|
|
696
|
+
|
|
673
697
|
it("vendSelf_roundtrip", async () => {
|
|
674
698
|
fetchSpy.mockResolvedValueOnce(
|
|
675
699
|
jsonResponse(200, {
|
package/src/vault-client.ts
CHANGED
|
@@ -693,6 +693,30 @@ export class VaultClient {
|
|
|
693
693
|
// -- STS operations (VLT-8) -----------------------------------------------
|
|
694
694
|
|
|
695
695
|
readonly sts = {
|
|
696
|
+
/**
|
|
697
|
+
* Vend membership-scoped credentials for a company the caller belongs to.
|
|
698
|
+
* Backed by the vault-service `POST /sts/vend` route — the multi-tenant
|
|
699
|
+
* path that resolves the company's per-entity bucket and builds the
|
|
700
|
+
* session policy from the caller's role + ACL grants server-side
|
|
701
|
+
* (owner/admin get full-access, member/guest get per-prefix scoping).
|
|
702
|
+
*
|
|
703
|
+
* This is the correct path for interactive reads (`hq files browse`/`cat`):
|
|
704
|
+
* the legacy `POST /vend` ({@link VaultClient.vend}) assumes a single
|
|
705
|
+
* static bucket and is non-functional in multi-tenant production.
|
|
706
|
+
*/
|
|
707
|
+
vend: async (input: {
|
|
708
|
+
companyUid: string;
|
|
709
|
+
durationSeconds?: number;
|
|
710
|
+
}): Promise<{
|
|
711
|
+
credentials: {
|
|
712
|
+
accessKeyId: string;
|
|
713
|
+
secretAccessKey: string;
|
|
714
|
+
sessionToken: string;
|
|
715
|
+
};
|
|
716
|
+
expiresAt: string;
|
|
717
|
+
}> => {
|
|
718
|
+
return this.post("/sts/vend", input);
|
|
719
|
+
},
|
|
696
720
|
/**
|
|
697
721
|
* Vend task-scoped child credentials strictly narrower than the caller's
|
|
698
722
|
* own membership. Backed by the vault-service `POST /sts/vend-child`
|