@9in-io/sdk 0.0.0-stage → 0.1.1

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,375 @@
1
+ export { NineError, normalizeIssuer } from "./util.js";
2
+ export interface VerifyOptions {
3
+ /** Your tenant's issuer (exactly as in the token's iss claim). */
4
+ issuer: string;
5
+ /** API identifier the token must be issued for (aud). */
6
+ audience?: string | string[];
7
+ /** Scopes that must all be present (space-separated or array). */
8
+ scope?: string | string[];
9
+ /**
10
+ * Shared secret of the HMAC key (HS256/384/512) your API signs with, from the console's
11
+ * key library. Only needed when the API uses an HMAC key; JWKS keys need nothing.
12
+ */
13
+ secret?: string;
14
+ /** Allowed clock skew in seconds (default 30). */
15
+ clockTolerance?: number;
16
+ /** Accept a plain-http issuer other than localhost (development only). */
17
+ allowInsecureIssuer?: boolean;
18
+ fetch?: typeof fetch;
19
+ }
20
+ export interface AccessTokenClaims {
21
+ iss: string;
22
+ sub: string;
23
+ aud: string | string[];
24
+ exp: number;
25
+ iat: number;
26
+ client_id: string;
27
+ scope: string;
28
+ jti?: string;
29
+ sid?: string;
30
+ auth_time?: number;
31
+ [claim: string]: unknown;
32
+ }
33
+ /**
34
+ * Verifies a 9in JWT access token locally: signature (against your tenant's JWKS, or your
35
+ * API's shared secret for HMAC keys), token type, issuer, audience, expiry and required
36
+ * scopes. Keys are cached and refetched once when a new key appears.
37
+ *
38
+ * This is a local check: a token revoked before it expires still passes. Use
39
+ * `createTokenVerifier` (hybrid mode) when revocation must take effect at once.
40
+ */
41
+ export declare function verifyAccessToken(token: string | null | undefined, options: VerifyOptions): Promise<AccessTokenClaims>;
42
+ export type VerificationMode = "local" | "hybrid" | "introspect";
43
+ export interface TokenVerifierOptions extends Omit<VerifyOptions, "scope"> {
44
+ /**
45
+ * - `hybrid` (default): the local check, then one introspection per token; the answer is
46
+ * cached and dropped when a `token.revoked`, `session.revoked` or user webhook arrives
47
+ * (pass events to `handleEvent`), or after `cacheTtl` at the latest.
48
+ * - `local`: signature and claims only. Fastest; a revoked token works until it expires.
49
+ * - `introspect`: ask 9in on every call. Always current; one request per call.
50
+ * Opaque tokens (`9in_at_…`) are always introspected (and cached in hybrid mode).
51
+ */
52
+ mode?: VerificationMode;
53
+ /** A confidential application's credentials, used for introspection. */
54
+ clientId?: string;
55
+ clientSecret?: string;
56
+ /** Longest time a hybrid answer is trusted without hearing from 9in, in seconds (default 300). */
57
+ cacheTtl?: number;
58
+ /** Most tokens kept in the cache (default 10 000; the least recently used go first). */
59
+ cacheSize?: number;
60
+ }
61
+ export interface TokenVerifier {
62
+ /** Verifies a bearer token (JWT or opaque) and returns its claims, or throws `NineError`. */
63
+ verify(token: string | null | undefined, options?: {
64
+ scope?: string | string[];
65
+ }): Promise<AccessTokenClaims>;
66
+ /**
67
+ * Drops cached answers a webhook event makes stale; returns how many. Pass events from
68
+ * `verifyWebhook` (subscribe the endpoint to token.revoked, session.revoked,
69
+ * user.sessions_revoked, user.updated and user.deleted).
70
+ */
71
+ handleEvent(event: {
72
+ type: string;
73
+ data: Record<string, unknown>;
74
+ }): number;
75
+ /** Forgets every cached answer. */
76
+ clear(): void;
77
+ }
78
+ /**
79
+ * Token verification with a choice of trade-off between speed and revocation (SSOfy's
80
+ * "hybrid" validation, kept): see `mode`.
81
+ *
82
+ * const verifier = createTokenVerifier({ issuer, audience: "https://api.acme.com", clientId, clientSecret });
83
+ * const claims = await verifier.verify(bearer, { scope: "orders:read" });
84
+ *
85
+ * // webhook endpoint (token.revoked, session.revoked, user.* events)
86
+ * verifier.handleEvent(await verifyWebhook(secret, request));
87
+ */
88
+ export declare function createTokenVerifier(options: TokenVerifierOptions): TokenVerifier;
89
+ export interface ManagementOptions {
90
+ issuer: string;
91
+ clientId: string;
92
+ clientSecret: string;
93
+ /** Accept a plain-http issuer other than localhost (development only). */
94
+ allowInsecureIssuer?: boolean;
95
+ fetch?: typeof fetch;
96
+ }
97
+ export interface ManagedUser {
98
+ id: string;
99
+ email: string | null;
100
+ email_verified: boolean;
101
+ phone_number: string | null;
102
+ username: string | null;
103
+ name: string | null;
104
+ picture: string | null;
105
+ blocked: boolean;
106
+ metadata: Record<string, unknown>;
107
+ createdAt: number;
108
+ lastLoginAt: number | null;
109
+ [field: string]: unknown;
110
+ }
111
+ /** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ms since epoch. */
112
+ export interface ManagedSession {
113
+ sid: string;
114
+ userId: string;
115
+ authTime: number;
116
+ amr: string[];
117
+ ip: string;
118
+ userAgent: string;
119
+ createdAt: number;
120
+ lastSeenAt: number;
121
+ expiresAt: number;
122
+ revokedAt: number | null;
123
+ }
124
+ /** One record for `importUsers`: the fields of `createUser` plus what an import may carry over. */
125
+ export interface ImportUser {
126
+ /** Keep the id from your old system (letters, digits and | _ . : @ + -, max 128). */
127
+ id?: string;
128
+ email?: string | null;
129
+ emailVerified?: boolean;
130
+ /** E.164, e.g. +447700900000. */
131
+ phone?: string | null;
132
+ phoneVerified?: boolean;
133
+ username?: string | null;
134
+ name?: string | null;
135
+ given_name?: string | null;
136
+ family_name?: string | null;
137
+ picture?: string | null;
138
+ locale?: string | null;
139
+ zoneinfo?: string | null;
140
+ birthdate?: string | null;
141
+ metadata?: Record<string, unknown>;
142
+ blocked?: boolean;
143
+ /** A plain password (at most 50 such records per call), or `passwordHash` from your old system. */
144
+ password?: string;
145
+ /** bcrypt, argon2, PBKDF2 or Firebase scrypt hash: users keep their password. */
146
+ passwordHash?: string;
147
+ /** Linked sign-ins, e.g. { connection: "social:google", subject: "1069…" }. */
148
+ identities?: {
149
+ connection: string;
150
+ subject: string;
151
+ }[];
152
+ /** ms since epoch */
153
+ createdAt?: number;
154
+ lastLoginAt?: number;
155
+ }
156
+ /** Fields of a new user (`createUser`, `updateUser`). */
157
+ export type NewUser = Omit<ImportUser, "id" | "identities" | "createdAt" | "lastLoginAt">;
158
+ export type ImportResult = {
159
+ index: number;
160
+ ok: boolean;
161
+ } & ({
162
+ status: "created" | "updated";
163
+ id: string;
164
+ } | {
165
+ status: "skipped";
166
+ id: string;
167
+ reason: string;
168
+ } | {
169
+ status: "failed";
170
+ error: string;
171
+ details?: unknown;
172
+ });
173
+ export declare class ManagementClient {
174
+ private readonly options;
175
+ /** One token per scope: each call asks only for what it needs, so an app granted some scopes works. */
176
+ private readonly tokens;
177
+ private readonly fetcher;
178
+ private readonly issuer;
179
+ constructor(options: ManagementOptions);
180
+ private accessToken;
181
+ private request;
182
+ listUsers(options?: {
183
+ query?: string;
184
+ cursor?: string;
185
+ limit?: number;
186
+ }): Promise<{
187
+ users: ManagedUser[];
188
+ nextCursor: string | null;
189
+ }>;
190
+ getUser(id: string): Promise<{
191
+ user: ManagedUser;
192
+ }>;
193
+ findUserByEmail(email: string): Promise<{
194
+ user: ManagedUser;
195
+ }>;
196
+ /** Looks a user up by email, phone (E.164) or username; throws `not_found` (404) when unknown. */
197
+ findUser(type: "email" | "phone" | "username", value: string): Promise<{
198
+ user: ManagedUser;
199
+ }>;
200
+ createUser(input: NewUser): Promise<{
201
+ user: ManagedUser;
202
+ }>;
203
+ /**
204
+ * Bulk import, up to 500 users per call. Records may keep their old id and carry password
205
+ * hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities and original dates.
206
+ * Results are per record, in order; one bad record does not stop the others.
207
+ */
208
+ importUsers(users: ImportUser[], options?: {
209
+ onConflict?: "skip" | "update";
210
+ source?: string;
211
+ }): Promise<{
212
+ results: ImportResult[];
213
+ }>;
214
+ updateUser(id: string, input: Record<string, unknown>): Promise<{
215
+ user: ManagedUser;
216
+ }>;
217
+ deleteUser(id: string): Promise<void>;
218
+ blockUser(id: string, blocked?: boolean): Promise<{
219
+ user: ManagedUser;
220
+ }>;
221
+ /**
222
+ * Single-use URL that signs the user in (magic links, handing a user over from your app).
223
+ * `returnTo` must be a registered redirect URL of one of your applications or a path on
224
+ * the login domain. Two-factor authentication still applies.
225
+ */
226
+ createLoginLink(id: string, options?: {
227
+ returnTo?: string;
228
+ ttlSeconds?: number;
229
+ }): Promise<{
230
+ url: string;
231
+ expires_at: number;
232
+ }>;
233
+ /** The user's active sessions, most recently used first. */
234
+ listUserSessions(id: string): Promise<{
235
+ sessions: ManagedSession[];
236
+ }>;
237
+ /** Signs the user out everywhere; returns how many sessions ended. */
238
+ revokeUserSessions(id: string): Promise<{
239
+ revoked: number;
240
+ }>;
241
+ /** Ends one session (its `sid`) and every token issued under it. */
242
+ revokeSession(sid: string): Promise<void>;
243
+ }
244
+ export interface AdapterIdentifier {
245
+ type: "email" | "username" | "phone";
246
+ value: string;
247
+ }
248
+ export interface AdapterUser {
249
+ id: string;
250
+ email?: string | null;
251
+ email_verified?: boolean | null;
252
+ phone_number?: string | null;
253
+ phone_number_verified?: boolean | null;
254
+ username?: string | null;
255
+ name?: string | null;
256
+ given_name?: string | null;
257
+ family_name?: string | null;
258
+ picture?: string | null;
259
+ locale?: string | null;
260
+ zoneinfo?: string | null;
261
+ birthdate?: string | null;
262
+ blocked?: boolean | null;
263
+ metadata?: Record<string, unknown> | null;
264
+ }
265
+ export interface AdapterEvent {
266
+ id: string;
267
+ type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
268
+ createdAt: string;
269
+ data: Record<string, unknown>;
270
+ }
271
+ export interface AdapterHandlers {
272
+ /** Shared secret from the console (Connections → User database). Several allow rotation. */
273
+ secret: string | string[];
274
+ /** Check a password. Return the user, "invalid" or "blocked". */
275
+ authenticate(identifier: AdapterIdentifier, password: string): Promise<AdapterUser | "invalid" | "blocked">;
276
+ /** Look a user up by identifier or id; null when unknown. */
277
+ findUser(query: {
278
+ identifier: AdapterIdentifier;
279
+ } | {
280
+ id: string;
281
+ }): Promise<AdapterUser | null>;
282
+ /** Notifications, including `otp.requested` when you deliver codes yourself. */
283
+ onEvent?(event: AdapterEvent): Promise<void>;
284
+ /** Signature tolerance in seconds (default 300). */
285
+ toleranceSeconds?: number;
286
+ }
287
+ /** Verifies a `9in-signature` header: t=<unix>,v1=<hex HMAC(t.METHOD.path.body)>. */
288
+ export declare function verifySignature(secret: string | string[], header: string | null, method: string, path: string, body: string, toleranceSeconds?: number): Promise<boolean>;
289
+ export declare function signPayload(secret: string, method: string, path: string, body: string): Promise<string>;
290
+ export type WebhookEventType = "user.created" | "user.updated" | "user.deleted" | "user.login" | "user.login_failed" | "user.password_changed" | "user.mfa_enabled" | "user.mfa_disabled" | "user.sessions_revoked" | "session.revoked" | "token.revoked" | "webhook.test";
291
+ export interface WebhookEvent<Data extends Record<string, unknown> = Record<string, unknown>> {
292
+ /** Event id: the same on every retry and redelivery, so you can ignore duplicates. */
293
+ id: string;
294
+ type: WebhookEventType;
295
+ createdAt: string;
296
+ /** Tenant id (tnt_…). */
297
+ tenant: string;
298
+ environment: "production" | "sandbox";
299
+ /** Event details, plus `userId` and a `user` snapshot (null once deleted) for user events. */
300
+ data: Data & {
301
+ userId?: string | null;
302
+ user?: ManagedUser | null;
303
+ };
304
+ }
305
+ export interface VerifyWebhookOptions {
306
+ /** Signature tolerance in seconds (default 300). */
307
+ toleranceSeconds?: number;
308
+ /** The path 9in posted to, when a proxy or framework rewrote `request.url` (default: its pathname). */
309
+ path?: string;
310
+ }
311
+ /**
312
+ * Verifies a webhook request from 9in (the `9in-signature` header over the raw body) and
313
+ * returns the parsed event. Throws `NineError` (`invalid_signature`, 401) otherwise. Reads
314
+ * the body, so pass `request.clone()` if you need it afterwards.
315
+ *
316
+ * const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
317
+ * if (event.type === "user.created") …
318
+ */
319
+ export declare function verifyWebhook(secret: string | string[], request: Request, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
320
+ /**
321
+ * Key-value store for `processEventOnce`. `add` must be atomic (insert only when the key is
322
+ * absent or expired): use a Durable Object, D1/SQL with a unique key, or Redis `SET NX EX`.
323
+ * Workers KV is eventually consistent and not suitable.
324
+ */
325
+ export interface EventStore {
326
+ add(key: string, value: string, ttlSeconds: number): Promise<boolean>;
327
+ get(key: string): Promise<string | null>;
328
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
329
+ delete(key: string): Promise<void>;
330
+ }
331
+ /** In-memory EventStore: one process only (tests, single-instance servers). */
332
+ export declare class MemoryEventStore implements EventStore {
333
+ private readonly items;
334
+ private live;
335
+ add(key: string, value: string, ttlSeconds: number): Promise<boolean>;
336
+ get(key: string): Promise<string | null>;
337
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
338
+ delete(key: string): Promise<void>;
339
+ }
340
+ export interface ProcessOnceOptions {
341
+ /** How long a processed id is remembered (default 3 days; 9in retries for about a day). */
342
+ ttlSeconds?: number;
343
+ /** How long a running handler holds the id before another delivery may take over (default 300). */
344
+ lockSeconds?: number;
345
+ /** Key prefix (default "9in:event:"). */
346
+ prefix?: string;
347
+ }
348
+ export type ProcessOnceResult<T> = {
349
+ status: "processed";
350
+ result: T;
351
+ } | {
352
+ status: "duplicate";
353
+ } | {
354
+ status: "in_progress";
355
+ };
356
+ /**
357
+ * Runs `handler` at most once per event id. 9in keeps the id on every retry and redelivery.
358
+ *
359
+ * - `processed`: first delivery, handler succeeded → answer 2xx.
360
+ * - `duplicate`: already processed → answer 2xx so 9in stops retrying.
361
+ * - `in_progress`: another delivery of the same event is still running → answer 409 so
362
+ * 9in tries again later.
363
+ * - The handler threw: the id is released and the error rethrown → answer 5xx so 9in retries.
364
+ *
365
+ * const event = await verifyWebhook(secret, request);
366
+ * const outcome = await processEventOnce(store, event.id, () => handle(event));
367
+ * return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
368
+ */
369
+ export declare function processEventOnce<T>(store: EventStore, eventId: string, handler: () => T | Promise<T>, options?: ProcessOnceOptions): Promise<ProcessOnceResult<T>>;
370
+ /**
371
+ * Fetch-API handler for the adapter protocol. Mount it at your adapter base URL (it serves
372
+ * `…/authenticate`, `…/user` and `…/event`) in Cloudflare Workers, Deno, Bun, Hono, or Node
373
+ * with any Request/Response bridge (Express: `@whatwg-node/server`, Next.js route handlers…).
374
+ */
375
+ export declare function adapterHandler(handlers: AdapterHandlers): (request: Request) => Promise<Response>;