@indigoai-us/hq-cloud 6.12.0 → 6.12.2
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +10 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +1 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +16 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +62 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +101 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +15 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +14 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +66 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +43 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +26 -0
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +70 -0
- package/dist/object-io.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +9 -0
- package/src/bin/sync-runner.test.ts +1 -0
- package/src/bin/sync-runner.ts +1 -0
- package/src/cli/share.test.ts +120 -0
- package/src/cli/share.ts +81 -3
- package/src/cli/sync.ts +26 -0
- package/src/ignore.test.ts +54 -1
- package/src/ignore.ts +73 -0
- package/src/object-io.test.ts +73 -0
- package/src/object-io.ts +29 -0
package/src/cli/share.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
36
36
|
migratePersonalVaultJournal,
|
|
37
37
|
} from "../journal.js";
|
|
38
|
-
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
38
|
+
import { createIgnoreFilter, isExpectedIgnore, isWithinSizeLimit } from "../ignore.js";
|
|
39
39
|
import {
|
|
40
40
|
wrapFilterWithPersonalVaultDefaults,
|
|
41
41
|
type PersonalVaultExclusion,
|
|
@@ -261,6 +261,7 @@ export function isMalformedVaultKey(key: string): boolean {
|
|
|
261
261
|
export const _testing = {
|
|
262
262
|
isEphemeralPath,
|
|
263
263
|
EPHEMERAL_PATH_PATTERN,
|
|
264
|
+
wrapFilterWithIgnoreVisibility,
|
|
264
265
|
};
|
|
265
266
|
|
|
266
267
|
/**
|
|
@@ -734,6 +735,13 @@ export interface ShareResult {
|
|
|
734
735
|
* its in-scope subset.
|
|
735
736
|
*/
|
|
736
737
|
filesExcludedByScope: number;
|
|
738
|
+
/**
|
|
739
|
+
* Number of distinct hq-root-relative paths skipped by the base ignore
|
|
740
|
+
* filter that look like real content rather than expected build/VCS/cache
|
|
741
|
+
* noise. Mirrors the `count` field of the `ignore-excluded` event (emitted
|
|
742
|
+
* once if this is > 0).
|
|
743
|
+
*/
|
|
744
|
+
filesExcludedByIgnore: number;
|
|
737
745
|
/**
|
|
738
746
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
739
747
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -792,6 +800,31 @@ function wrapFilterWithScope(
|
|
|
792
800
|
};
|
|
793
801
|
}
|
|
794
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Wrap the base ignore filter so push can observe paths it rejects before
|
|
805
|
+
* personal-vault policy or ACL scope wrappers add their own exclusions.
|
|
806
|
+
* Every base-ignore rejection increments `onAnyExcluded`; only rejections
|
|
807
|
+
* that do NOT match expected build/VCS/cache noise are tagged through
|
|
808
|
+
* `onIgnoreExcluded` for the one-shot `ignore-excluded` summary.
|
|
809
|
+
*/
|
|
810
|
+
export function wrapFilterWithIgnoreVisibility(
|
|
811
|
+
underlying: (absPath: string, isDir?: boolean) => boolean,
|
|
812
|
+
hqRoot: string,
|
|
813
|
+
onIgnoreExcluded: (relPath: string) => void,
|
|
814
|
+
onAnyExcluded?: () => void,
|
|
815
|
+
): (absPath: string, isDir?: boolean) => boolean {
|
|
816
|
+
return (absPath: string, isDir?: boolean) => {
|
|
817
|
+
const allowed = underlying(absPath, isDir);
|
|
818
|
+
if (allowed) return true;
|
|
819
|
+
|
|
820
|
+
onAnyExcluded?.();
|
|
821
|
+
const rel = path.relative(hqRoot, absPath).split(path.sep).join("/");
|
|
822
|
+
if (rel === "" || rel.startsWith("..")) return false;
|
|
823
|
+
if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
|
|
824
|
+
return false;
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
795
828
|
/**
|
|
796
829
|
* Share local file(s) to the entity vault.
|
|
797
830
|
*/
|
|
@@ -857,6 +890,8 @@ interface PushRunContext {
|
|
|
857
890
|
excludedSet: Set<string>;
|
|
858
891
|
excludedById: Record<string, number>;
|
|
859
892
|
scopeExcludedSet: Set<string>;
|
|
893
|
+
ignoreExcludedSet: Set<string>;
|
|
894
|
+
ignoreExcludedTotal: { value: number };
|
|
860
895
|
}
|
|
861
896
|
|
|
862
897
|
interface ShareCounters {
|
|
@@ -939,6 +974,18 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
939
974
|
: path.join(hqRoot, "companies", ctx.slug);
|
|
940
975
|
|
|
941
976
|
const ignoreFilter = createIgnoreFilter(hqRoot);
|
|
977
|
+
const ignoreExcludedSet = new Set<string>();
|
|
978
|
+
const ignoreExcludedTotal = { value: 0 };
|
|
979
|
+
const recordedIgnoreFilter = wrapFilterWithIgnoreVisibility(
|
|
980
|
+
ignoreFilter,
|
|
981
|
+
hqRoot,
|
|
982
|
+
(rel) => {
|
|
983
|
+
ignoreExcludedSet.add(rel);
|
|
984
|
+
},
|
|
985
|
+
() => {
|
|
986
|
+
ignoreExcludedTotal.value++;
|
|
987
|
+
},
|
|
988
|
+
);
|
|
942
989
|
const excludedSet = new Set<string>();
|
|
943
990
|
const excludedById: Record<string, number> = {};
|
|
944
991
|
const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
|
|
@@ -951,8 +998,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
951
998
|
scopeExcludedSet.add(rel);
|
|
952
999
|
};
|
|
953
1000
|
const baseFilter = options.personalMode === true
|
|
954
|
-
? wrapFilterWithPersonalVaultDefaults(
|
|
955
|
-
:
|
|
1001
|
+
? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
|
|
1002
|
+
: recordedIgnoreFilter;
|
|
956
1003
|
const shouldSync = options.prefixSet !== undefined
|
|
957
1004
|
? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
|
|
958
1005
|
: baseFilter;
|
|
@@ -981,6 +1028,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
981
1028
|
excludedSet,
|
|
982
1029
|
excludedById,
|
|
983
1030
|
scopeExcludedSet,
|
|
1031
|
+
ignoreExcludedSet,
|
|
1032
|
+
ignoreExcludedTotal,
|
|
984
1033
|
};
|
|
985
1034
|
}
|
|
986
1035
|
|
|
@@ -1494,6 +1543,20 @@ function finalizeShareJournal(run: PushRunContext): void {
|
|
|
1494
1543
|
samplePaths,
|
|
1495
1544
|
});
|
|
1496
1545
|
}
|
|
1546
|
+
|
|
1547
|
+
if (run.ignoreExcludedSet.size > 0) {
|
|
1548
|
+
const samplePaths: string[] = [];
|
|
1549
|
+
for (const p of run.ignoreExcludedSet) {
|
|
1550
|
+
samplePaths.push(p);
|
|
1551
|
+
if (samplePaths.length >= 10) break;
|
|
1552
|
+
}
|
|
1553
|
+
run.emit({
|
|
1554
|
+
type: "ignore-excluded",
|
|
1555
|
+
count: run.ignoreExcludedSet.size,
|
|
1556
|
+
totalExcluded: run.ignoreExcludedTotal.value,
|
|
1557
|
+
samplePaths,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1497
1560
|
}
|
|
1498
1561
|
|
|
1499
1562
|
function throwUploadWorkerErrors(workerErrors: Error[]): void {
|
|
@@ -1528,6 +1591,7 @@ function buildShareResult(
|
|
|
1528
1591
|
filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
|
|
1529
1592
|
filesExcludedByPolicy: run.excludedSet.size,
|
|
1530
1593
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1594
|
+
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
1531
1595
|
conflictPaths,
|
|
1532
1596
|
aborted,
|
|
1533
1597
|
};
|
|
@@ -1589,6 +1653,20 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1589
1653
|
` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
|
|
1590
1654
|
` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
|
|
1591
1655
|
);
|
|
1656
|
+
} else if (event.type === "ignore-excluded") {
|
|
1657
|
+
// The "not silent" surface: an ignore rule dropped content that doesn't
|
|
1658
|
+
// look like expected build/VCS/cache noise, so it never reached the vault.
|
|
1659
|
+
// Surface it as a WARN naming the paths so an over-broad exclusion is
|
|
1660
|
+
// observable rather than invisible (DEV-1791 / feedback_b9dfc7ae).
|
|
1661
|
+
console.warn(
|
|
1662
|
+
` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
|
|
1663
|
+
);
|
|
1664
|
+
for (const p of event.samplePaths) {
|
|
1665
|
+
console.warn(` · ${p}`);
|
|
1666
|
+
}
|
|
1667
|
+
if (event.count > event.samplePaths.length) {
|
|
1668
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1669
|
+
}
|
|
1592
1670
|
}
|
|
1593
1671
|
}
|
|
1594
1672
|
|
package/src/cli/sync.ts
CHANGED
|
@@ -287,6 +287,22 @@ export type SyncProgressEvent =
|
|
|
287
287
|
count: number;
|
|
288
288
|
samplePaths: string[];
|
|
289
289
|
}
|
|
290
|
+
| {
|
|
291
|
+
/**
|
|
292
|
+
* Emitted at most ONCE per `share()` push leg when the base ignore
|
|
293
|
+
* filter dropped one or more NOTEWORTHY paths: content that does not
|
|
294
|
+
* match expected build/VCS/cache noise classes. Surfaces an over-broad
|
|
295
|
+
* exclusion that would otherwise drop content silently (DEV-1791).
|
|
296
|
+
*
|
|
297
|
+
* `count` is the number of distinct noteworthy paths; `totalExcluded`
|
|
298
|
+
* is all base-ignore rejections including expected noise; `samplePaths`
|
|
299
|
+
* carries up to 10 noteworthy paths. Not emitted when `count === 0`.
|
|
300
|
+
*/
|
|
301
|
+
type: "ignore-excluded";
|
|
302
|
+
count: number;
|
|
303
|
+
totalExcluded: number;
|
|
304
|
+
samplePaths: string[];
|
|
305
|
+
}
|
|
290
306
|
| {
|
|
291
307
|
/**
|
|
292
308
|
* Emitted by the PUSH leg (`share()`) once per key whose upload was
|
|
@@ -2268,5 +2284,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2268
2284
|
if (event.count > event.samplePaths.length) {
|
|
2269
2285
|
console.log(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2270
2286
|
}
|
|
2287
|
+
} else if (event.type === "ignore-excluded") {
|
|
2288
|
+
console.warn(
|
|
2289
|
+
` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
|
|
2290
|
+
);
|
|
2291
|
+
for (const p of event.samplePaths) {
|
|
2292
|
+
console.warn(` · ${p}`);
|
|
2293
|
+
}
|
|
2294
|
+
if (event.count > event.samplePaths.length) {
|
|
2295
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2296
|
+
}
|
|
2271
2297
|
}
|
|
2272
2298
|
}
|
package/src/ignore.test.ts
CHANGED
|
@@ -11,7 +11,31 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
12
|
import * as os from "os";
|
|
13
13
|
import * as path from "path";
|
|
14
|
-
import { createIgnoreFilter } from "./ignore.js";
|
|
14
|
+
import { createIgnoreFilter, isExpectedIgnore } from "./ignore.js";
|
|
15
|
+
import { wrapFilterWithIgnoreVisibility } from "./cli/share.js";
|
|
16
|
+
|
|
17
|
+
describe("isExpectedIgnore", () => {
|
|
18
|
+
it("classifies top-level HQ-managed roots as expected", () => {
|
|
19
|
+
expect(isExpectedIgnore("repos")).toBe(true);
|
|
20
|
+
expect(isExpectedIgnore("repos/private/x.ts")).toBe(true);
|
|
21
|
+
expect(isExpectedIgnore("workspace/tmp/file.md")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("classifies build, VCS, cache, and known basename noise as expected", () => {
|
|
25
|
+
expect(isExpectedIgnore("node_modules/react/index.js")).toBe(true);
|
|
26
|
+
expect(isExpectedIgnore("apps/web/node_modules/react/index.js")).toBe(true);
|
|
27
|
+
expect(isExpectedIgnore(".git/config")).toBe(true);
|
|
28
|
+
expect(isExpectedIgnore(".DS_Store")).toBe(true);
|
|
29
|
+
expect(isExpectedIgnore("x/.env")).toBe(true);
|
|
30
|
+
expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
|
|
31
|
+
expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps nested repos/workspace and ordinary content noteworthy", () => {
|
|
35
|
+
expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
|
|
36
|
+
expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
15
39
|
|
|
16
40
|
describe("createIgnoreFilter", () => {
|
|
17
41
|
let hqRoot: string;
|
|
@@ -69,6 +93,35 @@ describe("createIgnoreFilter", () => {
|
|
|
69
93
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
|
|
70
94
|
});
|
|
71
95
|
|
|
96
|
+
it("visibility fires on an OVER-BROAD exclusion (real filter + recorder, DEV-1791)", () => {
|
|
97
|
+
// Simulate the regressed over-broad bug: an un-anchored `repos/` in
|
|
98
|
+
// .hqignore that matches ANY nested repos segment — exactly what
|
|
99
|
+
// silently ate /discover knowledge under companies/*/knowledge/repos/
|
|
100
|
+
// before the root-anchor fix. The visibility recorder must surface the
|
|
101
|
+
// dropped knowledge as NOTEWORTHY while staying quiet on build noise.
|
|
102
|
+
fs.writeFileSync(path.join(hqRoot, ".hqignore"), "repos/\n");
|
|
103
|
+
const ignoreFilter = createIgnoreFilter(hqRoot);
|
|
104
|
+
const noteworthy: string[] = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
const recorded = wrapFilterWithIgnoreVisibility(
|
|
107
|
+
ignoreFilter,
|
|
108
|
+
hqRoot,
|
|
109
|
+
(rel) => { noteworthy.push(rel); },
|
|
110
|
+
() => { total++; },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Knowledge under a nested repos/ is dropped by the over-broad rule and
|
|
114
|
+
// is genuine content → captured as noteworthy.
|
|
115
|
+
expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/repos/foo/notes.md"))).toBe(false);
|
|
116
|
+
// Build noise is also excluded but is EXPECTED → counted, not noteworthy.
|
|
117
|
+
expect(recorded(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
|
|
118
|
+
// Ordinary knowledge that the filter ALLOWS fires nothing.
|
|
119
|
+
expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/overview.md"))).toBe(true);
|
|
120
|
+
|
|
121
|
+
expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo/notes.md"]);
|
|
122
|
+
expect(total).toBe(2); // both exclusions counted; only one is noteworthy
|
|
123
|
+
});
|
|
124
|
+
|
|
72
125
|
it("v15+ layout (core/core.yaml present): root core.yaml is ignored, nested core/core.yaml syncs", () => {
|
|
73
126
|
// On v15+ the scaffold/version manifest lives at `core/core.yaml` (a real
|
|
74
127
|
// project file that DOES sync). Any root-level `core.yaml` here is a stale
|
package/src/ignore.ts
CHANGED
|
@@ -209,6 +209,79 @@ function segmentToRegex(seg: string): RegExp {
|
|
|
209
209
|
return new RegExp(`^${body}$`);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
export const EXPECTED_IGNORE_SEGMENTS = new Set([
|
|
213
|
+
".git",
|
|
214
|
+
"node_modules",
|
|
215
|
+
".pnpm-store",
|
|
216
|
+
"dist",
|
|
217
|
+
"build",
|
|
218
|
+
".next",
|
|
219
|
+
".nuxt",
|
|
220
|
+
".svelte-kit",
|
|
221
|
+
".turbo",
|
|
222
|
+
".parcel-cache",
|
|
223
|
+
".vite",
|
|
224
|
+
"coverage",
|
|
225
|
+
"target",
|
|
226
|
+
"__pycache__",
|
|
227
|
+
".pytest_cache",
|
|
228
|
+
".mypy_cache",
|
|
229
|
+
".ruff_cache",
|
|
230
|
+
".venv",
|
|
231
|
+
"venv",
|
|
232
|
+
"vendor",
|
|
233
|
+
"out",
|
|
234
|
+
".cache",
|
|
235
|
+
"tmp",
|
|
236
|
+
".tmp",
|
|
237
|
+
".hq",
|
|
238
|
+
".claude",
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Classify a base-ignore rejection as expected noise vs noteworthy content.
|
|
243
|
+
*
|
|
244
|
+
* `true` means the ignored path belongs to an expected by-design exclusion
|
|
245
|
+
* class: HQ-managed top-level roots, build/VCS/cache/machine-local segments,
|
|
246
|
+
* or known generated/noise basenames. `false` means the rejected path looks
|
|
247
|
+
* like real user content and should be surfaced by push observability.
|
|
248
|
+
*
|
|
249
|
+
* Top-level `repos/` and `workspace/` are expected, but nested segments named
|
|
250
|
+
* `repos` or `workspace` are deliberately noteworthy: that is the DEV-1791
|
|
251
|
+
* regression signal this classifier exists to preserve.
|
|
252
|
+
*/
|
|
253
|
+
export function isExpectedIgnore(relPath: string): boolean {
|
|
254
|
+
if (
|
|
255
|
+
relPath === "repos" ||
|
|
256
|
+
relPath.startsWith("repos/") ||
|
|
257
|
+
relPath === "workspace" ||
|
|
258
|
+
relPath.startsWith("workspace/")
|
|
259
|
+
) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const segments = relPath.split("/");
|
|
264
|
+
for (const segment of segments) {
|
|
265
|
+
if (EXPECTED_IGNORE_SEGMENTS.has(segment)) return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const basename = segments[segments.length - 1] ?? "";
|
|
269
|
+
return (
|
|
270
|
+
basename === ".DS_Store" ||
|
|
271
|
+
basename === "Thumbs.db" ||
|
|
272
|
+
basename === "company.yaml" ||
|
|
273
|
+
basename === "INDEX.md" ||
|
|
274
|
+
basename === "modules.lock" ||
|
|
275
|
+
basename.endsWith(".pyc") ||
|
|
276
|
+
basename.endsWith(".class") ||
|
|
277
|
+
basename.endsWith(".pid") ||
|
|
278
|
+
basename.startsWith(".hq-") ||
|
|
279
|
+
basename === ".env" ||
|
|
280
|
+
basename.startsWith(".env.") ||
|
|
281
|
+
basename === ".mcp.json"
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
212
285
|
export function createIgnoreFilter(
|
|
213
286
|
hqRoot: string,
|
|
214
287
|
): (filePath: string, isDir?: boolean) => boolean {
|
package/src/object-io.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./object-io.js";
|
|
20
20
|
import type { EntityContext } from "./types.js";
|
|
21
21
|
import type { PresignResultRow, VaultListedObject } from "./vault-client.js";
|
|
22
|
+
import { isAccessDenied } from "./sync-core.js";
|
|
22
23
|
|
|
23
24
|
const COMPANY = "cmp_test";
|
|
24
25
|
|
|
@@ -205,6 +206,59 @@ describe("PresignObjectIO.putObject", () => {
|
|
|
205
206
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
it("a write denial (FILES_PRESIGN_FORBIDDEN) throws the access-denied shape so the push skips it like a denied GET", async () => {
|
|
210
|
+
const { vault, setPresign } = makeVault();
|
|
211
|
+
// The server refuses to MINT a PUT URL for a key the caller can't write
|
|
212
|
+
// (private-by-default: members hold read-only on a shared knowledge prefix).
|
|
213
|
+
setPresign([
|
|
214
|
+
{
|
|
215
|
+
key: "knowledge/clients/acme/brief.md",
|
|
216
|
+
op: "put",
|
|
217
|
+
error: "Caller lacks write permission on 'knowledge/clients/acme/brief.md'",
|
|
218
|
+
code: "FILES_PRESIGN_FORBIDDEN",
|
|
219
|
+
},
|
|
220
|
+
]);
|
|
221
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
222
|
+
let caught: unknown;
|
|
223
|
+
await io
|
|
224
|
+
.putObject({
|
|
225
|
+
key: "knowledge/clients/acme/brief.md",
|
|
226
|
+
body: Buffer.from("z"),
|
|
227
|
+
contentType: "text/markdown",
|
|
228
|
+
})
|
|
229
|
+
.catch((e) => {
|
|
230
|
+
caught = e;
|
|
231
|
+
});
|
|
232
|
+
// Same shape a denied GET/HEAD already throws (name === "Forbidden"), so the
|
|
233
|
+
// share.ts push catch routes it through `isAccessDenied` into the silent
|
|
234
|
+
// per-path skip (scopeExcludedSet) + the "N paths skipped — outside your
|
|
235
|
+
// granted access" aggregate, instead of a fatal `type:"error"` that exits
|
|
236
|
+
// the runner non-zero and floods Sentry. This is the PUT/GET symmetry that
|
|
237
|
+
// fixes the fleet-wide presign-put-denied crash-loop.
|
|
238
|
+
expect((caught as Error).name).toBe("Forbidden");
|
|
239
|
+
expect(isAccessDenied(caught)).toBe(true);
|
|
240
|
+
// Denied at mint time — the byte-moving PUT is never attempted.
|
|
241
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("a non-authorization presign failure stays a generic (fatal) error — not silently skipped", async () => {
|
|
245
|
+
const { vault, setPresign } = makeVault();
|
|
246
|
+
setPresign([
|
|
247
|
+
{ key: "secret/x", op: "put", error: "boom", code: "PRESIGN_INTERNAL" },
|
|
248
|
+
]);
|
|
249
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
250
|
+
let caught: unknown;
|
|
251
|
+
await io
|
|
252
|
+
.putObject({ key: "secret/x", body: Buffer.from("z"), contentType: "x" })
|
|
253
|
+
.catch((e) => {
|
|
254
|
+
caught = e;
|
|
255
|
+
});
|
|
256
|
+
// Only `*_FORBIDDEN` authorization denials are reclassified; a real backend
|
|
257
|
+
// failure must still surface loudly so it is not masked as an expected skip.
|
|
258
|
+
expect(isAccessDenied(caught)).toBe(false);
|
|
259
|
+
expect((caught as Error).message).toMatch(/denied for secret\/x.*PRESIGN_INTERNAL/);
|
|
260
|
+
});
|
|
261
|
+
|
|
208
262
|
it("throws when the PUT itself fails", async () => {
|
|
209
263
|
const { vault, setPresign } = makeVault();
|
|
210
264
|
setPresign([{ key: "k", op: "put", url: "https://s3/put" }]);
|
|
@@ -516,6 +570,25 @@ describe("PresignObjectIO.headObject", () => {
|
|
|
516
570
|
expect(await io.headObject("gone")).toBeNull();
|
|
517
571
|
});
|
|
518
572
|
|
|
573
|
+
it("returns null on a server-confirmed FILES_PRESIGN_NOT_FOUND (absence, not denial — no GET issued)", async () => {
|
|
574
|
+
// The presign lambda HEAD'd S3 and confirmed absence. Unlike a read
|
|
575
|
+
// DENIAL (Forbidden, below), this is unambiguous "object missing", so
|
|
576
|
+
// headObject reports absent (null) and the push creates the new object —
|
|
577
|
+
// never reaching a 403-masked presigned GET.
|
|
578
|
+
const { vault, setPresign } = makeVault();
|
|
579
|
+
setPresign([
|
|
580
|
+
{
|
|
581
|
+
key: "new.md",
|
|
582
|
+
op: "get",
|
|
583
|
+
error: "Object not found: 'new.md'",
|
|
584
|
+
code: "FILES_PRESIGN_NOT_FOUND",
|
|
585
|
+
},
|
|
586
|
+
]);
|
|
587
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
588
|
+
expect(await io.headObject("new.md")).toBeNull();
|
|
589
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
590
|
+
});
|
|
591
|
+
|
|
519
592
|
it("throws Forbidden when presign denies the key — denial is NOT absence", async () => {
|
|
520
593
|
// Regression: pre-fix this returned null ("absent"), which made
|
|
521
594
|
// share.ts's push guard (`if (remoteMeta)`) skip every conflict check
|
package/src/object-io.ts
CHANGED
|
@@ -354,6 +354,24 @@ function firstRowOrThrow(
|
|
|
354
354
|
throw new Error(`presign ${op} returned no row for ${key}`);
|
|
355
355
|
}
|
|
356
356
|
if (row.error || !row.url) {
|
|
357
|
+
// An authorization denial (server `*_FORBIDDEN` code — e.g.
|
|
358
|
+
// FILES_PRESIGN_FORBIDDEN when the caller lacks write on a shared prefix,
|
|
359
|
+
// or FILES_RAW_FORBIDDEN) must throw the SAME `name: "Forbidden"` shape the
|
|
360
|
+
// GET/HEAD path uses (see {@link accessDeniedError}) so it routes through
|
|
361
|
+
// the `isAccessDenied` skip in sync.ts / share.ts. Without this, a denied
|
|
362
|
+
// PUT presign fell to the generic Error below — `isAccessDenied` returned
|
|
363
|
+
// false — and the per-file ACL skip the GET path handles gracefully became
|
|
364
|
+
// a FATAL sync error: the crash-loop + Sentry flood behind the fleet-wide
|
|
365
|
+
// "presign put denied" incident (members pushing net-new keys to a
|
|
366
|
+
// company-wide-read prefix under private-by-default). The denial is
|
|
367
|
+
// expected; only the fatal handling of it was the bug.
|
|
368
|
+
if (typeof row.code === "string" && row.code.endsWith("_FORBIDDEN")) {
|
|
369
|
+
throw accessDeniedError(
|
|
370
|
+
key,
|
|
371
|
+
`no write access to '${key}' (server ${op} denied: ${row.code}) — ` +
|
|
372
|
+
`ask an admin to grant write on this shared prefix`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
357
375
|
throw new Error(
|
|
358
376
|
`presign ${op} denied for ${key}: ${row.error ?? "no url"}${
|
|
359
377
|
row.code ? ` (${row.code})` : ""
|
|
@@ -853,6 +871,17 @@ export class PresignObjectIO implements ObjectIO {
|
|
|
853
871
|
throw err;
|
|
854
872
|
}
|
|
855
873
|
const row = results[0];
|
|
874
|
+
if (row?.code === "FILES_PRESIGN_NOT_FOUND") {
|
|
875
|
+
// The presign lambda HEAD'd S3 and confirmed the object does NOT
|
|
876
|
+
// exist — an unambiguous absence, distinct from the read DENIAL
|
|
877
|
+
// handled below. Report absent (null) so the push creates the new
|
|
878
|
+
// object. Without this, a brand-new file's first upload is blocked
|
|
879
|
+
// forever: its presigned GET 403s (S3 masks absence when the URL
|
|
880
|
+
// lacks `s3:ListBucket`) and routes through the access-denied skip
|
|
881
|
+
// path. The server's explicit NOT_FOUND is the ONLY safe absence
|
|
882
|
+
// signal — a 403 stays "unknown", never "missing" (see below).
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
856
885
|
if (!row || row.error || !row.url) {
|
|
857
886
|
// A per-key denial means the caller can't READ the key — it says
|
|
858
887
|
// nothing about whether the object EXISTS. Pre-fix this returned
|