predictable-ai 0.1.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.
- package/README.md +265 -0
- package/dist/args.js +76 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.js +17 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/bundle.js +754 -0
- package/dist/commands/bundle.js.map +1 -0
- package/dist/commands/execute.js +266 -0
- package/dist/commands/execute.js.map +1 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/diagnostics.js +99 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/commands.json +881 -0
- package/dist/generated/csv-safety.js +181 -0
- package/dist/generated/csv-safety.js.map +1 -0
- package/dist/generated/csv-safety.ts +184 -0
- package/dist/help.js +133 -0
- package/dist/help.js.map +1 -0
- package/dist/http.js +146 -0
- package/dist/http.js.map +1 -0
- package/dist/output.js +102 -0
- package/dist/output.js.map +1 -0
- package/dist/parity.js +30 -0
- package/dist/parity.js.map +1 -0
- package/dist/registry.js +359 -0
- package/dist/registry.js.map +1 -0
- package/dist/run.js +102 -0
- package/dist/run.js.map +1 -0
- package/dist/surface.js +24 -0
- package/dist/surface.js.map +1 -0
- package/dist/version.js +91 -0
- package/dist/version.js.map +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: dashboard/src/lib/exa-agent/csv-safety.ts
|
|
5
|
+
* Regenerate: npm run generate:agent-surface (from dashboard/)
|
|
6
|
+
* Guard: npm run guard:agent-surface (CI re-derives and diffs)
|
|
7
|
+
*
|
|
8
|
+
* §5.1a requires ONE serializer for every CSV egress. The CLI is a separate
|
|
9
|
+
* npm package that publishes only dist/, so it cannot import across the
|
|
10
|
+
* repo; it gets a generated mirror instead, on the same terms as
|
|
11
|
+
* commands.json. Edit the source above, never this file.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* THE CANONICAL CSV SAFETY LIB — one hazard probe, one serializer (spec §5.1
|
|
15
|
+
* step 3 and §5.1a; ticket ga-mfacl.8 / T7, ACs 2 and 2b).
|
|
16
|
+
*
|
|
17
|
+
* ONE IMPLEMENTATION. Everything that emits CSV out of this product imports
|
|
18
|
+
* from here: this epic's routes, T8's egress re-cuts (the live exporter, the
|
|
19
|
+
* unresolved-suppression export, the campaign-bundle writers) and the CLI's
|
|
20
|
+
* bundle assembly. Nothing re-implements it. The spec's reason is measured
|
|
21
|
+
* history, not tidiness — three separate censuses of "who writes CSV" each
|
|
22
|
+
* missed a writer, and the mayor's own pass found a FOURTH local quoting helper
|
|
23
|
+
* (`dashboard/src/lib/suppression/import-preview.ts:77`) after the gate had
|
|
24
|
+
* finished counting. T8 replaces census with construction: a static audit that
|
|
25
|
+
* fails any non-test file emitting CSV without importing this module.
|
|
26
|
+
*
|
|
27
|
+
* ── DETECT AT INGEST, NEUTRALIZE AT EGRESS ─────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* The r1 F1 ruling reversed an earlier design that neutralized on the way IN.
|
|
30
|
+
* Two things killed it. It corrupted legitimate data (the stored cell stopped
|
|
31
|
+
* being what the client uploaded), and it was bypassable: promotion `btrim`s
|
|
32
|
+
* the staged title before the global write
|
|
33
|
+
* (`20260820000017_exa_agent_email_claim_and_dispatch.sql:894`), so any
|
|
34
|
+
* ingest-time FIRST-CHARACTER rule dies to one leading space. Hence:
|
|
35
|
+
*
|
|
36
|
+
* - ingest stores the cell BYTE-IDENTICAL and records `formula_hazard` as
|
|
37
|
+
* metadata beside it (T7-AC2);
|
|
38
|
+
* - that stored flag is INFORMATIONAL ONLY — the review report, counts, a UI
|
|
39
|
+
* cue. It is never an enforcement input, because a stored flag goes stale
|
|
40
|
+
* the moment anything transforms the value (r1 F3 ruling);
|
|
41
|
+
* - enforcement is always a FRESH probe of the FINAL value at the boundary,
|
|
42
|
+
* which is what `serializeCsvRows` does and what T8's provider-payload
|
|
43
|
+
* refusal will do.
|
|
44
|
+
*
|
|
45
|
+
* ── THE PROBE TABLE LIVES IN JSON, NOT IN THIS FILE ────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* `csv-hazard.fixture.json` is the one address for the case table. vitest
|
|
48
|
+
* drives this implementation from it; T8's Python mirror inside
|
|
49
|
+
* `exabase/scripts/crm_push.py` drives the Python implementation from the SAME
|
|
50
|
+
* file. Two runtimes, one table. A second copy of the table would be a
|
|
51
|
+
* duplicated live fact and the two probes would diverge on the first case
|
|
52
|
+
* anyone added.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The four characters a spreadsheet treats as "this cell is a formula".
|
|
56
|
+
* `@` is the Lotus-compatible lead-in Excel still honours; `-` and `+` are the
|
|
57
|
+
* signed-number lead-ins that also start an expression.
|
|
58
|
+
*/
|
|
59
|
+
const TRIGGERS = new Set(['=', '+', '-', '@']);
|
|
60
|
+
/**
|
|
61
|
+
* The characters stripped off the FRONT before the trigger test, as one maximal
|
|
62
|
+
* run over the UNION of the classes — not one class at a time.
|
|
63
|
+
*
|
|
64
|
+
* Each class is here because it is invisible to (or skipped by) a spreadsheet
|
|
65
|
+
* while hiding a trigger behind it, and each was measured rather than assumed
|
|
66
|
+
* (see the probe-class table in `csv-safety.test.ts`):
|
|
67
|
+
*
|
|
68
|
+
* \p{White_Space} every Unicode space, plus U+0085 which `\s` misses.
|
|
69
|
+
* \p{Cc} C0 (U+0000-U+001F), DEL and C1 (U+007F-U+009F).
|
|
70
|
+
* \p{Cf} format characters: BOM U+FEFF, the zero-width family
|
|
71
|
+
* U+200B-U+200D, the bidi marks and overrides
|
|
72
|
+
* U+200E/U+200F/U+202A-U+202E and the isolates
|
|
73
|
+
* U+2066-U+2069, soft hyphen U+00AD.
|
|
74
|
+
* ' " ‘ ’ “ ” apostrophes and quotes. AN UPLOADED APOSTROPHE IS DATA,
|
|
75
|
+
* NOT OUR NEUTRALIZATION: honouring it would let whoever
|
|
76
|
+
* wrote the cell decide whether their own cell gets defanged.
|
|
77
|
+
* The serializer always adds its own.
|
|
78
|
+
*
|
|
79
|
+
* STRIPPING MORE CAN ONLY FLAG MORE. No character in this class is a trigger,
|
|
80
|
+
* so widening it never turns a hazard into a pass — the failure direction is
|
|
81
|
+
* false positives, which the spec accepts by name (see `serializeCsvRows`).
|
|
82
|
+
*/
|
|
83
|
+
const LEADING_RUN = /^[\p{White_Space}\p{Cc}\p{Cf}'"‘’“”]+/u;
|
|
84
|
+
/** C0/DEL/C1 — the second clause of the rule tests the ORIGINAL against this. */
|
|
85
|
+
const CONTROL = /\p{Cc}/u;
|
|
86
|
+
/**
|
|
87
|
+
* Is this value a CSV formula hazard?
|
|
88
|
+
*
|
|
89
|
+
* THE RULE, verbatim from spec §5.1 step 3: NFKC-fold full-width =+-@;
|
|
90
|
+
* strip the maximal leading run above; hazard iff the next code point is one of
|
|
91
|
+
* the four triggers, OR the ORIGINAL begins with a C0/C1 control.
|
|
92
|
+
*
|
|
93
|
+
* NFKC IS INSPECTION ONLY. It folds = (U+FF1D) to `=`, + to `+`, - to `-`
|
|
94
|
+
* and @ to `@` — measured, not assumed — so a full-width payload cannot walk
|
|
95
|
+
* past a test written against ASCII. The folded string is never stored,
|
|
96
|
+
* exported or returned; callers keep their own value.
|
|
97
|
+
*
|
|
98
|
+
* WHY THE CONTROL CLAUSE TESTS THE ORIGINAL. It is a claim about the bytes the
|
|
99
|
+
* client actually uploaded. Folding first would let a future Unicode revision
|
|
100
|
+
* that maps some code point onto a control decide it, and "the value starts
|
|
101
|
+
* with a control character" is a fact about the input, not about a normal form
|
|
102
|
+
* of it.
|
|
103
|
+
*/
|
|
104
|
+
export function isFormulaHazard(value) {
|
|
105
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
106
|
+
return false;
|
|
107
|
+
// Clause 2 first: it reads the ORIGINAL, so no folding may have happened yet.
|
|
108
|
+
const first = String.fromCodePoint(value.codePointAt(0));
|
|
109
|
+
if (CONTROL.test(first))
|
|
110
|
+
return true;
|
|
111
|
+
const rest = value.normalize('NFKC').replace(LEADING_RUN, '');
|
|
112
|
+
if (rest.length === 0)
|
|
113
|
+
return false;
|
|
114
|
+
return TRIGGERS.has(String.fromCodePoint(rest.codePointAt(0)));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Coerce any value a caller holds into the string that will be written.
|
|
118
|
+
*
|
|
119
|
+
* ONE COERCION, DEFINED ONCE. Every adopter needs it, and an adopter-local
|
|
120
|
+
* version is how two exports come to disagree about what `null` looks like.
|
|
121
|
+
* The rules match the live exporter's `escapeCsv`
|
|
122
|
+
* (`api/export/[icpId]/route.ts:10-17`) exactly, so T8's swap changes what a
|
|
123
|
+
* hazardous cell looks like and nothing else.
|
|
124
|
+
*/
|
|
125
|
+
export function toCsvValue(value) {
|
|
126
|
+
if (value === null || value === undefined)
|
|
127
|
+
return '';
|
|
128
|
+
if (typeof value === 'string')
|
|
129
|
+
return value;
|
|
130
|
+
if (typeof value === 'object')
|
|
131
|
+
return JSON.stringify(value);
|
|
132
|
+
return String(value);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Serialize ONE cell: always quoted, embedded quotes doubled, a
|
|
136
|
+
* serializer-owned apostrophe inside the quotes when the FINAL value is
|
|
137
|
+
* hazardous.
|
|
138
|
+
*
|
|
139
|
+
* ALWAYS QUOTED, not "quoted when it contains a delimiter". A conditional
|
|
140
|
+
* quote is a second decision that can disagree with the first, and RFC 4180
|
|
141
|
+
* permits quoting every field. It also means a cell can never grow a delimiter
|
|
142
|
+
* meaning through a later edit.
|
|
143
|
+
*
|
|
144
|
+
* THE APOSTROPHE GOES INSIDE THE QUOTES AND IS NOT DOUBLED. It is the
|
|
145
|
+
* serializer's own character, not part of the value: `'` then the escaped
|
|
146
|
+
* value, all inside one pair of quotes. A reader that strips it gets the
|
|
147
|
+
* original back.
|
|
148
|
+
*/
|
|
149
|
+
export function serializeCsvCell(value) {
|
|
150
|
+
const str = toCsvValue(value);
|
|
151
|
+
const prefix = isFormulaHazard(str) ? "'" : '';
|
|
152
|
+
return `"${prefix}${str.replace(/"/g, '""')}"`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Serialize a whole document: comma delimiter, CRLF records, every cell through
|
|
156
|
+
* `serializeCsvCell`.
|
|
157
|
+
*
|
|
158
|
+
* HEADERS SERIALIZE AS DATA. There is no header parameter. A header row is
|
|
159
|
+
* `rows[0]`, quoted and probed exactly like any other row — a header is a cell
|
|
160
|
+
* an attacker can often choose (a merge-var name, a column label), and a
|
|
161
|
+
* serializer with a privileged header path is a serializer with an unprobed
|
|
162
|
+
* one.
|
|
163
|
+
*
|
|
164
|
+
* TRAILING CRLF ON EVERY RECORD, including the last. RFC 4180 allows either;
|
|
165
|
+
* emitting it unconditionally means there is no last-row branch to get wrong.
|
|
166
|
+
*
|
|
167
|
+
* THE ACCEPTED COST, stated where it is paid: benign leading `-`/`+` data
|
|
168
|
+
* (phone numbers, "-5 Main St") gains an apostrophe in the export. That is the
|
|
169
|
+
* OWASP-documented integrity cost of CSV hazard handling, ruled acceptable in
|
|
170
|
+
* §5.1 step 3, and it costs nothing in the database — the stored value stays
|
|
171
|
+
* byte-identical either way.
|
|
172
|
+
*
|
|
173
|
+
* THE HONESTY BOUNDARY, also from the spec: neutralization is best-effort
|
|
174
|
+
* across spreadsheet consumers. LibreOffice import options can still evaluate.
|
|
175
|
+
* If "cannot execute anywhere" ever becomes a hard requirement, the answer is
|
|
176
|
+
* XLSX with explicit string cells, which v2 excludes by ruling.
|
|
177
|
+
*/
|
|
178
|
+
export function serializeCsvRows(rows) {
|
|
179
|
+
return rows.map((row) => row.map(serializeCsvCell).join(',') + '\r\n').join('');
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=csv-safety.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csv-safety.js","sourceRoot":"","sources":["../../src/generated/csv-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH;;;;GAIG;AACH,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,GAAG,wCAAwC,CAAA;AAE5D,iFAAiF;AACjF,MAAM,OAAO,GAAG,SAAS,CAAA;AAEzB;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEjE,8EAA8E;IAC9E,MAAM,KAAK,GAAG,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAW,CAAC,CAAA;IAClE,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEpC,MAAM,IAAI,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;IAC7D,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IACnC,OAAO,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAW,CAAC,CAAC,CAAA;AAC1E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAA;IACpD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAA;IAC3D,OAAO,MAAM,CAAC,KAAK,CAAC,CAAA;AACtB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;IAC9C,OAAO,IAAI,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAA;AAChD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqC;IACpE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACjF,CAAC"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: dashboard/src/lib/exa-agent/csv-safety.ts
|
|
5
|
+
* Regenerate: npm run generate:agent-surface (from dashboard/)
|
|
6
|
+
* Guard: npm run guard:agent-surface (CI re-derives and diffs)
|
|
7
|
+
*
|
|
8
|
+
* §5.1a requires ONE serializer for every CSV egress. The CLI is a separate
|
|
9
|
+
* npm package that publishes only dist/, so it cannot import across the
|
|
10
|
+
* repo; it gets a generated mirror instead, on the same terms as
|
|
11
|
+
* commands.json. Edit the source above, never this file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* THE CANONICAL CSV SAFETY LIB — one hazard probe, one serializer (spec §5.1
|
|
16
|
+
* step 3 and §5.1a; ticket ga-mfacl.8 / T7, ACs 2 and 2b).
|
|
17
|
+
*
|
|
18
|
+
* ONE IMPLEMENTATION. Everything that emits CSV out of this product imports
|
|
19
|
+
* from here: this epic's routes, T8's egress re-cuts (the live exporter, the
|
|
20
|
+
* unresolved-suppression export, the campaign-bundle writers) and the CLI's
|
|
21
|
+
* bundle assembly. Nothing re-implements it. The spec's reason is measured
|
|
22
|
+
* history, not tidiness — three separate censuses of "who writes CSV" each
|
|
23
|
+
* missed a writer, and the mayor's own pass found a FOURTH local quoting helper
|
|
24
|
+
* (`dashboard/src/lib/suppression/import-preview.ts:77`) after the gate had
|
|
25
|
+
* finished counting. T8 replaces census with construction: a static audit that
|
|
26
|
+
* fails any non-test file emitting CSV without importing this module.
|
|
27
|
+
*
|
|
28
|
+
* ── DETECT AT INGEST, NEUTRALIZE AT EGRESS ─────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* The r1 F1 ruling reversed an earlier design that neutralized on the way IN.
|
|
31
|
+
* Two things killed it. It corrupted legitimate data (the stored cell stopped
|
|
32
|
+
* being what the client uploaded), and it was bypassable: promotion `btrim`s
|
|
33
|
+
* the staged title before the global write
|
|
34
|
+
* (`20260820000017_exa_agent_email_claim_and_dispatch.sql:894`), so any
|
|
35
|
+
* ingest-time FIRST-CHARACTER rule dies to one leading space. Hence:
|
|
36
|
+
*
|
|
37
|
+
* - ingest stores the cell BYTE-IDENTICAL and records `formula_hazard` as
|
|
38
|
+
* metadata beside it (T7-AC2);
|
|
39
|
+
* - that stored flag is INFORMATIONAL ONLY — the review report, counts, a UI
|
|
40
|
+
* cue. It is never an enforcement input, because a stored flag goes stale
|
|
41
|
+
* the moment anything transforms the value (r1 F3 ruling);
|
|
42
|
+
* - enforcement is always a FRESH probe of the FINAL value at the boundary,
|
|
43
|
+
* which is what `serializeCsvRows` does and what T8's provider-payload
|
|
44
|
+
* refusal will do.
|
|
45
|
+
*
|
|
46
|
+
* ── THE PROBE TABLE LIVES IN JSON, NOT IN THIS FILE ────────────────────────
|
|
47
|
+
*
|
|
48
|
+
* `csv-hazard.fixture.json` is the one address for the case table. vitest
|
|
49
|
+
* drives this implementation from it; T8's Python mirror inside
|
|
50
|
+
* `exabase/scripts/crm_push.py` drives the Python implementation from the SAME
|
|
51
|
+
* file. Two runtimes, one table. A second copy of the table would be a
|
|
52
|
+
* duplicated live fact and the two probes would diverge on the first case
|
|
53
|
+
* anyone added.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The four characters a spreadsheet treats as "this cell is a formula".
|
|
58
|
+
* `@` is the Lotus-compatible lead-in Excel still honours; `-` and `+` are the
|
|
59
|
+
* signed-number lead-ins that also start an expression.
|
|
60
|
+
*/
|
|
61
|
+
const TRIGGERS = new Set(['=', '+', '-', '@'])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The characters stripped off the FRONT before the trigger test, as one maximal
|
|
65
|
+
* run over the UNION of the classes — not one class at a time.
|
|
66
|
+
*
|
|
67
|
+
* Each class is here because it is invisible to (or skipped by) a spreadsheet
|
|
68
|
+
* while hiding a trigger behind it, and each was measured rather than assumed
|
|
69
|
+
* (see the probe-class table in `csv-safety.test.ts`):
|
|
70
|
+
*
|
|
71
|
+
* \p{White_Space} every Unicode space, plus U+0085 which `\s` misses.
|
|
72
|
+
* \p{Cc} C0 (U+0000-U+001F), DEL and C1 (U+007F-U+009F).
|
|
73
|
+
* \p{Cf} format characters: BOM U+FEFF, the zero-width family
|
|
74
|
+
* U+200B-U+200D, the bidi marks and overrides
|
|
75
|
+
* U+200E/U+200F/U+202A-U+202E and the isolates
|
|
76
|
+
* U+2066-U+2069, soft hyphen U+00AD.
|
|
77
|
+
* ' " ‘ ’ “ ” apostrophes and quotes. AN UPLOADED APOSTROPHE IS DATA,
|
|
78
|
+
* NOT OUR NEUTRALIZATION: honouring it would let whoever
|
|
79
|
+
* wrote the cell decide whether their own cell gets defanged.
|
|
80
|
+
* The serializer always adds its own.
|
|
81
|
+
*
|
|
82
|
+
* STRIPPING MORE CAN ONLY FLAG MORE. No character in this class is a trigger,
|
|
83
|
+
* so widening it never turns a hazard into a pass — the failure direction is
|
|
84
|
+
* false positives, which the spec accepts by name (see `serializeCsvRows`).
|
|
85
|
+
*/
|
|
86
|
+
const LEADING_RUN = /^[\p{White_Space}\p{Cc}\p{Cf}'"‘’“”]+/u
|
|
87
|
+
|
|
88
|
+
/** C0/DEL/C1 — the second clause of the rule tests the ORIGINAL against this. */
|
|
89
|
+
const CONTROL = /\p{Cc}/u
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Is this value a CSV formula hazard?
|
|
93
|
+
*
|
|
94
|
+
* THE RULE, verbatim from spec §5.1 step 3: NFKC-fold full-width =+-@;
|
|
95
|
+
* strip the maximal leading run above; hazard iff the next code point is one of
|
|
96
|
+
* the four triggers, OR the ORIGINAL begins with a C0/C1 control.
|
|
97
|
+
*
|
|
98
|
+
* NFKC IS INSPECTION ONLY. It folds = (U+FF1D) to `=`, + to `+`, - to `-`
|
|
99
|
+
* and @ to `@` — measured, not assumed — so a full-width payload cannot walk
|
|
100
|
+
* past a test written against ASCII. The folded string is never stored,
|
|
101
|
+
* exported or returned; callers keep their own value.
|
|
102
|
+
*
|
|
103
|
+
* WHY THE CONTROL CLAUSE TESTS THE ORIGINAL. It is a claim about the bytes the
|
|
104
|
+
* client actually uploaded. Folding first would let a future Unicode revision
|
|
105
|
+
* that maps some code point onto a control decide it, and "the value starts
|
|
106
|
+
* with a control character" is a fact about the input, not about a normal form
|
|
107
|
+
* of it.
|
|
108
|
+
*/
|
|
109
|
+
export function isFormulaHazard(value: string): boolean {
|
|
110
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
111
|
+
|
|
112
|
+
// Clause 2 first: it reads the ORIGINAL, so no folding may have happened yet.
|
|
113
|
+
const first = String.fromCodePoint(value.codePointAt(0) as number)
|
|
114
|
+
if (CONTROL.test(first)) return true
|
|
115
|
+
|
|
116
|
+
const rest = value.normalize('NFKC').replace(LEADING_RUN, '')
|
|
117
|
+
if (rest.length === 0) return false
|
|
118
|
+
return TRIGGERS.has(String.fromCodePoint(rest.codePointAt(0) as number))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Coerce any value a caller holds into the string that will be written.
|
|
123
|
+
*
|
|
124
|
+
* ONE COERCION, DEFINED ONCE. Every adopter needs it, and an adopter-local
|
|
125
|
+
* version is how two exports come to disagree about what `null` looks like.
|
|
126
|
+
* The rules match the live exporter's `escapeCsv`
|
|
127
|
+
* (`api/export/[icpId]/route.ts:10-17`) exactly, so T8's swap changes what a
|
|
128
|
+
* hazardous cell looks like and nothing else.
|
|
129
|
+
*/
|
|
130
|
+
export function toCsvValue(value: unknown): string {
|
|
131
|
+
if (value === null || value === undefined) return ''
|
|
132
|
+
if (typeof value === 'string') return value
|
|
133
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
134
|
+
return String(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialize ONE cell: always quoted, embedded quotes doubled, a
|
|
139
|
+
* serializer-owned apostrophe inside the quotes when the FINAL value is
|
|
140
|
+
* hazardous.
|
|
141
|
+
*
|
|
142
|
+
* ALWAYS QUOTED, not "quoted when it contains a delimiter". A conditional
|
|
143
|
+
* quote is a second decision that can disagree with the first, and RFC 4180
|
|
144
|
+
* permits quoting every field. It also means a cell can never grow a delimiter
|
|
145
|
+
* meaning through a later edit.
|
|
146
|
+
*
|
|
147
|
+
* THE APOSTROPHE GOES INSIDE THE QUOTES AND IS NOT DOUBLED. It is the
|
|
148
|
+
* serializer's own character, not part of the value: `'` then the escaped
|
|
149
|
+
* value, all inside one pair of quotes. A reader that strips it gets the
|
|
150
|
+
* original back.
|
|
151
|
+
*/
|
|
152
|
+
export function serializeCsvCell(value: unknown): string {
|
|
153
|
+
const str = toCsvValue(value)
|
|
154
|
+
const prefix = isFormulaHazard(str) ? "'" : ''
|
|
155
|
+
return `"${prefix}${str.replace(/"/g, '""')}"`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Serialize a whole document: comma delimiter, CRLF records, every cell through
|
|
160
|
+
* `serializeCsvCell`.
|
|
161
|
+
*
|
|
162
|
+
* HEADERS SERIALIZE AS DATA. There is no header parameter. A header row is
|
|
163
|
+
* `rows[0]`, quoted and probed exactly like any other row — a header is a cell
|
|
164
|
+
* an attacker can often choose (a merge-var name, a column label), and a
|
|
165
|
+
* serializer with a privileged header path is a serializer with an unprobed
|
|
166
|
+
* one.
|
|
167
|
+
*
|
|
168
|
+
* TRAILING CRLF ON EVERY RECORD, including the last. RFC 4180 allows either;
|
|
169
|
+
* emitting it unconditionally means there is no last-row branch to get wrong.
|
|
170
|
+
*
|
|
171
|
+
* THE ACCEPTED COST, stated where it is paid: benign leading `-`/`+` data
|
|
172
|
+
* (phone numbers, "-5 Main St") gains an apostrophe in the export. That is the
|
|
173
|
+
* OWASP-documented integrity cost of CSV hazard handling, ruled acceptable in
|
|
174
|
+
* §5.1 step 3, and it costs nothing in the database — the stored value stays
|
|
175
|
+
* byte-identical either way.
|
|
176
|
+
*
|
|
177
|
+
* THE HONESTY BOUNDARY, also from the spec: neutralization is best-effort
|
|
178
|
+
* across spreadsheet consumers. LibreOffice import options can still evaluate.
|
|
179
|
+
* If "cannot execute anywhere" ever becomes a hard requirement, the answer is
|
|
180
|
+
* XLSX with explicit string cells, which v2 excludes by ruling.
|
|
181
|
+
*/
|
|
182
|
+
export function serializeCsvRows(rows: readonly (readonly unknown[])[]): string {
|
|
183
|
+
return rows.map((row) => row.map(serializeCsvCell).join(',') + '\r\n').join('')
|
|
184
|
+
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { BASE_URL_ENV_VAR, DEFAULT_BASE_URL, TOKEN_ENV_VAR, CONFIG_DIR_NAME, CONFIG_FILE_NAME } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Help text.
|
|
4
|
+
*
|
|
5
|
+
* NO EXAMPLE HERE EVER PUTS A TOKEN ON A COMMAND LINE (ticket AC5). The
|
|
6
|
+
* authentication note names the environment variable and the config file and
|
|
7
|
+
* stops there; `test/hygiene.test.ts` renders every command's help and asserts
|
|
8
|
+
* it. Writing `--token` into a single example would ship the habit to every
|
|
9
|
+
* user who copies it.
|
|
10
|
+
*/
|
|
11
|
+
const AUTH_NOTE = [
|
|
12
|
+
'Authentication:',
|
|
13
|
+
` Set ${TOKEN_ENV_VAR} in your environment, or put {"token": "<your token>"} in`,
|
|
14
|
+
` ~/${CONFIG_DIR_NAME}/${CONFIG_FILE_NAME} with mode 600. The token is never read from the`,
|
|
15
|
+
' command line — anything you type is visible in your shell history and in ps.',
|
|
16
|
+
].join('\n');
|
|
17
|
+
const BASE_URL_NOTE = [
|
|
18
|
+
'Deployment:',
|
|
19
|
+
` ${DEFAULT_BASE_URL} by default; set ${BASE_URL_ENV_VAR} to talk to a preview.`,
|
|
20
|
+
].join('\n');
|
|
21
|
+
const EXIT_NOTE = [
|
|
22
|
+
'Exit codes:',
|
|
23
|
+
' 0 ok · 1 usage or a rejected request · 2 credential refused · 3 rate limited',
|
|
24
|
+
' 4 server error or a verb this deployment does not serve yet · 5 quote required',
|
|
25
|
+
].join('\n');
|
|
26
|
+
/**
|
|
27
|
+
* Soft-wrap a paragraph to `width`, so a long line of coaching does not run off
|
|
28
|
+
* an 80-column terminal. Word boundaries only; a single word longer than the
|
|
29
|
+
* width is left whole rather than cut.
|
|
30
|
+
*/
|
|
31
|
+
function wrapAt(text, width) {
|
|
32
|
+
const out = [];
|
|
33
|
+
let line = '';
|
|
34
|
+
for (const word of text.split(/\s+/)) {
|
|
35
|
+
if (line === '')
|
|
36
|
+
line = word;
|
|
37
|
+
else if (line.length + 1 + word.length <= width)
|
|
38
|
+
line += ` ${word}`;
|
|
39
|
+
else {
|
|
40
|
+
out.push(line);
|
|
41
|
+
line = word;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (line !== '')
|
|
45
|
+
out.push(line);
|
|
46
|
+
return out.join('\n');
|
|
47
|
+
}
|
|
48
|
+
function flagLine(name, placeholder, describe) {
|
|
49
|
+
const left = placeholder ? `--${name} ${placeholder}` : `--${name}`;
|
|
50
|
+
return ` ${left.padEnd(24)}${describe}`;
|
|
51
|
+
}
|
|
52
|
+
export function renderCommandHelp(spec) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(`predictable ${spec.name} — ${spec.summary}`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(`Usage: predictable ${spec.name}${spec.params.length > 0 ? ' [flags]' : ''}`);
|
|
57
|
+
if (spec.params.length > 0) {
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('Flags:');
|
|
60
|
+
for (const param of spec.params) {
|
|
61
|
+
const suffix = param.required ? ' (required)' : '';
|
|
62
|
+
lines.push(flagLine(param.name, param.boolean ? '' : param.placeholder, `${param.describe}${suffix}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const actions = [...new Set(spec.routes.filter((r) => r.action).map((r) => r.action))];
|
|
66
|
+
if (actions.length > 1) {
|
|
67
|
+
lines.push(flagLine('action', `<${actions.join('|')}>`, `Which call to make. Default: ${spec.defaultAction}.`));
|
|
68
|
+
}
|
|
69
|
+
// THE COACHING GOES ABOVE THE FLAGS, not below the routes (T15 §15.1, AC2).
|
|
70
|
+
// A reader who has typed `--help` because they are about to run the verb sees
|
|
71
|
+
// it before the mechanics, which is the only position where guidance about
|
|
72
|
+
// WHAT to ask for can still change what they type.
|
|
73
|
+
//
|
|
74
|
+
// The text is the MANIFEST's — `buildRegistry` copies it off the artifact and
|
|
75
|
+
// nothing here writes one. `cli/test/guide.test.ts` plants a divergence and
|
|
76
|
+
// requires it to go red.
|
|
77
|
+
if (spec.guidance) {
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push(wrapAt(spec.guidance, 76));
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('Common flags:');
|
|
83
|
+
lines.push(flagLine('json', '', "Print the server's response body verbatim."));
|
|
84
|
+
lines.push(flagLine('help', '', 'Show this message.'));
|
|
85
|
+
if (spec.mode === 'paid') {
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('This verb spends credits, and it is two steps:');
|
|
88
|
+
lines.push(` 1. predictable ${spec.name} --preview [flags] prints a quote id, its credits and its expiry`);
|
|
89
|
+
lines.push(` 2. predictable ${spec.name} --quote <id> [flags] spends against that quote`);
|
|
90
|
+
lines.push(' Without --quote, step 2 refuses locally and sends nothing.');
|
|
91
|
+
}
|
|
92
|
+
if (spec.routes.length > 0) {
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('Routes:');
|
|
95
|
+
for (const route of spec.routes)
|
|
96
|
+
lines.push(` ${route.method} ${route.path}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(AUTH_NOTE);
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(BASE_URL_NOTE);
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
export function renderRootHelp(specs, version, surfaceVersion) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push(`predictable ${version} — the Predictable agent surface, on the command line.`);
|
|
107
|
+
lines.push(`Agent surface ${surfaceVersion}.`);
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push('Usage: predictable <command> [flags]');
|
|
110
|
+
lines.push(' predictable <command> --help');
|
|
111
|
+
lines.push('');
|
|
112
|
+
const packs = [...new Set(specs.map((s) => s.pack))];
|
|
113
|
+
for (const pack of packs) {
|
|
114
|
+
lines.push(`${pack} commands:`);
|
|
115
|
+
for (const spec of specs.filter((s) => s.pack === pack)) {
|
|
116
|
+
const paid = spec.mode === 'paid' ? ' [spends credits]' : '';
|
|
117
|
+
lines.push(` ${spec.name.padEnd(22)}${spec.summary}${paid}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
lines.push('Global:');
|
|
122
|
+
lines.push(flagLine('version', '', 'Print this client and the deployment surface version.'));
|
|
123
|
+
lines.push(flagLine('json', '', "Print the server's response body verbatim."));
|
|
124
|
+
lines.push(flagLine('help', '', 'Show this message.'));
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push(AUTH_NOTE);
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(BASE_URL_NOTE);
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(EXIT_NOTE);
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=help.js.map
|
package/dist/help.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"help.js","sourceRoot":"","sources":["../src/help.ts"],"names":[],"mappings":"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"}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Join the deployment's base URL and a route path.
|
|
4
|
+
*
|
|
5
|
+
* The base URL may carry a PATH of its own — a preview served under a prefix,
|
|
6
|
+
* or a proxy that mounts the API somewhere other than the root. `new URL(path,
|
|
7
|
+
* base)` with an absolute `/api/...` path discards that prefix silently and
|
|
8
|
+
* sends the request to the wrong place, so the prefix is joined explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export function buildUrl(baseUrl, path, query) {
|
|
11
|
+
const base = new URL(baseUrl);
|
|
12
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
13
|
+
// Rebuilt from the base URL itself, not from its origin: an origin drops any
|
|
14
|
+
// userinfo the base carried, and dropping half a URL is the failure this is
|
|
15
|
+
// fixing, not a smaller version of it. Assigning `pathname` leaves the
|
|
16
|
+
// caller's percent-escapes alone.
|
|
17
|
+
const url = new URL(base.toString());
|
|
18
|
+
// ga-paal4 item 2: SPLIT THE PATH FROM ITS QUERY FIRST. Assigning `pathname`
|
|
19
|
+
// says "all of this is a path", so a `?` inside `path` is percent-encoded to
|
|
20
|
+
// %3F and the request goes to a literal segment ending `...%3Ftarget=5` while
|
|
21
|
+
// the server sees no query at all — a silently dropped parameter on a client
|
|
22
|
+
// whose whole job is calling parameterized routes.
|
|
23
|
+
//
|
|
24
|
+
// Split on the FIRST unescaped `?` only. An already-escaped %3F is pathname
|
|
25
|
+
// data the caller asked for and stays escaped; everything after the first `?`
|
|
26
|
+
// is the query, including any later `?` characters, which is what a URL parser
|
|
27
|
+
// does. Assigning `pathname` still leaves the caller's other percent-escapes
|
|
28
|
+
// alone.
|
|
29
|
+
const split = path.indexOf('?');
|
|
30
|
+
const pathPart = split === -1 ? path : path.slice(0, split);
|
|
31
|
+
const embeddedQuery = split === -1 ? '' : path.slice(split + 1);
|
|
32
|
+
url.pathname = `${prefix}/${pathPart.replace(/^\/+/, '')}`;
|
|
33
|
+
url.search = '';
|
|
34
|
+
url.hash = '';
|
|
35
|
+
// The embedded query goes on FIRST so an explicit `query` entry overrides it:
|
|
36
|
+
// the caller passing a parameter by name is the more specific statement, and
|
|
37
|
+
// leaving the precedence to whichever assignment happened to run second is how
|
|
38
|
+
// two sources for one parameter become a coin flip.
|
|
39
|
+
for (const [key, value] of new URLSearchParams(embeddedQuery)) {
|
|
40
|
+
if (value !== '')
|
|
41
|
+
url.searchParams.set(key, value);
|
|
42
|
+
}
|
|
43
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
44
|
+
if (value !== undefined && value !== '')
|
|
45
|
+
url.searchParams.set(key, value);
|
|
46
|
+
}
|
|
47
|
+
return url.toString();
|
|
48
|
+
}
|
|
49
|
+
function serverErrorCode(text) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text);
|
|
52
|
+
if (typeof parsed.error === 'string')
|
|
53
|
+
return parsed.error;
|
|
54
|
+
if (typeof parsed.reason === 'string')
|
|
55
|
+
return parsed.reason;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* a non-JSON body has no code to name */
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Send one request. Transport failures become a server-class CliError. */
|
|
63
|
+
export async function send(ctx, call) {
|
|
64
|
+
// A supplied transport replaces the network entirely — no base URL is joined
|
|
65
|
+
// and no bearer is attached, because an in-process caller has neither and
|
|
66
|
+
// must not be handed a credential it did not present.
|
|
67
|
+
if (ctx.transport)
|
|
68
|
+
return ctx.transport(call);
|
|
69
|
+
const headers = { accept: 'application/json', ...ctx.extraHeaders };
|
|
70
|
+
if (ctx.token)
|
|
71
|
+
headers.authorization = `Bearer ${ctx.token}`;
|
|
72
|
+
if (call.body !== undefined)
|
|
73
|
+
headers['content-type'] = 'application/json';
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetch(buildUrl(ctx.baseUrl, call.path, call.query), {
|
|
77
|
+
method: call.method,
|
|
78
|
+
headers,
|
|
79
|
+
...(call.body !== undefined ? { body: call.body } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
throw new CliError(EXIT.SERVER, `could not reach ${ctx.baseUrl}`, cause instanceof Error ? cause.message : String(cause));
|
|
84
|
+
}
|
|
85
|
+
return { status: response.status, headers: response.headers, text: await response.text() };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Turn a non-2xx into the CliError its status calls for.
|
|
89
|
+
*
|
|
90
|
+
* `unavailable` is set when the deployment's own manifest says this route is
|
|
91
|
+
* pending or gated: a 404 then means "your client is newer than this
|
|
92
|
+
* deployment", which is a different fact from "that id does not exist", and
|
|
93
|
+
* saying so is the difference between a usable message and a raw 404.
|
|
94
|
+
*/
|
|
95
|
+
export function classify(response, call, unavailable) {
|
|
96
|
+
if (response.status >= 200 && response.status < 300)
|
|
97
|
+
return undefined;
|
|
98
|
+
const code = serverErrorCode(response.text);
|
|
99
|
+
const where = `${call.method} ${call.template ?? call.path}`;
|
|
100
|
+
if (response.status === 401 || response.status === 403) {
|
|
101
|
+
return new CliError(EXIT.AUTH, `error: ${code ?? 'unauthorized'}`, `${where} → ${response.status}`);
|
|
102
|
+
}
|
|
103
|
+
if (response.status === 429) {
|
|
104
|
+
const retry = response.headers.get('retry-after');
|
|
105
|
+
return new CliError(EXIT.RATE_LIMITED, `error: ${code ?? 'rate_limited'}`, retry ? `retry after ${retry}s (${where})` : where);
|
|
106
|
+
}
|
|
107
|
+
if (response.status === 404 && unavailable) {
|
|
108
|
+
return new CliError(EXIT.SERVER, 'not available on this deployment yet', unavailable);
|
|
109
|
+
}
|
|
110
|
+
if (response.status >= 500) {
|
|
111
|
+
return new CliError(EXIT.SERVER, `error: ${code ?? `server ${response.status}`}`, where);
|
|
112
|
+
}
|
|
113
|
+
return new CliError(EXIT.USAGE, `error: ${code ?? `request rejected (${response.status})`}`, where);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Ask the deployment whether it serves a route yet.
|
|
117
|
+
*
|
|
118
|
+
* Best effort by design: it needs a token and a reachable server, and it runs
|
|
119
|
+
* only after a 404 has already happened, so a failure here just means the
|
|
120
|
+
* caller gets the plain 404 message it would have got anyway.
|
|
121
|
+
*/
|
|
122
|
+
export async function routeUnavailableReason(ctx, route) {
|
|
123
|
+
if (!ctx.token)
|
|
124
|
+
return undefined;
|
|
125
|
+
let manifest;
|
|
126
|
+
try {
|
|
127
|
+
const response = await send(ctx, { method: 'GET', path: '/api/agent/manifest' });
|
|
128
|
+
if (response.status !== 200)
|
|
129
|
+
return undefined;
|
|
130
|
+
manifest = JSON.parse(response.text);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const key = `${route.method} ${route.path}`;
|
|
136
|
+
if (manifest.pending?.includes(key)) {
|
|
137
|
+
return `${key} has no route file on this deployment yet (manifest version ${manifest.version}).`;
|
|
138
|
+
}
|
|
139
|
+
const gated = manifest.gated ?? [];
|
|
140
|
+
const hit = gated.find((g) => g.method === route.method && g.path === route.path);
|
|
141
|
+
if (hit) {
|
|
142
|
+
return `${key} is held out of this deployment's allowlist until ${hit.gatedUntil ?? 'a later slice'}.`;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=http.js.map
|