@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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Typed errors.
3
+ *
4
+ * `code` is the wire contract: stable, machine-readable, never renamed once
5
+ * shipped. `message` is for humans and may change freely. Every error carries
6
+ * enough structured detail to act on — a `ProfileInUseError` that does not say
7
+ * when the lease expires just moves the guesswork to the caller.
8
+ */
9
+ import type { CapName, ExitTarget, Timestamp } from './types.js';
10
+ export declare const OUTCRAWL_ERROR_CODES: readonly ["unauthorized", "profile_in_use", "cap_exceeded", "exit_unavailable", "no_capacity", "tenant_scope", "bad_request", "not_found", "profile_not_found", "method_not_allowed", "capability_unavailable", "quota_exceeded", "concurrency_limit", "plan_required", "internal"];
11
+ export type OutcrawlErrorCode = (typeof OUTCRAWL_ERROR_CODES)[number];
12
+ /**
13
+ * What **we produce**. Every error this package throws serialises to exactly
14
+ * this, with a code drawn from the closed union above.
15
+ */
16
+ export interface SerializedOutcrawlError {
17
+ code: OutcrawlErrorCode;
18
+ message: string;
19
+ details: Record<string, unknown>;
20
+ }
21
+ /**
22
+ * What **a client parses**. Deliberately wider: `code` is an open `string`.
23
+ *
24
+ * Strict in what we emit, lenient in what we accept. A client that rejects an
25
+ * error body because the code is newer than the client turns a 404 into a
26
+ * crash — and the failure lands during an incident, when a new code is most
27
+ * likely to be in flight and least likely to be welcome. Branch on the codes
28
+ * you know and fall through on the rest.
29
+ */
30
+ export interface WireError {
31
+ code: string;
32
+ message: string;
33
+ details?: Record<string, unknown>;
34
+ }
35
+ /** Narrows an open wire code to the closed union, for exhaustive branching. */
36
+ export declare function isKnownErrorCode(code: string): code is OutcrawlErrorCode;
37
+ export declare abstract class OutcrawlError extends Error {
38
+ /** Stable across releases. Callers branch on this, never on `message`. */
39
+ readonly code: OutcrawlErrorCode;
40
+ /** Default HTTP status for the API layer. */
41
+ readonly status: number;
42
+ /** Structured, JSON-safe payload. Subclasses also expose it as typed fields. */
43
+ readonly details: Readonly<Record<string, unknown>>;
44
+ protected constructor(code: OutcrawlErrorCode, message: string, status: number, details: Readonly<Record<string, unknown>>);
45
+ toJSON(): SerializedOutcrawlError;
46
+ }
47
+ /**
48
+ * True for our errors, including ones raised by a second copy of this package
49
+ * in the same process — the SDK and the server can end up with their own — where
50
+ * `instanceof` is false despite an identical shape. Callers that only ever see
51
+ * errors from one copy can use `instanceof OutcrawlError` directly.
52
+ */
53
+ export declare function isOutcrawlError(value: unknown): value is OutcrawlError;
54
+ /**
55
+ * A profile is leased while in use. A second concurrent open throws rather than
56
+ * silently forking state, because the alternative is two sessions both writing
57
+ * cookies, last-writer-wins, and an account mysteriously logged out.
58
+ */
59
+ export declare class ProfileInUseError extends OutcrawlError {
60
+ readonly profileId: string;
61
+ /** When the current lease expires on its own. Retry after this. */
62
+ readonly heldUntil: Date;
63
+ /** The session holding the lease, when the caller is allowed to see it. */
64
+ readonly heldBy: string | undefined;
65
+ constructor(profileId: string, heldUntil: Date, heldBy?: string);
66
+ }
67
+ /**
68
+ * A ceiling the caller set was reached. Caps, not refunds: the cost is bounded
69
+ * before it is incurred rather than argued about afterwards.
70
+ *
71
+ * An agent run that trips a cap mid-flight returns `status: 'partial' | 'failed'`
72
+ * with its data attached — this error is for a request that asks for more than
73
+ * the account or the call allows, refused before a browser is allocated.
74
+ */
75
+ export declare class CapExceededError extends OutcrawlError {
76
+ /** Which ceiling: `steps`, `budget` or `duration`. */
77
+ readonly cap: CapName;
78
+ /** The ceiling that was set. Credits and durations are strings, steps a number. */
79
+ readonly limit: string | number;
80
+ /** What was reached or requested. Same units as `limit`. */
81
+ readonly observed: string | number;
82
+ constructor(cap: CapName, limit: string | number, observed: string | number);
83
+ }
84
+ /**
85
+ * No exit could be resolved for the requested geography — the country, region
86
+ * or city is not in the pool, or every exit there is currently unhealthy.
87
+ * Never silently substitute a neighbouring city: a profile whose exit moved is
88
+ * an identity that cannot exist.
89
+ */
90
+ export declare class ExitUnavailableError extends OutcrawlError {
91
+ readonly target: ExitTarget;
92
+ readonly reason: string | undefined;
93
+ /** Set when the exit exists but is temporarily unhealthy. */
94
+ readonly retryAfter: Timestamp | undefined;
95
+ constructor(target: ExitTarget, reason?: string, retryAfter?: Timestamp);
96
+ }
97
+ /**
98
+ * A capability this deployment cannot serve, because nothing is configured
99
+ * behind it.
100
+ *
101
+ * The request was valid: the route exists, the arguments were accepted, and
102
+ * another worker with a different configuration would have answered it. What is
103
+ * missing is a dependency on this side — a result source for `search`, an exit
104
+ * provisioner for a request that names a geography — so the caller is told
105
+ * plainly rather than handed an incident id.
106
+ *
107
+ * Not retryable. The identical call against the same deployment fails
108
+ * identically until an operator configures something; the caller's only other
109
+ * move is a different request.
110
+ *
111
+ * `detail` exists to separate the two states that look the same from outside:
112
+ * a deployment that deliberately has no such dependency, and a build where
113
+ * nobody bound one. The second is now a type error at every seam in this
114
+ * repository, which is what lets this message promise it is the first.
115
+ */
116
+ export declare class CapabilityUnavailableError extends OutcrawlError {
117
+ /** What was asked for: `search`, `a request that names an exit`. */
118
+ readonly capability: string;
119
+ /** What is not configured: `result source`, `exit provisioner`. */
120
+ readonly missing: string;
121
+ constructor(capability: string, missing: string, detail?: string);
122
+ }
123
+ /**
124
+ * A CDP connection touched a target outside its own tenant. This is the
125
+ * perimeter now that clients connect directly to workers: one connection may
126
+ * only ever drive its own BrowserContext, and enforcement sits at the two
127
+ * lookups every target resolution funnels through, so a method added in a
128
+ * future Chromium is scoped automatically rather than needing an allowlist.
129
+ */
130
+ export declare class TenantScopeError extends OutcrawlError {
131
+ /** The CDP method that was refused, e.g. `Target.attachToTarget`. */
132
+ readonly method: string;
133
+ /** The target that was out of scope, when one was named. */
134
+ readonly targetId: string | undefined;
135
+ constructor(method: string, targetId?: string);
136
+ }
137
+ /** Why the router refused the caller. Never says which value was presented. */
138
+ export type UnauthorizedReason = 'invalid_key' | 'ip_not_allowed' | 'missing_token';
139
+ /**
140
+ * The router refused the request at the front door — before a worker is picked
141
+ * and before anything is billed.
142
+ */
143
+ export declare class UnauthorizedError extends OutcrawlError {
144
+ readonly reason: UnauthorizedReason;
145
+ constructor(reason: UnauthorizedReason);
146
+ }
147
+ /**
148
+ * No worker could take the session: none is `ready` with free contexts and a
149
+ * cookie schema at least as new as the profile's. Cookie schema is the real
150
+ * compatibility key — an older worker cannot read a newer profile's stored
151
+ * state, and handing it one would silently lose the login rather than fail.
152
+ *
153
+ * Retryable: capacity is a moment-in-time property of the fleet, not of the
154
+ * request.
155
+ */
156
+ export declare class NoCapacityError extends OutcrawlError {
157
+ /** The minimum cookie schema the caller's profile requires. */
158
+ readonly cookieSchema: number;
159
+ /** Set when the caller asked for a specific region. */
160
+ readonly region: string | undefined;
161
+ constructor(detail: {
162
+ cookieSchema: number;
163
+ region?: string;
164
+ });
165
+ }
166
+ /** Which ceiling refused the request. Both are per-account, never per-key. */
167
+ export type QuotaCeiling = 'requests_per_minute' | 'monthly_credits';
168
+ /**
169
+ * An account is over a ceiling its plan sets, refused before a worker is
170
+ * picked and before anything is billed.
171
+ *
172
+ * ## Why this is not `no_capacity`
173
+ *
174
+ * `no_capacity` is documented retryable *because the fleet is busy*, so a
175
+ * client that reads one backs off and tries again — correctly. Answering a
176
+ * quota refusal with it tells a customer who has exhausted their month to
177
+ * retry until the month ends, and it puts their traffic in the router's
178
+ * capacity metrics, where an operator then goes looking for workers that were
179
+ * never missing. Two facts, two codes.
180
+ *
181
+ * ## Why 429 and not 402
182
+ *
183
+ * `cap_exceeded` is 402 and means a ceiling the *caller* set for one call.
184
+ * This is a ceiling somebody set *for* the caller, and it lapses on its own at
185
+ * `resetAt` — which is precisely what 429 plus `Retry-After` describes. A 402
186
+ * says "pay"; this says "wait, and here is exactly how long".
187
+ *
188
+ * `limit` and `observed` are `string | number` for the same reason
189
+ * `CapExceededError` makes them so: a request count is an integer and credits
190
+ * are an exact decimal string that must never have been a float.
191
+ */
192
+ export declare class QuotaExceededError extends OutcrawlError {
193
+ readonly ceiling: QuotaCeiling;
194
+ /** The ceiling the plan sets. */
195
+ readonly limit: string | number;
196
+ /** What the account has reached. Same units as `limit`. */
197
+ readonly observed: string | number;
198
+ /** When the ceiling lapses on its own. */
199
+ readonly resetAt: Timestamp;
200
+ /**
201
+ * `Retry-After`, in seconds, and always at least 1.
202
+ *
203
+ * Carried on the error rather than computed by whoever renders it, because
204
+ * the header and the instant have to agree: a caller that trusts the header
205
+ * and a dashboard that trusts `resetAt` disagreeing by a minute is a support
206
+ * ticket nobody can reproduce.
207
+ */
208
+ readonly retryAfterSeconds: number;
209
+ constructor(detail: {
210
+ ceiling: QuotaCeiling;
211
+ limit: string | number;
212
+ observed: string | number;
213
+ resetAt: Timestamp;
214
+ retryAfterSeconds: number;
215
+ });
216
+ }
217
+ /**
218
+ * The account is holding every browser its plan allows at once, refused before
219
+ * a worker is picked and before anything is billed.
220
+ *
221
+ * ## Why this is not `no_capacity`
222
+ *
223
+ * Because it is not about our hardware at all. `no_capacity` means no ready
224
+ * worker has a free context, which is a property of the FLEET at that instant
225
+ * and is fixed by machines; this means the customer is using everything they
226
+ * bought, which is a property of THEIR account and is fixed by ending a
227
+ * session or moving to a larger plan. Sharing a code between them tells a
228
+ * customer at their ceiling to blame us and wait, and it puts their traffic in
229
+ * the router's capacity metrics — where an operator then provisions Macs that
230
+ * will not help. Two facts, two codes, and this is the third one.
231
+ *
232
+ * ## Why this is not `quota_exceeded`
233
+ *
234
+ * Both of that error's ceilings lapse on their own: a rolling minute drains
235
+ * and a calendar month ends, so it carries a `resetAt` and a `Retry-After`
236
+ * that a client can obey. A concurrency slot lapses when the CUSTOMER closes a
237
+ * browser and at no other time, so there is no instant to name. Putting a
238
+ * number in `Retry-After` here would be an invention, and a client obeying an
239
+ * invented backoff is worse off than one told plainly that the next move is
240
+ * theirs — which is why `errorResponse` sets that header for `quota_exceeded`
241
+ * and deliberately not for this.
242
+ *
243
+ * ## Why 429 rather than 402 or 503
244
+ *
245
+ * 429 is literally "too many requests", and too many AT ONCE is the same
246
+ * family as too many per minute — a client that already handles one handles
247
+ * this. 402 (`cap_exceeded`) says "pay", which is wrong for a customer who has
248
+ * paid and is simply using it all. 503 says "we are broken", which is a lie
249
+ * about a healthy fleet.
250
+ *
251
+ * `plan` is named in the message because that is what makes the sentence
252
+ * actionable: `5` on its own is a number, and `plan builder allows 5` tells a
253
+ * customer which line of the pricing page to read and an operator which key to
254
+ * move. `observed` is the count at the instant of refusal, so support can tell
255
+ * "at the ceiling" from "nowhere near it, something else is wrong".
256
+ */
257
+ export declare class ConcurrencyLimitError extends OutcrawlError {
258
+ /** The plan's name, as the pricing page spells it. */
259
+ readonly plan: string;
260
+ /** Browsers the plan allows at once. */
261
+ readonly limit: number;
262
+ /** Browsers the account was holding when it asked for one more. */
263
+ readonly observed: number;
264
+ constructor(detail: {
265
+ plan: string;
266
+ limit: number;
267
+ observed: number;
268
+ });
269
+ }
270
+ /**
271
+ * The key authenticated and names no plan this deployment can serve, so there
272
+ * is no ceiling to enforce and the request is refused.
273
+ *
274
+ * Three records reach this and the answer is the same for all three, because
275
+ * the operator's next action is: a key minted before plans existed, a key
276
+ * naming a tier that is not published, and a key carrying the pre-tier pair of
277
+ * loose ceiling numbers with no tier name at all.
278
+ *
279
+ * ## Why refusing is the only safe direction
280
+ *
281
+ * The alternative is treating an absent plan as unlimited, and the failure mode
282
+ * of that choice is a free fleet: one key opening sessions until the machines
283
+ * fall over, with the invoice as the first thing that notices. A key that
284
+ * cannot be metered against a ceiling is a key that must not be served, so an
285
+ * absent plan fails closed here rather than defaulting anywhere.
286
+ *
287
+ * ## Why it is not `unauthorized`
288
+ *
289
+ * Nothing is wrong with the credential. Reporting `invalid_key` would send a
290
+ * customer to rotate a perfectly good key, and rotation carries the plan over,
291
+ * so they would rotate forever. The party who can fix this is the operator who
292
+ * minted the key without one, and 403 with this code is what tells support
293
+ * which of the two to call.
294
+ *
295
+ * `keyId` is echoed deliberately. It is the half of the token that already
296
+ * travels in the clear so the record can be fetched by primary key, so it is
297
+ * not a secret, and it is the exact string an operator types into
298
+ * `outcrawl admin keys get`.
299
+ */
300
+ export declare class PlanRequiredError extends OutcrawlError {
301
+ readonly keyId: string;
302
+ constructor(keyId: string);
303
+ }
304
+ /** Malformed or missing input. The caller can fix this and retry. */
305
+ export declare class BadRequestError extends OutcrawlError {
306
+ constructor(message: string, details?: Record<string, unknown>);
307
+ }
308
+ /** No such resource, or none this caller is allowed to know exists. */
309
+ export declare class NotFoundError extends OutcrawlError {
310
+ readonly resource: string;
311
+ constructor(resource: string, id?: string);
312
+ }
313
+ /**
314
+ * A named profile does not exist, or belongs to another account.
315
+ *
316
+ * Deliberately indistinguishable from the not-belonging case: profiles are
317
+ * named by the customer, so a distinct "exists but is not yours" would let one
318
+ * account enumerate another's profile labels.
319
+ */
320
+ export declare class ProfileNotFoundError extends OutcrawlError {
321
+ readonly profileId: string;
322
+ constructor(profileId: string);
323
+ }
324
+ /** Wrong HTTP method. `allow` populates the `Allow` header RFC 9110 requires. */
325
+ export declare class MethodNotAllowedError extends OutcrawlError {
326
+ readonly allow: readonly string[];
327
+ constructor(method: string, allow: readonly string[]);
328
+ }
329
+ /**
330
+ * Something broke on our side.
331
+ *
332
+ * `message` is fixed and carries no detail on purpose — an internal error is
333
+ * the one place where a helpful message leaks a stack, a query or a hostname to
334
+ * whoever provoked it. Diagnostics go to logs under `incidentId`, which the
335
+ * caller can quote to support.
336
+ */
337
+ export declare class InternalError extends OutcrawlError {
338
+ readonly incidentId: string | undefined;
339
+ constructor(incidentId?: string);
340
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@outcrawl/core` — the shared contract.
3
+ *
4
+ * Wire types, the capability registry every surface is checked against, and the
5
+ * typed errors. No dependencies and no runtime beyond validation, because every
6
+ * other package imports this one.
7
+ */
8
+ export * from './types.js';
9
+ export * from './agent-alias.js';
10
+ export * from './brand.js';
11
+ export * from './certificate.js';
12
+ export * from './cron.js';
13
+ export * from './money.js';
14
+ export * from './rules.js';
15
+ export * from './secrets.js';
16
+ export * from './registry.js';
17
+ export * from './errors.js';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Exact decimal arithmetic on strings.
3
+ *
4
+ * Money and meters are never floats. `BilledResources.credits` and
5
+ * `UsageEventRow.quantity` are decimal strings precisely so a value cannot pass
6
+ * through binary64 and come back a fraction of a cent different, and every
7
+ * operation on them has to preserve that.
8
+ *
9
+ * This lives in core because three places need it — the agent loop's budget
10
+ * cap, the API's meter, and billing — and two independent implementations of
11
+ * money addition is how they disagree by a cent in a reconciliation nobody can
12
+ * attribute. It is the same argument as `ListResponse` and `WallClock`, with a
13
+ * worse failure mode: a wrong number that looks right.
14
+ *
15
+ * Everything here is bigint underneath. No `Number` appears in an arithmetic
16
+ * path, only in the scale bookkeeping, where the values are small integers.
17
+ */
18
+ export declare const ZERO_CREDITS = "0";
19
+ export declare function isDecimalString(value: unknown): value is string;
20
+ /** Exact addition. The result keeps the wider of the two scales. */
21
+ export declare function addDecimal(a: string, b: string): string;
22
+ /**
23
+ * Exact multiplication. The result's scale is the **sum** of the two scales,
24
+ * which is the only scale at which a product is exact: `0.001 * 0.002` is
25
+ * `0.000002`, and any narrower result has rounded.
26
+ *
27
+ * No rounding is applied here on purpose. A rate card times a quantity is a
28
+ * number three different consumers reconcile against — the meter, the token
29
+ * cost of a model call, and a captcha rate card amortised per solve — and each
30
+ * rounds at a different point for a different reason. Rounding here would make
31
+ * that decision once, silently, in the wrong place.
32
+ */
33
+ export declare function multiplyDecimal(a: string, b: string): string;
34
+ /** -1, 0 or 1. `compareDecimal('0.30', '0.3') === 0`: scale is not value. */
35
+ export declare function compareDecimal(a: string, b: string): number;
36
+ /**
37
+ * The published price of one second of an agent run, and of one page it loads.
38
+ *
39
+ * ── Why this is in core and not only in the rate card ───────────────────────
40
+ *
41
+ * Because `AgentCaps.budget` is a promise about the number on the INVOICE, and
42
+ * until this existed the two were different quantities. `CapLedger` was charged
43
+ * only from `ModelChoice.credits` — model spend, priced in `@outcrawl/model`'s
44
+ * USD card — and `ModelClientOptions.creditsPerUsd` is deliberately unset in
45
+ * production, because the plan tiers price a credit at 204/251/313 to the
46
+ * dollar and the worker does not know the caller's tier. So `creditsFor`
47
+ * returned `undefined`, the loop's `if (choice.credits !== undefined)` guard
48
+ * never fired, and the ledger held `'0'` for the whole run. `tripped()` ran on
49
+ * every step and compared `'0' >= '0.50'` forever.
50
+ *
51
+ * The cap was not failing to hold the line. It was measuring a quantity that is
52
+ * always zero, while the customer was billed for three others. Measured on
53
+ * production 2026-09-08: 26 runs carried a budget, 12 exceeded it, worst 4.20x,
54
+ * and `max-budget` had never once been the stop reason.
55
+ *
56
+ * The invoice is `page_load + agent_seconds + context_seconds` — the exact rows
57
+ * `meterAgentRun` writes in `@outcrawl/api`. On `run_7a643435`, cap `0.50`:
58
+ *
59
+ * page_load 1 x 1 = 1.000000
60
+ * agent_seconds 7.606 @ 5/60 = 0.633833
61
+ * context_seconds 7.606 @ 1/60 = 0.126767
62
+ * tokens 9,954 = 0 <- the rate card has no token line
63
+ * ----------
64
+ * 1.760600 = 3.52x the cap
65
+ *
66
+ * So the run's price is a rate on TIME plus a rate on PAGES, and the loop can
67
+ * evaluate both while it is still running. It cannot reach the rate card:
68
+ * `@outcrawl/persistence` depends on `@outcrawl/runtime`, so the runtime
69
+ * importing it back is a cycle. Injecting the rate from the host was the option
70
+ * rejected — a host that forgets to pass it silently unbinds the cap, which is
71
+ * `creditsPerUsd`'s defect wearing a different name. So the number lives here,
72
+ * where both sides can reach it, and `test/rate-card.test.ts` in
73
+ * `@outcrawl/persistence` asserts these two constants against
74
+ * `OUTCRAWL_CREDIT_RATES` so a reprice cannot move one without the other.
75
+ *
76
+ * `'0.1'` and not the ratio `6/60`: it is the SUM of the two per-minute rates
77
+ * the invoice charges — Agent at +5 and the browser it holds at 1 — and six
78
+ * over sixty is exactly a tenth, so pricing a second needs no division and
79
+ * therefore no rounding scale. That matters because `multiplyDecimal` is exact
80
+ * and division is not: `divide` in the rate card has to choose six places, and a
81
+ * cap that rounded UP would stop a run fractionally before its ceiling while a
82
+ * cap that rounded DOWN would let it past. Neither is defensible in a ceiling,
83
+ * and at a tenth per second the question does not arise.
84
+ *
85
+ * The invoice still prices the two minute-rates as separate rows and rounds each
86
+ * to six places, so the ledger's figure and the bill's can differ by at most one
87
+ * unit in the last place of each row — 2e-6 credits. On `run_7a643435` the two
88
+ * roundings cancelled exactly: `0.633833 + 0.126767 = 0.760600`, and
89
+ * `7.606 x 0.1 = 0.7606`, the same number. That residual is an artefact of the
90
+ * invoice showing two lines, not a second opinion about the price.
91
+ */
92
+ export declare const RUN_CREDITS_PER_SECOND = "0.1";
93
+ /** One rendered page, at the published Scrape rate. See {@link RUN_CREDITS_PER_SECOND}. */
94
+ export declare const RUN_CREDITS_PER_PAGE = "1";
95
+ /**
96
+ * What a run is charged for, as one value a ledger can be constructed with.
97
+ *
98
+ * Both fields are required. An optional rate reads as "this deployment does not
99
+ * charge for time", which is the shape that made the budget cap a no-op.
100
+ */
101
+ export interface RunPricing {
102
+ /** Credits per second of run time. */
103
+ readonly creditsPerSecond: string;
104
+ /** Credits per page load. */
105
+ readonly creditsPerPage: string;
106
+ }
107
+ /** The published price list, as {@link RunPricing}. */
108
+ export declare const OUTCRAWL_RUN_PRICING: RunPricing;