@indigoai-us/hq-cloud 5.41.0 → 5.42.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.
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { coalescePrefixes, isCoveredByAny } from "./prefix-coalesce.js";
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
+ });
@@ -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
+ }
@@ -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;