@outcrawl/sdk 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 +198 -0
- package/dist/index.js +2493 -0
- package/dist/types/_deps/core/agent-alias.d.ts +219 -0
- package/dist/types/_deps/core/brand.d.ts +62 -0
- package/dist/types/_deps/core/certificate.d.ts +100 -0
- package/dist/types/_deps/core/cron.d.ts +116 -0
- package/dist/types/_deps/core/errors.d.ts +340 -0
- package/dist/types/_deps/core/index.d.ts +17 -0
- package/dist/types/_deps/core/money.d.ts +108 -0
- package/dist/types/_deps/core/registry.d.ts +466 -0
- package/dist/types/_deps/core/rules.d.ts +237 -0
- package/dist/types/_deps/core/secrets.d.ts +278 -0
- package/dist/types/_deps/core/types.d.ts +1677 -0
- package/dist/types/_deps/integrations/connector.d.ts +153 -0
- package/dist/types/_deps/replay/events.d.ts +1004 -0
- package/dist/types/agent.d.ts +313 -0
- package/dist/types/availability.d.ts +86 -0
- package/dist/types/browser.d.ts +107 -0
- package/dist/types/client.d.ts +128 -0
- package/dist/types/hands.d.ts +259 -0
- package/dist/types/index.d.ts +111 -0
- package/dist/types/integrations.d.ts +63 -0
- package/dist/types/monitors.d.ts +24 -0
- package/dist/types/page.d.ts +96 -0
- package/dist/types/profiles.d.ts +26 -0
- package/dist/types/result.d.ts +90 -0
- package/dist/types/rules.d.ts +41 -0
- package/dist/types/scrape.d.ts +68 -0
- package/dist/types/secrets.d.ts +36 -0
- package/dist/types/sessions.d.ts +124 -0
- package/dist/types/transport.d.ts +250 -0
- package/package.json +70 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness a customer names, and the models behind it that they never see.
|
|
3
|
+
*
|
|
4
|
+
* ── One name on the wire ────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A customer asks for `Scout 1` or `Voyager 1`. That is the entire vocabulary:
|
|
7
|
+
* {@link AgentAlias} has two members, `AgentRequest.agent` accepts nothing else,
|
|
8
|
+
* and `AgentRequest.model` — which documented itself as "`'default'` for the
|
|
9
|
+
* model we host, or a BYOK model id" and was read by exactly one function whose
|
|
10
|
+
* only behaviour was to refuse it — is gone. The harness is the product name;
|
|
11
|
+
* which model runs underneath it is ours to change without telling anyone,
|
|
12
|
+
* including on the failover branch, mid-run.
|
|
13
|
+
*
|
|
14
|
+
* That is not a preference, it is the only shape that survives the arithmetic.
|
|
15
|
+
* A published per-minute price and a customer-chosen model cannot coexist: the
|
|
16
|
+
* customer picks the model, we absorb the difference, and the difference between
|
|
17
|
+
* the cheapest and the most expensive credible driver measured on one real
|
|
18
|
+
* production run is a factor of 126. So the customer picks the *capability* and
|
|
19
|
+
* we pick what delivers it.
|
|
20
|
+
*
|
|
21
|
+
* ── What the indirection buys, stated so nobody removes it ──────────────────
|
|
22
|
+
*
|
|
23
|
+
* If a model in a chain changes — a cheaper planner clears the Voyager bar, a
|
|
24
|
+
* preview slug is withdrawn, a rank is reordered — that is a ONE-LINE EDIT to
|
|
25
|
+
* {@link AGENT_MODEL_CHAINS} and nothing else moves: no wire type, no
|
|
26
|
+
* accept-list, no error string, no test fixture, no customer-visible byte, no
|
|
27
|
+
* migration. That property is the entire reason this file exists rather than a
|
|
28
|
+
* model id sitting on `AgentRequest`, and it is the thing somebody will be
|
|
29
|
+
* tempted to "simplify" away in six months. It is not indirection for its own
|
|
30
|
+
* sake; it is what makes the model a decision we can revisit weekly against a
|
|
31
|
+
* price we published once.
|
|
32
|
+
*
|
|
33
|
+
* ── Why this file is not `types.ts` ─────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* `types.ts` is the wire contract — the thing every surface is checked against,
|
|
36
|
+
* with no dependency and no secret in it. {@link AGENT_MODEL_CHAINS} is the
|
|
37
|
+
* opposite: it is the one table in `@outcrawl/core` that must never reach a
|
|
38
|
+
* customer, and it drags a second concern (which vendor bills us, in what order)
|
|
39
|
+
* that has no business sitting beside `AgentResult`. Keeping it here means the
|
|
40
|
+
* audit question "can a slug reach a projector" has a one-file answer.
|
|
41
|
+
*
|
|
42
|
+
* Nothing in this file is serialized. `AgentAlias` crosses the wire; every other
|
|
43
|
+
* export is consumed by `@outcrawl/model`, which resolves a chain into clients,
|
|
44
|
+
* and by the cost gate, which prices it. No wire type, no projector and no error
|
|
45
|
+
* string reads a slug from here — which is a property that was audited across
|
|
46
|
+
* `Usage`, `aggregateCache`, `Meter.tokens`, `StepTrace`, the replay metadata and
|
|
47
|
+
* `webhookBody` before it was written down.
|
|
48
|
+
*
|
|
49
|
+
* ── `role` and `rank`, and why not one flat union ───────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* A slot is what a model DOES ({@link AgentModelRole}) and where it sits in that
|
|
52
|
+
* role's failover order ({@link AgentModelChoice.rank}). The obvious alternative
|
|
53
|
+
* — a single union of `primary | failover | reasoner | actuator` — makes the two
|
|
54
|
+
* aliases two different data structures, because Scout is one role with three
|
|
55
|
+
* ranks and Voyager is two roles with one rank each, and a consumer then needs a
|
|
56
|
+
* branch per alias. Split this way both are the same list and the cost gate has
|
|
57
|
+
* exactly one formula:
|
|
58
|
+
*
|
|
59
|
+
* cost/min(alias) = SUM over roles ( MAX over ranks ( perCall x callsPerStep ) x steps/min )
|
|
60
|
+
*
|
|
61
|
+
* `MAX` within a role prices the failover branch, because only one rank is ever
|
|
62
|
+
* live at a time and the expensive branch is the one that must not go underwater
|
|
63
|
+
* unnoticed. `SUM` across roles prices Voyager's pair, because both run in the
|
|
64
|
+
* same run. Adding a third role forces that switch to be re-decided before the
|
|
65
|
+
* build goes green, which is the point.
|
|
66
|
+
*
|
|
67
|
+
* ── No prices here, and no measured numbers ─────────────────────────────────
|
|
68
|
+
*
|
|
69
|
+
* {@link AgentModelChoice} carries no `$/MTok` and no tokens-per-call. Prices
|
|
70
|
+
* live in the cost gate's committed OpenRouter snapshot, keyed by slug, and the
|
|
71
|
+
* seam is: **topology here, measurement there**. `callsPerStep` is on this side
|
|
72
|
+
* because it is a harness decision — how often our loop chooses to invoke a slot
|
|
73
|
+
* — and it moves only when the loop changes. Tokens per call is on the other
|
|
74
|
+
* side because it is measured off production and moves whenever traffic does.
|
|
75
|
+
* One source of truth each, and a weight in here would have forced an edit to
|
|
76
|
+
* this file every time somebody re-measured.
|
|
77
|
+
*
|
|
78
|
+
* Every slug below is a literal string constant. Not composed, not interpolated,
|
|
79
|
+
* not read from the environment — so the gate can enumerate them offline with no
|
|
80
|
+
* network and fail the build on any slug it has no price for. Adding a model
|
|
81
|
+
* therefore forces the cost calculation to be redone, which is the whole reason
|
|
82
|
+
* the gate exists.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* The harness names, as they appear on the wire.
|
|
86
|
+
*
|
|
87
|
+
* Lower-case and hyphenated because this is a JSON string a customer types, and
|
|
88
|
+
* `Scout 1` is the name in the documentation. There is no `'default'` member:
|
|
89
|
+
* absence means {@link DEFAULT_AGENT_ALIAS}, and a third spelling of the same
|
|
90
|
+
* request is a third thing to keep consistent.
|
|
91
|
+
*/
|
|
92
|
+
export type AgentAlias = 'scout-1' | 'voyager-1';
|
|
93
|
+
/**
|
|
94
|
+
* Every alias, for validation and for iterating the table.
|
|
95
|
+
*
|
|
96
|
+
* `satisfies` rather than a bare annotation, so the array is checked against the
|
|
97
|
+
* union in both directions: a member missing here does not compile, and a
|
|
98
|
+
* misspelled one does not either.
|
|
99
|
+
*/
|
|
100
|
+
export declare const AGENT_ALIASES: readonly ["scout-1", "voyager-1"];
|
|
101
|
+
/**
|
|
102
|
+
* The harness a request gets when it names none.
|
|
103
|
+
*
|
|
104
|
+
* `scout-1`, because it is the cheap one. A default that silently selected the
|
|
105
|
+
* expensive pair would put an unasked-for bill on every caller who omitted a
|
|
106
|
+
* field, and the omission is the common case — the SDK, the CLI and every MCP
|
|
107
|
+
* client will reach this path far more often than they name a harness.
|
|
108
|
+
*/
|
|
109
|
+
export declare const DEFAULT_AGENT_ALIAS: AgentAlias;
|
|
110
|
+
/** Whether a value is one of the two names. Narrows, for use at a boundary. */
|
|
111
|
+
export declare function isAgentAlias(value: unknown): value is AgentAlias;
|
|
112
|
+
/**
|
|
113
|
+
* The alias, or a throw naming the whole closed set.
|
|
114
|
+
*
|
|
115
|
+
* The message lists both names rather than saying "invalid", because a caller
|
|
116
|
+
* who guessed wrong has no documentation open and one round trip should teach
|
|
117
|
+
* them the entire vocabulary. `undefined` is NOT accepted here: absence is a
|
|
118
|
+
* decision the boundary makes with {@link DEFAULT_AGENT_ALIAS}, and folding it
|
|
119
|
+
* in would make `parseAgentAlias(undefined)` silently mean `scout-1` at call
|
|
120
|
+
* sites that meant to require a value.
|
|
121
|
+
*/
|
|
122
|
+
export declare function parseAgentAlias(value: unknown): AgentAlias;
|
|
123
|
+
/**
|
|
124
|
+
* What a model does in a harness.
|
|
125
|
+
*
|
|
126
|
+
* `actuator` reads the page and emits the action: it receives the rendered
|
|
127
|
+
* accessibility tree and it runs on every step. `planner` decides where the run
|
|
128
|
+
* is going: it receives a distilled digest, never a tree, and it runs
|
|
129
|
+
* occasionally.
|
|
130
|
+
*
|
|
131
|
+
* The distinction is economic before it is architectural. Input dominates cost
|
|
132
|
+
* and input IS the snapshot — a measured 529,164 input tokens per minute on a
|
|
133
|
+
* content-heavy production run, which is the tree re-sent every step. A planner
|
|
134
|
+
* that saw that tree would cost 69x the per-minute price at frontier rates, so
|
|
135
|
+
* the pair only exists at all because the planner does not see it. Consumers may
|
|
136
|
+
* rely on that: the planner seam takes a digest type and has no tree parameter.
|
|
137
|
+
*/
|
|
138
|
+
export type AgentModelRole = 'planner' | 'actuator';
|
|
139
|
+
/** One model, in one role, at one position in that role's failover order. */
|
|
140
|
+
export interface AgentModelChoice {
|
|
141
|
+
/**
|
|
142
|
+
* The provider's own id, exactly as it goes on the wire to them — so
|
|
143
|
+
* `vendor/slug` for OpenRouter. The same string keys the rate card and the
|
|
144
|
+
* action cache, and a second spelling anywhere silently misses both.
|
|
145
|
+
*/
|
|
146
|
+
readonly slug: string;
|
|
147
|
+
readonly role: AgentModelRole;
|
|
148
|
+
/**
|
|
149
|
+
* `0` is the model this role uses. `1` and up are tried in ascending order
|
|
150
|
+
* when a call to a lower rank fails in a way that another model could survive.
|
|
151
|
+
*
|
|
152
|
+
* Rank is the ordering contract, not array position. Do not sort this list and
|
|
153
|
+
* do not infer order from it.
|
|
154
|
+
*/
|
|
155
|
+
readonly rank: number;
|
|
156
|
+
/**
|
|
157
|
+
* How many times this slot is invoked per agent step, as a harness decision.
|
|
158
|
+
*
|
|
159
|
+
* `1` for a slot the loop calls every step. A fraction for one it calls
|
|
160
|
+
* periodically: `0.2` is once every five steps. This is topology and not a
|
|
161
|
+
* measurement — it changes when the loop changes, never when traffic does —
|
|
162
|
+
* which is why it lives here and why no token count or price does.
|
|
163
|
+
*/
|
|
164
|
+
readonly callsPerStep: number;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Which models each harness runs, and in what order it falls back.
|
|
168
|
+
*
|
|
169
|
+
* ── scout-1 ─────────────────────────────────────────────────────────────────
|
|
170
|
+
*
|
|
171
|
+
* One actuator, three ranks. Mercury drives; the two Gemini Flash models exist
|
|
172
|
+
* so that a rate limit or a decommissioned preview slug does not end a run.
|
|
173
|
+
*
|
|
174
|
+
* The ranks are not equally affordable and that is a live risk rather than a
|
|
175
|
+
* footnote. On the measured content-heavy burn Mercury costs roughly 1.4x the
|
|
176
|
+
* per-minute price and `google/gemini-3-flash-preview` costs roughly 17x, so the
|
|
177
|
+
* failover branch is where this alias loses money. The cost gate prices
|
|
178
|
+
* max-over-ranks precisely so that this is visible, and every demotion is
|
|
179
|
+
* logged, because failover share is the number nobody measures yet.
|
|
180
|
+
*
|
|
181
|
+
* ── voyager-1 ───────────────────────────────────────────────────────────────
|
|
182
|
+
*
|
|
183
|
+
* A pair, run concurrently: a reasoner that plans and an actuator that acts. The
|
|
184
|
+
* planner is deliberately the most capable model on the board and deliberately
|
|
185
|
+
* the one that never sees a page — it reads a digest, at one call per five
|
|
186
|
+
* steps. That combination is what makes hours-long autonomy priceable at all,
|
|
187
|
+
* and it is still planner-dominated: the planner is roughly three quarters of
|
|
188
|
+
* this alias's cost per minute on a content-heavy run and nearly all of it on an
|
|
189
|
+
* app-shaped one.
|
|
190
|
+
*
|
|
191
|
+
* The actuator is the same slug as scout-1's rank 0, which is not a coincidence
|
|
192
|
+
* to be tidied away — turning a plan into a click is the same job in both
|
|
193
|
+
* harnesses, and the cheapest model that does it well should do it in both.
|
|
194
|
+
*
|
|
195
|
+
* Both voyager slugs are OpenRouter `vendor/slug` ids, so both resolve to one
|
|
196
|
+
* provider. That satisfies the constraint `modelConfigFrom` already enforces for
|
|
197
|
+
* `OUTCRAWL_EXTRACT_MODEL` — one client holds one provider — without loosening
|
|
198
|
+
* it: the pair is two clients over one shared ledger, one per slot.
|
|
199
|
+
*/
|
|
200
|
+
export declare const AGENT_MODEL_CHAINS: Readonly<Record<AgentAlias, readonly AgentModelChoice[]>>;
|
|
201
|
+
/** One harness's slots. Pure; the typed accessor for {@link AGENT_MODEL_CHAINS}. */
|
|
202
|
+
export declare function agentModelChain(alias: AgentAlias): readonly AgentModelChoice[];
|
|
203
|
+
/**
|
|
204
|
+
* The slots for one role, in failover order, or an empty list when the harness
|
|
205
|
+
* has no model in that role.
|
|
206
|
+
*
|
|
207
|
+
* Sorted by {@link AgentModelChoice.rank} here rather than trusting the table's
|
|
208
|
+
* order, so the ordering contract is enforced in one function instead of relied
|
|
209
|
+
* upon at every reader.
|
|
210
|
+
*/
|
|
211
|
+
export declare function agentModelsInRole(alias: AgentAlias, role: AgentModelRole): readonly AgentModelChoice[];
|
|
212
|
+
/**
|
|
213
|
+
* Every distinct slug any harness can reach.
|
|
214
|
+
*
|
|
215
|
+
* The cost gate's enumeration point: each of these must have a price in the
|
|
216
|
+
* committed snapshot or the build fails, so adding a model to a chain forces the
|
|
217
|
+
* per-minute cost to be recalculated. Sorted for a stable diff.
|
|
218
|
+
*/
|
|
219
|
+
export declare function allAgentModelSlugs(): readonly string[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brand icon set, as bytes.
|
|
3
|
+
*
|
|
4
|
+
* GENERATED by `node packages/browser/scripts/build-brand-icons.mjs` from
|
|
5
|
+
* `packages/browser/branding/logo.png` (512x512 RGBA). Do not hand-edit: run
|
|
6
|
+
* the script, which is also where the reasoning lives — why these sizes, why
|
|
7
|
+
* PNG payloads inside the .ico, why there is no SVG, and why the whole image
|
|
8
|
+
* pipeline is hand-written.
|
|
9
|
+
*
|
|
10
|
+
* WHY BYTES IN THE BUNDLE and not an R2 object, which was the other candidate.
|
|
11
|
+
* `/favicon.ico` is the single most requested path on any origin and the one a
|
|
12
|
+
* browser asks for without being told to. Fetching it from R2 puts a network
|
|
13
|
+
* call, an availability dependency and a cold-start latency spike underneath a
|
|
14
|
+
* request that must never fail — and the failure mode is a 404, which is the
|
|
15
|
+
* exact symptom this change exists to remove. Embedded, the answer is a slice
|
|
16
|
+
* of memory the isolate already holds: no round trip, no binding, nothing to be
|
|
17
|
+
* down, and correct on the first request after a deploy.
|
|
18
|
+
*
|
|
19
|
+
* The cost is bundle size, so it was measured rather than waved at:
|
|
20
|
+
* 28012 bytes of image data across 5 files
|
|
21
|
+
* — 27.4 KB against a 1 MB Worker limit. Decoded
|
|
22
|
+
* ONCE at module scope, not per request, so serving an icon allocates nothing
|
|
23
|
+
* beyond the `Response`.
|
|
24
|
+
*/
|
|
25
|
+
/** 32x32 PNG — the desktop tab, and the ICO fallback. */
|
|
26
|
+
export declare const BRAND_ICON_32_BASE64: string;
|
|
27
|
+
/** @see BRAND_ICON_32_BASE64 */
|
|
28
|
+
export declare const BRAND_ICON_32: Uint8Array<ArrayBufferLike>;
|
|
29
|
+
/** 180x180 PNG — apple-touch-icon: iOS home screen, the size Apple reads. */
|
|
30
|
+
export declare const BRAND_ICON_180_BASE64: string;
|
|
31
|
+
/** @see BRAND_ICON_180_BASE64 */
|
|
32
|
+
export declare const BRAND_ICON_180: Uint8Array<ArrayBufferLike>;
|
|
33
|
+
/** 192x192 PNG — the web app manifest, Android home screen. */
|
|
34
|
+
export declare const BRAND_ICON_192_BASE64: string;
|
|
35
|
+
/** @see BRAND_ICON_192_BASE64 */
|
|
36
|
+
export declare const BRAND_ICON_192: Uint8Array<ArrayBufferLike>;
|
|
37
|
+
/** 512x512 PNG — the manifest splash entry, and the MCP client icon. */
|
|
38
|
+
export declare const BRAND_ICON_512_BASE64: string;
|
|
39
|
+
/** @see BRAND_ICON_512_BASE64 */
|
|
40
|
+
export declare const BRAND_ICON_512: Uint8Array<ArrayBufferLike>;
|
|
41
|
+
/**
|
|
42
|
+
* `favicon.ico`, carrying 16, 32, 48px PNG entries.
|
|
43
|
+
*
|
|
44
|
+
* Still shipped, because the path is hardcoded in the client: a browser
|
|
45
|
+
* requests `/favicon.ico` from the root of an origin whether or not any page
|
|
46
|
+
* mentions it. That request is the one that was 404ing.
|
|
47
|
+
*/
|
|
48
|
+
export declare const BRAND_FAVICON_ICO_BASE64: string;
|
|
49
|
+
/** @see BRAND_FAVICON_ICO_BASE64 */
|
|
50
|
+
export declare const BRAND_FAVICON_ICO: Uint8Array<ArrayBufferLike>;
|
|
51
|
+
/**
|
|
52
|
+
* The 32px icon as a `data:` URI, for an HTML surface to inline.
|
|
53
|
+
*
|
|
54
|
+
* A URI and not a path, and the reason is an invariant rather than a
|
|
55
|
+
* preference: a replay is a frozen artefact, and a self-contained export of one
|
|
56
|
+
* is a single HTML file that may be opened from disk with no origin at all. A
|
|
57
|
+
* `<link rel=icon href=/favicon.ico>` in that file resolves against wherever
|
|
58
|
+
* it happens to be opened — a 404 at best, somebody else's icon at worst.
|
|
59
|
+
* Inline bytes cannot miss, cost no request, and are already covered by the
|
|
60
|
+
* player's `img-src 'self' blob: data:` without widening it.
|
|
61
|
+
*/
|
|
62
|
+
export declare const BRAND_ICON_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAwFBMVEUAAABLG/NLG/hNG/pMG/VLG/U2API6BfI8CPI+C/JBDvJCEPJEEvNFE/NLG/RLG/RHFvNLG/NLG/NNHPlOHPxNHvNQIvNPHf9UJ/RYK/RgNvRnPvVtRfVvSfVxS/V5VfZ+XfaFZPeLbPeOb/eWeviehPihiPiljfmpkvmslvmvmvmxnPmzn/m2ovq6qPrBsfrIufvKvPvNwPvQw/vZz/zc0/zf1/zi2vzl3v3q5P3t6f328/749v75+P77+v7////JaF8sAAAAQHRSTlMABA8PQYL//////////9fm//r/////////////////////////////////////////////////////////////zvITygAAAaxJREFUeNqFU11v2kAQnL019hkMTS2gEkkeouT//5z2uSIPtJg0iQ3Gvo/tA8a20ot6T6ududnZ0R2BhFXKhI9HXO0dCZNE2fxfGCCeKeeJEN3AEQAwXIfxpRDGq2WV8QVH00YdofYMAOQVG8W6u3++e6g7/HFTXxhOs0ptZ4DM8sEwM5un3KiuZ1OeXk0nhd+4wtvz5tvPXXa1Q5T3tvm4bJGgoegwc8MyAwFc3a8i2N/PmRs106E8Pa4VoObxPpG+q0Z4ngNVBazyyxIAgGgQ81O47R7re5UeEFAAFF5/ab17+9Ac/J5hvdbeoqGQSYmcfr+9zdOjOrAER8h3nRMtkx8Ij4CbfoEIFvozAh9beA9T0ycEs9ob5rZYtQiZ5Gb+ZHen9+LrXVXHEgjK3WC9eMM6weIFoSRpAq814FVMQYIYkAAgtBI0qUqAiAioOERw0+IPecDTyyEdHgRrGrI+zmIv6vQ8Slo4HuT4XGcTqrdt1BPIsp+5q4bEdZU021PSD5BJSWo+6Rlg48CTAWdTsjitfK9BcQQZcJSOybc8eqMio+Da0hL97/v/BWW4vDuRYfz0AAAAAElFTkSuQmCC";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a certificate says about itself, and the one function that reads it.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS IN `core` AND NOT BESIDE EITHER CALLER. Two packages must agree
|
|
5
|
+
* on this to the millisecond, for a reason that is the whole point of the
|
|
6
|
+
* feature:
|
|
7
|
+
*
|
|
8
|
+
* `@outcrawl/worker` parses the PEM bytes it is about to hand to
|
|
9
|
+
* `Bun.serve` and reports the result, so what it reports is provably the
|
|
10
|
+
* certificate ON THE WIRE.
|
|
11
|
+
*
|
|
12
|
+
* `@outcrawl/agent` parses the PEM on disk to decide whether to renew.
|
|
13
|
+
*
|
|
14
|
+
* Those two answers DIVERGE on purpose — a renewal that has landed on disk but
|
|
15
|
+
* whose worker has not been respawned yet shows a new file and an old wire —
|
|
16
|
+
* and that divergence is only legible if both sides derive their answer the
|
|
17
|
+
* same way. Two copies of "read notAfter out of a PEM" that disagreed by a
|
|
18
|
+
* timezone would make the interesting case unreadable.
|
|
19
|
+
*
|
|
20
|
+
* `node:crypto`'s `X509Certificate` does the parsing. It is in Bun and in
|
|
21
|
+
* workerd-free Node alike, and it means no ASN.1 parser here: this module
|
|
22
|
+
* consumes attacker-adjacent input (a PEM file), so unlike the DER *encoder*
|
|
23
|
+
* in `@outcrawl/agent`'s `acme.ts` it must not be hand-rolled.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The leaf's own claims. Deliberately small: every field here is something an
|
|
27
|
+
* operator reads in `admin fleet` or an alert quotes.
|
|
28
|
+
*/
|
|
29
|
+
export interface CertificateFacts {
|
|
30
|
+
/** ISO 8601. The instant customers start failing handshakes. */
|
|
31
|
+
readonly notAfter: string;
|
|
32
|
+
readonly notBefore: string;
|
|
33
|
+
/** e.g. `CN=mac-01.fleet.outcrawl.ai`. */
|
|
34
|
+
readonly subject: string;
|
|
35
|
+
/**
|
|
36
|
+
* e.g. `C=US, O=Let's Encrypt, CN=R11`. The field that distinguishes a real
|
|
37
|
+
* certificate from a STAGING one, which is the mistake that looks correct
|
|
38
|
+
* from the machine and fails every client — see the same warning in
|
|
39
|
+
* `scripts/issue-fleet-cert.sh`.
|
|
40
|
+
*/
|
|
41
|
+
readonly issuer: string;
|
|
42
|
+
/** Uppercase hex, no separators. Changes on every renewal, so it is the proof a renewal actually replaced anything. */
|
|
43
|
+
readonly serial: string;
|
|
44
|
+
/** DNS names this certificate is valid for, `subjectAltName` only. */
|
|
45
|
+
readonly dnsNames: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
export declare class CertificateReadError extends Error {
|
|
48
|
+
readonly name = "CertificateReadError";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads the LEAF of a PEM chain.
|
|
52
|
+
*
|
|
53
|
+
* The leaf and not the whole chain: `fullchain.pem` starts with the leaf and
|
|
54
|
+
* continues with intermediates whose `notAfter` is years away, so a reader
|
|
55
|
+
* that took the last or the longest-lived block would report a comfortable
|
|
56
|
+
* date for an expired certificate. `X509Certificate` given a multi-block PEM
|
|
57
|
+
* parses the FIRST block, which is the leaf — asserted by the staging
|
|
58
|
+
* end-to-end run rather than assumed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readCertificateFacts(pem: string): CertificateFacts;
|
|
61
|
+
/**
|
|
62
|
+
* Does this certificate cover `hostname`?
|
|
63
|
+
*
|
|
64
|
+
* Wildcard matching is included because the fleet is MID-MIGRATION: the live
|
|
65
|
+
* certificate is `*.fleet.outcrawl.ai` and the replacement is
|
|
66
|
+
* `mac-01.fleet.outcrawl.ai`, and during the cutover a machine must be able to
|
|
67
|
+
* recognise that the wildcard it already holds is still serving it and NOT
|
|
68
|
+
* treat that as "no usable certificate" and issue on every tick.
|
|
69
|
+
*
|
|
70
|
+
* One label only, and never the parent: `*.fleet.outcrawl.ai` matches
|
|
71
|
+
* `mac-01.fleet.outcrawl.ai` but not `a.b.fleet.outcrawl.ai` and not
|
|
72
|
+
* `fleet.outcrawl.ai`. That is RFC 6125 §6.4.3, and it is the reason
|
|
73
|
+
* `scripts/issue-fleet-cert.sh` orders the bare name alongside the wildcard.
|
|
74
|
+
*/
|
|
75
|
+
export declare function certificateCovers(facts: CertificateFacts, hostname: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Whole days until `notAfter`, floored, and NEGATIVE once expired.
|
|
78
|
+
*
|
|
79
|
+
* Floored rather than rounded so "1 day left" never reads as 2, and signed
|
|
80
|
+
* rather than clamped at zero because "expired 3 days ago" and "expires in 3
|
|
81
|
+
* days" must not print identically in `admin fleet`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function daysUntilExpiry(notAfter: string, now: number): number;
|
|
84
|
+
/**
|
|
85
|
+
* Does this private key belong to this certificate?
|
|
86
|
+
*
|
|
87
|
+
* Compares the two SPKI encodings — the certificate's public key, and the
|
|
88
|
+
* public key derived from the private key. Equal DER means the pair matches.
|
|
89
|
+
*
|
|
90
|
+
* WORTH ITS OWN FUNCTION because a mismatched pair is a REAL failure class on
|
|
91
|
+
* this fleet, not a hypothetical. Today's certificate and key were issued on
|
|
92
|
+
* one machine and hand-copied to another, and a copy that picked up a new
|
|
93
|
+
* `fleet.crt` beside a stale `fleet.key` produces precisely the outage this
|
|
94
|
+
* whole ticket exists to prevent: the worker refuses to bind, or binds and
|
|
95
|
+
* fails every handshake, while the agent keeps dialling out and the fleet
|
|
96
|
+
* reports the machine healthy. Renewal writes two files, so it can introduce
|
|
97
|
+
* the same mismatch if it is ever interrupted between them — this is the check
|
|
98
|
+
* that makes that detectable instead of silent.
|
|
99
|
+
*/
|
|
100
|
+
export declare function certificateMatchesPrivateKey(certificatePem: string, privateKeyPem: string): boolean;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five-field cron expressions, and the only question anything asks of one:
|
|
3
|
+
* when is this next due?
|
|
4
|
+
*
|
|
5
|
+
* This lives in core because three packages need the same answer and any second
|
|
6
|
+
* implementation is a disagreement waiting to happen. The API refuses an
|
|
7
|
+
* unparseable schedule at `monitors.create`, the worker's scheduler computes the
|
|
8
|
+
* next run after each check, and the server's control plane clamps what a worker
|
|
9
|
+
* reports. If those three parsed cron differently, a monitor could be accepted
|
|
10
|
+
* by one, never selected by another, and rejected by the third — the silent
|
|
11
|
+
* never-fires failure the monitors work exists to end.
|
|
12
|
+
*
|
|
13
|
+
* **Everything is UTC.** `MonitorSpec.schedule` has no timezone field, the
|
|
14
|
+
* monitors table has no timezone column and the API takes no timezone
|
|
15
|
+
* parameter, so there is no tenant-local zone to resolve against. Inventing one
|
|
16
|
+
* here would be inventing a product feature in a parser, and the customer would
|
|
17
|
+
* have no way to see or set it. UTC is therefore explicit in every signature and
|
|
18
|
+
* every returned instant rather than left to whatever `TZ` the process booted
|
|
19
|
+
* with — a scheduler and a control plane that disagree about local midnight are
|
|
20
|
+
* the same class of bug as two cron parsers.
|
|
21
|
+
*
|
|
22
|
+
* No clock is read here. The instant is always a parameter, for the reason
|
|
23
|
+
* `packages/persistence/src/lease.ts` gives: the clock that matters belongs to
|
|
24
|
+
* the caller — the database's `now()` for a real decision, a fixed instant for a
|
|
25
|
+
* test or a replay.
|
|
26
|
+
*
|
|
27
|
+
* No dependencies and no `node:` imports: this runs on workerd as well as Bun.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* How many candidate days `nextRunAfter` examines before returning `null`.
|
|
31
|
+
*
|
|
32
|
+
* The bound is the point of the function. `0 0 30 2 *` is well-formed and can
|
|
33
|
+
* never match, and a scheduler that scanned for it forever would hang the
|
|
34
|
+
* process holding the lease — strictly worse than one monitor that never fires.
|
|
35
|
+
*
|
|
36
|
+
* Ten years, not the four you would guess. The longest legitimate gap between
|
|
37
|
+
* consecutive matches of any well-formed expression is `0 0 29 2 *`, and leap
|
|
38
|
+
* days are eight years apart across a skipped century — 2096-02-29 to
|
|
39
|
+
* 2104-02-29, because 2100 is not a leap year. A five-year horizon would report
|
|
40
|
+
* `null` for a valid expression, which is the silent-failure mode this whole
|
|
41
|
+
* bound exists to avoid. Ten years clears the worst real gap with room, and
|
|
42
|
+
* nothing that fails to match inside it can match at all.
|
|
43
|
+
*
|
|
44
|
+
* The cost of the bound is a loop over days, not minutes: 3660 iterations of
|
|
45
|
+
* three membership tests, which is microseconds.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CRON_HORIZON_DAYS = 3660;
|
|
48
|
+
/** Thrown by {@link parseCron}. Never thrown for a well-formed expression. */
|
|
49
|
+
export declare class CronSyntaxError extends Error {
|
|
50
|
+
/**
|
|
51
|
+
* The offending field — `minute`, `hour`, `day-of-month`, `month`,
|
|
52
|
+
* `day-of-week` — or `expression` when the fault is the expression as a whole.
|
|
53
|
+
* Callers turning this into a 400 can point the customer at one token.
|
|
54
|
+
*/
|
|
55
|
+
readonly field: string;
|
|
56
|
+
constructor(field: string, message: string);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A parsed expression: every field expanded to the exact set of values it
|
|
60
|
+
* matches, sorted ascending, with no duplicates.
|
|
61
|
+
*
|
|
62
|
+
* Expanded rather than kept as syntax because matching a day then becomes three
|
|
63
|
+
* membership tests, and because two expressions that mean the same thing —
|
|
64
|
+
* `@daily` and `0 0 * * *`, `SUN` and `0` and `7` — become the same object.
|
|
65
|
+
*/
|
|
66
|
+
export interface CronSchedule {
|
|
67
|
+
/** 0-59. */
|
|
68
|
+
readonly minutes: readonly number[];
|
|
69
|
+
/** 0-23. */
|
|
70
|
+
readonly hours: readonly number[];
|
|
71
|
+
/** 1-31. Values above the length of a given month simply never match it. */
|
|
72
|
+
readonly daysOfMonth: readonly number[];
|
|
73
|
+
/** 1-12, January = 1. */
|
|
74
|
+
readonly months: readonly number[];
|
|
75
|
+
/** 0-6, Sunday = 0. A `7` in the expression is folded to `0` here. */
|
|
76
|
+
readonly daysOfWeek: readonly number[];
|
|
77
|
+
/**
|
|
78
|
+
* The day-of-month field did not begin with `*`. Together with
|
|
79
|
+
* {@link daysOfWeekRestricted} this decides whether the two day fields are
|
|
80
|
+
* combined with OR or with AND — see {@link nextRunAfter}.
|
|
81
|
+
*/
|
|
82
|
+
readonly daysOfMonthRestricted: boolean;
|
|
83
|
+
/** The day-of-week field did not begin with `*`. */
|
|
84
|
+
readonly daysOfWeekRestricted: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The five fields as parsed, single-space separated, macros expanded. What
|
|
87
|
+
* `@daily` actually meant, in a form you can log or show back to a customer.
|
|
88
|
+
*/
|
|
89
|
+
readonly expression: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parses a five-field expression, or one of the `@` macros.
|
|
93
|
+
*
|
|
94
|
+
* Throws {@link CronSyntaxError} naming the offending field, what was received
|
|
95
|
+
* and what is allowed. The message reaches a customer through the API's 400, so
|
|
96
|
+
* "invalid cron" would be useless: they cannot fix a field they cannot identify.
|
|
97
|
+
*/
|
|
98
|
+
export declare function parseCron(expression: string): CronSchedule;
|
|
99
|
+
/** True when {@link parseCron} would accept the expression. */
|
|
100
|
+
export declare function isValidCron(expression: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The first instant matching `expression` strictly after `after`, in UTC, with
|
|
103
|
+
* seconds and milliseconds zeroed. `null` when nothing matches within
|
|
104
|
+
* {@link CRON_HORIZON_DAYS}.
|
|
105
|
+
*
|
|
106
|
+
* **Strictly after**, and **truncated to the minute**, both for the same
|
|
107
|
+
* consumer. A cron expression's resolution is one minute, so a scheduler that
|
|
108
|
+
* stored a result equal to the instant it asked about would select the same
|
|
109
|
+
* check on the next tick and every tick after: an infinite loop that re-runs one
|
|
110
|
+
* monitor and starves the rest. `nextRunAfter(s, t)` is always greater than `t`.
|
|
111
|
+
*
|
|
112
|
+
* The search walks candidate *days* and only then matches hours and minutes.
|
|
113
|
+
* Minute-by-minute would be ~5.3 million iterations per call for the
|
|
114
|
+
* never-matching case, once per monitor per check.
|
|
115
|
+
*/
|
|
116
|
+
export declare function nextRunAfter(expression: string | CronSchedule, after: Date): Date | null;
|