@indigoai-us/hq-cli 5.345.147 → 5.345.149
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/CHANGELOG.md +65 -0
- package/README.md +2 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +341 -11
- package/dist/command-catalog.generated.js +421 -11
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
- package/dist/commands/core-refresh-vault-access-native.js +356 -28
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +9 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +18 -3
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +8 -8
- package/dist/commands/install-global.js +139 -61
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/reindex.js +19 -0
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +43 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +76 -5
- package/dist/lazy-commands.js +2 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
- package/dist/lib/doctor/checks/anywhere.js +77 -11
- package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
- package/dist/lib/doctor/checks/cli-path.js +46 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +17 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +10 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +125 -0
- package/dist/lib/windows/user-path.js +275 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
package/dist/commands/access.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hq access <path-or-query
|
|
2
|
+
* `hq access <path-or-query...>` — existence + ACL probe with a self-healing
|
|
3
3
|
* ladder: resolve, list, probe ACL, then (with --fix) download / pin / repair
|
|
4
4
|
* sync or request access from the grantor.
|
|
5
5
|
*
|
|
@@ -85,6 +85,10 @@ function normalizeTarget(raw, slug) {
|
|
|
85
85
|
const anchor = `companies/${slug}/`;
|
|
86
86
|
if (t.startsWith(anchor))
|
|
87
87
|
t = t.slice(anchor.length);
|
|
88
|
+
// `folder`, `folder/` and `folder/*` name the same folder for the ACL
|
|
89
|
+
// resolver (policy hq-authorization-path-canonicalization); use one form for
|
|
90
|
+
// listing and ACL lookups.
|
|
91
|
+
t = t.replace(/\/\*$/, "").replace(/\/+$/, "");
|
|
88
92
|
return t;
|
|
89
93
|
}
|
|
90
94
|
function fileGrantCoversPath(grantPath, targetPath) {
|
|
@@ -123,41 +127,6 @@ function httpStatusOf(err) {
|
|
|
123
127
|
}
|
|
124
128
|
return undefined;
|
|
125
129
|
}
|
|
126
|
-
/**
|
|
127
|
-
* List under `prefix`. A 403 from the list route (`/v1/files/list`) means the
|
|
128
|
-
* caller cannot enumerate the prefix — surfaced as `{ denied: true }` so the
|
|
129
|
-
* caller maps it to outcome `no-access` (exit 3) instead of a bare error.
|
|
130
|
-
*/
|
|
131
|
-
async function listKeys(s3, bucket, prefix) {
|
|
132
|
-
try {
|
|
133
|
-
return { keys: await listKeysOrThrow(s3, bucket, prefix), denied: false };
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
if (httpStatusOf(err) === 403)
|
|
137
|
-
return { keys: [], denied: true };
|
|
138
|
-
throw err;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
async function listKeysOrThrow(s3, bucket, prefix) {
|
|
142
|
-
const keys = [];
|
|
143
|
-
let continuationToken;
|
|
144
|
-
do {
|
|
145
|
-
const resp = (await s3.send(new ListObjectsV2Command({
|
|
146
|
-
Bucket: bucket,
|
|
147
|
-
Prefix: prefix,
|
|
148
|
-
ContinuationToken: continuationToken,
|
|
149
|
-
})));
|
|
150
|
-
for (const obj of resp.Contents ?? []) {
|
|
151
|
-
if (!obj.Key)
|
|
152
|
-
continue;
|
|
153
|
-
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
|
|
154
|
-
continue;
|
|
155
|
-
keys.push(obj.Key);
|
|
156
|
-
}
|
|
157
|
-
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
158
|
-
} while (continuationToken);
|
|
159
|
-
return keys;
|
|
160
|
-
}
|
|
161
130
|
function treeIsEmpty(tree) {
|
|
162
131
|
const noDirect = tree.directRow == null;
|
|
163
132
|
const noInherited = !tree.inherited || tree.inherited.length === 0;
|
|
@@ -233,19 +202,15 @@ function grantorLabel(g) {
|
|
|
233
202
|
const name = g.name ? `${g.name} ` : "";
|
|
234
203
|
return `${name}${g.email}`.trim();
|
|
235
204
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const relative = normalizeTarget(input.target, slug);
|
|
243
|
-
const anchored = (key) => `companies/${slug}/${key}`;
|
|
244
|
-
const finishCtx = { hqRoot: deps.hqRoot, now: deps.now, stderr };
|
|
245
|
-
let vendDenied = false;
|
|
205
|
+
/**
|
|
206
|
+
* One STS vend for the company. A 403 is a company-boundary denial, a 422
|
|
207
|
+
* write-scope truncation still allows presigned reads, a 404 is reported and
|
|
208
|
+
* tolerated; anything else is thrown.
|
|
209
|
+
*/
|
|
210
|
+
async function vendForAccess(deps) {
|
|
246
211
|
try {
|
|
247
212
|
await deps.vaultClient.sts.vend({ companyUid: deps.companyUid });
|
|
248
|
-
|
|
213
|
+
return { denied: false, step: "vend: ok" };
|
|
249
214
|
}
|
|
250
215
|
catch (err) {
|
|
251
216
|
const status = httpStatusOf(err);
|
|
@@ -253,29 +218,98 @@ export async function runAccess(input) {
|
|
|
253
218
|
? err
|
|
254
219
|
: undefined;
|
|
255
220
|
if (status === 403) {
|
|
256
|
-
|
|
257
|
-
steps.push("vend: 403 (no-access)");
|
|
221
|
+
return { denied: true, step: "vend: 403 (no-access)" };
|
|
258
222
|
}
|
|
259
|
-
|
|
223
|
+
if ((status === 422 || vendError?.statusCode === 422) &&
|
|
260
224
|
vendError?.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
|
|
261
225
|
// This failure means the company-wide STS write policy could not fit.
|
|
262
226
|
// Company reads use the separate ACL-filtered presign API, which remains
|
|
263
227
|
// the authority for listing and materializing this particular object.
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
else if (status === 404) {
|
|
267
|
-
steps.push("vend: 404");
|
|
228
|
+
return { denied: false, step: "vend: write policy truncated; using presigned read" };
|
|
268
229
|
}
|
|
269
|
-
|
|
270
|
-
|
|
230
|
+
if (status === 404) {
|
|
231
|
+
return { denied: false, step: "vend: 404" };
|
|
271
232
|
}
|
|
233
|
+
throw err;
|
|
272
234
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Mark a promise that runs ahead of its use. A rejection is not reported as
|
|
238
|
+
* unhandled; it still surfaces at the point the promise is awaited.
|
|
239
|
+
*/
|
|
240
|
+
function startEarly(promise) {
|
|
241
|
+
promise.catch(() => undefined);
|
|
242
|
+
return promise;
|
|
243
|
+
}
|
|
244
|
+
function verdictDenied(verdict) {
|
|
245
|
+
return verdict.kind === "tree" ? aclDenied(verdict.tree) : false;
|
|
246
|
+
}
|
|
247
|
+
/** Bucket name for the company: the entity's bucket, else the conventional name. */
|
|
248
|
+
function bucketFor(entity, slug) {
|
|
249
|
+
return entity?.bucketName ?? `hq-vault-cmp-${slug}`;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* One list request under `prefix`, never followed by a continuation. A 403
|
|
253
|
+
* from the list route (`/v1/files/list`) means the caller cannot enumerate
|
|
254
|
+
* the prefix, surfaced as `{ denied: true }` so the caller maps it to outcome
|
|
255
|
+
* `no-access` (exit 3) instead of a bare error.
|
|
256
|
+
*/
|
|
257
|
+
async function listPage(s3, bucket, prefix) {
|
|
258
|
+
let resp;
|
|
259
|
+
try {
|
|
260
|
+
resp = (await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix })));
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (httpStatusOf(err) === 403)
|
|
264
|
+
return { keys: [], truncated: false, denied: true };
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
const keys = [];
|
|
268
|
+
for (const obj of resp.Contents ?? []) {
|
|
269
|
+
if (!obj.Key)
|
|
270
|
+
continue;
|
|
271
|
+
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
|
|
272
|
+
continue;
|
|
273
|
+
keys.push(obj.Key);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
keys,
|
|
277
|
+
truncated: Boolean(resp.IsTruncated || resp.NextContinuationToken),
|
|
278
|
+
denied: false,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Fuzzy-match pool for a miss: one page under the closest parent folder that
|
|
283
|
+
* has keys, walking up from the deepest. A bare query with no folder part has
|
|
284
|
+
* only the bucket root as its parent, so it gets one root page. A denied
|
|
285
|
+
* parent stops the walk and is reported as denied.
|
|
286
|
+
*/
|
|
287
|
+
async function listClosestParent(s3, bucket, relative) {
|
|
288
|
+
const segments = relative.split("/");
|
|
289
|
+
if (segments.length === 1)
|
|
290
|
+
return listPage(s3, bucket, "");
|
|
291
|
+
for (let depth = segments.length - 1; depth >= 1; depth -= 1) {
|
|
292
|
+
const parent = `${segments.slice(0, depth).join("/")}/`;
|
|
293
|
+
const page = await listPage(s3, bucket, parent);
|
|
294
|
+
if (page.denied || page.keys.length > 0)
|
|
295
|
+
return page;
|
|
296
|
+
}
|
|
297
|
+
return { keys: [], truncated: false, denied: false };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Existence + ACL for one target: exact key first, then a folder check, then a
|
|
301
|
+
* bounded fuzzy match. Every list call is a single page.
|
|
302
|
+
*/
|
|
303
|
+
async function locateTarget(target, deps, ctx) {
|
|
304
|
+
const slug = deps.companySlug;
|
|
305
|
+
const steps = [ctx.vend.step];
|
|
306
|
+
const relative = normalizeTarget(target, slug);
|
|
307
|
+
const anchored = (key) => `companies/${slug}/${key}`;
|
|
308
|
+
const { s3, bucket } = ctx;
|
|
276
309
|
let listDenied = false;
|
|
310
|
+
let directory = false;
|
|
277
311
|
let key;
|
|
278
|
-
if (
|
|
312
|
+
if (ctx.vend.denied) {
|
|
279
313
|
// The caller is denied at the company boundary. Do not use list results to
|
|
280
314
|
// resolve or disclose matching keys, even if a separate listing succeeds.
|
|
281
315
|
listDenied = true;
|
|
@@ -283,8 +317,24 @@ export async function runAccess(input) {
|
|
|
283
317
|
steps.push("list: skipped after vend 403 (no-access)");
|
|
284
318
|
}
|
|
285
319
|
else {
|
|
286
|
-
const prefixList = await
|
|
287
|
-
|
|
320
|
+
const prefixList = await listPage(s3, bucket, relative);
|
|
321
|
+
// An exact key sorts first among the keys that start with it, so the
|
|
322
|
+
// first page always holds it when it exists.
|
|
323
|
+
const exact = prefixList.keys.includes(relative);
|
|
324
|
+
const folderPrefix = `${relative}/`;
|
|
325
|
+
let isFolder = !exact &&
|
|
326
|
+
relative !== "" &&
|
|
327
|
+
prefixList.keys.some((k) => k.startsWith(folderPrefix));
|
|
328
|
+
if (!prefixList.denied &&
|
|
329
|
+
!exact &&
|
|
330
|
+
!isFolder &&
|
|
331
|
+
relative !== "" &&
|
|
332
|
+
prefixList.truncated) {
|
|
333
|
+
// Siblings such as `name-other/` sort before `name/` and can fill the
|
|
334
|
+
// first page; ask for the folder itself.
|
|
335
|
+
const folderList = await listPage(s3, bucket, folderPrefix);
|
|
336
|
+
isFolder = !folderList.denied && folderList.keys.length > 0;
|
|
337
|
+
}
|
|
288
338
|
if (prefixList.denied) {
|
|
289
339
|
// The server refused to enumerate — the caller has no read on this
|
|
290
340
|
// prefix. Keep the target as the key so the request rung can still name
|
|
@@ -294,45 +344,58 @@ export async function runAccess(input) {
|
|
|
294
344
|
steps.push("list: 403 (no-access)");
|
|
295
345
|
}
|
|
296
346
|
else if (exact) {
|
|
297
|
-
key =
|
|
347
|
+
key = relative;
|
|
298
348
|
steps.push("resolve: exact key");
|
|
299
349
|
steps.push("list: 1 hit");
|
|
300
350
|
}
|
|
351
|
+
else if (isFolder) {
|
|
352
|
+
key = relative;
|
|
353
|
+
directory = true;
|
|
354
|
+
steps.push("resolve: folder");
|
|
355
|
+
steps.push("list: 1 hit");
|
|
356
|
+
}
|
|
301
357
|
else {
|
|
302
|
-
const allList = await listKeys(s3, bucket, "");
|
|
303
358
|
const q = relative.toLowerCase();
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
359
|
+
// Keys under the target prefix already contain the query. Only when
|
|
360
|
+
// there are none, look one page under the closest existing parent.
|
|
361
|
+
const parentList = prefixList.keys.length > 0
|
|
362
|
+
? { keys: [], truncated: false, denied: false }
|
|
363
|
+
: await listClosestParent(s3, bucket, relative);
|
|
364
|
+
const pool = [
|
|
365
|
+
...new Set([...prefixList.keys, ...parentList.keys]),
|
|
366
|
+
].sort();
|
|
308
367
|
const fuzzy = pool.filter((k) => k.toLowerCase().includes(q));
|
|
309
|
-
if (
|
|
368
|
+
if (parentList.denied && fuzzy.length === 0) {
|
|
310
369
|
listDenied = true;
|
|
311
370
|
key = relative;
|
|
312
371
|
steps.push("list: 403 (no-access)");
|
|
313
372
|
}
|
|
314
373
|
else if (fuzzy.length === 0) {
|
|
315
374
|
steps.push("list: 0 hits");
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
375
|
+
const verdict = await ctx.aclFor(relative);
|
|
376
|
+
if (verdictDenied(verdict)) {
|
|
318
377
|
steps.push("acl: deny");
|
|
319
|
-
|
|
320
|
-
|
|
378
|
+
return {
|
|
379
|
+
kind: "terminal",
|
|
380
|
+
outcome: "no-access",
|
|
381
|
+
fields: {
|
|
382
|
+
path: anchored(relative) || target,
|
|
383
|
+
company: slug,
|
|
384
|
+
exists: true,
|
|
385
|
+
steps,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
kind: "terminal",
|
|
391
|
+
outcome: "never-existed",
|
|
392
|
+
fields: {
|
|
393
|
+
path: anchored(relative) || target,
|
|
321
394
|
company: slug,
|
|
322
|
-
exists:
|
|
395
|
+
exists: false,
|
|
323
396
|
steps,
|
|
324
|
-
},
|
|
325
|
-
|
|
326
|
-
return result;
|
|
327
|
-
}
|
|
328
|
-
const result = finish("never-existed", {
|
|
329
|
-
path: anchored(relative) || input.target,
|
|
330
|
-
company: slug,
|
|
331
|
-
exists: false,
|
|
332
|
-
steps,
|
|
333
|
-
}, finishCtx);
|
|
334
|
-
emit(input, result, log);
|
|
335
|
-
return result;
|
|
397
|
+
},
|
|
398
|
+
};
|
|
336
399
|
}
|
|
337
400
|
else if (fuzzy.length === 1) {
|
|
338
401
|
key = fuzzy[0];
|
|
@@ -343,42 +406,70 @@ export async function runAccess(input) {
|
|
|
343
406
|
const candidates = fuzzy.map(anchored);
|
|
344
407
|
steps.push(`list: ${fuzzy.length} hits`);
|
|
345
408
|
let picked = null;
|
|
346
|
-
if (deps.pick) {
|
|
409
|
+
if (ctx.allowPick && deps.pick) {
|
|
347
410
|
picked = await deps.pick(candidates);
|
|
348
411
|
}
|
|
349
412
|
if (!picked) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
413
|
+
return {
|
|
414
|
+
kind: "terminal",
|
|
415
|
+
outcome: "ambiguous",
|
|
416
|
+
fields: {
|
|
417
|
+
path: target,
|
|
418
|
+
company: slug,
|
|
419
|
+
exists: false,
|
|
420
|
+
candidates,
|
|
421
|
+
steps,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
359
424
|
}
|
|
360
|
-
|
|
361
|
-
key = stripped;
|
|
425
|
+
key = normalizeTarget(picked, slug);
|
|
362
426
|
steps.push("resolve: pick");
|
|
363
427
|
}
|
|
364
428
|
}
|
|
365
429
|
}
|
|
366
430
|
const resolvedKey = key;
|
|
367
|
-
const
|
|
368
|
-
|
|
431
|
+
const verdict = await ctx.aclFor(resolvedKey);
|
|
432
|
+
return {
|
|
433
|
+
kind: "found",
|
|
434
|
+
resolvedKey,
|
|
435
|
+
directory,
|
|
436
|
+
vendDenied: ctx.vend.denied,
|
|
437
|
+
listDenied,
|
|
438
|
+
verdict,
|
|
439
|
+
steps,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
async function resolveRequester(requester) {
|
|
443
|
+
return typeof requester === "function" ? requester() : requester;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Disk check, self-heal and request rung for one located target, then the
|
|
447
|
+
* single telemetry record via `finish`.
|
|
448
|
+
*/
|
|
449
|
+
async function completeTarget(input, deps, located) {
|
|
450
|
+
const { json } = input;
|
|
451
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
452
|
+
const stderr = deps.stderr ?? ((line) => console.error(line));
|
|
453
|
+
const slug = deps.companySlug;
|
|
454
|
+
const finishCtx = { hqRoot: deps.hqRoot, now: deps.now, stderr };
|
|
455
|
+
if (located.kind === "terminal") {
|
|
456
|
+
return finish(located.outcome, located.fields, finishCtx);
|
|
457
|
+
}
|
|
458
|
+
const { resolvedKey, directory, vendDenied, listDenied, verdict, steps } = located;
|
|
459
|
+
const resolvedPath = `companies/${slug}/${resolvedKey}`;
|
|
369
460
|
let noAccess = vendDenied || listDenied;
|
|
370
|
-
if (
|
|
461
|
+
if (verdict.kind === "resolved") {
|
|
462
|
+
steps.push(`acl: ${verdict.permission}`);
|
|
463
|
+
}
|
|
464
|
+
else if (verdict.tree == null || treeIsEmpty(verdict.tree)) {
|
|
371
465
|
steps.push("acl: no record (membership-distributed)");
|
|
372
466
|
}
|
|
373
|
-
else if (aclDenied(tree)) {
|
|
467
|
+
else if (aclDenied(verdict.tree)) {
|
|
374
468
|
noAccess = true;
|
|
375
469
|
steps.push("acl: deny");
|
|
376
470
|
}
|
|
377
471
|
else {
|
|
378
|
-
steps.push(`acl: ${tree.effectivePermission ?? "ok"}`);
|
|
379
|
-
}
|
|
380
|
-
if (vendDenied || listDenied) {
|
|
381
|
-
noAccess = true;
|
|
472
|
+
steps.push(`acl: ${verdict.tree.effectivePermission ?? "ok"}`);
|
|
382
473
|
}
|
|
383
474
|
const onDisk = fs.existsSync(path.join(deps.hqRoot, "companies", slug, resolvedKey));
|
|
384
475
|
steps.push(onDisk ? "disk: present" : "disk: missing");
|
|
@@ -394,7 +485,11 @@ export async function runAccess(input) {
|
|
|
394
485
|
}
|
|
395
486
|
let fixed;
|
|
396
487
|
let localPath;
|
|
397
|
-
if (outcome === "not-synced" && input.fix === true &&
|
|
488
|
+
if (outcome === "not-synced" && input.fix === true && directory) {
|
|
489
|
+
// `get` fetches one object; a folder is pulled by sync.
|
|
490
|
+
steps.push("get: skipped (folder; run hq sync)");
|
|
491
|
+
}
|
|
492
|
+
else if (outcome === "not-synced" && input.fix === true && !noAccess) {
|
|
398
493
|
const absLocal = path.join(deps.hqRoot, "companies", slug, resolvedKey);
|
|
399
494
|
const getFn = deps.get ??
|
|
400
495
|
((anchoredPath) => runGet({
|
|
@@ -492,8 +587,9 @@ export async function runAccess(input) {
|
|
|
492
587
|
steps.push("request: no dm transport");
|
|
493
588
|
}
|
|
494
589
|
else {
|
|
590
|
+
const grantorTree = verdict.kind === "tree" ? verdict.tree : await deps.fetchAclTree(resolvedKey);
|
|
495
591
|
const members = await deps.listMembers();
|
|
496
|
-
const resolved = resolveGrantor(
|
|
592
|
+
const resolved = resolveGrantor(grantorTree, members);
|
|
497
593
|
if (!resolved) {
|
|
498
594
|
steps.push("request: no grantor found");
|
|
499
595
|
requestNote = "No grantor found. Contact a company owner.";
|
|
@@ -508,8 +604,9 @@ export async function runAccess(input) {
|
|
|
508
604
|
else
|
|
509
605
|
log(grantorLine);
|
|
510
606
|
const nowMs = (deps.now ?? Date.now)();
|
|
607
|
+
const requester = await resolveRequester(deps.requester);
|
|
511
608
|
const recent = findRecentAccessRequest(deps.hqRoot, {
|
|
512
|
-
requester:
|
|
609
|
+
requester: requester.email,
|
|
513
610
|
prefix: resolvedKey,
|
|
514
611
|
company: slug,
|
|
515
612
|
grantor: resolved.email,
|
|
@@ -546,8 +643,8 @@ export async function runAccess(input) {
|
|
|
546
643
|
}
|
|
547
644
|
}
|
|
548
645
|
if (approved && outcome === "no-access") {
|
|
549
|
-
const message = `${
|
|
550
|
-
const prompt = `hq files share ${resolvedKey} --with ${
|
|
646
|
+
const message = `${requester.email} is asking for read access to ${resolvedKey} in ${slug}.`;
|
|
647
|
+
const prompt = `hq files share ${resolvedKey} --with ${requester.email} --permission read --company ${slug}`;
|
|
551
648
|
const sent = await deps.dm.send({
|
|
552
649
|
recipient: resolved.email,
|
|
553
650
|
message,
|
|
@@ -557,7 +654,7 @@ export async function runAccess(input) {
|
|
|
557
654
|
requestSentAt = sentAt;
|
|
558
655
|
try {
|
|
559
656
|
recordAccessRequest(deps.hqRoot, {
|
|
560
|
-
requester:
|
|
657
|
+
requester: requester.email,
|
|
561
658
|
prefix: resolvedKey,
|
|
562
659
|
company: slug,
|
|
563
660
|
grantor: resolved.email,
|
|
@@ -591,31 +688,157 @@ export async function runAccess(input) {
|
|
|
591
688
|
...(alreadyAskedAt ? { alreadyAskedAt } : {}),
|
|
592
689
|
...(requestNote ? { requestNote } : {}),
|
|
593
690
|
}, finishCtx);
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
export async function runAccess(input) {
|
|
694
|
+
const { deps } = input;
|
|
695
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
696
|
+
const slug = deps.companySlug;
|
|
697
|
+
const relative = normalizeTarget(input.target, slug);
|
|
698
|
+
// Vend, the bucket lookup, and the ACL record for the target as typed do not
|
|
699
|
+
// depend on each other; start them together.
|
|
700
|
+
const vendP = startEarly(vendForAccess(deps));
|
|
701
|
+
const entityP = startEarly(deps.vaultClient.entity.findInMyNamespace("company", slug));
|
|
702
|
+
// acl/resolve answers a granted permission in a fraction of the time
|
|
703
|
+
// acl/tree takes. The tree is only needed when resolve has no permission
|
|
704
|
+
// (deny or no record), so without resolve it starts early as before, and
|
|
705
|
+
// with resolve it is fetched on demand.
|
|
706
|
+
// A failed resolve call falls back to the tree rather than failing the check.
|
|
707
|
+
const resolvedP = deps.resolveAcl
|
|
708
|
+
? deps.resolveAcl([relative]).catch(() => new Map())
|
|
709
|
+
: null;
|
|
710
|
+
const trees = new Map();
|
|
711
|
+
const treeFor = (key) => {
|
|
712
|
+
let p = trees.get(key);
|
|
713
|
+
if (!p) {
|
|
714
|
+
p = deps.fetchAclTree(key);
|
|
715
|
+
trees.set(key, p);
|
|
716
|
+
}
|
|
717
|
+
return p;
|
|
718
|
+
};
|
|
719
|
+
if (!resolvedP)
|
|
720
|
+
startEarly(treeFor(relative));
|
|
721
|
+
const vend = await vendP;
|
|
722
|
+
const entity = await entityP;
|
|
723
|
+
const ctx = {
|
|
724
|
+
vend,
|
|
725
|
+
bucket: bucketFor(entity, slug),
|
|
726
|
+
s3: deps.companyClient({ companyUid: deps.companyUid }),
|
|
727
|
+
aclFor: async (key) => {
|
|
728
|
+
if (resolvedP && key === relative) {
|
|
729
|
+
const permission = (await resolvedP).get(key);
|
|
730
|
+
if (permission)
|
|
731
|
+
return { kind: "resolved", permission };
|
|
732
|
+
}
|
|
733
|
+
return { kind: "tree", tree: await treeFor(key) };
|
|
734
|
+
},
|
|
735
|
+
allowPick: true,
|
|
736
|
+
};
|
|
737
|
+
const located = await locateTarget(input.target, deps, ctx);
|
|
738
|
+
const result = await completeTarget(input, deps, located);
|
|
594
739
|
emit(input, result, log);
|
|
595
740
|
return result;
|
|
596
741
|
}
|
|
742
|
+
async function mapLimit(items, limit, fn) {
|
|
743
|
+
const out = new Array(items.length);
|
|
744
|
+
let next = 0;
|
|
745
|
+
const worker = async () => {
|
|
746
|
+
while (next < items.length) {
|
|
747
|
+
const i = next;
|
|
748
|
+
next += 1;
|
|
749
|
+
out[i] = await fn(items[i]);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Several targets in one company: one vend, one bucket lookup, and one
|
|
757
|
+
* acl/resolve call for all of them, then bounded-concurrency existence
|
|
758
|
+
* checks. Requests (DM rung) run one at a time in input order so prompts and
|
|
759
|
+
* grantor lines never interleave. Several fuzzy hits are reported as
|
|
760
|
+
* ambiguous; the interactive picker is single-path only.
|
|
761
|
+
*/
|
|
762
|
+
export async function runAccessBatch(input) {
|
|
763
|
+
const { deps } = input;
|
|
764
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
765
|
+
const slug = deps.companySlug;
|
|
766
|
+
const relatives = input.targets.map((t) => normalizeTarget(t, slug));
|
|
767
|
+
const unique = [...new Set(relatives)];
|
|
768
|
+
const vendP = startEarly(vendForAccess(deps));
|
|
769
|
+
const entityP = startEarly(deps.vaultClient.entity.findInMyNamespace("company", slug));
|
|
770
|
+
const resolvedP = startEarly(deps.resolveAcl
|
|
771
|
+
? deps.resolveAcl(unique).catch(() => new Map())
|
|
772
|
+
: Promise.resolve(new Map()));
|
|
773
|
+
const vend = await vendP;
|
|
774
|
+
const entity = await entityP;
|
|
775
|
+
const permissions = await resolvedP;
|
|
776
|
+
const trees = new Map();
|
|
777
|
+
const treeFor = (key) => {
|
|
778
|
+
let p = trees.get(key);
|
|
779
|
+
if (!p) {
|
|
780
|
+
p = deps.fetchAclTree(key);
|
|
781
|
+
trees.set(key, p);
|
|
782
|
+
}
|
|
783
|
+
return p;
|
|
784
|
+
};
|
|
785
|
+
const ctx = {
|
|
786
|
+
vend,
|
|
787
|
+
bucket: bucketFor(entity, slug),
|
|
788
|
+
s3: deps.companyClient({ companyUid: deps.companyUid }),
|
|
789
|
+
aclFor: async (key) => {
|
|
790
|
+
const permission = permissions.get(key);
|
|
791
|
+
// A null permission can mean an explicit deny or no ACL record at all;
|
|
792
|
+
// only the tree tells those apart, so it decides as it does for a
|
|
793
|
+
// single path.
|
|
794
|
+
if (permission)
|
|
795
|
+
return { kind: "resolved", permission };
|
|
796
|
+
return { kind: "tree", tree: await treeFor(key) };
|
|
797
|
+
},
|
|
798
|
+
allowPick: false,
|
|
799
|
+
};
|
|
800
|
+
const located = await mapLimit(input.targets, input.concurrency ?? 4, (target) => locateTarget(target, deps, ctx));
|
|
801
|
+
const results = [];
|
|
802
|
+
for (let i = 0; i < input.targets.length; i += 1) {
|
|
803
|
+
const target = input.targets[i];
|
|
804
|
+
if (!input.json)
|
|
805
|
+
log(`==> ${target} <==`);
|
|
806
|
+
const result = await completeTarget(input, deps, located[i]);
|
|
807
|
+
if (!input.json)
|
|
808
|
+
log(formatAccessOutcome(result));
|
|
809
|
+
results.push({ target, result });
|
|
810
|
+
}
|
|
811
|
+
if (input.json) {
|
|
812
|
+
log(JSON.stringify(results.map(({ target, result }) => ({ target, ...jsonPayload(result) })), null, 2));
|
|
813
|
+
}
|
|
814
|
+
const exitCode = results.reduce((max, { result }) => (result.exitCode > max ? result.exitCode : max), 0);
|
|
815
|
+
return { results, exitCode };
|
|
816
|
+
}
|
|
817
|
+
function jsonPayload(result) {
|
|
818
|
+
const payload = {
|
|
819
|
+
outcome: result.outcome,
|
|
820
|
+
path: result.path,
|
|
821
|
+
company: result.company,
|
|
822
|
+
...(result.exists !== undefined ? { exists: result.exists } : {}),
|
|
823
|
+
steps: result.steps,
|
|
824
|
+
};
|
|
825
|
+
if (result.grantor)
|
|
826
|
+
payload.grantor = result.grantor;
|
|
827
|
+
if (result.candidates)
|
|
828
|
+
payload.candidates = result.candidates;
|
|
829
|
+
if (result.fixed)
|
|
830
|
+
payload.fixed = result.fixed;
|
|
831
|
+
if (result.localPath)
|
|
832
|
+
payload.localPath = result.localPath;
|
|
833
|
+
if (result.requestSentAt)
|
|
834
|
+
payload.requestSentAt = result.requestSentAt;
|
|
835
|
+
if (result.alreadyAskedAt)
|
|
836
|
+
payload.alreadyAskedAt = result.alreadyAskedAt;
|
|
837
|
+
return payload;
|
|
838
|
+
}
|
|
597
839
|
function emit(input, result, log) {
|
|
598
840
|
if (input.json) {
|
|
599
|
-
|
|
600
|
-
outcome: result.outcome,
|
|
601
|
-
path: result.path,
|
|
602
|
-
company: result.company,
|
|
603
|
-
...(result.exists !== undefined ? { exists: result.exists } : {}),
|
|
604
|
-
steps: result.steps,
|
|
605
|
-
};
|
|
606
|
-
if (result.grantor)
|
|
607
|
-
payload.grantor = result.grantor;
|
|
608
|
-
if (result.candidates)
|
|
609
|
-
payload.candidates = result.candidates;
|
|
610
|
-
if (result.fixed)
|
|
611
|
-
payload.fixed = result.fixed;
|
|
612
|
-
if (result.localPath)
|
|
613
|
-
payload.localPath = result.localPath;
|
|
614
|
-
if (result.requestSentAt)
|
|
615
|
-
payload.requestSentAt = result.requestSentAt;
|
|
616
|
-
if (result.alreadyAskedAt)
|
|
617
|
-
payload.alreadyAskedAt = result.alreadyAskedAt;
|
|
618
|
-
log(JSON.stringify(payload, null, 2));
|
|
841
|
+
log(JSON.stringify(jsonPayload(result), null, 2));
|
|
619
842
|
}
|
|
620
843
|
else {
|
|
621
844
|
log(formatAccessOutcome(result));
|
|
@@ -674,11 +897,12 @@ function treeFromBody(body) {
|
|
|
674
897
|
}
|
|
675
898
|
export function registerAccessCommand(program) {
|
|
676
899
|
program
|
|
677
|
-
.command("access
|
|
678
|
-
.description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
|
|
900
|
+
.command("access [vault-path...]")
|
|
901
|
+
.description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Takes one or more paths or queries. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
|
|
679
902
|
.option("--company <slug>", "Company slug")
|
|
680
903
|
.option("--as <person>", "Check a person's access (email, prs_, or agt_ uid; self or owner/admin)")
|
|
681
|
-
.option("--json", "Print a single JSON object")
|
|
904
|
+
.option("--json", "Print a single JSON object (an array for several paths)")
|
|
905
|
+
.option("--stdin", "Read more paths from stdin, one per line")
|
|
682
906
|
.option("--no-fix", "Diagnose only: do not fetch, pin, or repair sync")
|
|
683
907
|
.option("--yes", "Answer the one send-confirmation yes (hosts must pass this explicitly)")
|
|
684
908
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
@@ -688,17 +912,34 @@ Exit codes:
|
|
|
688
912
|
2 never-existed (the path was never created)
|
|
689
913
|
3 no-access (the file exists but you cannot read it)
|
|
690
914
|
4 ambiguous (several keys matched; pick one)
|
|
915
|
+
|
|
916
|
+
With several paths, each gets its own result block and the exit code is the
|
|
917
|
+
highest one across the paths.
|
|
691
918
|
`)
|
|
692
|
-
.action(async (
|
|
919
|
+
.action(async (paths, opts) => {
|
|
693
920
|
try {
|
|
921
|
+
const targets = [...paths];
|
|
922
|
+
if (opts.stdin)
|
|
923
|
+
targets.push(...parseTargetLines(await readStdin()));
|
|
924
|
+
if (targets.length === 0) {
|
|
925
|
+
throw new Error("Pass at least one path, or --stdin with one path per line.");
|
|
926
|
+
}
|
|
694
927
|
if (opts.as) {
|
|
695
|
-
|
|
928
|
+
if (targets.length !== 1) {
|
|
929
|
+
throw new Error("--as checks one path at a time.");
|
|
930
|
+
}
|
|
931
|
+
const result = await runAccessForPerson(targets[0], opts.as, opts);
|
|
696
932
|
console.log(opts.json ? JSON.stringify(result) : formatAccessForPerson(result));
|
|
697
933
|
process.exitCode = result.exitCode;
|
|
698
934
|
return;
|
|
699
935
|
}
|
|
700
|
-
|
|
701
|
-
|
|
936
|
+
if (targets.length === 1 && !opts.stdin) {
|
|
937
|
+
const result = await runAccessForPath(targets[0], opts);
|
|
938
|
+
process.exitCode = result.exitCode;
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const batch = await runAccessForPaths(targets, opts);
|
|
942
|
+
process.exitCode = batch.exitCode;
|
|
702
943
|
}
|
|
703
944
|
catch (err) {
|
|
704
945
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -707,6 +948,20 @@ Exit codes:
|
|
|
707
948
|
}
|
|
708
949
|
});
|
|
709
950
|
}
|
|
951
|
+
/** One target per non-blank line; surrounding whitespace is ignored. */
|
|
952
|
+
export function parseTargetLines(text) {
|
|
953
|
+
return text
|
|
954
|
+
.split(/\r?\n/)
|
|
955
|
+
.map((line) => line.trim())
|
|
956
|
+
.filter((line) => line.length > 0);
|
|
957
|
+
}
|
|
958
|
+
async function readStdin() {
|
|
959
|
+
const chunks = [];
|
|
960
|
+
for await (const chunk of process.stdin) {
|
|
961
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
962
|
+
}
|
|
963
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
964
|
+
}
|
|
710
965
|
/**
|
|
711
966
|
* Resolve the company `hq access` runs against: explicit `company` (the
|
|
712
967
|
* ladder forwards the slug stamped on a 403), else the slug anchored in the
|
|
@@ -798,15 +1053,14 @@ export async function runAccessForPerson(rawPath, personUid, opts) {
|
|
|
798
1053
|
export function formatAccessForPerson(result) {
|
|
799
1054
|
return `${result.personUid} has ${result.permission ?? "no access"} access to ${result.path}.`;
|
|
800
1055
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
804
|
-
const slug = resolveAccessCompany(target, opts);
|
|
805
|
-
if (!slug) {
|
|
806
|
-
throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
|
|
807
|
-
}
|
|
1056
|
+
/** Live deps for one company: vault session, ACL routes, lazy caller lookup. */
|
|
1057
|
+
async function buildLiveAccessDeps(slug, hqRoot) {
|
|
808
1058
|
const { token, client } = await resolveBrowseSession();
|
|
809
1059
|
const companyUid = await getCompanyUid(token, slug);
|
|
1060
|
+
const aclError = async (res, route) => {
|
|
1061
|
+
const body = (await res.json().catch(() => ({})));
|
|
1062
|
+
return new Error(body.message ?? body.error ?? `${route} failed (${res.status})`);
|
|
1063
|
+
};
|
|
810
1064
|
const fetchAclTree = async (prefix) => {
|
|
811
1065
|
const res = await vaultApiFetch({
|
|
812
1066
|
token,
|
|
@@ -815,41 +1069,67 @@ export async function runAccessForPath(path, opts) {
|
|
|
815
1069
|
});
|
|
816
1070
|
if (res.status === 404)
|
|
817
1071
|
return null;
|
|
818
|
-
if (!res.ok)
|
|
819
|
-
|
|
820
|
-
throw new Error(body.message ?? body.error ?? `acl/tree failed (${res.status})`);
|
|
821
|
-
}
|
|
1072
|
+
if (!res.ok)
|
|
1073
|
+
throw await aclError(res, "acl/tree");
|
|
822
1074
|
const tree = (await res.json());
|
|
823
1075
|
return treeFromBody(tree);
|
|
824
1076
|
};
|
|
825
|
-
const
|
|
826
|
-
const
|
|
827
|
-
|
|
1077
|
+
const resolveAcl = async (targets) => {
|
|
1078
|
+
const res = await vaultApiFetch({
|
|
1079
|
+
token,
|
|
1080
|
+
path: `/files/${encodeURIComponent(companyUid)}/acl/resolve`,
|
|
1081
|
+
method: "POST",
|
|
1082
|
+
body: { targets },
|
|
1083
|
+
});
|
|
1084
|
+
if (!res.ok)
|
|
1085
|
+
throw await aclError(res, "acl/resolve");
|
|
1086
|
+
const data = (await res.json());
|
|
1087
|
+
const out = new Map();
|
|
1088
|
+
for (const row of data.results ?? []) {
|
|
1089
|
+
if (typeof row.target !== "string")
|
|
1090
|
+
continue;
|
|
1091
|
+
out.set(row.target, typeof row.permission === "string" ? row.permission : null);
|
|
1092
|
+
}
|
|
1093
|
+
return out;
|
|
1094
|
+
};
|
|
1095
|
+
// Members and the caller are needed only to send an access request, so
|
|
1096
|
+
// they load on first use and at most once.
|
|
1097
|
+
let membersP;
|
|
1098
|
+
const listMembers = () => {
|
|
1099
|
+
membersP ??= listActiveMembers(token, companyUid).then((rows) => rows
|
|
828
1100
|
.filter((m) => ["owner", "admin", "member", "guest"].includes(m.role))
|
|
829
1101
|
.map((m) => ({
|
|
830
1102
|
personUid: m.personUid,
|
|
831
1103
|
personEmail: m.personEmail ?? "",
|
|
832
1104
|
personName: m.personName,
|
|
833
1105
|
role: m.role,
|
|
834
|
-
}));
|
|
1106
|
+
})));
|
|
1107
|
+
return membersP;
|
|
1108
|
+
};
|
|
1109
|
+
let requesterP;
|
|
1110
|
+
const requester = () => {
|
|
1111
|
+
requesterP ??= (async () => {
|
|
1112
|
+
const members = await listMembers();
|
|
1113
|
+
const caller = await resolveCallerUid(token);
|
|
1114
|
+
const me = members.find((m) => m.personUid === caller.uid);
|
|
1115
|
+
// An agent has no mailbox and no person entity. `hq files share --with`
|
|
1116
|
+
// accepts its agt_* uid, so the access request names the agent by uid.
|
|
1117
|
+
let requesterEmail = caller.kind === "agent" ? caller.uid : me?.personEmail;
|
|
1118
|
+
const requesterName = me?.personName;
|
|
1119
|
+
if (!requesterEmail) {
|
|
1120
|
+
const claims = peekIdToken(token);
|
|
1121
|
+
const emailClaim = claims.email;
|
|
1122
|
+
if (typeof emailClaim === "string" && emailClaim.length > 0) {
|
|
1123
|
+
requesterEmail = emailClaim;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (!requesterEmail) {
|
|
1127
|
+
throw new Error("Could not resolve your email; pass --company and ensure you are an active member");
|
|
1128
|
+
}
|
|
1129
|
+
return { email: requesterEmail, name: requesterName };
|
|
1130
|
+
})();
|
|
1131
|
+
return requesterP;
|
|
835
1132
|
};
|
|
836
|
-
const members = await listMembers();
|
|
837
|
-
const caller = await resolveCallerUid(token);
|
|
838
|
-
const me = members.find((m) => m.personUid === caller.uid);
|
|
839
|
-
// An agent has no mailbox and no person entity. `hq files share --with`
|
|
840
|
-
// accepts its agt_* uid, so the access request names the agent by uid.
|
|
841
|
-
let requesterEmail = caller.kind === "agent" ? caller.uid : me?.personEmail;
|
|
842
|
-
const requesterName = me?.personName;
|
|
843
|
-
if (!requesterEmail) {
|
|
844
|
-
const claims = peekIdToken(token);
|
|
845
|
-
const emailClaim = claims.email;
|
|
846
|
-
if (typeof emailClaim === "string" && emailClaim.length > 0) {
|
|
847
|
-
requesterEmail = emailClaim;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
if (!requesterEmail) {
|
|
851
|
-
throw new Error("Could not resolve your email; pass --company and ensure you are an active member");
|
|
852
|
-
}
|
|
853
1133
|
const dm = {
|
|
854
1134
|
async send(msg) {
|
|
855
1135
|
const body = buildDmBody({
|
|
@@ -889,65 +1169,99 @@ export async function runAccessForPath(path, opts) {
|
|
|
889
1169
|
return t === "y" || t === "yes";
|
|
890
1170
|
}
|
|
891
1171
|
: undefined;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1172
|
+
return {
|
|
1173
|
+
hqRoot,
|
|
1174
|
+
companySlug: slug,
|
|
1175
|
+
companyUid,
|
|
1176
|
+
vaultClient: client,
|
|
1177
|
+
companyClient: makeCompanyPresignFactory(token, slug),
|
|
1178
|
+
region: "us-east-1",
|
|
1179
|
+
fetchAclTree,
|
|
1180
|
+
listMembers,
|
|
1181
|
+
requester,
|
|
1182
|
+
resolveAcl,
|
|
1183
|
+
dm,
|
|
1184
|
+
confirm,
|
|
1185
|
+
now: Date.now,
|
|
1186
|
+
get: (anchoredPath) => runGet({
|
|
1187
|
+
path: anchoredPath,
|
|
898
1188
|
hqRoot,
|
|
899
1189
|
companySlug: slug,
|
|
900
|
-
companyUid,
|
|
901
1190
|
vaultClient: client,
|
|
902
1191
|
companyClient: makeCompanyPresignFactory(token, slug),
|
|
903
1192
|
region: "us-east-1",
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1193
|
+
}),
|
|
1194
|
+
syncStatus: async () => {
|
|
1195
|
+
const journals = listJournals();
|
|
1196
|
+
if (journals.length === 0)
|
|
1197
|
+
return "no sync journal yet";
|
|
1198
|
+
let files = 0;
|
|
1199
|
+
for (const j of journals) {
|
|
1200
|
+
const entries = Object.entries(j.journal.files ?? {});
|
|
1201
|
+
files += entries.filter(([, e]) => !e.removedAt).length;
|
|
1202
|
+
}
|
|
1203
|
+
return `${journals.length} journals, ${files} files tracked`;
|
|
1204
|
+
},
|
|
1205
|
+
syncDoctor: async (apply) => {
|
|
1206
|
+
const doctorResult = await syncDoctor({
|
|
1207
|
+
entity: "local",
|
|
1208
|
+
vaultConfig: {},
|
|
913
1209
|
hqRoot,
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
return `${journals.length} journals, ${files} files tracked`;
|
|
929
|
-
},
|
|
930
|
-
syncDoctor: async (apply) => {
|
|
931
|
-
const doctorResult = await syncDoctor({
|
|
932
|
-
entity: "local",
|
|
933
|
-
vaultConfig: {},
|
|
934
|
-
hqRoot,
|
|
935
|
-
yes: apply,
|
|
936
|
-
reconcileConflicts: true,
|
|
937
|
-
});
|
|
938
|
-
const twins = doctorResult.conflictTwins?.plan?.length ?? 0;
|
|
939
|
-
const bulkRefused = Boolean(doctorResult.plan?.bulkRefused);
|
|
940
|
-
return {
|
|
941
|
-
twins,
|
|
942
|
-
summary: `${twins} conflict twins`,
|
|
943
|
-
bulkRefused,
|
|
944
|
-
breakerMessage: bulkRefused
|
|
945
|
-
? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
|
|
946
|
-
: undefined,
|
|
947
|
-
};
|
|
948
|
-
},
|
|
1210
|
+
yes: apply,
|
|
1211
|
+
reconcileConflicts: true,
|
|
1212
|
+
});
|
|
1213
|
+
const twins = doctorResult.conflictTwins?.plan?.length ?? 0;
|
|
1214
|
+
const bulkRefused = Boolean(doctorResult.plan?.bulkRefused);
|
|
1215
|
+
return {
|
|
1216
|
+
twins,
|
|
1217
|
+
summary: `${twins} conflict twins`,
|
|
1218
|
+
bulkRefused,
|
|
1219
|
+
breakerMessage: bulkRefused
|
|
1220
|
+
? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
|
|
1221
|
+
: undefined,
|
|
1222
|
+
};
|
|
949
1223
|
},
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Company for a set of targets. Every target must resolve to the same company:
|
|
1228
|
+
* one vend and one ACL call cover the whole batch.
|
|
1229
|
+
*/
|
|
1230
|
+
function resolveBatchCompany(targets, opts) {
|
|
1231
|
+
const slugs = new Set(targets.map((t) => resolveAccessCompany(t, opts)));
|
|
1232
|
+
if (slugs.has(undefined)) {
|
|
1233
|
+
throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
|
|
1234
|
+
}
|
|
1235
|
+
if (slugs.size > 1) {
|
|
1236
|
+
throw new Error(`Paths span several companies (${[...slugs].join(", ")}); run hq access once per company or pass --company <slug>.`);
|
|
1237
|
+
}
|
|
1238
|
+
return [...slugs][0];
|
|
1239
|
+
}
|
|
1240
|
+
export async function runAccessForPath(path, opts) {
|
|
1241
|
+
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
1242
|
+
const slug = resolveAccessCompany(path, opts);
|
|
1243
|
+
if (!slug) {
|
|
1244
|
+
throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
|
|
1245
|
+
}
|
|
1246
|
+
const deps = await buildLiveAccessDeps(slug, hqRoot);
|
|
1247
|
+
return runAccess({
|
|
1248
|
+
target: path,
|
|
1249
|
+
fix: opts?.fix !== false,
|
|
1250
|
+
json: Boolean(opts?.json),
|
|
1251
|
+
yes: Boolean(opts?.yes),
|
|
1252
|
+
deps: { ...deps, pick: promptPick },
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
export async function runAccessForPaths(paths, opts) {
|
|
1256
|
+
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
1257
|
+
const slug = resolveBatchCompany(paths, opts);
|
|
1258
|
+
const deps = await buildLiveAccessDeps(slug, hqRoot);
|
|
1259
|
+
return runAccessBatch({
|
|
1260
|
+
targets: paths,
|
|
1261
|
+
fix: opts?.fix !== false,
|
|
1262
|
+
json: Boolean(opts?.json),
|
|
1263
|
+
yes: Boolean(opts?.yes),
|
|
1264
|
+
deps,
|
|
950
1265
|
});
|
|
951
|
-
return result;
|
|
952
1266
|
}
|
|
953
1267
|
//# sourceMappingURL=access.js.map
|