@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.
Files changed (47) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +8 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +15 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner.d.ts +12 -0
  9. package/dist/bin/sync-runner.d.ts.map +1 -1
  10. package/dist/bin/sync-runner.js +26 -0
  11. package/dist/bin/sync-runner.js.map +1 -1
  12. package/dist/bin/sync-runner.test.js +68 -1
  13. package/dist/bin/sync-runner.test.js.map +1 -1
  14. package/dist/cli/sync-scope.test.js +3 -1
  15. package/dist/cli/sync-scope.test.js.map +1 -1
  16. package/dist/cli/sync.d.ts +6 -6
  17. package/dist/cli/sync.d.ts.map +1 -1
  18. package/dist/cli/sync.js +38 -41
  19. package/dist/cli/sync.js.map +1 -1
  20. package/dist/cli/sync.test.js +51 -2
  21. package/dist/cli/sync.test.js.map +1 -1
  22. package/dist/manifest-reconcile.d.ts +27 -0
  23. package/dist/manifest-reconcile.d.ts.map +1 -0
  24. package/dist/manifest-reconcile.js +120 -0
  25. package/dist/manifest-reconcile.js.map +1 -0
  26. package/dist/manifest-reconcile.test.d.ts +2 -0
  27. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  28. package/dist/manifest-reconcile.test.js +97 -0
  29. package/dist/manifest-reconcile.test.js.map +1 -0
  30. package/dist/s3.d.ts +12 -1
  31. package/dist/s3.d.ts.map +1 -1
  32. package/dist/s3.js +51 -18
  33. package/dist/s3.js.map +1 -1
  34. package/dist/s3.test.js +12 -1
  35. package/dist/s3.test.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/bin/sync-runner-company.ts +7 -0
  38. package/src/bin/sync-runner-planning.ts +16 -2
  39. package/src/bin/sync-runner.test.ts +82 -1
  40. package/src/bin/sync-runner.ts +43 -0
  41. package/src/cli/sync-scope.test.ts +3 -1
  42. package/src/cli/sync.test.ts +59 -2
  43. package/src/cli/sync.ts +53 -53
  44. package/src/manifest-reconcile.test.ts +107 -0
  45. package/src/manifest-reconcile.ts +160 -0
  46. package/src/s3.test.ts +18 -0
  47. 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 it is
352
- * invalid for the vault's scope — today, a `companies/…`-prefixed key in
353
- * a company-scoped vault (the server rejects these with
354
- * INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
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
- reportInvalidScopedKeys(run, plan);
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 scope-invalid remote keys the planner skipped (see the
955
- * `invalidScopedKeys` doc on PullPlan). One warning event per key on the
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
- * INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
961
- * on any non-zero count with no threshold tuning.
961
+ * invalid-key occurrences fleet-wide, so consumers can alert on any non-zero
962
+ * count with no threshold tuning.
962
963
  */
963
- function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
964
- if (plan.invalidScopedKeys.length === 0) return;
965
- for (const key of plan.invalidScopedKeys) {
966
- run.emit({ type: "skip-invalid-scoped-key", path: key });
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 they are invalid for the vault's scope
1995
- * (company mode only: literal `companies/…` keys bucket poisoning from a
1996
- * doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
1997
- * carried by key so the caller can log one warning per key and emit
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
- invalidScopedKeys: string[];
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
- // Company-mode remote keys invalid for the vault's scope (`companies/…` in a
2079
- // company bucket) collected so the caller can warn + emit telemetry.
2080
- const invalidScopedKeys: string[] = [];
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
- invalidScopedKeys,
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 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
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 function validateVaultUploadKey(
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: "company" | "personal",
574
- ): void {
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
- fail("Invalid key: must not be empty", "INVALID_KEY_EMPTY");
590
+ return {
591
+ code: "INVALID_KEY_EMPTY",
592
+ message: "Invalid key: must not be empty",
593
+ };
580
594
  }
581
595
  if (key.startsWith("/")) {
582
- fail("Invalid key: leading '/' not allowed", "INVALID_KEY_LEADING_SLASH");
596
+ return {
597
+ code: "INVALID_KEY_LEADING_SLASH",
598
+ message: "Invalid key: leading '/' not allowed",
599
+ };
583
600
  }
584
601
  if (key.endsWith("/")) {
585
- fail(
586
- "Invalid key: trailing '/' (folder marker, not an object)",
587
- "INVALID_KEY_TRAILING_SLASH",
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
- fail(
592
- "Invalid key: vault keys are bucket-relative and the vault is already " +
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
- "INVALID_KEY_COMPANIES_SCOPED",
597
- );
615
+ };
598
616
  }
599
617
  if (KEY_CONTROL_CHARS.test(key)) {
600
- fail("Invalid key: contains control characters", "INVALID_KEY_CONTROL_CHARS");
618
+ return {
619
+ code: "INVALID_KEY_CONTROL_CHARS",
620
+ message: "Invalid key: contains control characters",
621
+ };
601
622
  }
602
623
  if (key.includes("\\")) {
603
- // Unreachable after toPosixKey normalization at the upload boundary, but
604
- // kept so the validator mirrors the server rule set 1:1 for other callers.
605
- fail(
606
- "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
607
- "INVALID_KEY_BACKSLASH",
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
- fail("Invalid key: consecutive slashes not allowed", "INVALID_KEY_DOUBLE_SLASH");
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
- fail(
616
- "Invalid key: '.' and '..' path components are not allowed",
617
- "INVALID_KEY_DOT_COMPONENT",
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): "company" | "personal" {
658
+ function uploadScopeFor(ctx: EntityContext): VaultKeyScope {
625
659
  return ctx.uid.startsWith("cmp_") ? "company" : "personal";
626
660
  }
627
661