ordainer 1.0.0 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +290 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/examples/express-server.js +18 -0
- package/index.d.ts +146 -0
- package/index.js +1018 -0
- package/package.json +38 -7
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Quickstart example: one paid tool behind Ordainer.
|
|
3
|
+
// npm install express (in your own project)
|
|
4
|
+
// ORDAINER_KEY=ord_test_... ORDAINER_BASE_URL=https://api.ordainer.dev node express-server.js
|
|
5
|
+
// curl -X POST localhost:3001/tools/research
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const { Ordainer } = require('ordainer');
|
|
8
|
+
|
|
9
|
+
const ordainer = new Ordainer(); // reads ORDAINER_KEY and ORDAINER_BASE_URL; mode follows the plan in the console
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
app.post('/tools/research', ordainer.middleware({ tool: 'research.run' }), (req, res) => {
|
|
13
|
+
// Only reached when Ordainer allows (or in shadow mode, or when Ordainer is unreachable).
|
|
14
|
+
res.json({ result: 'expensive research done', ordainer: req.ordainer.decision, remaining: req.ordainer.remaining });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const port = Number(process.env.PORT || 3001);
|
|
18
|
+
app.listen(port, () => console.log(`vendor tool on http://localhost:${port}/tools/research`));
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export type Mode = 'enforce' | 'shadow';
|
|
2
|
+
export type FailMode = 'open' | 'closed';
|
|
3
|
+
/** Billing-lapse setting (Terms 10.3(b)): what to do on 403 subscription_inactive. Separate from FailMode. Default 'open'. */
|
|
4
|
+
export type BillingLapse = 'open' | 'closed';
|
|
5
|
+
/** Quota-deny status: 429 Too Many Requests (default) or 402 Payment Required. Anything else throws at construction. */
|
|
6
|
+
export type DenyStatus = 429 | 402;
|
|
7
|
+
type Resolvable<T, A extends unknown[]> = T | ((...args: A) => T | Promise<T>);
|
|
8
|
+
|
|
9
|
+
export interface DecideResult {
|
|
10
|
+
/** Run the tool? Accounts for mode, fail mode and the billing-lapse setting. */
|
|
11
|
+
allowed: boolean;
|
|
12
|
+
/** Ordainer's answer, or null if Ordainer could not answer. */
|
|
13
|
+
decision: 'allow' | 'deny' | null;
|
|
14
|
+
reason: string | null;
|
|
15
|
+
/** Allows left this period. null when the plan has no cap (D1) or Ordainer didn't answer: check before doing arithmetic. */
|
|
16
|
+
remaining: number | null;
|
|
17
|
+
/** The plan's cap for this period. null when the plan has no cap (D1) or Ordainer didn't answer. */
|
|
18
|
+
limit: number | null;
|
|
19
|
+
resetsAt: string | null;
|
|
20
|
+
requestId: string | null;
|
|
21
|
+
/** Effective mode ('shadow' never blocks). */
|
|
22
|
+
mode: Mode | null;
|
|
23
|
+
/** A shadow deny: Ordainer said deny, mode is 'shadow', and the call ran (allowed true). False on an enforce deny (allowed false; check decision === 'deny') and on an allow. Before 1.2.2 it was true on every deny. */
|
|
24
|
+
wouldDeny: boolean;
|
|
25
|
+
/** failMode 'open' let the call run without a decision: Ordainer couldn't answer (timeout, network error, 5xx, malformed 200). Never true for 403 subscription_inactive (see billingLapse). Never true together with failClosed or rejected. */
|
|
26
|
+
failOpen: boolean;
|
|
27
|
+
/** failMode 'closed' blocked the call because there was no decision: an outage (timeout, network error, 5xx, malformed 200; the block response is 503 ordainer_unavailable with Retry-After: 5). False for a rejected call and for the subscription_inactive lapse: failMode didn't decide those. */
|
|
28
|
+
failClosed: boolean;
|
|
29
|
+
/** Ordainer refused the call (any 4xx, except 403 subscription_inactive under billingLapse 'open'), or no agent key / tool was given. Blocked whatever failMode says; failClosed stays false. A retry won't fix it, so the block response is plan_check_rejected (403 with the neutral message for subscription_required or the lapse under billingLapse 'closed', 500 otherwise) with no Retry-After, except 429 rate_limited (see rateLimited). */
|
|
30
|
+
rejected: boolean;
|
|
31
|
+
/** The server answered from a stored decision for this idempotency key (header Idempotent-Replayed), not counted again. */
|
|
32
|
+
replayed: boolean;
|
|
33
|
+
/** Sticky deny: Ordainer couldn't answer (would fail open), but this (agent key, tool) got quota_exceeded within stickyDenyMs, so the call is denied (allowed false, reason 'quota_exceeded', 429 plan_limit_reached), or in shadow allowed and reported (wouldDeny). `error` says why there was no answer. */
|
|
34
|
+
stickyDeny: boolean;
|
|
35
|
+
/** The agent key's label (its name), sent by the API on denies only, cleaned and at most 64 characters. null on allows, errors, older servers, or unless exposeKeyLabel: true. Shown on the 429 plan_limit_reached body as error.label. The message does not include it. */
|
|
36
|
+
label: string | null;
|
|
37
|
+
/** HTTP attempts made (retries reuse the same idempotency key). 0 when no request was sent. */
|
|
38
|
+
attempts: number;
|
|
39
|
+
/** Ordainer answered 403 subscription_inactive (subscription inactive; follows billingLapse, Terms 10.3(b)) or subscription_required (never paid; always blocked): billing, not an outage. `reason` is that code. */
|
|
40
|
+
billingInactive: boolean;
|
|
41
|
+
/** The billing-lapse setting applied to a 403 subscription_inactive answer ('open': the call ran unchecked; 'closed': blocked, rejected true). null for every other answer. */
|
|
42
|
+
billingLapse: BillingLapse | null;
|
|
43
|
+
/** Ordainer denied with test_limit_reached: the account's free monthly test-key decisions are used up. Not an error. Enforce: blocked. Shadow (1.2.0; the server answers mode 'shadow'): the call runs as a would-deny (allowed true, wouldDeny true). */
|
|
44
|
+
testLimitReached: boolean;
|
|
45
|
+
/** 1.2.0: Ordainer answered 429 safety_limit_reached (shadow calls past the safety ceiling, 10x the account's monthly test limit). A safety limit, not a deny: the call RUNS in every failMode (allowed true, failOpen false, rejected false), unchecked and not recorded. error.limitScope 'org_safety'; resetsAt and retryAfterSec say when it ends (the start of next month, UTC). */
|
|
46
|
+
safetyLimited: boolean;
|
|
47
|
+
/** Ordainer answered 429 rate_limited (this agent key is over the per-key abuse limit; error.limitScope 'per_key') or 429 concurrency_limited (too many decides in flight at once for this key; error.limitScope 'per_key_concurrency', reason 'concurrency_limited'): a rate limit, not an outage. Blocked in any failMode; always warned. False for the per-address 429 too_many_failed_auth (a bad key: rejected, error.limitScope 'per_address'). */
|
|
48
|
+
rateLimited: boolean;
|
|
49
|
+
/** Seconds from the 429's Retry-After header when rateLimited or safetyLimited, else null. */
|
|
50
|
+
retryAfterSec: number | null;
|
|
51
|
+
/**
|
|
52
|
+
* Why there was no decision. `limitScope` only on a 429 (not `limit`: result.limit is the plan's cap): 'per_key' (rate_limited: this key is over its request rate),
|
|
53
|
+
* 'per_key_concurrency' (concurrency_limited: too many decides in flight at once for this key) or
|
|
54
|
+
* 'per_address' (too_many_failed_auth: too many missing/unknown keys from your server's address; never sent for a valid key) or
|
|
55
|
+
* 'org_safety' (safety_limit_reached: shadow calls past the safety ceiling; the call ran).
|
|
56
|
+
* `dnsCode` (1.2.0): 'ENOTFOUND' or 'EAI_AGAIN' on a network_error where the base URL's host didn't resolve.
|
|
57
|
+
*/
|
|
58
|
+
error: { code: string; message: string; status?: number; limitScope?: 'per_key' | 'per_key_concurrency' | 'per_address' | 'org_safety'; dnsCode?: 'ENOTFOUND' | 'EAI_AGAIN' } | null;
|
|
59
|
+
/** HTTP status a blocked quota deny answers with: 429 (default) or 402. */
|
|
60
|
+
denyStatus: DenyStatus;
|
|
61
|
+
tool: string;
|
|
62
|
+
idempotencyKey: string;
|
|
63
|
+
latencyMs: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OrdainerOptions {
|
|
67
|
+
apiKey?: Resolvable<string | undefined, any[]>;
|
|
68
|
+
baseUrl?: string;
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
mode?: Mode;
|
|
71
|
+
/** Applies ONLY to timeouts, network errors, 5xx and malformed answers; a 4xx always blocks, except 403 subscription_inactive (billingLapse). Default 'open'. */
|
|
72
|
+
failMode?: FailMode;
|
|
73
|
+
/** Terms 10.3(b): on 403 subscription_inactive (your Ordainer subscription is inactive), 'open' (default) runs the call unchecked; 'closed' blocks it with 403 plan_check_rejected (no Retry-After, neutral message). Separate from failMode. */
|
|
74
|
+
billingLapse?: BillingLapse;
|
|
75
|
+
/** Extra attempts after a timeout, network error or 503, with the same idempotency key (0-5, default 1). 4xx and other 5xx are never retried. */
|
|
76
|
+
retries?: number;
|
|
77
|
+
/** Total time (ms) for all attempts of one decide(), default 650. A retry starts only with at least 50 ms left. */
|
|
78
|
+
retryBudgetMs?: number;
|
|
79
|
+
/** Sticky deny window (ms) after a quota_exceeded deny for an (agent key, tool): a would-be fail-open for that pair is denied (shadow: reported). Default 60000, 0 = off, max 300000. Ends early at the plan's reset or on an allow. */
|
|
80
|
+
stickyDenyMs?: number;
|
|
81
|
+
denyStatus?: DenyStatus;
|
|
82
|
+
/** Put the agent key's label on the 429 plan_limit_reached body (error.label) when the API sends it. The message does not include it. Default false (labels stay out of caller responses and result.label is null); set true to opt in. */
|
|
83
|
+
exposeKeyLabel?: boolean;
|
|
84
|
+
onDecision?: (result: DecideResult) => void | Promise<void>;
|
|
85
|
+
/** Called on every call answered 403 subscription_inactive (subscription inactive, under either billingLapse setting) OR 403 subscription_required (live key, no paid subscription yet); check result.error.code. The log warning is rate-limited; this hook is not. */
|
|
86
|
+
onBillingInactive?: (result: DecideResult) => void | Promise<void>;
|
|
87
|
+
/** Called on every call denied with test_limit_reached (not rate-limited). */
|
|
88
|
+
onTestLimit?: (result: DecideResult) => void | Promise<void>;
|
|
89
|
+
/** Called on every call answered 429 rate_limited (not rate-limited itself). */
|
|
90
|
+
onRateLimited?: (result: DecideResult) => void | Promise<void>;
|
|
91
|
+
/** 1.2.0: called on every call answered 429 safety_limit_reached (the call ran; not rate-limited itself). */
|
|
92
|
+
onSafetyLimit?: (result: DecideResult) => void | Promise<void>;
|
|
93
|
+
/** Minimum gap between log warnings of one kind (fail-open/closed per error code, billing, test limit, rate limit, safety limit). Default 60000. */
|
|
94
|
+
warningIntervalMs?: number;
|
|
95
|
+
/** @deprecated old name of warningIntervalMs */
|
|
96
|
+
billingWarningIntervalMs?: number;
|
|
97
|
+
/** Default console; false = silent. error() (if present, else warn()) gets the one-time "base URL host doesn't resolve" line (1.2.0). */
|
|
98
|
+
logger?: { warn(message: string): void; error?(message: string): void } | false;
|
|
99
|
+
fetch?: typeof fetch;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CallOptions<A extends unknown[]> {
|
|
103
|
+
tool: Resolvable<string, A>;
|
|
104
|
+
apiKey?: Resolvable<string | undefined, A>;
|
|
105
|
+
/**
|
|
106
|
+
* From your own trusted data only, one key per real operation. Default: a fresh random key per call.
|
|
107
|
+
* The middleware never forwards the incoming request's Idempotency-Key header.
|
|
108
|
+
*/
|
|
109
|
+
idempotencyKey?: Resolvable<string | undefined, A>;
|
|
110
|
+
mode?: Mode;
|
|
111
|
+
failMode?: FailMode;
|
|
112
|
+
billingLapse?: BillingLapse;
|
|
113
|
+
retries?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class Ordainer {
|
|
117
|
+
constructor(opts?: OrdainerOptions);
|
|
118
|
+
decide(opts: { tool: string; apiKey?: string; idempotencyKey?: string; mode?: Mode; failMode?: FailMode; billingLapse?: BillingLapse; retries?: number }): Promise<DecideResult>;
|
|
119
|
+
middleware(opts: CallOptions<[any]> & { denyStatus?: DenyStatus; onDeny?: (req: any, res: any, result: DecideResult) => void }): (req: any, res: any, next: (err?: unknown) => void) => void;
|
|
120
|
+
wrap<A extends unknown[], R>(fn: (...args: A) => R | Promise<R>, opts: CallOptions<A> & { onDeny?: (result: DecideResult, ...args: A) => R | Promise<R> }): (...args: A) => Promise<R>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class OrdainerDeniedError extends Error {
|
|
124
|
+
result: DecideResult;
|
|
125
|
+
status: number;
|
|
126
|
+
headers: Record<string, string>;
|
|
127
|
+
body: { error: { code: string; message: string; request_id: string | null; [k: string]: unknown } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function denyResponse(result: DecideResult): { status: number; headers: Record<string, string>; body: OrdainerDeniedError['body'] };
|
|
131
|
+
export function sendDeny(res: any, result: DecideResult): void;
|
|
132
|
+
export const DEFAULT_TIMEOUT_MS: number;
|
|
133
|
+
export const BILLING_WARNING_INTERVAL_MS: number;
|
|
134
|
+
export const TEST_LIMIT_REASON: 'test_limit_reached';
|
|
135
|
+
export const SAFETY_LIMIT_CODE: 'safety_limit_reached';
|
|
136
|
+
export const MAX_IDEMPOTENCY_LEN: 255;
|
|
137
|
+
export const MAX_RETRIES: number;
|
|
138
|
+
/** Longest agent label the SDK shows (characters); longer ones are cut and end with "…". */
|
|
139
|
+
export const MAX_LABEL_LENGTH: 64;
|
|
140
|
+
/** The cleaned label as the SDK shows it, or null. */
|
|
141
|
+
export function safeKeyLabel(label: unknown): string | null;
|
|
142
|
+
/** The neutral, Legal-approved message a blocked end user gets when the vendor's Ordainer billing blocks the call (Terms 10.3(b)). */
|
|
143
|
+
export const BILLING_BLOCKED_MESSAGE: string;
|
|
144
|
+
export function idempotencyKeyFor(tool: string, raw: string, opts?: { prefixTool?: boolean }): string;
|
|
145
|
+
/** User-facing brand strings (working name; the rename sweep edits this one block). */
|
|
146
|
+
export const BRAND: Readonly<{ name: string; baseUrl: string; envKey: string; envBaseUrl: string; logPrefix: string; userAgent: string; unavailableCode: string; rejectedCode: string }>;
|