@msg91comm/m91 0.0.0-stage → 1.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +425 -2
  4. package/dist/src/client.d.ts +165 -0
  5. package/dist/src/client.d.ts.map +1 -0
  6. package/dist/src/client.js +221 -0
  7. package/dist/src/client.js.map +1 -0
  8. package/dist/src/exceptions.d.ts +114 -0
  9. package/dist/src/exceptions.d.ts.map +1 -0
  10. package/dist/src/exceptions.js +199 -0
  11. package/dist/src/exceptions.js.map +1 -0
  12. package/dist/src/http.d.ts +71 -0
  13. package/dist/src/http.d.ts.map +1 -0
  14. package/dist/src/http.js +150 -0
  15. package/dist/src/http.js.map +1 -0
  16. package/dist/src/index.d.ts +49 -0
  17. package/dist/src/index.d.ts.map +1 -0
  18. package/dist/src/index.js +45 -0
  19. package/dist/src/index.js.map +1 -0
  20. package/dist/src/integrations/index.d.ts +22 -0
  21. package/dist/src/integrations/index.d.ts.map +1 -0
  22. package/dist/src/integrations/index.js +21 -0
  23. package/dist/src/integrations/index.js.map +1 -0
  24. package/dist/src/integrations/langchain.d.ts +43 -0
  25. package/dist/src/integrations/langchain.d.ts.map +1 -0
  26. package/dist/src/integrations/langchain.js +75 -0
  27. package/dist/src/integrations/langchain.js.map +1 -0
  28. package/dist/src/integrations/langgraph.d.ts +79 -0
  29. package/dist/src/integrations/langgraph.d.ts.map +1 -0
  30. package/dist/src/integrations/langgraph.js +114 -0
  31. package/dist/src/integrations/langgraph.js.map +1 -0
  32. package/dist/src/integrations/specs.d.ts +131 -0
  33. package/dist/src/integrations/specs.d.ts.map +1 -0
  34. package/dist/src/integrations/specs.js +156 -0
  35. package/dist/src/integrations/specs.js.map +1 -0
  36. package/dist/src/integrations/vercel-ai.d.ts +36 -0
  37. package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
  38. package/dist/src/integrations/vercel-ai.js +67 -0
  39. package/dist/src/integrations/vercel-ai.js.map +1 -0
  40. package/dist/src/models.d.ts +274 -0
  41. package/dist/src/models.d.ts.map +1 -0
  42. package/dist/src/models.js +383 -0
  43. package/dist/src/models.js.map +1 -0
  44. package/dist/src/platform.d.ts +176 -0
  45. package/dist/src/platform.d.ts.map +1 -0
  46. package/dist/src/platform.js +286 -0
  47. package/dist/src/platform.js.map +1 -0
  48. package/dist/src/url-builder.d.ts +65 -0
  49. package/dist/src/url-builder.d.ts.map +1 -0
  50. package/dist/src/url-builder.js +167 -0
  51. package/dist/src/url-builder.js.map +1 -0
  52. package/examples/approval-gate.ts +35 -0
  53. package/examples/basic-alert.ts +53 -0
  54. package/examples/langchain-agent.ts +65 -0
  55. package/examples/langgraph-hitl.ts +70 -0
  56. package/examples/vercel-ai-agent.ts +34 -0
  57. package/examples/webhook-urls.ts +44 -0
  58. package/package.json +83 -4
  59. package/src/client.ts +324 -0
  60. package/src/exceptions.ts +229 -0
  61. package/src/http.ts +174 -0
  62. package/src/index.ts +91 -0
  63. package/src/integrations/index.ts +23 -0
  64. package/src/integrations/langchain.ts +104 -0
  65. package/src/integrations/langgraph.ts +142 -0
  66. package/src/integrations/specs.ts +224 -0
  67. package/src/integrations/vercel-ai.ts +99 -0
  68. package/src/models.ts +660 -0
  69. package/src/platform.ts +343 -0
  70. package/src/url-builder.ts +225 -0
