@hegemonart/get-design-done 1.30.5 → 1.31.0

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 (43) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +22 -1
  5. package/SKILL.md +1 -0
  6. package/agents/design-integration-checker.md +1 -1
  7. package/agents/design-planner.md +1 -1
  8. package/agents/gdd-graph-refresh.md +90 -0
  9. package/bin/gdd-graph +261 -0
  10. package/connections/connections.md +10 -9
  11. package/connections/graphify.md +65 -54
  12. package/package.json +8 -3
  13. package/reference/capability-gap-stage-gate.md +7 -4
  14. package/reference/model-tiers.md +2 -2
  15. package/reference/start-interview.md +1 -1
  16. package/scripts/detect-stale-refs.cjs +6 -0
  17. package/scripts/lib/figma-extract/digest.cjs +430 -0
  18. package/scripts/lib/figma-extract/parse-url.cjs +87 -0
  19. package/scripts/lib/figma-extract/payload-schema.json +108 -0
  20. package/scripts/lib/figma-extract/pull.cjs +394 -0
  21. package/scripts/lib/figma-extract/receiver.cjs +273 -0
  22. package/scripts/lib/figma-extract/render-md.cjs +143 -0
  23. package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
  24. package/scripts/lib/figma-extract/walk.cjs +100 -0
  25. package/scripts/lib/graph/atomic-write.mjs +68 -0
  26. package/scripts/lib/graph/build.mjs +124 -0
  27. package/scripts/lib/graph/diff.mjs +90 -0
  28. package/scripts/lib/graph/index.mjs +14 -0
  29. package/scripts/lib/graph/query.mjs +155 -0
  30. package/scripts/lib/graph/schema.json +69 -0
  31. package/scripts/lib/graph/schema.mjs +47 -0
  32. package/scripts/lib/graph/status.mjs +88 -0
  33. package/scripts/lib/graph/token-estimate.mjs +27 -0
  34. package/scripts/lib/graph/upsert.mjs +210 -0
  35. package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
  36. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
  37. package/skills/connections/connections-onboarding.md +6 -6
  38. package/skills/figma-extract/SKILL.md +64 -0
  39. package/skills/graphify/SKILL.md +11 -10
  40. package/skills/health/SKILL.md +10 -0
  41. package/skills/scan/scan-procedure.md +9 -8
  42. package/agents/gdd-graphify-sync.md +0 -110
  43. /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
