@indigoai-us/hq-cloud 6.2.4 → 6.2.6
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 +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +37 -8
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +33 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +36 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +131 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +99 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +20 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +32 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cli/watch-event-push-conflict.test.d.ts +25 -0
- package/dist/cli/watch-event-push-conflict.test.d.ts.map +1 -0
- package/dist/cli/watch-event-push-conflict.test.js +210 -0
- package/dist/cli/watch-event-push-conflict.test.js.map +1 -0
- package/dist/prefix-coalesce.d.ts +22 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +35 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +39 -0
- package/src/bin/sync-runner.ts +44 -14
- package/src/cli/share.test.ts +109 -0
- package/src/cli/share.ts +159 -3
- package/src/cli/sync.test.ts +36 -0
- package/src/cli/sync.ts +44 -1
- package/src/cli/watch-event-push-conflict.test.ts +234 -0
- package/src/prefix-coalesce.ts +35 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard: the watch + event-push path must NOT mint phantom
|
|
3
|
+
* `.conflict-*` mirrors for an ordinary single-writer local edit.
|
|
4
|
+
*
|
|
5
|
+
* Background (feedback_ef2b7c8c): a user editing files under
|
|
6
|
+
* `companies/{slug}/` saw the menubar's watch/event-push runner mint a
|
|
7
|
+
* `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
|
|
8
|
+
* thousands of phantom files in a single session. The watch/event-push runner
|
|
9
|
+
* reacts to a local change by running a targeted `--direction push` pass, i.e.
|
|
10
|
+
* `share()` over the changed company subtree, so the conflict-minting logic is
|
|
11
|
+
* share()'s push-side conflict gate. The original gate flagged a conflict on
|
|
12
|
+
* `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
|
|
13
|
+
* every edit of any already-synced file. The gate now requires
|
|
14
|
+
* `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
|
|
15
|
+
* has `remoteChanged` (the live S3 ETag still equals the journal's recorded
|
|
16
|
+
* baseline), so an ordinary edit is uploaded, not mirrored.
|
|
17
|
+
*
|
|
18
|
+
* This test pins that contract end-to-end through `share()` using the same
|
|
19
|
+
* mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
|
|
20
|
+
* ETag is always exactly what THIS device last uploaded. A positive control
|
|
21
|
+
* (a genuine out-of-band peer write) proves the guard is not trivially
|
|
22
|
+
* always-zero: real divergence is still detected.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
25
|
+
import * as fs from "fs";
|
|
26
|
+
import * as path from "path";
|
|
27
|
+
import * as os from "os";
|
|
28
|
+
import { clearContextCache } from "../context.js";
|
|
29
|
+
vi.mock("../s3.js", () => ({
|
|
30
|
+
toPosixKey: (key) => key.split("\\").join("/"),
|
|
31
|
+
uploadFile: vi.fn(),
|
|
32
|
+
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
|
|
33
|
+
downloadFile: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
listRemoteFiles: vi.fn().mockResolvedValue([]),
|
|
35
|
+
deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
headRemoteFile: vi.fn(),
|
|
37
|
+
primeObjectTransport: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
primeUploads: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("readline", () => ({
|
|
41
|
+
createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
|
|
42
|
+
}));
|
|
43
|
+
import { share } from "./share.js";
|
|
44
|
+
import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
|
|
45
|
+
const mockConfig = {
|
|
46
|
+
apiUrl: "https://vault-api.test",
|
|
47
|
+
authToken: "test-jwt-token",
|
|
48
|
+
region: "us-east-1",
|
|
49
|
+
};
|
|
50
|
+
const mockEntity = {
|
|
51
|
+
uid: "cmp_01ABCDEF",
|
|
52
|
+
slug: "acme",
|
|
53
|
+
bucketName: "hq-vault-acme-123",
|
|
54
|
+
status: "active",
|
|
55
|
+
};
|
|
56
|
+
function setupFetchMock() {
|
|
57
|
+
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url) => {
|
|
58
|
+
const u = String(url);
|
|
59
|
+
if (u.includes("/entity/check-slug/me"))
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
status: 200,
|
|
63
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
64
|
+
text: async () => "",
|
|
65
|
+
};
|
|
66
|
+
if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
|
|
67
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
68
|
+
if (u.includes("/sts/vend"))
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
status: 200,
|
|
72
|
+
json: async () => ({
|
|
73
|
+
credentials: {
|
|
74
|
+
accessKeyId: "ASIA_TEST",
|
|
75
|
+
secretAccessKey: "s",
|
|
76
|
+
sessionToken: "t",
|
|
77
|
+
expiration: new Date(Date.now() + 9e5).toISOString(),
|
|
78
|
+
},
|
|
79
|
+
expiresAt: new Date(Date.now() + 9e5).toISOString(),
|
|
80
|
+
}),
|
|
81
|
+
text: async () => "",
|
|
82
|
+
};
|
|
83
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
/** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
|
|
87
|
+
function countConflictMirrors(root) {
|
|
88
|
+
const out = [];
|
|
89
|
+
const walk = (d) => {
|
|
90
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
91
|
+
const p = path.join(d, e.name);
|
|
92
|
+
if (e.isDirectory())
|
|
93
|
+
walk(p);
|
|
94
|
+
else if (/\.conflict-/.test(e.name))
|
|
95
|
+
out.push(p);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(root);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* In-memory single-writer S3: the object's ETag is always exactly what THIS
|
|
103
|
+
* device last uploaded (no peer ever writes). `lastModified` advances on each
|
|
104
|
+
* PUT to model S3 stamping it server-side. The S3-module signatures are
|
|
105
|
+
* `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
|
|
106
|
+
* `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
|
|
107
|
+
*/
|
|
108
|
+
function wireSingleWriterRemote() {
|
|
109
|
+
const remote = new Map();
|
|
110
|
+
let clock = Date.now();
|
|
111
|
+
let seq = 0;
|
|
112
|
+
vi.mocked(uploadFile).mockImplementation(async (_ctx, localPath, key) => {
|
|
113
|
+
const etag = `"etag-${seq++}"`;
|
|
114
|
+
clock += 1000;
|
|
115
|
+
remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
|
|
116
|
+
return { etag };
|
|
117
|
+
});
|
|
118
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
119
|
+
const r = remote.get(key);
|
|
120
|
+
return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
|
|
121
|
+
});
|
|
122
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
123
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
124
|
+
fs.writeFileSync(dest, "remote-bytes\n");
|
|
125
|
+
return {};
|
|
126
|
+
});
|
|
127
|
+
return remote;
|
|
128
|
+
}
|
|
129
|
+
describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
|
|
130
|
+
let tmpDir;
|
|
131
|
+
let stateDir;
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
clearContextCache();
|
|
134
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
|
|
135
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
|
|
136
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
137
|
+
setupFetchMock();
|
|
138
|
+
});
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
vi.clearAllMocks();
|
|
142
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
144
|
+
delete process.env.HQ_STATE_DIR;
|
|
145
|
+
});
|
|
146
|
+
it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
|
|
147
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
148
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
149
|
+
const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
|
|
150
|
+
for (const f of files)
|
|
151
|
+
fs.writeFileSync(f, "v0\n");
|
|
152
|
+
wireSingleWriterRemote();
|
|
153
|
+
const conflictPathsSeen = [];
|
|
154
|
+
// The event-push runner pushes the changed company subtree. Mirror that:
|
|
155
|
+
// share() over the company root on every settled change.
|
|
156
|
+
const eventPush = async () => {
|
|
157
|
+
const r = await share({
|
|
158
|
+
paths: [companyRoot],
|
|
159
|
+
company: "acme",
|
|
160
|
+
vaultConfig: mockConfig,
|
|
161
|
+
hqRoot: tmpDir,
|
|
162
|
+
onConflict: "keep",
|
|
163
|
+
onEvent: () => { },
|
|
164
|
+
});
|
|
165
|
+
conflictPathsSeen.push(...r.conflictPaths);
|
|
166
|
+
};
|
|
167
|
+
// Initial sync establishes the journal baseline (first upload of each file).
|
|
168
|
+
await eventPush();
|
|
169
|
+
// An editing session: 30 ordinary local edits, round-robin across the
|
|
170
|
+
// files, each followed by its targeted event-push.
|
|
171
|
+
for (let i = 0; i < 30; i++) {
|
|
172
|
+
fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
|
|
173
|
+
await eventPush();
|
|
174
|
+
}
|
|
175
|
+
expect(countConflictMirrors(tmpDir)).toEqual([]);
|
|
176
|
+
expect(conflictPathsSeen).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
it("positive control: a genuine peer write is still detected as a conflict", async () => {
|
|
179
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
180
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
181
|
+
const file = path.join(companyRoot, "contested.md");
|
|
182
|
+
fs.writeFileSync(file, "v0\n");
|
|
183
|
+
const remote = wireSingleWriterRemote();
|
|
184
|
+
const conflictPathsSeen = [];
|
|
185
|
+
const eventPush = async () => {
|
|
186
|
+
const r = await share({
|
|
187
|
+
paths: [companyRoot],
|
|
188
|
+
company: "acme",
|
|
189
|
+
vaultConfig: mockConfig,
|
|
190
|
+
hqRoot: tmpDir,
|
|
191
|
+
onConflict: "keep",
|
|
192
|
+
onEvent: () => { },
|
|
193
|
+
});
|
|
194
|
+
conflictPathsSeen.push(...r.conflictPaths);
|
|
195
|
+
};
|
|
196
|
+
await eventPush(); // baseline
|
|
197
|
+
// A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
|
|
198
|
+
// keys are company-relative, so the key is "contested.md".
|
|
199
|
+
remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
|
|
200
|
+
// ...and we also edit locally → both sides moved → a genuine conflict.
|
|
201
|
+
fs.writeFileSync(file, "v1-local\n");
|
|
202
|
+
await eventPush();
|
|
203
|
+
// Existing-entry push conflicts surface via conflictPaths (the inspection
|
|
204
|
+
// mirror is written by the pull leg). The contract under test: a REAL
|
|
205
|
+
// divergence is still flagged — the single-writer guard above did not
|
|
206
|
+
// over-correct into swallowing true conflicts.
|
|
207
|
+
expect(conflictPathsSeen).toContain("contested.md");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
//# sourceMappingURL=watch-event-push-conflict.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch-event-push-conflict.test.js","sourceRoot":"","sources":["../../src/cli/watch-event-push-conflict.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -35,6 +35,28 @@ export declare function coalescePrefixes(prefixes: readonly string[]): string[];
|
|
|
35
35
|
* `startsWith` semantics as `coalescePrefixes`.
|
|
36
36
|
*/
|
|
37
37
|
export declare function isCoveredByAny(path: string, prefixSet: readonly string[]): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
|
|
40
|
+
* into directory `relDir` (company-relative, no leading slash) given the
|
|
41
|
+
* granted `prefixSet`?
|
|
42
|
+
*
|
|
43
|
+
* A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
|
|
44
|
+
* descended whenever it COULD contain an in-scope file — which is true in two
|
|
45
|
+
* directions:
|
|
46
|
+
* - the directory sits INSIDE a granted prefix (`knowledge/sub` under
|
|
47
|
+
* `knowledge/`), or
|
|
48
|
+
* - a granted prefix sits INSIDE the directory (`knowledge/` under the
|
|
49
|
+
* company root `""`, or `knowledge/README.md` under `knowledge/`).
|
|
50
|
+
* Without the second case the walk would refuse to descend into `knowledge/`
|
|
51
|
+
* to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
|
|
52
|
+
* to nothing.
|
|
53
|
+
*
|
|
54
|
+
* The directory is normalized to a trailing-slash form so the `startsWith`
|
|
55
|
+
* comparisons line up with coalesced prefixes (which are themselves either
|
|
56
|
+
* trailing-slash dir prefixes or exact-file keys). The empty string (company
|
|
57
|
+
* root) always descends when any prefix exists.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isDirInScope(relDir: string, prefixSet: readonly string[]): boolean;
|
|
38
60
|
/**
|
|
39
61
|
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
40
62
|
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prefix-coalesce.d.ts","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAyBtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAKT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAezE"}
|
|
1
|
+
{"version":3,"file":"prefix-coalesce.d.ts","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAyBtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAKT;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAST;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAezE"}
|
package/dist/prefix-coalesce.js
CHANGED
|
@@ -66,6 +66,41 @@ export function isCoveredByAny(path, prefixSet) {
|
|
|
66
66
|
}
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
|
|
71
|
+
* into directory `relDir` (company-relative, no leading slash) given the
|
|
72
|
+
* granted `prefixSet`?
|
|
73
|
+
*
|
|
74
|
+
* A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
|
|
75
|
+
* descended whenever it COULD contain an in-scope file — which is true in two
|
|
76
|
+
* directions:
|
|
77
|
+
* - the directory sits INSIDE a granted prefix (`knowledge/sub` under
|
|
78
|
+
* `knowledge/`), or
|
|
79
|
+
* - a granted prefix sits INSIDE the directory (`knowledge/` under the
|
|
80
|
+
* company root `""`, or `knowledge/README.md` under `knowledge/`).
|
|
81
|
+
* Without the second case the walk would refuse to descend into `knowledge/`
|
|
82
|
+
* to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
|
|
83
|
+
* to nothing.
|
|
84
|
+
*
|
|
85
|
+
* The directory is normalized to a trailing-slash form so the `startsWith`
|
|
86
|
+
* comparisons line up with coalesced prefixes (which are themselves either
|
|
87
|
+
* trailing-slash dir prefixes or exact-file keys). The empty string (company
|
|
88
|
+
* root) always descends when any prefix exists.
|
|
89
|
+
*/
|
|
90
|
+
export function isDirInScope(relDir, prefixSet) {
|
|
91
|
+
const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
|
|
92
|
+
for (const p of prefixSet) {
|
|
93
|
+
if (p === "")
|
|
94
|
+
return true; // full scope
|
|
95
|
+
if (dir === "")
|
|
96
|
+
return true; // company root — descend to reach grants
|
|
97
|
+
if (dir.startsWith(p))
|
|
98
|
+
return true; // dir is inside a granted prefix
|
|
99
|
+
if (p.startsWith(dir))
|
|
100
|
+
return true; // a granted prefix is inside dir
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
69
104
|
/**
|
|
70
105
|
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
71
106
|
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prefix-coalesce.js","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,UAAU,gBAAgB,CAAC,QAA2B;IAC1D,wBAAwB;IACxB,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;YACtC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,uEAAuE;IACvE,0EAA0E;IAC1E,oBAAoB;IACpB,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;IAClC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,QAAQ,GAAkB,IAAI,CAAC;IACnC,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,IAAI,QAAQ,KAAK,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAChD,uCAAuC;YACvC,SAAS;QACX,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACf,QAAQ,GAAG,CAAC,CAAC;IACf,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAY,EACZ,SAA4B;IAE5B,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,KAAK,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAiB,EAAE,IAAY;IAC/D,IAAI,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC9C,2CAA2C;IAC3C,MAAM,aAAa,GAAG,aAAa,IAAI,GAAG,CAAC;IAC3C,MAAM,UAAU,GAAG,GAAG,IAAI,GAAG,CAAC;IAC9B,IAAI,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAChC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;SAAM,IAAI,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACpC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IACD,wDAAwD;IACxD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC,CAAC,oCAAoC;IAC1E,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB;IAChE,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB;IAC9D,OAAO,CAAC,CAAC,CAAC,iEAAiE;AAC7E,CAAC"}
|
|
1
|
+
{"version":3,"file":"prefix-coalesce.js","sourceRoot":"","sources":["../src/prefix-coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,UAAU,gBAAgB,CAAC,QAA2B;IAC1D,wBAAwB;IACxB,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;YACtC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,uEAAuE;IACvE,0EAA0E;IAC1E,oBAAoB;IACpB,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;IAClC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,QAAQ,GAAkB,IAAI,CAAC;IACnC,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,IAAI,QAAQ,KAAK,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAChD,uCAAuC;YACvC,SAAS;QACX,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACf,QAAQ,GAAG,CAAC,CAAC;IACf,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAY,EACZ,SAA4B;IAE5B,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,KAAK,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,YAAY,CAC1B,MAAc,EACd,SAA4B;IAE5B,MAAM,GAAG,GAAG,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC;IAC1E,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC,CAAC,aAAa;QACxC,IAAI,GAAG,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC,CAAC,yCAAyC;QACtE,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC,CAAC,iCAAiC;QACrE,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC,CAAC,iCAAiC;IACvE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAiB,EAAE,IAAY;IAC/D,IAAI,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC9C,2CAA2C;IAC3C,MAAM,aAAa,GAAG,aAAa,IAAI,GAAG,CAAC;IAC3C,MAAM,UAAU,GAAG,GAAG,IAAI,GAAG,CAAC;IAC9B,IAAI,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAChC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;SAAM,IAAI,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACpC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IACD,wDAAwD;IACxD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC,CAAC,oCAAoC;IAC1E,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB;IAChE,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB;IAC9D,OAAO,CAAC,CAAC,CAAC,iEAAiE;AAC7E,CAAC"}
|
package/package.json
CHANGED
|
@@ -120,6 +120,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
|
|
|
120
120
|
filesRefusedStale: 0,
|
|
121
121
|
filesRefusedStalePaths: [],
|
|
122
122
|
filesExcludedByPolicy: 0,
|
|
123
|
+
filesExcludedByScope: 0,
|
|
123
124
|
conflictPaths: [],
|
|
124
125
|
aborted: false,
|
|
125
126
|
...overrides,
|
|
@@ -1781,6 +1782,44 @@ describe("--direction", () => {
|
|
|
1781
1782
|
expect(opts.paths).toEqual(["/tmp/fake-hq/companies/acme"]);
|
|
1782
1783
|
expect(opts.company).toBe("cmp_a");
|
|
1783
1784
|
expect(opts.hqRoot).toBe("/tmp/fake-hq");
|
|
1785
|
+
// Owner / `all` scope (no membership sync-config) → NO prefixSet forwarded,
|
|
1786
|
+
// preserving the pre-fix company-target args shape (full access).
|
|
1787
|
+
expect(opts.prefixSet).toBeUndefined();
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
|
|
1791
|
+
// Plumbing regression for the fresh fix: a member/guest's push must be
|
|
1792
|
+
// scoped to their granted prefixes so out-of-scope keys are filtered
|
|
1793
|
+
// instead of drawing the server's 403 and aborting the whole company.
|
|
1794
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1795
|
+
const client = {
|
|
1796
|
+
...makeVaultStub({
|
|
1797
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1798
|
+
entityGet: (uid: string) =>
|
|
1799
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1800
|
+
}),
|
|
1801
|
+
getMembershipSyncConfig: async () => ({
|
|
1802
|
+
membershipId: "mk_a",
|
|
1803
|
+
syncMode: "shared",
|
|
1804
|
+
isDefault: false,
|
|
1805
|
+
}),
|
|
1806
|
+
listMyExplicitGrants: async () => [
|
|
1807
|
+
{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
|
|
1808
|
+
{ companyUid: "cmp_a", path: "policies/", permission: "read", source: "group" },
|
|
1809
|
+
],
|
|
1810
|
+
} as unknown as ReturnType<typeof makeVaultStub>;
|
|
1811
|
+
const deps = makeDeps({
|
|
1812
|
+
createVaultClient: () => client,
|
|
1813
|
+
sync: vi.fn(),
|
|
1814
|
+
share: shareSpy,
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
await runRunner(
|
|
1818
|
+
["--companies", "--direction", "push", "--hq-root", "/tmp/fake-hq"],
|
|
1819
|
+
deps,
|
|
1820
|
+
);
|
|
1821
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
1822
|
+
expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
1784
1823
|
});
|
|
1785
1824
|
|
|
1786
1825
|
it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -274,6 +274,7 @@ export type RunnerEvent =
|
|
|
274
274
|
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
275
275
|
| ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
|
|
276
276
|
| { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
|
|
277
|
+
| { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
|
|
277
278
|
| ({
|
|
278
279
|
type: "complete";
|
|
279
280
|
company: string;
|
|
@@ -1243,6 +1244,16 @@ export async function runRunner(
|
|
|
1243
1244
|
company: companyLabel,
|
|
1244
1245
|
files: event.files,
|
|
1245
1246
|
});
|
|
1247
|
+
} else if (event.type === "scope-excluded") {
|
|
1248
|
+
// Push-side ACL scope exclusions — surface the named paths tagged to
|
|
1249
|
+
// this company so the menubar/CLI can show "N skipped, outside your
|
|
1250
|
+
// access" instead of the file silently never uploading.
|
|
1251
|
+
emit({
|
|
1252
|
+
type: "scope-excluded",
|
|
1253
|
+
company: companyLabel,
|
|
1254
|
+
count: event.count,
|
|
1255
|
+
samplePaths: event.samplePaths,
|
|
1256
|
+
});
|
|
1246
1257
|
}
|
|
1247
1258
|
};
|
|
1248
1259
|
|
|
@@ -1256,6 +1267,7 @@ export async function runRunner(
|
|
|
1256
1267
|
filesRefusedStale: 0,
|
|
1257
1268
|
filesRefusedStalePaths: [],
|
|
1258
1269
|
filesExcludedByPolicy: 0,
|
|
1270
|
+
filesExcludedByScope: 0,
|
|
1259
1271
|
conflictPaths: [],
|
|
1260
1272
|
aborted: false,
|
|
1261
1273
|
};
|
|
@@ -1307,6 +1319,26 @@ export async function runRunner(
|
|
|
1307
1319
|
.map((p) => p.slug),
|
|
1308
1320
|
);
|
|
1309
1321
|
|
|
1322
|
+
// Resolve the membership's effective ACL scope ONCE so BOTH the push and
|
|
1323
|
+
// pull legs respect the granted prefixes. The vault vends a child
|
|
1324
|
+
// credential scoped to these prefixes; without filtering the PUSH plan to
|
|
1325
|
+
// them, share() would HEAD/PUT keys outside the grant and the server's
|
|
1326
|
+
// correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
|
|
1327
|
+
// generic error + exit 2. Personal-vault legs have no membership
|
|
1328
|
+
// sync-config — they stay full-scope ("all"). Degrades to "all" on any
|
|
1329
|
+
// error (a transient failure must never silently filter/prune the tree).
|
|
1330
|
+
// Hoisted above the push block (it used to be resolved only for pull) so
|
|
1331
|
+
// push gets the same scope; the pull leg below reuses this value.
|
|
1332
|
+
const scope: PullScope =
|
|
1333
|
+
target.personalMode === true
|
|
1334
|
+
? { syncMode: "all" }
|
|
1335
|
+
: await resolvePullScope(
|
|
1336
|
+
client,
|
|
1337
|
+
target.uid,
|
|
1338
|
+
target.slug,
|
|
1339
|
+
parsed.hqRoot,
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1310
1342
|
if (doPush) {
|
|
1311
1343
|
activePhase = "push";
|
|
1312
1344
|
// For the personal slot we hand share() both (a) the top-level
|
|
@@ -1365,6 +1397,13 @@ export async function runRunner(
|
|
|
1365
1397
|
...(decommissionPrefixes && decommissionPrefixes.length > 0
|
|
1366
1398
|
? { decommissionPrefixes }
|
|
1367
1399
|
: {}),
|
|
1400
|
+
// US-005 symmetry: scope the PUSH plan to the membership's granted
|
|
1401
|
+
// ACL prefixes so out-of-scope keys are skipped (and surfaced via a
|
|
1402
|
+
// `scope-excluded` event) instead of drawing the server's 403 and
|
|
1403
|
+
// aborting the company. `undefined` for `syncMode: "all"` (owner /
|
|
1404
|
+
// personal) → no scope filter, identical to the pre-fix shape so the
|
|
1405
|
+
// "company-target args" contract test stays green.
|
|
1406
|
+
...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
|
|
1368
1407
|
});
|
|
1369
1408
|
}
|
|
1370
1409
|
|
|
@@ -1373,20 +1412,11 @@ export async function runRunner(
|
|
|
1373
1412
|
// whichever side `--on-conflict abort` just protected.
|
|
1374
1413
|
if (doPull && !pushResult.aborted) {
|
|
1375
1414
|
activePhase = "pull";
|
|
1376
|
-
// US-005:
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
const pullScope: PullScope =
|
|
1382
|
-
target.personalMode === true
|
|
1383
|
-
? { syncMode: "all" }
|
|
1384
|
-
: await resolvePullScope(
|
|
1385
|
-
client,
|
|
1386
|
-
target.uid,
|
|
1387
|
-
target.slug,
|
|
1388
|
-
parsed.hqRoot,
|
|
1389
|
-
);
|
|
1415
|
+
// US-005: the pull only materializes in-scope keys (and prunes clean
|
|
1416
|
+
// orphans when scope shrank). Reuse the `scope` resolved once above so
|
|
1417
|
+
// push and pull apply the SAME granted prefixes and we avoid a second
|
|
1418
|
+
// `listMyExplicitGrants` round-trip per company.
|
|
1419
|
+
const pullScope: PullScope = scope;
|
|
1390
1420
|
pullResult = await syncFn({
|
|
1391
1421
|
company: target.uid,
|
|
1392
1422
|
vaultConfig,
|
package/src/cli/share.test.ts
CHANGED
|
@@ -586,6 +586,114 @@ describe("share", () => {
|
|
|
586
586
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
587
587
|
});
|
|
588
588
|
|
|
589
|
+
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
590
|
+
// Real case (look-optic): a FILE_ACL grant covered {knowledge,policies,
|
|
591
|
+
// workers}/* + company.yaml, but the upload plan also contained
|
|
592
|
+
// settings/.gitkeep + projects/.gitkeep. Pre-fix, the push walked the
|
|
593
|
+
// whole company tree, HEAD'd an out-of-scope key, the scoped child
|
|
594
|
+
// credential drew a 403 SCOPE_EXCEEDS_PARENT, and the runner aborted the
|
|
595
|
+
// ENTIRE company (exit 2) naming no path. The fix scopes the push plan to
|
|
596
|
+
// the granted prefixSet (symmetric with the pull-side skip-out-of-scope):
|
|
597
|
+
// in-scope files upload, out-of-scope paths are skipped + surfaced via a
|
|
598
|
+
// `scope-excluded` event, and the company completes.
|
|
599
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
600
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
|
|
601
|
+
fs.mkdirSync(path.join(companyRoot, "policies"), { recursive: true });
|
|
602
|
+
fs.mkdirSync(path.join(companyRoot, "settings"), { recursive: true });
|
|
603
|
+
fs.mkdirSync(path.join(companyRoot, "projects"), { recursive: true });
|
|
604
|
+
fs.writeFileSync(path.join(companyRoot, "company.yaml"), "name: Acme\n");
|
|
605
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "readme.md"), "# kb\n");
|
|
606
|
+
fs.writeFileSync(path.join(companyRoot, "policies", "p.md"), "policy\n");
|
|
607
|
+
fs.writeFileSync(path.join(companyRoot, "settings", ".gitkeep"), "");
|
|
608
|
+
fs.writeFileSync(path.join(companyRoot, "projects", ".gitkeep"), "");
|
|
609
|
+
|
|
610
|
+
// No remote anywhere → every in-scope file is a clean upload.
|
|
611
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
612
|
+
|
|
613
|
+
const events: Array<{ type?: string; path?: string; count?: number; samplePaths?: string[] }> = [];
|
|
614
|
+
const result = await share({
|
|
615
|
+
paths: [companyRoot],
|
|
616
|
+
company: "acme",
|
|
617
|
+
vaultConfig: mockConfig,
|
|
618
|
+
hqRoot: tmpDir,
|
|
619
|
+
// Coalesced company-relative grant prefixes (what resolvePullScope hands
|
|
620
|
+
// the runner for a shared membership).
|
|
621
|
+
prefixSet: ["company.yaml", "knowledge/", "policies/", "workers/"],
|
|
622
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// In-scope subset uploaded; out-of-scope never PUT. (company.yaml is in
|
|
626
|
+
// the grant but is independently dropped by the base ignore filter — it's
|
|
627
|
+
// in DEFAULT_IGNORES — so it never uploads regardless of scope; the scope
|
|
628
|
+
// filter layers on top of the ignore filter, not under it.)
|
|
629
|
+
const uploadedPaths = events
|
|
630
|
+
.filter((e) => e.type === "progress")
|
|
631
|
+
.map((e) => e.path)
|
|
632
|
+
.sort();
|
|
633
|
+
expect(uploadedPaths).toEqual(["knowledge/readme.md", "policies/p.md"]);
|
|
634
|
+
expect(result.filesUploaded).toBe(2);
|
|
635
|
+
// The two out-of-scope directories were excluded and named.
|
|
636
|
+
expect(result.filesExcludedByScope).toBe(2);
|
|
637
|
+
const scopeEv = events.find((e) => e.type === "scope-excluded") as
|
|
638
|
+
| { count: number; samplePaths: string[] }
|
|
639
|
+
| undefined;
|
|
640
|
+
expect(scopeEv).toBeDefined();
|
|
641
|
+
expect(scopeEv!.count).toBe(2);
|
|
642
|
+
expect(scopeEv!.samplePaths.sort()).toEqual(["projects/", "settings/"]);
|
|
643
|
+
// No conflict, no error — the company completed cleanly (no abort).
|
|
644
|
+
expect(result.aborted).toBe(false);
|
|
645
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
646
|
+
// uploadFile was never called for an out-of-scope key.
|
|
647
|
+
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
648
|
+
expect(putKeys).not.toContain("settings/.gitkeep");
|
|
649
|
+
expect(putKeys).not.toContain("projects/.gitkeep");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("scoped push defense-in-depth: a 403 on HEAD skips that key (named error) instead of aborting the company", async () => {
|
|
653
|
+
// Belt-and-suspenders: even if a key slips past the prefix filter (a grant
|
|
654
|
+
// that changed mid-run, a pin outside the grant, prefix-coalesce
|
|
655
|
+
// imprecision), the server's correct 403 on the HEAD must NOT abort the
|
|
656
|
+
// whole company. Pre-fix the HEAD sat outside the per-file PUT try/catch,
|
|
657
|
+
// so the throw bubbled to workerErrors -> `throw first` -> exit 2.
|
|
658
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
659
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
660
|
+
const okFile = path.join(companyRoot, "in-scope.md");
|
|
661
|
+
const blockedFile = path.join(companyRoot, "out-of-reach.md");
|
|
662
|
+
fs.writeFileSync(okFile, "ok\n");
|
|
663
|
+
fs.writeFileSync(blockedFile, "denied\n");
|
|
664
|
+
|
|
665
|
+
// The scoped credential 403s on the out-of-scope key's HEAD; the other
|
|
666
|
+
// key heads cleanly (no remote).
|
|
667
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
668
|
+
if (key === "out-of-reach.md") {
|
|
669
|
+
const err = new Error("access denied");
|
|
670
|
+
(err as { name: string }).name = "AccessDenied";
|
|
671
|
+
throw err;
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
677
|
+
const result = await share({
|
|
678
|
+
paths: [okFile, blockedFile],
|
|
679
|
+
company: "acme",
|
|
680
|
+
vaultConfig: mockConfig,
|
|
681
|
+
hqRoot: tmpDir,
|
|
682
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Company did NOT abort; the in-scope file still uploaded.
|
|
686
|
+
expect(result.aborted).toBe(false);
|
|
687
|
+
expect(result.filesUploaded).toBe(1);
|
|
688
|
+
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
689
|
+
expect(putKeys).toEqual(["in-scope.md"]);
|
|
690
|
+
// The blocked key surfaced as a path-named, scope-clear error event.
|
|
691
|
+
const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
|
|
692
|
+
expect(errs).toHaveLength(1);
|
|
693
|
+
expect(errs[0].path).toBe("out-of-reach.md");
|
|
694
|
+
expect(errs[0].message).toMatch(/outside granted ACL scope/i);
|
|
695
|
+
});
|
|
696
|
+
|
|
589
697
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
590
698
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
591
699
|
// that exists on S3 used to trigger a push conflict because the
|
|
@@ -810,6 +918,7 @@ describe("share", () => {
|
|
|
810
918
|
e.type === "plan" ||
|
|
811
919
|
e.type === "new-files" ||
|
|
812
920
|
e.type === "personal-vault-out-of-policy" ||
|
|
921
|
+
e.type === "scope-excluded" ||
|
|
813
922
|
e.type === "delete-refused-bulk-asymmetry"
|
|
814
923
|
) return;
|
|
815
924
|
events.push({
|