@indigoai-us/hq-cli 5.345.191 → 5.345.194
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 +431 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +145 -19
- package/dist/command-catalog.generated.js +184 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +4 -3
- package/dist/commands/bot.js +265 -140
- package/dist/commands/core-jobs-schedule.js +10 -0
- package/dist/commands/daemon.js +37 -0
- package/dist/commands/files.d.ts +41 -6
- package/dist/commands/files.js +108 -27
- package/dist/commands/pack-start-bot.d.ts +4 -6
- package/dist/commands/pack-start-bot.js +4 -8
- package/dist/commands/signals-run.d.ts +46 -0
- package/dist/commands/signals-run.js +180 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +58 -0
- package/dist/commands/sources.js +294 -26
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +35 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/api.d.ts +3 -2
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-cron.d.ts +121 -0
- package/dist/lib/bot/fleet-cron.js +319 -0
- package/dist/lib/bot/fleet-local.d.ts +93 -1
- package/dist/lib/bot/fleet-local.js +546 -34
- package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
- package/dist/lib/bot/fleet-owner-companies.js +30 -0
- package/dist/lib/bot/fleet-watch.d.ts +25 -0
- package/dist/lib/bot/fleet-watch.js +51 -0
- package/dist/lib/bot/index.d.ts +1 -1
- package/dist/lib/bot/index.js +1 -1
- package/dist/lib/bot/job-run.d.ts +5 -4
- package/dist/lib/bot/job-run.js +5 -4
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
- package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
- package/dist/lib/bot/local-jobs.d.ts +13 -2
- package/dist/lib/bot/local-jobs.js +17 -4
- package/dist/lib/bot/progress.d.ts +14 -0
- package/dist/lib/bot/progress.js +47 -1
- package/dist/lib/bot/run.d.ts +55 -2
- package/dist/lib/bot/run.js +192 -10
- package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
- package/dist/lib/bot/setup-tool-offer.js +16 -0
- package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
- package/dist/lib/bot/signals-engine-flag.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +29 -9
- package/dist/lib/daemon/legacy.d.ts +6 -0
- package/dist/lib/daemon/legacy.js +16 -10
- package/dist/lib/daemon/run.d.ts +1 -0
- package/dist/lib/daemon/run.js +10 -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.d.ts +13 -1
- package/dist/lib/flag-registry.js +46 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
- package/dist/lib/hot-channels/adapters/attio.js +367 -29
- package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
- package/dist/lib/hot-channels/adapters/gmail.js +59 -2
- package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
- package/dist/lib/hot-channels/adapters/linear.js +71 -1
- package/dist/lib/hot-channels/adapters/meetings.js +4 -1
- package/dist/lib/hot-channels/adapters/slack.js +7 -3
- package/dist/lib/hot-channels/alert.js +1 -1
- package/dist/lib/hot-channels/contract.d.ts +11 -0
- package/dist/lib/hot-channels/contract.js +1 -0
- package/dist/lib/hot-channels/cursors.js +1 -1
- package/dist/lib/hot-channels/landing.d.ts +6 -2
- package/dist/lib/hot-channels/landing.js +13 -6
- package/dist/lib/hot-channels/ledger.js +2 -0
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +19 -4
- package/dist/lib/hot-channels/registry.js +9 -1
- package/dist/lib/hot-channels/run.d.ts +18 -0
- package/dist/lib/hot-channels/run.js +15 -4
- package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
- package/dist/lib/hot-channels/signals-pass.js +149 -38
- package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
- package/dist/lib/hot-channels/signals-redo.js +207 -0
- package/dist/lib/hot-channels/sync-lock.js +22 -6
- package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
- package/dist/lib/mesh/live/daemon/install.js +117 -30
- package/dist/lib/signals/adapters-catalog.d.ts +77 -0
- package/dist/lib/signals/adapters-catalog.js +194 -0
- package/dist/lib/signals/engine.d.ts +202 -0
- package/dist/lib/signals/engine.js +504 -0
- package/dist/lib/signals/failure-alert.d.ts +89 -0
- package/dist/lib/signals/failure-alert.js +175 -0
- package/dist/lib/signals/local-date.d.ts +3 -0
- package/dist/lib/signals/local-date.js +6 -0
- package/dist/lib/signals/projection.d.ts +42 -0
- package/dist/lib/signals/projection.js +67 -0
- package/dist/lib/signals/reply.d.ts +112 -0
- package/dist/lib/signals/reply.js +368 -0
- package/dist/lib/signals/run-lock.d.ts +36 -0
- package/dist/lib/signals/run-lock.js +148 -0
- package/dist/lib/signals/run-record.d.ts +24 -0
- package/dist/lib/signals/run-record.js +82 -0
- package/dist/lib/signals/sanitize.d.ts +105 -0
- package/dist/lib/signals/sanitize.js +439 -0
- package/dist/lib/signals/settings.d.ts +10 -0
- package/dist/lib/signals/settings.js +48 -0
- package/dist/lib/signals/steps/adapters.d.ts +14 -0
- package/dist/lib/signals/steps/adapters.js +156 -0
- package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
- package/dist/lib/signals/steps/entity-similarity.js +47 -0
- package/dist/lib/signals/steps/extract.d.ts +118 -0
- package/dist/lib/signals/steps/extract.js +793 -0
- package/dist/lib/signals/steps/garden.d.ts +24 -0
- package/dist/lib/signals/steps/garden.js +250 -0
- package/dist/lib/signals/steps/types.d.ts +117 -0
- package/dist/lib/signals/steps/types.js +4 -0
- package/dist/lib/signals/steps/upload.d.ts +43 -0
- package/dist/lib/signals/steps/upload.js +324 -0
- package/dist/lib/signals/write-guard.d.ts +63 -0
- package/dist/lib/signals/write-guard.js +287 -0
- package/dist/utils/resident-package-preload.js +16 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +9 -9
- package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
- package/dist/lib/bot/fleet-default-flag.js +0 -127
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upload step (Hot-Signal Sync US-005).
|
|
3
|
+
*
|
|
4
|
+
* The step changes nothing about vault sync. `hq sync` and the background
|
|
5
|
+
* runner push and pull exactly what they did before Hot-Signal Sync (owner
|
|
6
|
+
* decision 2026-11-01). Promoted signals and company sources/ stay local
|
|
7
|
+
* because hq-cloud refuses client uploads of signals/** and sources/**;
|
|
8
|
+
* sharing promoted signals through audience ACLs is a later, separate
|
|
9
|
+
* hq-cloud change.
|
|
10
|
+
*
|
|
11
|
+
* 1. Member projection: pulls the caller's own
|
|
12
|
+
* `ontology/projections/members/{memberUid}/v1/` prefix into
|
|
13
|
+
* workspace/signals/projections/{company}/{memberUid}/v1/ and records its
|
|
14
|
+
* state. hq-pro has no export trigger route yet (triggerProjectionExport).
|
|
15
|
+
* 2. Contributions: every signal promoted since the upload watermark
|
|
16
|
+
* (workspace/signals/upload/{company}/{memberUid}.json) is sanitized
|
|
17
|
+
* (src/lib/signals/sanitize.ts) and posted, one request each, to
|
|
18
|
+
* POST /v1/files/contributions/company/{slug}/meetings
|
|
19
|
+
* { destination: "member-signals/{memberUid}/observations/meetings/@{aud}/{type}/{id}.md",
|
|
20
|
+
* contribution: sanitized-contribution.v1 }
|
|
21
|
+
* The server writes only under the caller's own member-signals prefix.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { refreshMemberProjection, triggerProjectionExport, memberProjectionPrefix } from "../projection.js";
|
|
26
|
+
import { sanitizeSignal, MEETING_SOURCE_RE, splitFrontmatter } from "../sanitize.js";
|
|
27
|
+
const MAX_TRACKED_SHAS = 5000;
|
|
28
|
+
export function contributionRoute(company) {
|
|
29
|
+
return `/v1/files/contributions/company/${encodeURIComponent(company)}/meetings`;
|
|
30
|
+
}
|
|
31
|
+
export function watermarkRel(company, memberUid) {
|
|
32
|
+
return `workspace/signals/upload/${company}/${memberUid}.json`;
|
|
33
|
+
}
|
|
34
|
+
export function readWatermark(hqRoot, company, memberUid) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(fs.readFileSync(path.join(hqRoot, watermarkRel(company, memberUid)), "utf8"));
|
|
37
|
+
return {
|
|
38
|
+
lastUploadedAt: typeof raw.lastUploadedAt === "string" ? raw.lastUploadedAt : null,
|
|
39
|
+
uploaded: Array.isArray(raw.uploaded) ? raw.uploaded.filter((s) => typeof s === "string") : [],
|
|
40
|
+
rejected: raw.rejected && typeof raw.rejected === "object" ? raw.rejected : {},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { lastUploadedAt: null, uploaded: [], rejected: {} };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Promoted signals for a company not yet uploaded or rejected, newer than the watermark. */
|
|
48
|
+
export function listSignalCandidates(hqRoot, company, wm) {
|
|
49
|
+
const companyRoot = path.join(hqRoot, "companies", company);
|
|
50
|
+
const signalsRoot = path.join(companyRoot, "signals");
|
|
51
|
+
const since = wm.lastUploadedAt ? Date.parse(wm.lastUploadedAt) : -Infinity;
|
|
52
|
+
const done = new Set([...wm.uploaded, ...Object.keys(wm.rejected)]);
|
|
53
|
+
const out = [];
|
|
54
|
+
const walk = (dir, depth) => {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
if (e.name.startsWith("_") || e.name.startsWith("."))
|
|
64
|
+
continue;
|
|
65
|
+
const abs = path.join(dir, e.name);
|
|
66
|
+
if (e.isDirectory() && depth < 2)
|
|
67
|
+
walk(abs, depth + 1);
|
|
68
|
+
else if (e.isFile() && e.name.endsWith(".md")) {
|
|
69
|
+
const sha = e.name.slice(0, -3);
|
|
70
|
+
if (done.has(sha))
|
|
71
|
+
continue;
|
|
72
|
+
const mtimeMs = fs.statSync(abs).mtimeMs;
|
|
73
|
+
if (mtimeMs <= since)
|
|
74
|
+
continue;
|
|
75
|
+
out.push({ rel: path.relative(companyRoot, abs).split(path.sep).join("/"), sha, mtimeMs });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk(signalsRoot, 0);
|
|
80
|
+
return out.sort((a, b) => a.mtimeMs - b.mtimeMs || a.rel.localeCompare(b.rel));
|
|
81
|
+
}
|
|
82
|
+
/** A 422 rejection that can pass on a later run (the source or receipt is not in the vault yet). */
|
|
83
|
+
function isRetryableRejection(parsed) {
|
|
84
|
+
return Array.isArray(parsed.reasons) && parsed.reasons.length > 0 && parsed.reasons.every((r) => r === "source_unavailable");
|
|
85
|
+
}
|
|
86
|
+
function parseBody(text) {
|
|
87
|
+
try {
|
|
88
|
+
const v = JSON.parse(text);
|
|
89
|
+
return v && typeof v === "object" ? v : {};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export const defaultUploadDeps = {
|
|
96
|
+
async getSession(company) {
|
|
97
|
+
const { ensureCognitoToken, DEFAULT_VAULT_API_URL } = await import("../../../utils/cognito-session.js");
|
|
98
|
+
const { resolveCallerPersonUid, getCompanyUid } = await import("../../../utils/vault-api.js");
|
|
99
|
+
let token;
|
|
100
|
+
try {
|
|
101
|
+
token = await ensureCognitoToken({ interactive: false });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const memberUid = await resolveCallerPersonUid(token, undefined, { command: "hq signals run" });
|
|
107
|
+
const companyUid = await getCompanyUid(token, company);
|
|
108
|
+
return { token, memberUid, companyUid, baseUrl: DEFAULT_VAULT_API_URL };
|
|
109
|
+
},
|
|
110
|
+
fetch: (url, init) => fetch(url, init),
|
|
111
|
+
async readVaultObject(session, company, key) {
|
|
112
|
+
const { createCompanyPresignClient } = await import("../../../commands/files-browse.js");
|
|
113
|
+
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
|
114
|
+
const client = createCompanyPresignClient({ token: session.token, companyUid: session.companyUid, companySlug: company });
|
|
115
|
+
try {
|
|
116
|
+
const resp = (await client.send(new GetObjectCommand({ Bucket: session.companyUid, Key: key })));
|
|
117
|
+
if (!resp.Body)
|
|
118
|
+
return null;
|
|
119
|
+
const chunks = [];
|
|
120
|
+
for await (const c of resp.Body)
|
|
121
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
122
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const e = err;
|
|
126
|
+
if (e.name === "NoSuchKey" || e.name === "NotFound" || e.status === 404 || e.$metadata?.httpStatusCode === 404)
|
|
127
|
+
return null;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
function base() {
|
|
133
|
+
return { status: "skipped", itemsIn: 0, itemsOut: 0, uploaded: { count: 0, bytes: 0 } };
|
|
134
|
+
}
|
|
135
|
+
async function planDryRun(ctx) {
|
|
136
|
+
// No network under --dry-run: the member is not resolved, so the watermark
|
|
137
|
+
// is read only when exactly one member has one for this company.
|
|
138
|
+
const wmDir = path.join(ctx.hqRoot, "workspace", "signals", "upload", ctx.company);
|
|
139
|
+
let memberUid = "<memberUid>";
|
|
140
|
+
try {
|
|
141
|
+
const files = fs.readdirSync(wmDir).filter((f) => f.endsWith(".json"));
|
|
142
|
+
if (files.length === 1)
|
|
143
|
+
memberUid = files[0].slice(0, -5);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// no watermark yet
|
|
147
|
+
}
|
|
148
|
+
const wm = readWatermark(ctx.hqRoot, ctx.company, memberUid);
|
|
149
|
+
const candidates = listSignalCandidates(ctx.hqRoot, ctx.company, wm);
|
|
150
|
+
const plan = [];
|
|
151
|
+
plan.push(`would pull ontology/projections/members/${memberUid}/v1/ into workspace/signals/projections/${ctx.company}/${memberUid}/v1/`);
|
|
152
|
+
let eligible = 0;
|
|
153
|
+
for (const c of candidates) {
|
|
154
|
+
let sourceRef = "";
|
|
155
|
+
try {
|
|
156
|
+
const fm = splitFrontmatter(fs.readFileSync(path.join(ctx.hqRoot, "companies", ctx.company, c.rel), "utf8")).frontmatter;
|
|
157
|
+
sourceRef = typeof fm.source_ref === "string" ? fm.source_ref : "";
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// unreadable; reported below
|
|
161
|
+
}
|
|
162
|
+
if (!MEETING_SOURCE_RE.test(sourceRef)) {
|
|
163
|
+
plan.push(`skip ${c.rel}: non-meeting-source`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
eligible += 1;
|
|
167
|
+
plan.push(`would POST ${contributionRoute(ctx.company)} ${c.rel} -> member-signals/${memberUid}/observations/meetings/...`);
|
|
168
|
+
}
|
|
169
|
+
plan.push(`would upload up to ${eligible} sanitized observation(s); nothing uploaded (dry run)`);
|
|
170
|
+
return { ...base(), itemsIn: candidates.length, plan };
|
|
171
|
+
}
|
|
172
|
+
export function createUploadStep(overrides = {}) {
|
|
173
|
+
return {
|
|
174
|
+
name: "upload",
|
|
175
|
+
dryRun: planDryRun,
|
|
176
|
+
async run(ctx) {
|
|
177
|
+
const deps = { ...defaultUploadDeps, ...overrides, ...(ctx.uploadDeps ?? {}) };
|
|
178
|
+
if (ctx.dryRun)
|
|
179
|
+
return planDryRun(ctx);
|
|
180
|
+
const session = await deps.getSession(ctx.company);
|
|
181
|
+
if (!session) {
|
|
182
|
+
return { ...base(), skippedReason: "no-login", errorKind: "signed-out" };
|
|
183
|
+
}
|
|
184
|
+
let projection;
|
|
185
|
+
try {
|
|
186
|
+
memberProjectionPrefix(session.memberUid);
|
|
187
|
+
projection = await refreshMemberProjection({
|
|
188
|
+
company: ctx.company,
|
|
189
|
+
memberUid: session.memberUid,
|
|
190
|
+
read: (key) => deps.readVaultObject(session, ctx.company, key),
|
|
191
|
+
write: ctx.write,
|
|
192
|
+
now: ctx.now,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
projection = { state: "missing", watermark: "none", entityCount: 0, error: err instanceof Error ? err.message : String(err) };
|
|
197
|
+
}
|
|
198
|
+
const exportTrigger = await triggerProjectionExport({ company: ctx.company, memberUid: session.memberUid });
|
|
199
|
+
const detail = {
|
|
200
|
+
projection: { state: projection.state, watermark: projection.watermark, entityCount: projection.entityCount, ...(projection.error ? { error: projection.error.slice(0, 200) } : {}) },
|
|
201
|
+
projectionExport: exportTrigger,
|
|
202
|
+
};
|
|
203
|
+
const wm = readWatermark(ctx.hqRoot, ctx.company, session.memberUid);
|
|
204
|
+
const candidates = listSignalCandidates(ctx.hqRoot, ctx.company, wm);
|
|
205
|
+
const companyRoot = path.join(ctx.hqRoot, "companies", ctx.company);
|
|
206
|
+
const url = `${session.baseUrl.replace(/\/+$/, "")}${contributionRoute(ctx.company)}`;
|
|
207
|
+
const skipped = {};
|
|
208
|
+
let count = 0;
|
|
209
|
+
let bytes = 0;
|
|
210
|
+
let transient = 0;
|
|
211
|
+
let rejected = 0;
|
|
212
|
+
let earliestRetryMs = Infinity;
|
|
213
|
+
let stop = null;
|
|
214
|
+
for (const c of candidates) {
|
|
215
|
+
const s = sanitizeSignal({
|
|
216
|
+
companyRoot,
|
|
217
|
+
signalRel: c.rel,
|
|
218
|
+
memberUid: session.memberUid,
|
|
219
|
+
companyUid: session.companyUid,
|
|
220
|
+
projectionState: projection.state,
|
|
221
|
+
projectionWatermark: projection.watermark,
|
|
222
|
+
});
|
|
223
|
+
if (!s.ok) {
|
|
224
|
+
skipped[s.reason] = (skipped[s.reason] ?? 0) + 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!s.destination.startsWith(`member-signals/${session.memberUid}/`)) {
|
|
228
|
+
throw new Error("upload: destination outside the caller's member-signals prefix");
|
|
229
|
+
}
|
|
230
|
+
const body = JSON.stringify({ destination: s.destination, contribution: s.contribution });
|
|
231
|
+
let status;
|
|
232
|
+
let parsed;
|
|
233
|
+
try {
|
|
234
|
+
const resp = await deps.fetch(url, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
Authorization: `Bearer ${session.token}`,
|
|
239
|
+
"x-hq-client-name": "@indigoai-us/hq-cli",
|
|
240
|
+
},
|
|
241
|
+
body,
|
|
242
|
+
});
|
|
243
|
+
status = resp.status;
|
|
244
|
+
parsed = parseBody(await resp.text());
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
transient += 1;
|
|
248
|
+
earliestRetryMs = Math.min(earliestRetryMs, c.mtimeMs);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (status >= 200 && status < 300) {
|
|
252
|
+
count += 1;
|
|
253
|
+
bytes += Buffer.byteLength(body);
|
|
254
|
+
wm.uploaded.push(c.sha);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (status === 401) {
|
|
258
|
+
stop = { ...base(), skippedReason: "no-login", errorKind: "signed-out", error: "hq-pro returned 401" };
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (status === 403 && parsed.code === "CONTRIBUTION_DISABLED") {
|
|
262
|
+
stop = { ...base(), skippedReason: "flag-off" };
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
// Company-wide answers: one request is enough, and the watermark holds
|
|
266
|
+
// so every signal goes once the company is reachable.
|
|
267
|
+
if (status === 404 && parsed.code === "WRITE_COMPANY_NOT_FOUND") {
|
|
268
|
+
stop = { ...base(), status: "failed", error: "hq-pro has no company with this slug (WRITE_COMPANY_NOT_FOUND)", errorKind: "config" };
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (parsed.code === "WRITE_BUCKET_NOT_PROVISIONED") {
|
|
272
|
+
stop = { ...base(), status: "failed", error: "the company vault is not provisioned yet (WRITE_BUCKET_NOT_PROVISIONED); will retry", errorKind: "transient" };
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if ((status === 422 || status === 409 || status === 400) && !isRetryableRejection(parsed)) {
|
|
276
|
+
rejected += 1;
|
|
277
|
+
wm.rejected[c.sha] = parsed.code ?? `http-${status}`;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (status === 403) {
|
|
281
|
+
stop = { ...base(), status: "failed", error: `hq-pro refused the contribution: ${parsed.code ?? "403"}`, errorKind: "other" };
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
transient += 1;
|
|
285
|
+
earliestRetryMs = Math.min(earliestRetryMs, c.mtimeMs);
|
|
286
|
+
}
|
|
287
|
+
if (stop && count === 0) {
|
|
288
|
+
// Keep the projection pull but write no watermark change.
|
|
289
|
+
return { ...stop, itemsIn: candidates.length, detail };
|
|
290
|
+
}
|
|
291
|
+
// Advance the watermark to the run start, except past a signal that
|
|
292
|
+
// must be retried.
|
|
293
|
+
const runStart = ctx.now().getTime();
|
|
294
|
+
const next = Number.isFinite(earliestRetryMs) ? Math.min(runStart, earliestRetryMs - 1) : runStart;
|
|
295
|
+
const prior = wm.lastUploadedAt ? Date.parse(wm.lastUploadedAt) : -Infinity;
|
|
296
|
+
if (!stop)
|
|
297
|
+
wm.lastUploadedAt = new Date(Math.max(prior, next)).toISOString();
|
|
298
|
+
wm.uploaded = wm.uploaded.slice(-MAX_TRACKED_SHAS);
|
|
299
|
+
ctx.write(watermarkRel(ctx.company, session.memberUid), `${JSON.stringify(wm, null, 2)}\n`);
|
|
300
|
+
const result = {
|
|
301
|
+
status: "ok",
|
|
302
|
+
itemsIn: candidates.length,
|
|
303
|
+
itemsOut: count,
|
|
304
|
+
uploaded: { count, bytes },
|
|
305
|
+
detail: { ...detail, rejected, skipped },
|
|
306
|
+
};
|
|
307
|
+
if (stop) {
|
|
308
|
+
return { ...stop, status: "partial", itemsIn: candidates.length, itemsOut: count, uploaded: { count, bytes }, detail: result.detail };
|
|
309
|
+
}
|
|
310
|
+
if (transient > 0) {
|
|
311
|
+
result.status = count > 0 ? "partial" : "failed";
|
|
312
|
+
result.error = `${transient} contribution(s) failed; will retry`;
|
|
313
|
+
result.errorKind = "transient";
|
|
314
|
+
}
|
|
315
|
+
else if (candidates.length === 0) {
|
|
316
|
+
result.status = "skipped";
|
|
317
|
+
result.skippedReason = "nothing-new";
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
export const uploadStep = createUploadStep();
|
|
324
|
+
//# sourceMappingURL=upload.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { WriteGuard } from "../hot-channels/loop.js";
|
|
2
|
+
export declare class WriteGuardError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface WriteGuardScope {
|
|
6
|
+
/** Limit the companies/{co}/ roots to this company. */
|
|
7
|
+
company?: string;
|
|
8
|
+
}
|
|
9
|
+
/** True when anything (a dangling symlink included) exists at p. */
|
|
10
|
+
export declare function lexists(p: string): boolean;
|
|
11
|
+
/** Returns the resolved absolute target path or throws WriteGuardError. */
|
|
12
|
+
export declare function assertAllowedPath(root: string, relPath: string, scope?: WriteGuardScope): string;
|
|
13
|
+
export declare function guardedWrite(root: string, relPath: string, data: string, scope?: WriteGuardScope): void;
|
|
14
|
+
/**
|
|
15
|
+
* One guard for both the signals engine and the hot-channel loop. It
|
|
16
|
+
* satisfies the WriteGuard interface in src/lib/hot-channels/loop.ts
|
|
17
|
+
* (beforeWrite) and adds the path-checked writer.
|
|
18
|
+
*/
|
|
19
|
+
export interface SignalsWriteGuard extends WriteGuard {
|
|
20
|
+
assertAllowed: (relPath: string) => string;
|
|
21
|
+
write: (relPath: string, data: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function createSignalsWriteGuard(root: string, beforeWrite?: () => Promise<void>, scope?: WriteGuardScope): SignalsWriteGuard;
|
|
24
|
+
/**
|
|
25
|
+
* Delete one file through the same checks as a write: the path must sit in an
|
|
26
|
+
* allowed root (and, scoped, in that company's folder) after links are
|
|
27
|
+
* followed, and may not itself be a link. Throws WriteGuardError otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export declare function guardedRemove(root: string, relPath: string, scope?: WriteGuardScope): void;
|
|
30
|
+
/** Remove an empty directory through the guard. A link is refused, never removed or followed. */
|
|
31
|
+
export declare function guardedRmdir(root: string, relPath: string, scope?: WriteGuardScope): void;
|
|
32
|
+
/**
|
|
33
|
+
* The HQ-relative path an absolute path really points at, with every link
|
|
34
|
+
* followed (the file's own link included), or null when it does not exist or
|
|
35
|
+
* resolves outside the HQ root. Company ownership for a delete is decided on
|
|
36
|
+
* this path, never on the lexical one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function realRelPath(root: string, absPath: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Refuse, before anything writes there, a tree under relDir that holds a link
|
|
41
|
+
* leaving relDir's own allowed root (with scope.company, that company's
|
|
42
|
+
* folder). The walk uses lstat and does not descend through links. It covers
|
|
43
|
+
* writers that take no HQ-relative path (the hot-channel loop, the candidate
|
|
44
|
+
* script); it is a preflight, so a link created after it is not seen.
|
|
45
|
+
*/
|
|
46
|
+
export declare function assertNoEscapingLinks(root: string, relDir: string, scope?: WriteGuardScope): void;
|
|
47
|
+
/**
|
|
48
|
+
* Guard for writers that hold an absolute vault root and an absolute file
|
|
49
|
+
* (the hot-channel loop: landing, cursors, ledger, sync lock, signals pass,
|
|
50
|
+
* alert state, push buffer cleanup). Call it before every write, rename,
|
|
51
|
+
* link or delete.
|
|
52
|
+
*
|
|
53
|
+
* When the vault root is `{hq}/companies/{co}`, the file goes through
|
|
54
|
+
* assertAllowedPath scoped to that company, the same check the signals engine
|
|
55
|
+
* uses: it must sit under the company's sources/, signals/ or ontology/ folder
|
|
56
|
+
* and stay there after links are followed. Any other vault root (a test
|
|
57
|
+
* folder, a person vault) gets the same rule against the vault itself: the
|
|
58
|
+
* file must be inside it lexically and after links are followed, the vault
|
|
59
|
+
* folder may not be a link, and the file may not be a link. Throws
|
|
60
|
+
* WriteGuardError otherwise.
|
|
61
|
+
*/
|
|
62
|
+
export declare function assertVaultPath(vaultRoot: string, file: string): string;
|
|
63
|
+
//# sourceMappingURL=write-guard.d.ts.map
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarded writer for the signals engine. Refuses any path outside:
|
|
3
|
+
* workspace/signals/, workspace/vault-staging/, personal/signals/,
|
|
4
|
+
* companies/{co}/ontology/, companies/{co}/signals/, companies/{co}/sources/
|
|
5
|
+
* Rejects absolute paths, `..` segments, symlinked parents that resolve
|
|
6
|
+
* outside the HQ root or outside an allowed root, and symlinks (dangling ones
|
|
7
|
+
* too) at the target. Writes are atomic: a temp file under a random name
|
|
8
|
+
* opened with O_EXCL ("wx"), then a rename. With `scope.company` set, the
|
|
9
|
+
* companies/ roots are limited to that one company.
|
|
10
|
+
*
|
|
11
|
+
* A path must also resolve inside the same allowed root it names: after
|
|
12
|
+
* symlinks are followed, `workspace/signals/x` has to land under
|
|
13
|
+
* workspace/signals/, and `companies/{co}/signals/x` under
|
|
14
|
+
* companies/{co}/signals/. A link that moves a write into another root (most
|
|
15
|
+
* importantly into another company's folder) is refused, scoped or not.
|
|
16
|
+
*/
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
export class WriteGuardError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "WriteGuardError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const ALLOWED = [
|
|
27
|
+
/^workspace\/signals\/.+/,
|
|
28
|
+
/^workspace\/vault-staging\/.+/,
|
|
29
|
+
/^personal\/signals\/.+/,
|
|
30
|
+
/^companies\/[^/]+\/ontology\/.+/,
|
|
31
|
+
/^companies\/[^/]+\/signals\/.+/,
|
|
32
|
+
/^companies\/[^/]+\/sources\/.+/,
|
|
33
|
+
];
|
|
34
|
+
function allowedRoot(rel, scope) {
|
|
35
|
+
const root = ALLOWED.findIndex((re) => re.test(rel));
|
|
36
|
+
if (root < 0)
|
|
37
|
+
return null;
|
|
38
|
+
if (!rel.startsWith("companies/"))
|
|
39
|
+
return { root };
|
|
40
|
+
const company = rel.split("/")[1];
|
|
41
|
+
// Exact, case-sensitive match: on a case-insensitive volume two spellings
|
|
42
|
+
// of a slug are one folder, so anything but the exact slug fails closed.
|
|
43
|
+
if (scope.company !== undefined && company !== scope.company)
|
|
44
|
+
return null;
|
|
45
|
+
return { root, company };
|
|
46
|
+
}
|
|
47
|
+
function sameRoot(a, b) {
|
|
48
|
+
return a.root === b.root && a.company === b.company;
|
|
49
|
+
}
|
|
50
|
+
/** True when anything (a dangling symlink included) exists at p. */
|
|
51
|
+
export function lexists(p) {
|
|
52
|
+
try {
|
|
53
|
+
fs.lstatSync(p);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** The first HQ-relative prefix of rel that is a symlink, for error messages. */
|
|
61
|
+
function firstLinkedSegment(realRoot, rel) {
|
|
62
|
+
const parts = rel.split("/");
|
|
63
|
+
for (let i = 1; i <= parts.length; i += 1) {
|
|
64
|
+
const prefix = parts.slice(0, i).join("/");
|
|
65
|
+
try {
|
|
66
|
+
if (fs.lstatSync(path.join(realRoot, prefix)).isSymbolicLink())
|
|
67
|
+
return prefix;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Returns the resolved absolute target path or throws WriteGuardError. */
|
|
76
|
+
export function assertAllowedPath(root, relPath, scope = {}) {
|
|
77
|
+
if (!relPath || path.isAbsolute(relPath) || /^[a-zA-Z]:/.test(relPath)) {
|
|
78
|
+
throw new WriteGuardError(`write-guard: path must be HQ-root relative: ${relPath}`);
|
|
79
|
+
}
|
|
80
|
+
const segments = relPath.split(/[\\/]+/);
|
|
81
|
+
if (segments.includes("..")) {
|
|
82
|
+
throw new WriteGuardError(`write-guard: path traversal rejected: ${relPath}`);
|
|
83
|
+
}
|
|
84
|
+
const rel = segments.filter((s) => s && s !== ".").join("/");
|
|
85
|
+
const lexicalRoot = allowedRoot(rel, scope);
|
|
86
|
+
if (!lexicalRoot) {
|
|
87
|
+
throw new WriteGuardError(`write-guard: path outside allowed roots: ${relPath}`);
|
|
88
|
+
}
|
|
89
|
+
const realRoot = fs.realpathSync(root);
|
|
90
|
+
const target = path.join(realRoot, rel);
|
|
91
|
+
// Resolve the nearest existing ancestor so a symlinked parent cannot escape.
|
|
92
|
+
// lexists, not existsSync: existsSync follows links, so a dangling link
|
|
93
|
+
// would read as absent and skip the check.
|
|
94
|
+
let ancestor = path.dirname(target);
|
|
95
|
+
const rest = [path.basename(target)];
|
|
96
|
+
while (!lexists(ancestor)) {
|
|
97
|
+
rest.unshift(path.basename(ancestor));
|
|
98
|
+
ancestor = path.dirname(ancestor);
|
|
99
|
+
}
|
|
100
|
+
let realAncestor;
|
|
101
|
+
try {
|
|
102
|
+
realAncestor = fs.realpathSync(ancestor);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new WriteGuardError(`write-guard: dangling symlink in path: ${relPath}`);
|
|
106
|
+
}
|
|
107
|
+
const resolved = path.join(realAncestor, ...rest);
|
|
108
|
+
const resolvedRel = path.relative(realRoot, resolved).split(path.sep).join("/");
|
|
109
|
+
if (resolvedRel.startsWith("..") || path.isAbsolute(resolvedRel)) {
|
|
110
|
+
throw new WriteGuardError(`write-guard: symlink escape rejected: ${relPath}`);
|
|
111
|
+
}
|
|
112
|
+
// Unscoped here so the error can name the root it landed in; sameRoot below
|
|
113
|
+
// ties it back to the lexical root, which already passed the scope check.
|
|
114
|
+
const resolvedRoot = allowedRoot(resolvedRel, {});
|
|
115
|
+
if (!resolvedRoot) {
|
|
116
|
+
throw new WriteGuardError(`write-guard: symlink escape rejected: ${relPath}`);
|
|
117
|
+
}
|
|
118
|
+
// A scoped write outside companies/ (workspace/signals/runs/{date}/{co}.json,
|
|
119
|
+
// watermarks/{co}/, projections/{co}/ and so on) names its company somewhere
|
|
120
|
+
// below the root, where the root check cannot see it. Hold it to its exact
|
|
121
|
+
// path: no link at all below the HQ root, so another company's state there
|
|
122
|
+
// cannot be reached through one.
|
|
123
|
+
const linkedWithinScopedWorkspace = scope.company !== undefined && lexicalRoot.company === undefined && resolvedRel !== rel;
|
|
124
|
+
if (linkedWithinScopedWorkspace && sameRoot(lexicalRoot, resolvedRoot)) {
|
|
125
|
+
throw new WriteGuardError(`write-guard: ${relPath} passes through a link at ${firstLinkedSegment(realRoot, rel) ?? "an unknown segment"}; ` +
|
|
126
|
+
"a company-scoped signals write may not pass through a link inside the HQ root");
|
|
127
|
+
}
|
|
128
|
+
if (!sameRoot(lexicalRoot, resolvedRoot) || linkedWithinScopedWorkspace) {
|
|
129
|
+
const where = resolvedRoot.company !== undefined ? `company ${resolvedRoot.company}'s folder` : resolvedRel;
|
|
130
|
+
throw new WriteGuardError(`write-guard: ${relPath} resolves through a link into ${where}; refusing a write that leaves its own root`);
|
|
131
|
+
}
|
|
132
|
+
if (lexists(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
|
|
133
|
+
throw new WriteGuardError(`write-guard: refusing to write through symlink: ${relPath}`);
|
|
134
|
+
}
|
|
135
|
+
return resolved;
|
|
136
|
+
}
|
|
137
|
+
export function guardedWrite(root, relPath, data, scope = {}) {
|
|
138
|
+
const abs = assertAllowedPath(root, relPath, scope);
|
|
139
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
140
|
+
const tmp = `${abs}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
141
|
+
// "wx" is O_CREAT|O_EXCL: it fails on any existing entry, symlinks included.
|
|
142
|
+
fs.writeFileSync(tmp, data, { flag: "wx" });
|
|
143
|
+
try {
|
|
144
|
+
fs.renameSync(tmp, abs);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
fs.rmSync(tmp, { force: true });
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function createSignalsWriteGuard(root, beforeWrite, scope = {}) {
|
|
152
|
+
return {
|
|
153
|
+
beforeWrite: beforeWrite ?? (async () => { }),
|
|
154
|
+
assertAllowed: (relPath) => assertAllowedPath(root, relPath, scope),
|
|
155
|
+
write: (relPath, data) => {
|
|
156
|
+
guardedWrite(root, relPath, data, scope);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Delete one file through the same checks as a write: the path must sit in an
|
|
162
|
+
* allowed root (and, scoped, in that company's folder) after links are
|
|
163
|
+
* followed, and may not itself be a link. Throws WriteGuardError otherwise.
|
|
164
|
+
*/
|
|
165
|
+
export function guardedRemove(root, relPath, scope = {}) {
|
|
166
|
+
const abs = assertAllowedPath(root, relPath, scope);
|
|
167
|
+
fs.rmSync(abs, { force: true });
|
|
168
|
+
}
|
|
169
|
+
/** Remove an empty directory through the guard. A link is refused, never removed or followed. */
|
|
170
|
+
export function guardedRmdir(root, relPath, scope = {}) {
|
|
171
|
+
const abs = assertAllowedPath(root, relPath, scope);
|
|
172
|
+
fs.rmdirSync(abs);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The HQ-relative path an absolute path really points at, with every link
|
|
176
|
+
* followed (the file's own link included), or null when it does not exist or
|
|
177
|
+
* resolves outside the HQ root. Company ownership for a delete is decided on
|
|
178
|
+
* this path, never on the lexical one.
|
|
179
|
+
*/
|
|
180
|
+
export function realRelPath(root, absPath) {
|
|
181
|
+
let real;
|
|
182
|
+
try {
|
|
183
|
+
real = fs.realpathSync(absPath);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const rel = path.relative(fs.realpathSync(root), real);
|
|
189
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
190
|
+
return null;
|
|
191
|
+
return rel.split(path.sep).join("/");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Refuse, before anything writes there, a tree under relDir that holds a link
|
|
195
|
+
* leaving relDir's own allowed root (with scope.company, that company's
|
|
196
|
+
* folder). The walk uses lstat and does not descend through links. It covers
|
|
197
|
+
* writers that take no HQ-relative path (the hot-channel loop, the candidate
|
|
198
|
+
* script); it is a preflight, so a link created after it is not seen.
|
|
199
|
+
*/
|
|
200
|
+
export function assertNoEscapingLinks(root, relDir, scope = {}) {
|
|
201
|
+
const probe = (rel) => {
|
|
202
|
+
assertAllowedPath(root, `${rel}/.write-guard-probe`, scope);
|
|
203
|
+
};
|
|
204
|
+
probe(relDir);
|
|
205
|
+
const realRoot = fs.realpathSync(root);
|
|
206
|
+
const walk = (rel) => {
|
|
207
|
+
let entries;
|
|
208
|
+
try {
|
|
209
|
+
entries = fs.readdirSync(path.join(realRoot, rel), { withFileTypes: true });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const e of entries) {
|
|
215
|
+
const child = `${rel}/${e.name}`;
|
|
216
|
+
if (e.isSymbolicLink())
|
|
217
|
+
probe(child);
|
|
218
|
+
else if (e.isDirectory())
|
|
219
|
+
walk(child);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(relDir);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* realpath of the nearest existing ancestor of an absolute path, with the
|
|
226
|
+
* missing tail appended. A dangling link on the way fails closed.
|
|
227
|
+
*/
|
|
228
|
+
function resolveNearest(abs, label) {
|
|
229
|
+
let ancestor = abs;
|
|
230
|
+
const rest = [];
|
|
231
|
+
while (!lexists(ancestor)) {
|
|
232
|
+
const parent = path.dirname(ancestor);
|
|
233
|
+
if (parent === ancestor)
|
|
234
|
+
break;
|
|
235
|
+
rest.unshift(path.basename(ancestor));
|
|
236
|
+
ancestor = parent;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return path.join(fs.realpathSync(ancestor), ...rest);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new WriteGuardError(`write-guard: dangling symlink in path: ${label}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Guard for writers that hold an absolute vault root and an absolute file
|
|
247
|
+
* (the hot-channel loop: landing, cursors, ledger, sync lock, signals pass,
|
|
248
|
+
* alert state, push buffer cleanup). Call it before every write, rename,
|
|
249
|
+
* link or delete.
|
|
250
|
+
*
|
|
251
|
+
* When the vault root is `{hq}/companies/{co}`, the file goes through
|
|
252
|
+
* assertAllowedPath scoped to that company, the same check the signals engine
|
|
253
|
+
* uses: it must sit under the company's sources/, signals/ or ontology/ folder
|
|
254
|
+
* and stay there after links are followed. Any other vault root (a test
|
|
255
|
+
* folder, a person vault) gets the same rule against the vault itself: the
|
|
256
|
+
* file must be inside it lexically and after links are followed, the vault
|
|
257
|
+
* folder may not be a link, and the file may not be a link. Throws
|
|
258
|
+
* WriteGuardError otherwise.
|
|
259
|
+
*/
|
|
260
|
+
export function assertVaultPath(vaultRoot, file) {
|
|
261
|
+
const absVault = path.resolve(vaultRoot);
|
|
262
|
+
const absFile = path.resolve(absVault, file);
|
|
263
|
+
const rel = path.relative(absVault, absFile);
|
|
264
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
265
|
+
throw new WriteGuardError(`write-guard: ${file} is outside the vault ${vaultRoot}`);
|
|
266
|
+
}
|
|
267
|
+
const relPosix = rel.split(path.sep).join("/");
|
|
268
|
+
const company = path.basename(absVault);
|
|
269
|
+
if (path.basename(path.dirname(absVault)) === "companies" && company) {
|
|
270
|
+
const hqRoot = path.dirname(path.dirname(absVault));
|
|
271
|
+
return assertAllowedPath(hqRoot, ["companies", company, relPosix].join("/"), { company });
|
|
272
|
+
}
|
|
273
|
+
if (lexists(absVault) && fs.lstatSync(absVault).isSymbolicLink()) {
|
|
274
|
+
throw new WriteGuardError(`write-guard: vault root ${vaultRoot} is a link`);
|
|
275
|
+
}
|
|
276
|
+
const realVault = resolveNearest(absVault, vaultRoot);
|
|
277
|
+
const resolved = resolveNearest(absFile, file);
|
|
278
|
+
const inside = path.relative(realVault, resolved);
|
|
279
|
+
if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
280
|
+
throw new WriteGuardError(`write-guard: ${file} resolves through a link outside the vault ${vaultRoot}`);
|
|
281
|
+
}
|
|
282
|
+
if (lexists(absFile) && fs.lstatSync(absFile).isSymbolicLink()) {
|
|
283
|
+
throw new WriteGuardError(`write-guard: refusing to write through symlink: ${file}`);
|
|
284
|
+
}
|
|
285
|
+
return resolved;
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=write-guard.js.map
|