package/src/client.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The send-link client: raise an alert, read what was decided, close it.
3
+ *
4
+ * A send link is a credential in a URL. It goes in a Grafana webhook field, a
5
+ * cron, a CI job or an agent's environment, and it needs no header, no account
6
+ * and no library — which is why this client is a convenience over one URL
7
+ * rather than the only way in.
8
+ *
9
+ * Two things it does that a bare `fetch` does not, and both are why it exists:
10
+ *
11
+ * - **It checks that somebody was actually reached.** A 201 means the alert was
12
+ * filed, not that a phone rang.
13
+ * - **It reads the decision, not the responses.** Only a SHARED response decides.
14
+ */
15
+
16
+ import {
17
+ M91DeliveryError,
18
+ M91ForbiddenError,
19
+ M91NotFoundError,
20
+ M91TimeoutError,
21
+ M91ValidationError,
22
+ } from './exceptions.js';
23
+ import { Transport, type TransportOptions, redact } from './http.js';
24
+ import {
25
+ type AlertDetail,
26
+ type AlertResponse,
27
+ type ChannelInfo,
28
+ type Decision,
29
+ type ResponseOption,
30
+ type Severity,
31
+ buildAlertPayload,
32
+ isApproved,
33
+ parseAlertDetail,
34
+ parseAlertResponse,
35
+ parseChannelInfo,
36
+ } from './models.js';
37
+ import { normalizeSendLink } from './url-builder.js';
38
+
39
+ /**
40
+ * Below this, polling is a busy loop against a rate-limited endpoint and buys
41
+ * nothing: a human waking up and reaching for a phone is not a sub-second event.
42
+ */
43
+ export const MIN_POLL_INTERVAL_MS = 2000;
44
+
45
+ export interface SendOptions {
46
+ description?: string;
47
+ /**
48
+ * LOW and MEDIUM deliver at alarm volume through Do Not Disturb. **HIGH and
49
+ * CRITICAL also place a voice call** if nobody answers in 45 seconds, so they
50
+ * are never the default — "I didn't specify" must not mean "ring everyone".
51
+ */
52
+ severity?: Severity;
53
+ /**
54
+ * Your own stable id for *the condition*, not for this occurrence. The same
55
+ * disk filling up is the same `customId` every time. Built from a timestamp
56
+ * or a UUID it defeats deduplication — a check running every minute raises
57
+ * sixty alerts an hour — and leaves nothing stable to close by.
58
+ */
59
+ customId?: string;
60
+ /**
61
+ * Left false, the alert stays open until somebody closes it, even after a
62
+ * SHARED response claims it. Set true when the first person taking it on is
63
+ * genuinely the end of the matter.
64
+ */
65
+ isAutoClose?: boolean;
66
+ responseOptions?: Array<ResponseOption | string>;
67
+ /**
68
+ * Whether the free-text "Something else" reply is offered. Tri-state: leave
69
+ * it unset and the server's default (on) stands.
70
+ */
71
+ other?: boolean;
72
+ /** https links to files the alert is about. URLs, never bytes. */
73
+ attachments?: string[];
74
+ /** Where the outcome is POSTed when somebody answers. */
75
+ callbackUrl?: string;
76
+ }
77
+
78
+ export interface WaitOptions {
79
+ timeoutMs?: number;
80
+ pollIntervalMs?: number;
81
+ throwOnTimeout?: boolean;
82
+ }
83
+
84
+ export interface ApprovalOptions {
85
+ customId: string;
86
+ description?: string;
87
+ severity?: Severity;
88
+ approveLabel?: string;
89
+ rejectLabel?: string;
90
+ timeoutMs?: number;
91
+ pollIntervalMs?: number;
92
+ /** A timeout returns false. There is deliberately no option making it true. */
93
+ timeoutMeansReject?: boolean;
94
+ }
95
+
96
+ export interface ClientOptions extends TransportOptions {
97
+ /**
98
+ * Throw `M91DeliveryError` when a send reaches nobody. On by default, because
99
+ * the silent version of this failure is an alert nobody ever hears about.
100
+ */
101
+ throwOnZeroDelivery?: boolean;
102
+ }
103
+
104
+ /** Alerts one channel, through one send link. */
105
+ export class M91Client {
106
+ readonly sendLink: string;
107
+ readonly throwOnZeroDelivery: boolean;
108
+ private readonly http: Transport;
109
+ private readonly sleep: (ms: number) => Promise<void>;
110
+ private readonly now: () => number;
111
+
112
+ constructor(sendLink: string, options: ClientOptions = {}) {
113
+ this.sendLink = normalizeSendLink(sendLink);
114
+ this.throwOnZeroDelivery = options.throwOnZeroDelivery !== false;
115
+ this.http = new Transport(options);
116
+ this.sleep = options.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
117
+ this.now = () => Date.now();
118
+ }
119
+
120
+ /** Redacted, always: a token in a log, a trace or a crash report can wake a team. */
121
+ toString(): string {
122
+ return `M91Client(${redact(this.sendLink)})`;
123
+ }
124
+
125
+ private url(suffix = ''): string {
126
+ return this.sendLink + suffix;
127
+ }
128
+
129
+ /**
130
+ * Check the link works, and see which channel it points at.
131
+ *
132
+ * **Raises no alert.** Safe on startup, in a health check, or while somebody
133
+ * is pasting the URL into a settings page — which is the point: a mistyped
134
+ * link otherwise shows "connected" and only reveals itself days later, when
135
+ * an alert silently fails to fire.
136
+ */
137
+ async verify(): Promise<ChannelInfo> {
138
+ return parseChannelInfo(await this.http.request({ method: 'GET', url: this.url() }));
139
+ }
140
+
141
+ /** Raise an alert on this channel. */
142
+ async send(title: string, options: SendOptions = {}): Promise<AlertResponse> {
143
+ const payload = buildAlertPayload(title, options);
144
+ const alert = parseAlertResponse(
145
+ await this.http.request({ method: 'POST', url: this.url(), body: payload }),
146
+ );
147
+ checkDelivery(alert, this.throwOnZeroDelivery);
148
+ return alert;
149
+ }
150
+
151
+ /**
152
+ * Read one alert back. `null` when there is no such alert on this channel.
153
+ *
154
+ * The id is **either** the `customId` you invented or the `_id` the send
155
+ * returned — a caller knows one of them and should not have to say which.
156
+ */
157
+ async getAlert(customIdOrAlertId: string): Promise<AlertDetail | null> {
158
+ try {
159
+ const raw = await this.http.request({
160
+ method: 'GET',
161
+ url: this.url(`/alerts/${encodeURIComponent(String(customIdOrAlertId))}`),
162
+ });
163
+ return parseAlertDetail(raw);
164
+ } catch (error) {
165
+ if (error instanceof M91NotFoundError) return null;
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Wait until somebody takes the alert on, or the clock runs out.
172
+ *
173
+ * Resolves with the SHARED response that claimed it. **PERSONAL responses
174
+ * never end this wait** — a "Seen" is an acknowledgement, not an answer, and
175
+ * treating it as one is the mistake `decision` exists to prevent.
176
+ *
177
+ * Stops early and resolves `null` if the alert is closed without ever being
178
+ * claimed: there is nothing left to wait for.
179
+ */
180
+ async waitForDecision(customId: string, options: WaitOptions = {}): Promise<Decision | null> {
181
+ const timeoutMs = options.timeoutMs ?? 300_000;
182
+ const requested = options.pollIntervalMs ?? 5000;
183
+ if (requested < MIN_POLL_INTERVAL_MS) {
184
+ console.warn(
185
+ `[m91] pollIntervalMs=${requested} is below ${MIN_POLL_INTERVAL_MS}. The send link is ` +
186
+ `rate limited to 60 requests a minute and a person cannot answer that fast; ` +
187
+ `using ${MIN_POLL_INTERVAL_MS}ms instead.`,
188
+ );
189
+ }
190
+ const interval = Math.max(requested, MIN_POLL_INTERVAL_MS);
191
+ const deadline = this.now() + timeoutMs;
192
+
193
+ for (;;) {
194
+ const verdict = waitVerdict(await this.getAlert(customId));
195
+ if (verdict.stop) return verdict.decision;
196
+
197
+ const remaining = deadline - this.now();
198
+ if (remaining <= 0) {
199
+ if (options.throwOnTimeout) throw new M91TimeoutError(customId, timeoutMs);
200
+ return null;
201
+ }
202
+ await this.sleep(Math.min(interval, remaining));
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Close an alert this link raised. Always `true` when there is nothing open.
208
+ *
209
+ * A monitor that sees the problem heal closes it itself rather than waiting
210
+ * for a person to wake up and notice.
211
+ *
212
+ * **404 and 403 are success.** "No such alert" and "not yours to close" both
213
+ * mean the alert is not open on this channel, which is the state the caller
214
+ * asked for. Anything else would make every close a branch, and every retry
215
+ * after a dropped response a false failure.
216
+ */
217
+ async close(options: { customId?: string; alertId?: string }): Promise<boolean> {
218
+ const { customId, alertId } = options ?? {};
219
+ if (Boolean(customId) === Boolean(alertId)) {
220
+ throw new M91ValidationError(
221
+ `close() needs exactly one of customId or alertId — got ${customId ? 'both' : 'neither'}`,
222
+ );
223
+ }
224
+ try {
225
+ await this.http.request({
226
+ method: 'POST',
227
+ url: this.url('/close'),
228
+ body: customId ? { customId } : { alertId },
229
+ });
230
+ } catch (error) {
231
+ if (error instanceof M91NotFoundError || error instanceof M91ForbiddenError) return true;
232
+ throw error;
233
+ }
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Ask a human, on their phone, and wait for the answer. `true` = approved.
239
+ *
240
+ * The shape of every human-in-the-loop gate: raise the question, block, act
241
+ * on the answer, close the alert either way. Severity defaults to HIGH here —
242
+ * unlike `send` — because something is *waiting* on the answer, so a phone
243
+ * call after 45 seconds of silence is the point rather than an escalation.
244
+ *
245
+ * Both labels are SHARED: approving and rejecting are each a final answer
246
+ * that settles the question for everybody, and neither is an "I saw it".
247
+ *
248
+ * On timeout with `timeoutMeansReject` (the default) it resolves false —
249
+ * nobody answered, so nothing consequential happens. Set it false to get
250
+ * `M91TimeoutError` instead. There is deliberately no way to make a timeout
251
+ * mean approved.
252
+ */
253
+ async requestApproval(title: string, options: ApprovalOptions): Promise<boolean> {
254
+ const {
255
+ customId,
256
+ description,
257
+ severity = 'HIGH',
258
+ approveLabel = 'Approve',
259
+ rejectLabel = 'Reject',
260
+ timeoutMs = 300_000,
261
+ pollIntervalMs = 5000,
262
+ timeoutMeansReject = true,
263
+ } = options;
264
+
265
+ await this.send(title, {
266
+ description,
267
+ severity,
268
+ customId,
269
+ isAutoClose: false,
270
+ responseOptions: [
271
+ { label: approveLabel, effect: 'SHARED' },
272
+ { label: rejectLabel, effect: 'SHARED' },
273
+ ],
274
+ });
275
+
276
+ try {
277
+ const decision = await this.waitForDecision(customId, { timeoutMs, pollIntervalMs });
278
+ if (decision === null) {
279
+ if (!timeoutMeansReject) throw new M91TimeoutError(customId, timeoutMs);
280
+ return false;
281
+ }
282
+ return isApproved(decision, approveLabel);
283
+ } finally {
284
+ // Answered or not, the question is no longer live. Left open, a rejected
285
+ // deploy keeps somebody's phone in the "needs attention" list for an
286
+ // action that already resolved.
287
+ await this.close({ customId });
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * A 2xx that woke nobody is a failure, and only the caller can decide what to do.
294
+ *
295
+ * `delivered` counts phones actually sent to, so this fires when a channel's
296
+ * devices are all stale or its members have not installed the app — a case that
297
+ * answers 201 and looks exactly like success.
298
+ */
299
+ export function checkDelivery(alert: AlertResponse, enabled: boolean): void {
300
+ if (!enabled || alert.actuallyReachedSomeone) return;
301
+ throw new M91DeliveryError(
302
+ `The alert was filed but reached nobody: delivered=0, ` +
303
+ `unregistered=${alert.unregistered.length}, noDevice=${alert.noDevice}, ` +
304
+ `pending=${alert.pending}. Nobody's phone rang.`,
305
+ alert.delivered,
306
+ alert.unregistered,
307
+ alert.noDevice,
308
+ alert.pending,
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Whether one poll ends the wait. Pure, so the rule lives in one place.
314
+ */
315
+ export function waitVerdict(detail: AlertDetail | null): { stop: boolean; decision: Decision | null } {
316
+ // Not visible yet. A send answers before the fan-out finishes, so an
317
+ // immediate read can outrun it; keep waiting rather than calling it gone.
318
+ if (detail === null) return { stop: false, decision: null };
319
+ if (detail.decision !== null) return { stop: true, decision: detail.decision };
320
+ // Closed with nobody having claimed it — by a monitor that saw the problem
321
+ // heal, or by hand. There is no answer coming.
322
+ if (detail.isClosed) return { stop: true, decision: null };
323
+ return { stop: false, decision: null };
324
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Every failure the SDK can throw, and the one place an HTTP response becomes one.
3
+ *
4
+ * Branch on `.code` — a stable string the API contract pins down — never on
5
+ * `.message`, which is written for a person reading a terminal and is expected
6
+ * to change.
7
+ */
8
+
9
+ export class M91Error extends Error {
10
+ readonly code: string;
11
+ readonly statusCode?: number;
12
+ readonly details?: unknown;
13
+
14
+ constructor(message: string, code = 'M91_ERROR', statusCode?: number, details?: unknown) {
15
+ super(message);
16
+ this.name = new.target.name;
17
+ this.code = code;
18
+ this.statusCode = statusCode;
19
+ this.details = details;
20
+ // Without this, `err instanceof M91ValidationError` is false when the
21
+ // package is compiled down to ES5 by a consumer's bundler.
22
+ Object.setPrototypeOf(this, new.target.prototype);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 400/413. The request was wrong, and it is never retried.
28
+ *
29
+ * Also thrown locally, before any HTTP call, when a field breaks a documented
30
+ * limit. Every schema server-side is `.strict()`, so a mistyped field name is a
31
+ * 400 rather than a silent drop — and a round trip to learn that is a round
32
+ * trip wasted.
33
+ */
34
+ export class M91ValidationError extends M91Error {
35
+ constructor(message: string, details?: unknown, statusCode = 400, code = 'VALIDATION_ERROR') {
36
+ super(message, code, statusCode, details);
37
+ }
38
+ }
39
+
40
+ /** 401. Credential invalid, expired or revoked — nothing is cached server-side. */
41
+ export class M91AuthError extends M91Error {
42
+ constructor(message = 'Unauthorized', details?: unknown) {
43
+ super(message, 'UNAUTHORIZED', 401, details);
44
+ }
45
+ }
46
+
47
+ /** 403. Authenticated, but not allowed. On `close()` this means success. */
48
+ export class M91ForbiddenError extends M91Error {
49
+ constructor(message = 'Forbidden', details?: unknown) {
50
+ super(message, 'FORBIDDEN', 403, details);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 404. Absent — **or present and not yours**. The API answers the same way to
56
+ * both on purpose, so an id cannot become an oracle for what exists.
57
+ */
58
+ export class M91NotFoundError extends M91Error {
59
+ constructor(message = 'Not found', details?: unknown) {
60
+ super(message, 'NOT_FOUND', 404, details);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 409. The channel has nobody who can actually be woken.
66
+ *
67
+ * `unregistered` is the invited numbers with no M91 account — invited, never
68
+ * installed the app, and unreachable. An ordinary SMS would not pierce Do Not
69
+ * Disturb, would not repeat, and would not escalate.
70
+ */
71
+ export class M91ChannelEmptyError extends M91Error {
72
+ readonly unregistered: string[];
73
+
74
+ constructor(message: string, unregistered: string[] = [], details?: unknown) {
75
+ super(message, 'CHANNEL_EMPTY', 409, details);
76
+ this.unregistered = unregistered;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 429. 60 sends a minute per send link.
82
+ *
83
+ * `retryAfter` always has a value: the server sets `Retry-After` on every 429
84
+ * precisely so a caller does not have to guess, and the usual guess is to retry
85
+ * immediately and forever.
86
+ */
87
+ export class M91RateLimitError extends M91Error {
88
+ readonly retryAfter: number;
89
+
90
+ constructor(message = 'Rate limited', retryAfter = 60, details?: unknown) {
91
+ super(message, 'RATE_LIMITED', 429, details);
92
+ this.retryAfter = retryAfter;
93
+ }
94
+ }
95
+
96
+ /** 5xx. Not the caller's fault. Retried with backoff before it reaches you. */
97
+ export class M91ServerError extends M91Error {
98
+ constructor(message = 'Internal server error', statusCode = 500, details?: unknown) {
99
+ super(message, 'INTERNAL_ERROR', statusCode, details);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The request never got an answer — DNS, TLS, connect or read timeout.
105
+ *
106
+ * Not retried. A send that timed out may or may not have raised an alert, and
107
+ * retrying it blindly is how one condition wakes a team four times.
108
+ */
109
+ export class M91NetworkError extends M91Error {
110
+ constructor(message = 'Network error', details?: unknown) {
111
+ super(message, 'NETWORK_ERROR', undefined, details);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The API said 2xx and **nobody's phone rang**.
117
+ *
118
+ * The failure mode integrations actually hit, and it is invisible if you only
119
+ * check the status code. `delivered` counts phones sent to, not the size of the
120
+ * roster: a channel of four whose device tokens are all stale answers 201 with
121
+ * `delivered: 0`.
122
+ *
123
+ * A *deduplicated* send is not this error — `delivered` is 0 because the same
124
+ * alert is already open and already ringing, which is success.
125
+ */
126
+ export class M91DeliveryError extends M91Error {
127
+ readonly delivered: number;
128
+ readonly unregistered: string[];
129
+ readonly noDevice: number;
130
+ readonly pending: number;
131
+
132
+ constructor(
133
+ message: string,
134
+ delivered = 0,
135
+ unregistered: string[] = [],
136
+ noDevice = 0,
137
+ pending = 0,
138
+ ) {
139
+ super(message, 'DELIVERY_FAILED');
140
+ this.delivered = delivered;
141
+ this.unregistered = unregistered;
142
+ this.noDevice = noDevice;
143
+ this.pending = pending;
144
+ }
145
+ }
146
+
147
+ /** `waitForDecision` ran out of time with no SHARED response. Nobody answered. */
148
+ export class M91TimeoutError extends M91Error {
149
+ readonly customId: string;
150
+ readonly timeoutMs: number;
151
+
152
+ constructor(customId: string, timeoutMs: number) {
153
+ super(
154
+ `Nobody answered '${customId}' within ${Math.round(timeoutMs / 1000)}s. The alert is still open.`,
155
+ 'TIMEOUT',
156
+ );
157
+ this.customId = customId;
158
+ this.timeoutMs = timeoutMs;
159
+ }
160
+ }
161
+
162
+ interface ErrorEnvelope {
163
+ success?: boolean;
164
+ data?: unknown;
165
+ error?: { code?: string; message?: string; details?: unknown };
166
+ }
167
+
168
+ /**
169
+ * Turn one HTTP response into either the payload or a thrown error.
170
+ *
171
+ * **Every M91 route answers in an envelope** — `{ success: true, data }` or
172
+ * `{ success: false, error: { code, message, details } }` — and this is the only
173
+ * place the SDK knows that. Everything above this line sees the inner `data`.
174
+ *
175
+ * The error class is chosen by HTTP status rather than by `error.code`, because
176
+ * the status is what the server derives the code from in the first place;
177
+ * keeping them in step in two places is how they drift apart. The body's `code`
178
+ * is still carried onto the error.
179
+ */
180
+ export function unwrap(status: number, body: unknown, headers?: Headers | Record<string, string>): unknown {
181
+ const envelope = (body ?? {}) as ErrorEnvelope;
182
+ const err = envelope.error ?? {};
183
+ const details = err.details;
184
+ const code = err.code;
185
+
186
+ if (status >= 200 && status < 300) {
187
+ if (envelope && typeof envelope === 'object' && 'data' in envelope) return envelope.data;
188
+ // A route outside the envelope, or a proxy that rewrote the body. Hand it
189
+ // back rather than inventing a failure the server never reported.
190
+ return body;
191
+ }
192
+
193
+ const message = err.message || `M91 request failed with HTTP ${status}`;
194
+
195
+ if (status === 409 || code === 'CHANNEL_EMPTY') {
196
+ const unregistered = (details as { unregistered?: string[] } | undefined)?.unregistered ?? [];
197
+ throw new M91ChannelEmptyError(message, unregistered, details);
198
+ }
199
+ if (status === 429) throw new M91RateLimitError(message, retryAfter(headers) ?? 60, details);
200
+ if (status === 400) throw withCode(new M91ValidationError(message, details), code);
201
+ if (status === 413) {
202
+ throw new M91ValidationError(message, details, 413, code || 'PAYLOAD_TOO_LARGE');
203
+ }
204
+ if (status === 401) throw withCode(new M91AuthError(message, details), code);
205
+ if (status === 403) throw withCode(new M91ForbiddenError(message, details), code);
206
+ if (status === 404) throw withCode(new M91NotFoundError(message, details), code);
207
+ if (status >= 500) throw new M91ServerError(message, status, details);
208
+
209
+ throw new M91Error(message, code || 'M91_ERROR', status, details);
210
+ }
211
+
212
+ function withCode<T extends M91Error>(error: T, code?: string): T {
213
+ if (code) (error as { code: string }).code = code;
214
+ return error;
215
+ }
216
+
217
+ /** Seconds from `Retry-After`, or undefined. Header lookups are case-insensitive. */
218
+ export function retryAfter(headers?: Headers | Record<string, string>): number | undefined {
219
+ if (!headers) return undefined;
220
+ const raw =
221
+ typeof (headers as Headers).get === 'function'
222
+ ? (headers as Headers).get('retry-after')
223
+ : Object.entries(headers as Record<string, string>).find(
224
+ ([k]) => k.toLowerCase() === 'retry-after',
225
+ )?.[1];
226
+ if (raw === null || raw === undefined) return undefined;
227
+ const seconds = Number(String(raw).trim());
228
+ return Number.isFinite(seconds) ? Math.max(0, Math.trunc(seconds)) : undefined;
229
+ }