@indigoai-us/hq-cloud 6.14.8 → 6.14.9
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 +8 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +15 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +12 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +26 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +68 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +3 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +6 -6
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +38 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +51 -2
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +27 -0
- package/dist/manifest-reconcile.d.ts.map +1 -0
- package/dist/manifest-reconcile.js +120 -0
- package/dist/manifest-reconcile.js.map +1 -0
- package/dist/manifest-reconcile.test.d.ts +2 -0
- package/dist/manifest-reconcile.test.d.ts.map +1 -0
- package/dist/manifest-reconcile.test.js +97 -0
- package/dist/manifest-reconcile.test.js.map +1 -0
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +51 -18
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +7 -0
- package/src/bin/sync-runner-planning.ts +16 -2
- package/src/bin/sync-runner.test.ts +82 -1
- package/src/bin/sync-runner.ts +43 -0
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +59 -2
- package/src/cli/sync.ts +53 -53
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +64 -30
package/src/cli/sync.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
headRemoteFile,
|
|
27
27
|
primeObjectTransport,
|
|
28
28
|
toPosixKey,
|
|
29
|
+
classifyVaultKey,
|
|
29
30
|
} from "../s3.js";
|
|
30
31
|
import type { DownloadModeWarning, RemoteFile } from "../s3.js";
|
|
31
32
|
import {
|
|
@@ -348,12 +349,10 @@ export type SyncProgressEvent =
|
|
|
348
349
|
}
|
|
349
350
|
| {
|
|
350
351
|
/**
|
|
351
|
-
* Emitted by the PULL leg once per remote key skipped because
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* Such keys are bucket poisoning from a client with a stale doubled
|
|
356
|
-
* local tree writing via direct S3 (incident 2026-07-11). Like
|
|
352
|
+
* Emitted by the PULL leg once per remote key skipped because the server
|
|
353
|
+
* will never presign it (for example, a `companies/…`-prefixed key in a
|
|
354
|
+
* company-scoped vault or a key with control characters). Such keys are
|
|
355
|
+
* bucket poisoning from an outdated direct-S3 client. Like
|
|
357
356
|
* `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
|
|
358
357
|
* object must never error a whole company sync (erroring gated
|
|
359
358
|
* heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
|
|
@@ -363,6 +362,8 @@ export type SyncProgressEvent =
|
|
|
363
362
|
*/
|
|
364
363
|
type: "skip-invalid-scoped-key";
|
|
365
364
|
path: string;
|
|
365
|
+
/** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
|
|
366
|
+
errorCode?: string;
|
|
366
367
|
};
|
|
367
368
|
|
|
368
369
|
export interface SyncOptions {
|
|
@@ -816,7 +817,7 @@ async function syncWithOperationLockHeld(
|
|
|
816
817
|
const plan = planPull(run);
|
|
817
818
|
|
|
818
819
|
emitPullPlan(run.emit, plan);
|
|
819
|
-
|
|
820
|
+
reportInvalidRemoteKeys(run, plan);
|
|
820
821
|
|
|
821
822
|
const scopePlan = planScopeShrink(run);
|
|
822
823
|
const scopeRun = executeScopeShrink(run, scopePlan);
|
|
@@ -951,30 +952,30 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
|
|
|
951
952
|
}
|
|
952
953
|
|
|
953
954
|
/**
|
|
954
|
-
* Surface
|
|
955
|
-
* `
|
|
955
|
+
* Surface permanently-unpresignable remote keys the planner skipped (see the
|
|
956
|
+
* `invalidRemoteKeys` doc on PullPlan). One warning event per key on the
|
|
956
957
|
* existing sync event channel (deliberately NOT `type: "error"` — see the
|
|
957
958
|
* `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
|
|
958
959
|
* event on the channel sync already uses (emitCloudTelemetry). Per policy
|
|
959
960
|
* hq-alert-baseline-calibration the documented healthy baseline is ZERO
|
|
960
|
-
*
|
|
961
|
-
*
|
|
961
|
+
* invalid-key occurrences fleet-wide, so consumers can alert on any non-zero
|
|
962
|
+
* count with no threshold tuning.
|
|
962
963
|
*/
|
|
963
|
-
function
|
|
964
|
-
if (plan.
|
|
965
|
-
|
|
966
|
-
|
|
964
|
+
function reportInvalidRemoteKeys(run: PullRunContext, plan: PullPlan): void {
|
|
965
|
+
if (plan.invalidRemoteKeys.length === 0) return;
|
|
966
|
+
const countsByErrorCode = new Map<string, number>();
|
|
967
|
+
for (const { path, errorCode } of plan.invalidRemoteKeys) {
|
|
968
|
+
run.emit({ type: "skip-invalid-scoped-key", path, errorCode });
|
|
969
|
+
countsByErrorCode.set(errorCode, (countsByErrorCode.get(errorCode) ?? 0) + 1);
|
|
970
|
+
}
|
|
971
|
+
for (const [errorCode, count] of countsByErrorCode) {
|
|
972
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
973
|
+
eventName: "sync_invalid_scoped_key_skipped",
|
|
974
|
+
source: "hq-sync",
|
|
975
|
+
companyUid: run.ctx.uid,
|
|
976
|
+
properties: { count, errorCode, leg: "pull" },
|
|
977
|
+
}, { claims: run.options.telemetryClaims });
|
|
967
978
|
}
|
|
968
|
-
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
969
|
-
eventName: "sync_invalid_scoped_key_skipped",
|
|
970
|
-
source: "hq-sync",
|
|
971
|
-
companyUid: run.ctx.uid,
|
|
972
|
-
properties: {
|
|
973
|
-
count: plan.invalidScopedKeys.length,
|
|
974
|
-
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
975
|
-
leg: "pull",
|
|
976
|
-
},
|
|
977
|
-
}, { claims: run.options.telemetryClaims });
|
|
978
979
|
}
|
|
979
980
|
|
|
980
981
|
/**
|
|
@@ -1991,14 +1992,13 @@ interface PullPlan {
|
|
|
1991
1992
|
*/
|
|
1992
1993
|
filesToTombstoneDelete: number;
|
|
1993
1994
|
/**
|
|
1994
|
-
* Remote keys skipped because
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* telemetry. Healthy baseline is an empty array (policy
|
|
1995
|
+
* Remote keys skipped because the server will never presign them. Counted in
|
|
1996
|
+
* `filesExcludedByPolicy`; carried with the server-compatible error code so
|
|
1997
|
+
* the caller can log one warning per key and emit telemetry. Healthy
|
|
1998
|
+
* baseline is an empty array (policy
|
|
1999
1999
|
* hq-alert-baseline-calibration: zero expected, any occurrence is signal).
|
|
2000
2000
|
*/
|
|
2001
|
-
|
|
2001
|
+
invalidRemoteKeys: Array<{ path: string; errorCode: string }>;
|
|
2002
2002
|
}
|
|
2003
2003
|
|
|
2004
2004
|
/**
|
|
@@ -2075,9 +2075,9 @@ function computePullPlan(
|
|
|
2075
2075
|
excludePrefixes: readonly ScopePrefixInput[] = [],
|
|
2076
2076
|
): PullPlan {
|
|
2077
2077
|
const items: PullPlanItem[] = [];
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
const
|
|
2078
|
+
// Remote keys the server will never presign — collected so the caller can
|
|
2079
|
+
// warn + emit telemetry without allowing one poison object to fail a pull.
|
|
2080
|
+
const invalidRemoteKeys: Array<{ path: string; errorCode: string }> = [];
|
|
2081
2081
|
// Clean/current/non-divergent files missing locally — collected here, then
|
|
2082
2082
|
// resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
|
|
2083
2083
|
const intentionalDeleteCandidates: Array<{
|
|
@@ -2086,6 +2086,23 @@ function computePullPlan(
|
|
|
2086
2086
|
}> = [];
|
|
2087
2087
|
|
|
2088
2088
|
for (const remoteFile of remoteFiles) {
|
|
2089
|
+
const invalidKey = classifyVaultKey(
|
|
2090
|
+
remoteFile.key,
|
|
2091
|
+
personalMode ? "personal" : "company",
|
|
2092
|
+
);
|
|
2093
|
+
if (invalidKey !== null) {
|
|
2094
|
+
invalidRemoteKeys.push({
|
|
2095
|
+
path: remoteFile.key,
|
|
2096
|
+
errorCode: invalidKey.code,
|
|
2097
|
+
});
|
|
2098
|
+
items.push({
|
|
2099
|
+
action: "skip-excluded-policy",
|
|
2100
|
+
remoteFile,
|
|
2101
|
+
localPath: companyRoot,
|
|
2102
|
+
});
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2089
2106
|
const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
|
|
2090
2107
|
if (localPath === null) {
|
|
2091
2108
|
items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
|
|
@@ -2103,23 +2120,6 @@ function computePullPlan(
|
|
|
2103
2120
|
continue;
|
|
2104
2121
|
}
|
|
2105
2122
|
|
|
2106
|
-
// Scope-invalid key filter (incident 2026-07-11). A company vault bucket
|
|
2107
|
-
// is already company-scoped, so a literal `companies/…` key can only be
|
|
2108
|
-
// bucket poisoning — a client with a stale doubled local tree
|
|
2109
|
-
// (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
|
|
2110
|
-
// transport, which bypasses the server validator. The presign download
|
|
2111
|
-
// API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
|
|
2112
|
-
// download them errors the WHOLE company sync. Skip them here — one bad
|
|
2113
|
-
// object must never take down a company sync — and record them on the
|
|
2114
|
-
// plan so the caller can warn + emit telemetry (baseline is zero; any
|
|
2115
|
-
// occurrence is signal). Mirrors the personalMode/malformed-key filters
|
|
2116
|
-
// above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
|
|
2117
|
-
if (!personalMode && remoteFile.key.startsWith("companies/")) {
|
|
2118
|
-
invalidScopedKeys.push(remoteFile.key);
|
|
2119
|
-
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2120
|
-
continue;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
2123
|
if (
|
|
2124
2124
|
personalMode &&
|
|
2125
2125
|
remoteFile.key.startsWith("companies/") &&
|
|
@@ -2680,7 +2680,7 @@ function computePullPlan(
|
|
|
2680
2680
|
filesOutOfScope,
|
|
2681
2681
|
filesToTombstoneDelete,
|
|
2682
2682
|
tombstones,
|
|
2683
|
-
|
|
2683
|
+
invalidRemoteKeys,
|
|
2684
2684
|
};
|
|
2685
2685
|
}
|
|
2686
2686
|
|
|
@@ -2765,7 +2765,7 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2765
2765
|
);
|
|
2766
2766
|
} else if (event.type === "skip-invalid-scoped-key") {
|
|
2767
2767
|
console.warn(
|
|
2768
|
-
` ! ${event.path} skipped — invalid
|
|
2768
|
+
` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
|
|
2769
2769
|
);
|
|
2770
2770
|
}
|
|
2771
2771
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
|
|
7
|
+
import { reconcileCompanyManifest } from "./manifest-reconcile.js";
|
|
8
|
+
import type { EntityInfo } from "./vault-client.js";
|
|
9
|
+
|
|
10
|
+
let hqRoot: string | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (hqRoot) {
|
|
14
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
15
|
+
hqRoot = undefined;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("reconcileCompanyManifest", () => {
|
|
20
|
+
it("atomically restores one successful cloud entry after the personal leg without replacing existing manifest state", async () => {
|
|
21
|
+
hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
|
|
22
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
23
|
+
fs.mkdirSync(path.join(companiesDir, "acme"), { recursive: true });
|
|
24
|
+
fs.mkdirSync(path.join(companiesDir, "failed"), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(companiesDir, "deleted"), { recursive: true });
|
|
26
|
+
|
|
27
|
+
// This is the older manifest the personal-vault leg pulled after the
|
|
28
|
+
// cloud-company legs completed. The final merge must retain this state and
|
|
29
|
+
// add only the live, successful cloud target.
|
|
30
|
+
const manifestPath = path.join(companiesDir, "manifest.yaml");
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
manifestPath,
|
|
33
|
+
`version: 3\nsettings:\n keep: true\ncompanies:\n local-only:\n name: Local Only\n repos:\n - repos/local\n preserved:\n name: Keep Me\n cloud_uid: cmp_preserved\n`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const entities: Record<string, EntityInfo> = {
|
|
37
|
+
cmp_acme: {
|
|
38
|
+
uid: "cmp_acme",
|
|
39
|
+
slug: "acme",
|
|
40
|
+
type: "company",
|
|
41
|
+
status: "active",
|
|
42
|
+
name: "Acme Inc",
|
|
43
|
+
bucketName: "hq-vault-cmp-acme",
|
|
44
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
45
|
+
},
|
|
46
|
+
cmp_failed: {
|
|
47
|
+
uid: "cmp_failed",
|
|
48
|
+
slug: "failed",
|
|
49
|
+
type: "company",
|
|
50
|
+
status: "active",
|
|
51
|
+
name: "Failed Corp",
|
|
52
|
+
bucketName: "hq-vault-cmp-failed",
|
|
53
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
54
|
+
},
|
|
55
|
+
cmp_deleted: {
|
|
56
|
+
uid: "cmp_deleted",
|
|
57
|
+
slug: "deleted",
|
|
58
|
+
type: "company",
|
|
59
|
+
status: "deleted",
|
|
60
|
+
name: "Deleted Corp",
|
|
61
|
+
bucketName: "hq-vault-cmp-deleted",
|
|
62
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const options = {
|
|
66
|
+
hqRoot,
|
|
67
|
+
targets: [
|
|
68
|
+
{ uid: "cmp_acme", slug: "acme" },
|
|
69
|
+
{ uid: "cmp_failed", slug: "failed" },
|
|
70
|
+
{ uid: "cmp_deleted", slug: "deleted" },
|
|
71
|
+
{ uid: "cmp_missing-directory", slug: "missing-directory" },
|
|
72
|
+
{ uid: "prs_me", slug: "personal", personalMode: true },
|
|
73
|
+
],
|
|
74
|
+
completedCompanySlugs: new Set(["acme", "deleted", "missing-directory", "personal"]),
|
|
75
|
+
getEntity: async (uid: string) => {
|
|
76
|
+
const entity = entities[uid];
|
|
77
|
+
if (!entity) throw new Error("entity not found");
|
|
78
|
+
return entity;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
await reconcileCompanyManifest(options);
|
|
83
|
+
const firstWrite = fs.readFileSync(manifestPath, "utf8");
|
|
84
|
+
await reconcileCompanyManifest(options);
|
|
85
|
+
|
|
86
|
+
expect(fs.readFileSync(manifestPath, "utf8")).toBe(firstWrite);
|
|
87
|
+
expect(yaml.load(firstWrite)).toEqual({
|
|
88
|
+
version: 3,
|
|
89
|
+
settings: { keep: true },
|
|
90
|
+
companies: {
|
|
91
|
+
"local-only": {
|
|
92
|
+
name: "Local Only",
|
|
93
|
+
repos: ["repos/local"],
|
|
94
|
+
},
|
|
95
|
+
preserved: {
|
|
96
|
+
name: "Keep Me",
|
|
97
|
+
cloud_uid: "cmp_preserved",
|
|
98
|
+
},
|
|
99
|
+
acme: {
|
|
100
|
+
name: "Acme Inc",
|
|
101
|
+
cloud_uid: "cmp_acme",
|
|
102
|
+
bucket_name: "hq-vault-cmp-acme",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile successfully pulled cloud companies into the local manifest.
|
|
3
|
+
*
|
|
4
|
+
* The personal-vault leg also carries `companies/manifest.yaml`, so this runs
|
|
5
|
+
* only after the complete fanout has settled. That makes the locally
|
|
6
|
+
* materialized cloud companies authoritative for their own manifest entries
|
|
7
|
+
* without discarding entries the personal vault already had.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
import type { EntityInfo } from "./vault-client.js";
|
|
16
|
+
|
|
17
|
+
export interface ManifestReconcileTarget {
|
|
18
|
+
uid: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
personalMode?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ManifestReconcileOptions {
|
|
24
|
+
hqRoot: string;
|
|
25
|
+
targets: readonly ManifestReconcileTarget[];
|
|
26
|
+
completedCompanySlugs: ReadonlySet<string>;
|
|
27
|
+
getEntity: (uid: string) => Promise<EntityInfo>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ManifestCompany {
|
|
31
|
+
name?: unknown;
|
|
32
|
+
cloud_uid?: unknown;
|
|
33
|
+
bucket_name?: unknown;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ManifestDocument {
|
|
38
|
+
companies?: Record<string, ManifestCompany>;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLiveCompany(entity: EntityInfo, uid: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
entity.uid === uid &&
|
|
49
|
+
entity.type === "company" &&
|
|
50
|
+
entity.status === "active" &&
|
|
51
|
+
typeof entity.name === "string" &&
|
|
52
|
+
entity.name.length > 0 &&
|
|
53
|
+
typeof entity.bucketName === "string" &&
|
|
54
|
+
entity.bucketName.length > 0
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasCompanyDirectory(companiesDir: string, slug: string): boolean {
|
|
59
|
+
const resolvedCompaniesDir = path.resolve(companiesDir);
|
|
60
|
+
const companyDir = path.resolve(resolvedCompaniesDir, slug);
|
|
61
|
+
if (path.dirname(companyDir) !== resolvedCompaniesDir) return false;
|
|
62
|
+
try {
|
|
63
|
+
return fs.statSync(companyDir).isDirectory();
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadManifest(manifestPath: string): ManifestDocument | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
72
|
+
const parsed = yaml.load(raw);
|
|
73
|
+
return isRecord(parsed) ? (parsed as ManifestDocument) : null;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeManifestAtomically(manifestPath: string, document: ManifestDocument): void {
|
|
81
|
+
const temporaryPath = path.join(
|
|
82
|
+
path.dirname(manifestPath),
|
|
83
|
+
`.manifest-${process.pid}-${randomUUID()}.yaml`,
|
|
84
|
+
);
|
|
85
|
+
fs.writeFileSync(temporaryPath, yaml.dump(document, { lineWidth: -1 }), "utf8");
|
|
86
|
+
fs.renameSync(temporaryPath, manifestPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomically merge live, successfully pulled company targets into
|
|
91
|
+
* `companies/manifest.yaml`. Targets that are personal, incomplete, deleted,
|
|
92
|
+
* no longer resolvable, or not materialized on disk are intentionally ignored.
|
|
93
|
+
*/
|
|
94
|
+
export async function reconcileCompanyManifest(
|
|
95
|
+
options: ManifestReconcileOptions,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const companiesDir = path.join(options.hqRoot, "companies");
|
|
98
|
+
const candidates: Array<{ slug: string; entity: EntityInfo }> = [];
|
|
99
|
+
|
|
100
|
+
for (const target of options.targets) {
|
|
101
|
+
if (
|
|
102
|
+
target.personalMode === true ||
|
|
103
|
+
!options.completedCompanySlugs.has(target.slug) ||
|
|
104
|
+
!hasCompanyDirectory(companiesDir, target.slug)
|
|
105
|
+
) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const entity = await options.getEntity(target.uid);
|
|
111
|
+
if (isLiveCompany(entity, target.uid)) {
|
|
112
|
+
candidates.push({ slug: target.slug, entity });
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// The target is not presently a live, resolvable company. Never mint a
|
|
116
|
+
// manifest entry from stale fanout data.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (candidates.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const manifestPath = path.join(companiesDir, "manifest.yaml");
|
|
123
|
+
const document = loadManifest(manifestPath);
|
|
124
|
+
if (!document) return;
|
|
125
|
+
|
|
126
|
+
let companies: Record<string, ManifestCompany>;
|
|
127
|
+
if (document.companies === undefined) {
|
|
128
|
+
companies = {};
|
|
129
|
+
document.companies = companies;
|
|
130
|
+
} else if (isRecord(document.companies)) {
|
|
131
|
+
companies = document.companies as Record<string, ManifestCompany>;
|
|
132
|
+
} else {
|
|
133
|
+
// A malformed `companies` key is user state; refusing to replace it is
|
|
134
|
+
// safer than losing a manifest that cannot be interpreted unambiguously.
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let changed = false;
|
|
139
|
+
for (const { slug, entity } of candidates) {
|
|
140
|
+
const existing = companies[slug];
|
|
141
|
+
if (existing !== undefined && !isRecord(existing)) continue;
|
|
142
|
+
const entry: ManifestCompany = existing ?? {};
|
|
143
|
+
if (
|
|
144
|
+
entry.name === entity.name &&
|
|
145
|
+
entry.cloud_uid === entity.uid &&
|
|
146
|
+
entry.bucket_name === entity.bucketName
|
|
147
|
+
) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
companies[slug] = {
|
|
151
|
+
...entry,
|
|
152
|
+
name: entity.name,
|
|
153
|
+
cloud_uid: entity.uid,
|
|
154
|
+
bucket_name: entity.bucketName,
|
|
155
|
+
};
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (changed) writeManifestAtomically(manifestPath, document);
|
|
160
|
+
}
|
package/src/s3.test.ts
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
decodeSymlinkMetadataValue,
|
|
94
94
|
FILE_MTIME_META_KEY,
|
|
95
95
|
FILE_BTIME_META_KEY,
|
|
96
|
+
classifyVaultKey,
|
|
96
97
|
validateVaultUploadKey,
|
|
97
98
|
} from "./s3.js";
|
|
98
99
|
import {
|
|
@@ -1450,6 +1451,23 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
|
|
|
1450
1451
|
},
|
|
1451
1452
|
);
|
|
1452
1453
|
|
|
1454
|
+
it.each([
|
|
1455
|
+
[
|
|
1456
|
+
"companies/indigo/knowledge/x.md",
|
|
1457
|
+
"company",
|
|
1458
|
+
"INVALID_KEY_COMPANIES_SCOPED",
|
|
1459
|
+
],
|
|
1460
|
+
["bad\x00key.md", "personal", "INVALID_KEY_CONTROL_CHARS"],
|
|
1461
|
+
["docs/good.md", "personal", null],
|
|
1462
|
+
] as const)(
|
|
1463
|
+
"classifyVaultKey reports the validator outcome without throwing: %j",
|
|
1464
|
+
(key, scope, code) => {
|
|
1465
|
+
expect(classifyVaultKey(key, scope)).toEqual(
|
|
1466
|
+
code === null ? null : expect.objectContaining({ code }),
|
|
1467
|
+
);
|
|
1468
|
+
},
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1453
1471
|
it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
|
|
1454
1472
|
await expect(
|
|
1455
1473
|
uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
|
package/src/s3.ts
CHANGED
|
@@ -561,67 +561,101 @@ export function toPosixKey(key: string): string {
|
|
|
561
561
|
* (`cmp_*` → "company", otherwise "personal").
|
|
562
562
|
*
|
|
563
563
|
* Errors carry the same `code` values the server throws so telemetry and
|
|
564
|
-
* operator messaging line up across client and server.
|
|
564
|
+
* operator messaging line up across client and server. Pull planning also
|
|
565
|
+
* uses the non-throwing classifier below to keep poison keys away from the
|
|
566
|
+
* presign transport.
|
|
565
567
|
*/
|
|
566
568
|
// Matching control chars is the whole point — block NUL/0x00–0x1F/0x7F in keys
|
|
567
569
|
// (they can smuggle past path checks or corrupt HTTP headers on the signed URL).
|
|
568
570
|
// eslint-disable-next-line no-control-regex
|
|
569
571
|
const KEY_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
|
|
570
572
|
|
|
571
|
-
export
|
|
573
|
+
export type VaultKeyScope = "company" | "personal";
|
|
574
|
+
|
|
575
|
+
export interface VaultKeyValidationIssue {
|
|
576
|
+
code: string;
|
|
577
|
+
message: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Classify a vault key with the same rules as {@link validateVaultUploadKey}
|
|
582
|
+
* without throwing. Callers that enumerate remote objects can use this before
|
|
583
|
+
* presigning so permanently-invalid legacy keys remain benign skips.
|
|
584
|
+
*/
|
|
585
|
+
export function classifyVaultKey(
|
|
572
586
|
key: string,
|
|
573
|
-
scope:
|
|
574
|
-
):
|
|
575
|
-
const fail = (message: string, code: string): never => {
|
|
576
|
-
throw Object.assign(new Error(message), { code });
|
|
577
|
-
};
|
|
587
|
+
scope: VaultKeyScope,
|
|
588
|
+
): VaultKeyValidationIssue | null {
|
|
578
589
|
if (key.length === 0) {
|
|
579
|
-
|
|
590
|
+
return {
|
|
591
|
+
code: "INVALID_KEY_EMPTY",
|
|
592
|
+
message: "Invalid key: must not be empty",
|
|
593
|
+
};
|
|
580
594
|
}
|
|
581
595
|
if (key.startsWith("/")) {
|
|
582
|
-
|
|
596
|
+
return {
|
|
597
|
+
code: "INVALID_KEY_LEADING_SLASH",
|
|
598
|
+
message: "Invalid key: leading '/' not allowed",
|
|
599
|
+
};
|
|
583
600
|
}
|
|
584
601
|
if (key.endsWith("/")) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
"
|
|
588
|
-
|
|
602
|
+
return {
|
|
603
|
+
code: "INVALID_KEY_TRAILING_SLASH",
|
|
604
|
+
message: "Invalid key: trailing '/' (folder marker, not an object)",
|
|
605
|
+
};
|
|
589
606
|
}
|
|
590
607
|
if (scope === "company" && key.startsWith("companies/")) {
|
|
591
|
-
|
|
592
|
-
|
|
608
|
+
return {
|
|
609
|
+
code: "INVALID_KEY_COMPANIES_SCOPED",
|
|
610
|
+
message:
|
|
611
|
+
"Invalid key: vault keys are bucket-relative and the vault is already " +
|
|
593
612
|
"company-scoped — do not prefix with 'companies/<slug>/'. This " +
|
|
594
613
|
"usually means a stale doubled local tree " +
|
|
595
614
|
"(companies/<slug>/companies/<slug>/…); remove the inner copy.",
|
|
596
|
-
|
|
597
|
-
);
|
|
615
|
+
};
|
|
598
616
|
}
|
|
599
617
|
if (KEY_CONTROL_CHARS.test(key)) {
|
|
600
|
-
|
|
618
|
+
return {
|
|
619
|
+
code: "INVALID_KEY_CONTROL_CHARS",
|
|
620
|
+
message: "Invalid key: contains control characters",
|
|
621
|
+
};
|
|
601
622
|
}
|
|
602
623
|
if (key.includes("\\")) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
);
|
|
624
|
+
return {
|
|
625
|
+
code: "INVALID_KEY_BACKSLASH",
|
|
626
|
+
message:
|
|
627
|
+
"Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
|
|
628
|
+
};
|
|
609
629
|
}
|
|
610
630
|
if (key.includes("//")) {
|
|
611
|
-
|
|
631
|
+
return {
|
|
632
|
+
code: "INVALID_KEY_DOUBLE_SLASH",
|
|
633
|
+
message: "Invalid key: consecutive slashes not allowed",
|
|
634
|
+
};
|
|
612
635
|
}
|
|
613
636
|
for (const segment of key.split("/")) {
|
|
614
637
|
if (segment === "." || segment === "..") {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
"
|
|
618
|
-
|
|
638
|
+
return {
|
|
639
|
+
code: "INVALID_KEY_DOT_COMPONENT",
|
|
640
|
+
message: "Invalid key: '.' and '..' path components are not allowed",
|
|
641
|
+
};
|
|
619
642
|
}
|
|
620
643
|
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function validateVaultUploadKey(
|
|
648
|
+
key: string,
|
|
649
|
+
scope: VaultKeyScope,
|
|
650
|
+
): void {
|
|
651
|
+
const issue = classifyVaultKey(key, scope);
|
|
652
|
+
if (issue !== null) {
|
|
653
|
+
throw Object.assign(new Error(issue.message), { code: issue.code });
|
|
654
|
+
}
|
|
621
655
|
}
|
|
622
656
|
|
|
623
657
|
/** Scope for {@link validateVaultUploadKey}, derived from the entity uid. */
|
|
624
|
-
function uploadScopeFor(ctx: EntityContext):
|
|
658
|
+
function uploadScopeFor(ctx: EntityContext): VaultKeyScope {
|
|
625
659
|
return ctx.uid.startsWith("cmp_") ? "company" : "personal";
|
|
626
660
|
}
|
|
627
661
|
|