@indigoai-us/hq-cloud 6.14.8 → 6.14.10
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/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +105 -3
- package/dist/cli/share.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 +7 -7
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +166 -82
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +292 -27
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.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/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +15 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +54 -19
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.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/share.test.ts +129 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +352 -26
- package/src/cli/sync.ts +226 -103
- package/src/journal.ts +28 -0
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +67 -30
- package/src/types.ts +13 -0
|
@@ -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/personal-vault.ts
CHANGED
|
@@ -105,6 +105,21 @@ export interface PersonalVaultOptions {
|
|
|
105
105
|
*/
|
|
106
106
|
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Release-owned scaffold and reindex-generated wrappers must never be
|
|
110
|
+
* delete-propagated from the personal vault. They are recreated locally by a
|
|
111
|
+
* release or `hq reindex`, so an ordinary local absence is not user intent.
|
|
112
|
+
*/
|
|
113
|
+
export function isPersonalVaultDeletionExcluded(key: string): boolean {
|
|
114
|
+
const normalized = key.split("\\").join("/").replace(/^\.\//, "");
|
|
115
|
+
return (
|
|
116
|
+
normalized === "core" ||
|
|
117
|
+
normalized.startsWith("core/") ||
|
|
118
|
+
normalized === ".claude/skills" ||
|
|
119
|
+
normalized.startsWith(".claude/skills/")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export function computePersonalVaultPaths(
|
|
109
124
|
hqRoot: string,
|
|
110
125
|
opts: PersonalVaultOptions = {},
|
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
|
|
|
@@ -775,6 +809,7 @@ export async function downloadFile(
|
|
|
775
809
|
ctx: EntityContext,
|
|
776
810
|
key: string,
|
|
777
811
|
localPath: string,
|
|
812
|
+
options: { beforeReplace?: () => void } = {},
|
|
778
813
|
): Promise<{
|
|
779
814
|
metadata?: Record<string, string>;
|
|
780
815
|
contentHash?: string;
|
|
@@ -868,6 +903,7 @@ export async function downloadFile(
|
|
|
868
903
|
const tempPath = downloadTempPath(localPath);
|
|
869
904
|
try {
|
|
870
905
|
fs.symlinkSync(symlinkTarget, tempPath);
|
|
906
|
+
options.beforeReplace?.();
|
|
871
907
|
fs.renameSync(tempPath, localPath);
|
|
872
908
|
} catch (err) {
|
|
873
909
|
removeTempPath(tempPath);
|
|
@@ -977,6 +1013,7 @@ export async function downloadFile(
|
|
|
977
1013
|
}
|
|
978
1014
|
}
|
|
979
1015
|
|
|
1016
|
+
options.beforeReplace?.();
|
|
980
1017
|
fs.renameSync(tempPath, localPath);
|
|
981
1018
|
tempReady = false;
|
|
982
1019
|
} finally {
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface JournalEntry {
|
|
|
26
26
|
size: number;
|
|
27
27
|
syncedAt: string;
|
|
28
28
|
direction: "up" | "down";
|
|
29
|
+
/** Kind of local object represented by this entry. */
|
|
30
|
+
kind?: "file" | "symlink";
|
|
29
31
|
/**
|
|
30
32
|
* Cognito `sub` of the file's author, captured from the object's
|
|
31
33
|
* `created-by-sub` S3 user-metadata at download time (zero extra network —
|
|
@@ -72,6 +74,17 @@ export interface JournalEntry {
|
|
|
72
74
|
*/
|
|
73
75
|
removedAt?: string;
|
|
74
76
|
removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
|
|
77
|
+
/**
|
|
78
|
+
* Explicit local-delete intent. Unlike the legacy `removedReason` marker,
|
|
79
|
+
* this is bound to the exact synced remote revision and local object shape
|
|
80
|
+
* that the user approved for deletion.
|
|
81
|
+
*/
|
|
82
|
+
localDeleteIntent?: {
|
|
83
|
+
version: 1;
|
|
84
|
+
remoteEtag: string;
|
|
85
|
+
localHash: string;
|
|
86
|
+
localKind: "file" | "symlink";
|
|
87
|
+
};
|
|
75
88
|
/**
|
|
76
89
|
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
90
|
* out-of-scope caller-authored or unknown-author entry on disk instead of
|