@@ -0,0 +1,394 @@
1
+ 'use strict';
2
+ // scripts/lib/figma-extract/pull.cjs — Plan 31-01 (Wave A.1)
3
+ // Productionized from spike 001 (.planning/spikes/001-figma-offcontext-extractor/extract.mjs).
4
+ //
5
+ // RAW-PULL stage of the two-stage Figma extractor pipeline. Pulls the 5 Figma
6
+ // REST endpoints the spike validated into a local cache dir, writing JSON to
7
+ // disk ONLY. Raw response bodies are never returned to a caller that could log
8
+ // them — they go straight to <outDir>/<name>.json. The digest/markdown stage
9
+ // (Plan 31-02) consumes the cache; this file does ZERO digest work.
10
+ //
11
+ // Decisions honored:
12
+ // D-01 Two-stage separation: pull.cjs only pulls + caches. No digest here.
13
+ // D-03 geometry=paths is DROPPED — the file endpoint is /files/:key with no
14
+ // geometry query param. Saves ~30% raw size for data the digest throws away.
15
+ // D-10 FIGMA_TOKEN is read from process.env.FIGMA_TOKEN (fallback
16
+ // FIGMA_PERSONAL_ACCESS_TOKEN) only. It is NEVER written to disk and
17
+ // NEVER passed to console.log / the logger seam. (31-10 ships a static
18
+ // analysis test scanning this dir for token persistence/logging.)
19
+ // D-11 Cache invalidation is content-based via Figma's `version` field, with
20
+ // a 1h wall-clock TTL fallback when no version field is available.
21
+ //
22
+ // Hardening over the spike: retry-with-backoff on transient 429/5xx, structured
23
+ // per-endpoint timing emitted to an injectable logger seam (NOT raw bodies),
24
+ // file-URL-or-bare-key input via parse-url.cjs, version-based cache skip.
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const { parseFigmaFileKey } = require('./parse-url.cjs');
29
+
30
+ const FIGMA_API_BASE = 'https://api.figma.com/v1';
31
+
32
+ // Endpoint inventory mirroring the spike's 5 pulls. D-03: the file endpoint is
33
+ // `/files/${k}` with NO `?geometry=paths`. Each entry: { name, path(key)→string,
34
+ // optional?:bool }. `optional` endpoints (Path A Variables) may 403 on
35
+ // non-Enterprise plans and are skipped gracefully rather than aborting the run.
36
+ const DEFAULT_ENDPOINTS = [
37
+ { name: 'file', path: (k) => `/files/${k}` },
38
+ { name: 'variables', path: (k) => `/files/${k}/variables/local`, optional: true },
39
+ { name: 'styles', path: (k) => `/files/${k}/styles` },
40
+ { name: 'components', path: (k) => `/files/${k}/components` },
41
+ { name: 'component_sets', path: (k) => `/files/${k}/component_sets` },
42
+ ];
43
+
44
+ // Retry/backoff tuning. Bounded — never an infinite loop.
45
+ const MAX_ATTEMPTS = 4; // 1 initial + 3 retries
46
+ const BACKOFF_BASE_MS = 250; // base × 2^attempt, capped
47
+ const BACKOFF_CAP_MS = 4000;
48
+ const TTL_MS = 60 * 60 * 1000; // 1h wall-clock fallback (D-11)
49
+
50
+ const noopLogger = { info() {}, warn() {}, error() {} };
51
+
52
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
53
+
54
+ function isTransient(status) {
55
+ return status === 429 || status >= 500;
56
+ }
57
+
58
+ /**
59
+ * Fetch a Figma API path with timing + bounded retry/backoff on transient errors.
60
+ * Returns { json, ms, bytes } on success. Throws a structured Error after the
61
+ * retry budget is exhausted, or immediately on a non-transient error.
62
+ *
63
+ * The token lives only inside `headers` (caller-provided); it is NEVER logged.
64
+ */
65
+ async function fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl }) {
66
+ const url = `${FIGMA_API_BASE}${apiPath}`;
67
+ const wait = sleepImpl || sleep;
68
+ let lastErr;
69
+
70
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
71
+ const t0 = Date.now();
72
+ let res;
73
+ try {
74
+ res = await fetchImpl(url, { headers });
75
+ } catch (networkErr) {
76
+ // Treat raw network/transport failures as transient too.
77
+ lastErr = networkErr;
78
+ logger.warn({ event: 'fetch_error', path: apiPath, attempt, message: networkErr.message });
79
+ if (attempt < MAX_ATTEMPTS - 1) {
80
+ await wait(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
81
+ continue;
82
+ }
83
+ const e = new Error(`Figma API network failure on ${apiPath}: ${networkErr.message}`);
84
+ e.path = apiPath;
85
+ throw e;
86
+ }
87
+
88
+ const ms = Date.now() - t0;
89
+
90
+ if (res.ok) {
91
+ const json = await res.json();
92
+ const bytes = Buffer.byteLength(JSON.stringify(json), 'utf8');
93
+ return { json, ms, bytes };
94
+ }
95
+
96
+ // Non-2xx. Read a short body prefix for diagnostics — NEVER the token.
97
+ let bodyPrefix = '';
98
+ try {
99
+ const text = await res.text();
100
+ bodyPrefix = String(text).slice(0, 200);
101
+ } catch {
102
+ bodyPrefix = '';
103
+ }
104
+
105
+ if (isTransient(res.status) && attempt < MAX_ATTEMPTS - 1) {
106
+ logger.warn({ event: 'transient_retry', path: apiPath, status: res.status, attempt });
107
+ await wait(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
108
+ lastErr = new Error(`Figma API ${res.status} on ${apiPath}`);
109
+ continue;
110
+ }
111
+
112
+ // Non-transient, or transient budget exhausted → structured throw.
113
+ const err = new Error(`Figma API ${res.status} on ${apiPath}: ${bodyPrefix}`);
114
+ err.status = res.status;
115
+ err.path = apiPath;
116
+ throw err;
117
+ }
118
+
119
+ // Defensive: loop only exits via return/throw above, but guard anyway.
120
+ const err = lastErr || new Error(`Figma API request failed on ${apiPath}`);
121
+ err.path = apiPath;
122
+ throw err;
123
+ }
124
+
125
+ /**
126
+ * Write one endpoint's JSON to <outDir>/<name>.json and emit structured timing.
127
+ * Returns { name, bytes, ms }. The logger receives only { endpoint, bytes, ms } —
128
+ * never the body, never the token (D-10).
129
+ */
130
+ function save(outDir, name, data, meta, logger) {
131
+ const filePath = path.join(outDir, `${name}.json`);
132
+ const body = JSON.stringify(data);
133
+ fs.writeFileSync(filePath, body);
134
+ const bytes = Buffer.byteLength(body, 'utf8');
135
+ logger.info({ event: 'endpoint_saved', endpoint: name, bytes, ms: meta.ms });
136
+ return { name, bytes, ms: meta.ms };
137
+ }
138
+
139
+ function readMetaIfPresent(outDir) {
140
+ const metaPath = path.join(outDir, '_meta.json');
141
+ try {
142
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Productionized Figma REST puller.
150
+ *
151
+ * @param {object} opts
152
+ * @param {string} opts.input - required: bare file key OR full Figma file URL
153
+ * @param {string} opts.outDir - required: raw/ cache dir to write *.json into
154
+ * @param {string} [opts.token] - defaults to env FIGMA_TOKEN / FIGMA_PERSONAL_ACCESS_TOKEN
155
+ * @param {Function} [opts.fetchImpl]- injectable fetch (defaults to global fetch)
156
+ * @param {object} [opts.logger] - { info(obj), warn(obj), error(obj) } structured sink
157
+ * @param {Date} [opts.now] - deterministic clock for TTL tests
158
+ * @param {boolean}[opts.forceRefresh]- bypass version/TTL cache check
159
+ * @param {Function}[opts.sleepImpl] - injectable sleep for fast backoff tests
160
+ * @returns {Promise<{fileKey,version,cached,endpoints,outDir}>}
161
+ *
162
+ * Effect: writes <outDir>/file.json, styles.json, components.json,
163
+ * component_sets.json (+ variables.json when Path A succeeds) and
164
+ * <outDir>/_meta.json { file_key, fetched_at, version, totals }.
165
+ * NEVER returns raw response bodies. NEVER logs/persists `token`.
166
+ */
167
+ async function pull(opts) {
168
+ const {
169
+ input,
170
+ outDir,
171
+ token,
172
+ fetchImpl = globalThis.fetch,
173
+ logger = noopLogger,
174
+ now,
175
+ forceRefresh = false,
176
+ sleepImpl,
177
+ } = opts || {};
178
+
179
+ if (!input) {
180
+ throw new TypeError('pull: opts.input (file key or URL) is required');
181
+ }
182
+ if (!outDir) {
183
+ throw new TypeError('pull: opts.outDir (cache dir) is required');
184
+ }
185
+ if (typeof fetchImpl !== 'function') {
186
+ throw new TypeError('pull: a fetch implementation is required (global fetch or opts.fetchImpl)');
187
+ }
188
+
189
+ // D-10: token from env only when not explicitly injected. NEVER interpolate
190
+ // the resolved token into any message, log line, or file.
191
+ const tok = token || process.env.FIGMA_TOKEN || process.env.FIGMA_PERSONAL_ACCESS_TOKEN;
192
+ if (!tok) {
193
+ throw new Error(
194
+ 'FIGMA_TOKEN not set. Export FIGMA_TOKEN=figd_… (get one at ' +
195
+ 'https://www.figma.com/developers/api#access-tokens).'
196
+ );
197
+ }
198
+
199
+ const headers = { 'X-Figma-Token': tok };
200
+ const fileKey = parseFigmaFileKey(input);
201
+ const clock = now instanceof Date ? now : new Date();
202
+
203
+ fs.mkdirSync(outDir, { recursive: true });
204
+
205
+ // ── Version probe (D-11) ────────────────────────────────────────────────
206
+ // Cheapest file call that still returns the `version` field. depth=1 avoids
207
+ // pulling the full (potentially hundreds-of-MB) node tree just to compare.
208
+ let probedVersion = null;
209
+ try {
210
+ const probe = await fetchJson(`/files/${fileKey}?depth=1`, {
211
+ fetchImpl,
212
+ headers,
213
+ logger,
214
+ sleepImpl,
215
+ });
216
+ probedVersion = probe.json && probe.json.version != null ? probe.json.version : null;
217
+ logger.info({ event: 'version_probe', endpoint: 'file_probe', version: probedVersion, ms: probe.ms });
218
+ } catch (probeErr) {
219
+ // Probe failure should not be fatal on its own — fall through to TTL logic
220
+ // and let the heavy pull surface a real error if the file is unreachable.
221
+ logger.warn({ event: 'version_probe_failed', message: probeErr.message });
222
+ probedVersion = null;
223
+ }
224
+
225
+ // ── Cache check (D-11) ──────────────────────────────────────────────────
226
+ const cachedMeta = readMetaIfPresent(outDir);
227
+ if (!forceRefresh && cachedMeta) {
228
+ const versionMatch =
229
+ probedVersion != null && cachedMeta.version != null && cachedMeta.version === probedVersion;
230
+
231
+ let ttlFresh = false;
232
+ if (probedVersion == null && cachedMeta.fetched_at) {
233
+ const age = clock.getTime() - new Date(cachedMeta.fetched_at).getTime();
234
+ ttlFresh = Number.isFinite(age) && age >= 0 && age < TTL_MS;
235
+ }
236
+
237
+ if (versionMatch || ttlFresh) {
238
+ logger.info({
239
+ event: 'cache_hit',
240
+ reason: versionMatch ? 'version_match' : 'ttl_fresh',
241
+ version: probedVersion != null ? probedVersion : cachedMeta.version || null,
242
+ });
243
+ return {
244
+ fileKey,
245
+ version: probedVersion != null ? probedVersion : cachedMeta.version || null,
246
+ cached: true,
247
+ endpoints: [],
248
+ outDir,
249
+ };
250
+ }
251
+ }
252
+
253
+ // ── Heavy pull ──────────────────────────────────────────────────────────
254
+ const endpoints = [];
255
+ for (const ep of DEFAULT_ENDPOINTS) {
256
+ const apiPath = ep.path(fileKey);
257
+ if (ep.optional) {
258
+ // Path A (Variables): may 403 on non-Enterprise plans → graceful skip.
259
+ try {
260
+ const result = await fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl });
261
+ endpoints.push(save(outDir, ep.name, result.json, result, logger));
262
+ } catch (e) {
263
+ const reason = e.status ? `HTTP ${e.status}` : e.message;
264
+ logger.warn({ event: 'endpoint_skipped', endpoint: ep.name, reason });
265
+ endpoints.push({ name: ep.name, bytes: 0, ms: 0, skipped: true, reason });
266
+ }
267
+ } else {
268
+ const result = await fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl });
269
+ endpoints.push(save(outDir, ep.name, result.json, result, logger));
270
+ }
271
+ }
272
+
273
+ // ── Persist _meta.json (D-11 shape, extends spike _meta with version) ─────
274
+ const meta = {
275
+ file_key: fileKey,
276
+ fetched_at: clock.toISOString(),
277
+ version: probedVersion,
278
+ totals: endpoints,
279
+ };
280
+ fs.writeFileSync(path.join(outDir, '_meta.json'), JSON.stringify(meta, null, 2));
281
+ logger.info({ event: 'pull_complete', fileKey, version: probedVersion, endpoints: endpoints.length });
282
+
283
+ return { fileKey, version: probedVersion, cached: false, endpoints, outDir };
284
+ }
285
+
286
+ module.exports = { pull, DEFAULT_ENDPOINTS, FIGMA_API_BASE };
287
+
288
+ // ── CLI entry ───────────────────────────────────────────────────────────────
289
+ // Usage: node scripts/lib/figma-extract/pull.cjs <file-url-or-key> [--out <dir>] [--force]
290
+ // node scripts/lib/figma-extract/pull.cjs --help
291
+ // FIGMA_TOKEN must be set in the environment (never passed as a flag — D-10).
292
+ if (require.main === module) {
293
+ const argv = process.argv.slice(2);
294
+
295
+ const HELP = `gdd figma-extract pull — raw Figma REST puller (Plan 31-01)
296
+
297
+ Usage:
298
+ node scripts/lib/figma-extract/pull.cjs <file-url-or-key> [options]
299
+
300
+ Arguments:
301
+ <file-url-or-key> A Figma file URL (https://www.figma.com/file/<key>/… or
302
+ /design/<key>/…) OR a bare file key.
303
+
304
+ Options:
305
+ --out <dir> Raw cache output dir.
306
+ Default: .figma-extract-cache/raw/<file-key>
307
+ --force Bypass version/TTL cache and re-pull all endpoints.
308
+ -h, --help Show this help.
309
+
310
+ Environment:
311
+ FIGMA_TOKEN Personal access token (required).
312
+ FIGMA_PERSONAL_ACCESS_TOKEN Fallback token env var.
313
+ (The token is read from the environment only — never accepted as a flag,
314
+ never logged, never written to disk. — D-10)
315
+
316
+ Notes:
317
+ - Drops geometry=paths from the file endpoint (D-03).
318
+ - Skips a 403 Variables endpoint gracefully (Path A, D-04).
319
+ - Content-version cache invalidation with 1h TTL fallback (D-11).
320
+ - Writes JSON to disk only; raw bodies never printed (off-context, D-01).
321
+ `;
322
+
323
+ if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
324
+ process.stdout.write(HELP);
325
+ process.exit(0);
326
+ }
327
+
328
+ // Minimal flag parse — first non-flag token is the input.
329
+ let input = null;
330
+ let outDir = null;
331
+ let forceRefresh = false;
332
+ for (let i = 0; i < argv.length; i++) {
333
+ const a = argv[i];
334
+ if (a === '--force') {
335
+ forceRefresh = true;
336
+ } else if (a === '--out') {
337
+ outDir = argv[++i];
338
+ } else if (!a.startsWith('-') && input === null) {
339
+ input = a;
340
+ }
341
+ }
342
+
343
+ if (!input) {
344
+ process.stderr.write('ERROR: a Figma file URL or key is required.\n\n' + HELP);
345
+ process.exit(1);
346
+ }
347
+
348
+ // Resolve default outDir lazily so we have the parsed key in the path.
349
+ (async () => {
350
+ try {
351
+ const fileKey = parseFigmaFileKey(input);
352
+ const resolvedOut =
353
+ outDir || path.join('.figma-extract-cache', 'raw', fileKey);
354
+
355
+ // CLI logger → structured JSON lines on stderr (never the token/body).
356
+ const cliLogger = {
357
+ info: (o) => process.stderr.write(JSON.stringify({ level: 'info', ...o }) + '\n'),
358
+ warn: (o) => process.stderr.write(JSON.stringify({ level: 'warn', ...o }) + '\n'),
359
+ error: (o) => process.stderr.write(JSON.stringify({ level: 'error', ...o }) + '\n'),
360
+ };
361
+
362
+ const result = await pull({
363
+ input,
364
+ outDir: resolvedOut,
365
+ logger: cliLogger,
366
+ forceRefresh,
367
+ });
368
+
369
+ process.stdout.write(
370
+ JSON.stringify(
371
+ {
372
+ fileKey: result.fileKey,
373
+ version: result.version,
374
+ cached: result.cached,
375
+ outDir: result.outDir,
376
+ endpoints: result.endpoints.map((e) => ({
377
+ name: e.name,
378
+ bytes: e.bytes,
379
+ ms: e.ms,
380
+ skipped: e.skipped || false,
381
+ })),
382
+ },
383
+ null,
384
+ 2
385
+ ) + '\n'
386
+ );
387
+ process.exit(0);
388
+ } catch (e) {
389
+ // Surface the message (which never contains the token) on stderr.
390
+ process.stderr.write('FAILED: ' + e.message + '\n');
391
+ process.exit(1);
392
+ }
393
+ })();
394
+ }
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+ // scripts/lib/figma-extract/receiver.cjs — Plan 31-06 (Wave B.3)
3
+ // Path C receiver; D-06: ephemeral + 127.0.0.1-only.
4
+ //
5
+ // The localhost half of Path C (D-04). The Figma plugin "GDD Sync" (31-05)
6
+ // reads `figma.variables` from inside Figma (works on any plan — sidesteps the
7
+ // spike's Variables-API-403 Enterprise blocker) and POSTs them here. This
8
+ // receiver validates the payload against payload-schema.json and writes it into
9
+ // the raw/ cache as variables.json, where digest.cjs (31-02) consumes it as
10
+ // Path C via the `source:'gdd-plugin'` marker.
11
+ //
12
+ // Security properties are the WHOLE point (D-06):
13
+ // - Binds 127.0.0.1 ONLY (host '127.0.0.1', never 0.0.0.0) — unreachable off
14
+ // the loopback interface.
15
+ // - REFUSES any non-loopback remote with 403 (req.socket.remoteAddress gate),
16
+ // even though the bind already makes that essentially unreachable — defense
17
+ // in depth, and asserted by test via a mocked remote address.
18
+ // - Validates EVERY payload against the schema BEFORE touching disk (400 on
19
+ // invalid; nothing written).
20
+ // - Port is HARDCODED to 5179 — NOT read from env or a CLI flag (acceptance
21
+ // criterion). Changing it requires a code edit. There is intentionally no
22
+ // `process.env.*PORT*` read in this module.
23
+ // - EPHEMERAL: listens only for the duration of one extract run and exits on
24
+ // the FIRST valid receipt OR on a timeout — never a lingering open port.
25
+ //
26
+ // D-10: this module handles design variables ONLY. It NEVER touches the Figma
27
+ // token (that's a REST-path concern, not Path C). There is no secret-handling
28
+ // code here, and the logger seam receives lifecycle events + counts only —
29
+ // never full payloads.
30
+
31
+ const http = require('node:http');
32
+ const fs = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const Ajv = require('ajv');
35
+
36
+ const payloadSchema = require('./payload-schema.json');
37
+
38
+ // ── constants (D-06 acceptance criterion: hardcoded, no env override) ─────────
39
+ const RECEIVER_HOST = '127.0.0.1'; // loopback ONLY
40
+ const RECEIVER_PORT = 5179; // HARDCODED — intentionally not read from process.env
41
+
42
+ // The marker digest.cjs (31-02) keys on to route variables.json to Path C.
43
+ const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
44
+
45
+ // Defensive body cap. Large design systems can ship sizeable variable sets
46
+ // (the risk register notes streaming for the raw pull); 50MB is generous for a
47
+ // variables-only JSON payload while still bounding memory from a hostile body.
48
+ const MAX_BODY_BYTES = 50 * 1024 * 1024;
49
+
50
+ // ── validator (Ajv is a hard repo dependency — package.json "ajv": "^8.18.0") ─
51
+ // Compiled once at module load. Ajv 8 CJS: require('ajv') is the constructor.
52
+ // NOTE: fail-fast (default, NO allErrors). The receiver validates an UNTRUSTED
53
+ // HTTP body from the plugin; `allErrors: true` would walk the entire (possibly
54
+ // hostile, deeply-nested) object collecting every violation — a resource-
55
+ // exhaustion / DoS amplifier (CodeQL js/resources-exhaustion). Fail-fast stops
56
+ // at the first violation, which is all the 400 response needs. The 50MB
57
+ // MAX_BODY_BYTES cap bounds input size; fail-fast bounds traversal cost.
58
+ const AjvCtor = Ajv.default || Ajv;
59
+ const _ajv = new AjvCtor({ strict: false });
60
+ const _validate = _ajv.compile(payloadSchema);
61
+
62
+ /**
63
+ * Validate a parsed body against payload-schema.json.
64
+ * @param {*} body
65
+ * @returns {{ valid: boolean, errors: Array }}
66
+ */
67
+ function validatePayload(body) {
68
+ const valid = _validate(body) === true;
69
+ return { valid, errors: valid ? [] : (_validate.errors || []) };
70
+ }
71
+
72
+ /** Normalize req.socket.remoteAddress to a loopback test (IPv4, IPv6, mapped). */
73
+ function isLoopbackRemote(remoteAddress) {
74
+ return (
75
+ remoteAddress === '127.0.0.1' ||
76
+ remoteAddress === '::1' ||
77
+ remoteAddress === '::ffff:127.0.0.1'
78
+ );
79
+ }
80
+
81
+ /** Read the full request body with a hard size cap. Rejects on overflow. */
82
+ function readBody(req) {
83
+ return new Promise((resolve, reject) => {
84
+ const chunks = [];
85
+ let size = 0;
86
+ req.on('data', (chunk) => {
87
+ size += chunk.length;
88
+ if (size > MAX_BODY_BYTES) {
89
+ reject(new Error('payload too large'));
90
+ req.destroy();
91
+ return;
92
+ }
93
+ chunks.push(chunk);
94
+ });
95
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
96
+ req.on('error', reject);
97
+ });
98
+ }
99
+
100
+ /** No-op logger fallback. The real seam receives lifecycle events + counts only. */
101
+ function emit(logger, event) {
102
+ if (typeof logger === 'function') {
103
+ try {
104
+ logger(event);
105
+ } catch {
106
+ /* a broken logger must never crash the receiver */
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build the request handler. Exported (`createHandler`) so tests can exercise
113
+ * the loopback gate / routing synchronously by invoking it with a fake req/res
114
+ * — no real remote socket needed (D-06 refusal path is asserted this way).
115
+ *
116
+ * The `onReceipt(parsed, filePath)` callback is invoked exactly once, on the
117
+ * first VALID localhost POST, AFTER the file is written. startReceiver wires it
118
+ * to close the server + resolve. Non-localhost (403), bad route (404), parse
119
+ * error / schema-invalid (400) NEVER call onReceipt — the server keeps waiting.
120
+ *
121
+ * @param {object} opts
122
+ * @param {string} opts.outDir
123
+ * @param {Function} [opts.logger]
124
+ * @param {Function} opts.onReceipt async (parsed, filePath) => void
125
+ * @returns {Function} (req, res) => void
126
+ */
127
+ function createHandler({ outDir, logger, onReceipt }) {
128
+ return function handler(req, res) {
129
+ // (1) Loopback gate FIRST (D-06) — defense in depth on top of the bind.
130
+ const remoteAddress = req.socket && req.socket.remoteAddress;
131
+ if (!isLoopbackRemote(remoteAddress)) {
132
+ emit(logger, { event: 'reject-403', reason: 'non-localhost' });
133
+ res.writeHead(403, { 'content-type': 'text/plain' });
134
+ res.end('forbidden: non-localhost');
135
+ return;
136
+ }
137
+
138
+ // (2) Route — only POST /variables is handled.
139
+ if (req.method !== 'POST' || req.url !== '/variables') {
140
+ emit(logger, { event: 'reject-404', method: req.method, url: req.url });
141
+ res.writeHead(404, { 'content-type': 'text/plain' });
142
+ res.end('not found');
143
+ return;
144
+ }
145
+
146
+ // (3) Read + parse + validate + write. Any thrown error → 500 (no crash).
147
+ readBody(req)
148
+ .then(async (raw) => {
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ } catch {
153
+ emit(logger, { event: 'reject-400', reason: 'malformed-json' });
154
+ res.writeHead(400, { 'content-type': 'application/json' });
155
+ res.end(JSON.stringify({ error: 'malformed-json' }));
156
+ return;
157
+ }
158
+
159
+ const { valid, errors } = validatePayload(parsed);
160
+ if (!valid) {
161
+ emit(logger, { event: 'reject-400', reason: 'schema', errorCount: errors.length });
162
+ res.writeHead(400, { 'content-type': 'application/json' });
163
+ res.end(JSON.stringify({ error: 'schema', details: errors }));
164
+ return;
165
+ }
166
+
167
+ // Valid. Stamp the marker if (defensively) absent, then write the file.
168
+ if (parsed.source !== PLUGIN_PAYLOAD_MARKER) parsed.source = PLUGIN_PAYLOAD_MARKER;
169
+ const filePath = path.join(outDir, 'variables.json');
170
+ await fs.mkdir(outDir, { recursive: true });
171
+ await fs.writeFile(filePath, JSON.stringify(parsed));
172
+
173
+ emit(logger, {
174
+ event: 'receipt',
175
+ path: filePath,
176
+ collections: Array.isArray(parsed.collections) ? parsed.collections.length : 0,
177
+ variables: Array.isArray(parsed.variables) ? parsed.variables.length : 0,
178
+ });
179
+
180
+ res.writeHead(200, { 'content-type': 'application/json' });
181
+ res.end(JSON.stringify({ ok: true }));
182
+
183
+ if (typeof onReceipt === 'function') await onReceipt(parsed, filePath);
184
+ })
185
+ .catch((err) => {
186
+ // Body-too-large or unexpected I/O error. Do NOT leak internals; do NOT
187
+ // resolve the receipt. 500 keeps the server waiting for a retry.
188
+ emit(logger, { event: 'error', message: err && err.message });
189
+ if (!res.headersSent) {
190
+ res.writeHead(500, { 'content-type': 'application/json' });
191
+ res.end(JSON.stringify({ error: 'internal' }));
192
+ }
193
+ });
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Start the ephemeral Path C receiver.
199
+ *
200
+ * @param {object} opts
201
+ * @param {string} opts.outDir REQUIRED — writes <outDir>/variables.json on valid receipt
202
+ * @param {number} [opts.timeoutMs=120000] exits if no valid payload arrives in time
203
+ * @param {Function} [opts.logger] structured lifecycle sink (never receives secrets/full payloads)
204
+ * @returns {Promise<{received:true, path:string} | {received:false, reason:'timeout'}>}
205
+ *
206
+ * Resolves with `{received:true, path}` on the FIRST valid POST /variables, or
207
+ * `{received:false, reason:'timeout'}` on timeout. The server is closed on BOTH
208
+ * exit paths (ephemeral — D-06). Non-localhost → 403; schema-invalid → 400;
209
+ * neither resolves the promise (the server keeps waiting until receipt/timeout).
210
+ */
211
+ function startReceiver({ outDir, timeoutMs = 120000, logger } = {}) {
212
+ if (!outDir) {
213
+ return Promise.reject(new TypeError('startReceiver: opts.outDir is required'));
214
+ }
215
+
216
+ return new Promise((resolve, reject) => {
217
+ let settled = false;
218
+ let timer = null;
219
+ let server = null;
220
+
221
+ const finish = (result) => {
222
+ if (settled) return;
223
+ settled = true;
224
+ if (timer) {
225
+ clearTimeout(timer);
226
+ timer = null;
227
+ }
228
+ // Close the server on BOTH exit paths so the port is never left open and
229
+ // the event loop can drain (process can exit). close() is idempotent-safe.
230
+ if (server) server.close(() => resolve(result));
231
+ else resolve(result);
232
+ };
233
+
234
+ const handler = createHandler({
235
+ outDir,
236
+ logger,
237
+ onReceipt: (_parsed, filePath) => finish({ received: true, path: filePath }),
238
+ });
239
+
240
+ server = http.createServer(handler);
241
+
242
+ server.on('error', (err) => {
243
+ // Most likely EADDRINUSE (another receiver already bound 5179) — surface
244
+ // it to the caller rather than hanging. Only meaningful before listen.
245
+ if (!settled) {
246
+ settled = true;
247
+ if (timer) clearTimeout(timer);
248
+ reject(err);
249
+ }
250
+ });
251
+
252
+ server.listen(RECEIVER_PORT, RECEIVER_HOST, () => {
253
+ emit(logger, { event: 'listen', host: RECEIVER_HOST, port: RECEIVER_PORT });
254
+ // Arm the timeout only once we are actually listening.
255
+ timer = setTimeout(() => {
256
+ emit(logger, { event: 'timeout', timeoutMs });
257
+ finish({ received: false, reason: 'timeout' });
258
+ }, timeoutMs);
259
+ // Don't let the timeout itself keep the process alive past its purpose.
260
+ if (typeof timer.unref === 'function') timer.unref();
261
+ });
262
+ });
263
+ }
264
+
265
+ module.exports = {
266
+ startReceiver,
267
+ createHandler,
268
+ validatePayload,
269
+ isLoopbackRemote,
270
+ RECEIVER_PORT,
271
+ RECEIVER_HOST,
272
+ PLUGIN_PAYLOAD_MARKER,
273
+ };