@9in-io/sdk 0.1.1 → 0.2.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/CHANGELOG.md +24 -0
- package/README.md +16 -6
- package/dist/server.d.ts +83 -38
- package/dist/server.js +9 -6
- package/package.json +4 -2
- package/src/index.ts +325 -0
- package/src/server.ts +843 -0
- package/src/util.ts +121 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Breaking: 9in's wire formats are now snake_case throughout, with ISO 8601 UTC times
|
|
6
|
+
(see "API conventions" in the docs). Needs the 9in release of 2026-11-10.
|
|
7
|
+
|
|
8
|
+
- `WebhookEvent`: `type` → `event_type`, `createdAt` → `created_at`; `data.userId` is gone, use
|
|
9
|
+
`data.user.id`. For `user.deleted`, `data.user` is the user as it was before the deletion.
|
|
10
|
+
`token.revoked` data has `token_type` and `client_id`.
|
|
11
|
+
- `ManagedUser`: `created_at`, `updated_at`, `last_login_at` (ISO strings), `login_count`,
|
|
12
|
+
and every profile field.
|
|
13
|
+
- `ManagedSession`: `user_id`, `auth_time`, `user_agent`, `created_at`, `last_seen_at`,
|
|
14
|
+
`expires_at` (ISO strings); `revokedAt` removed.
|
|
15
|
+
- `getUser` returns `ManagedUserDetail` (`user`, `identities`, `sessions`, `has_password`,
|
|
16
|
+
`mfa`); `listUsers` returns `next_cursor`.
|
|
17
|
+
- `NewUser` / `ImportUser`: `email_verified`, `phone_number`, `phone_number_verified`,
|
|
18
|
+
`password_hash`, and ISO `created_at` / `last_login_at`. The server refuses the old names.
|
|
19
|
+
- `findUser("phone_number", …)` replaces `findUser("phone", …)`.
|
|
20
|
+
- `createLoginLink` returns `expires_at` as an ISO string.
|
|
21
|
+
- `AdapterEvent`: `event_type`, `created_at`, and `data.user_id` / `data.expires_at`.
|
|
22
|
+
- `TokenVerifier.handleEvent` reads the new webhook shape.
|
|
23
|
+
- The package ships `src/` too, so the `@9in/source` export condition resolves (0.1.x listed
|
|
24
|
+
it without shipping the files, which broke bundlers configured with that condition).
|
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ webhook events, or a short `cacheTtl`.
|
|
|
88
88
|
import { ManagementClient } from "@9in-io/sdk/server";
|
|
89
89
|
|
|
90
90
|
const nine = new ManagementClient({ issuer, clientId, clientSecret }); // app with management scopes
|
|
91
|
-
await nine.createUser({ email: "jane@acme.com", password: "…",
|
|
91
|
+
await nine.createUser({ email: "jane@acme.com", password: "…", email_verified: true });
|
|
92
|
+
await nine.updateUser(userId, { phone_number: "+447700900000", phone_number_verified: true });
|
|
92
93
|
await nine.blockUser(userId);
|
|
93
|
-
const { user } = await nine.findUser("
|
|
94
|
+
const { user } = await nine.findUser("phone_number", "+447700900000"); // or "email", "username"
|
|
94
95
|
|
|
95
96
|
// Sessions: list them, end one (its sid), or sign the user out everywhere
|
|
96
97
|
const { sessions } = await nine.listUserSessions(userId);
|
|
@@ -101,9 +102,16 @@ await nine.revokeUserSessions(userId);
|
|
|
101
102
|
const { url } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", ttlSeconds: 600 });
|
|
102
103
|
|
|
103
104
|
// Bulk import (500 per call): old ids, password hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities
|
|
104
|
-
const { results } = await nine.importUsers(
|
|
105
|
+
const { results } = await nine.importUsers(
|
|
106
|
+
[{ id: "auth0|42", email: "jane@acme.com", password_hash: "$2b$10$…", created_at: "2021-03-04T05:06:07Z" }],
|
|
107
|
+
{ onConflict: "skip", source: "auth0" },
|
|
108
|
+
);
|
|
105
109
|
```
|
|
106
110
|
|
|
111
|
+
Users, sessions and requests use snake_case field names, the same going in and coming out
|
|
112
|
+
(`email_verified`, `phone_number`, `password_hash`), and times are ISO 8601 UTC strings
|
|
113
|
+
(`created_at`, `last_login_at`). Method options stay camelCase (`returnTo`, `onConflict`).
|
|
114
|
+
|
|
107
115
|
## Keep users in your own database (adapter)
|
|
108
116
|
|
|
109
117
|
```ts
|
|
@@ -123,7 +131,8 @@ export default {
|
|
|
123
131
|
return user ? toAdapterUser(user) : null;
|
|
124
132
|
},
|
|
125
133
|
async onEvent(event) {
|
|
126
|
-
|
|
134
|
+
// { id, event_type, created_at, data }; data.user_id is your own user id
|
|
135
|
+
if (event.event_type === "otp.requested") await sendCode(event.data); // user_id, channel, to, code, purpose, expires_at
|
|
127
136
|
},
|
|
128
137
|
}),
|
|
129
138
|
};
|
|
@@ -144,8 +153,9 @@ import { verifyWebhook } from "@9in-io/sdk/server";
|
|
|
144
153
|
export default {
|
|
145
154
|
async fetch(request: Request, env: { NINE_WEBHOOK_SECRET: string }) {
|
|
146
155
|
const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
147
|
-
// event.id is the same on every
|
|
148
|
-
|
|
156
|
+
// { id, event_type, created_at, tenant, environment, data }. event.id is the same on every
|
|
157
|
+
// retry and redelivery: ignore ones you have seen. data.user is the user the event is about.
|
|
158
|
+
if (event.event_type === "user.created") await addToCrm(event.data.user);
|
|
149
159
|
return new Response(null, { status: 204 });
|
|
150
160
|
},
|
|
151
161
|
};
|
package/dist/server.d.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface TokenVerifier {
|
|
|
69
69
|
* user.sessions_revoked, user.updated and user.deleted).
|
|
70
70
|
*/
|
|
71
71
|
handleEvent(event: {
|
|
72
|
-
|
|
72
|
+
event_type: string;
|
|
73
73
|
data: Record<string, unknown>;
|
|
74
74
|
}): number;
|
|
75
75
|
/** Forgets every cached answer. */
|
|
@@ -94,42 +94,73 @@ export interface ManagementOptions {
|
|
|
94
94
|
allowInsecureIssuer?: boolean;
|
|
95
95
|
fetch?: typeof fetch;
|
|
96
96
|
}
|
|
97
|
+
/** A user as the Management API and webhooks send it. Times are ISO 8601 UTC strings. */
|
|
97
98
|
export interface ManagedUser {
|
|
98
99
|
id: string;
|
|
99
100
|
email: string | null;
|
|
100
101
|
email_verified: boolean;
|
|
101
102
|
phone_number: string | null;
|
|
103
|
+
phone_number_verified: boolean;
|
|
102
104
|
username: string | null;
|
|
105
|
+
preferred_username: string | null;
|
|
103
106
|
name: string | null;
|
|
107
|
+
given_name: string | null;
|
|
108
|
+
middle_name: string | null;
|
|
109
|
+
family_name: string | null;
|
|
110
|
+
nickname: string | null;
|
|
111
|
+
gender: string | null;
|
|
112
|
+
website: string | null;
|
|
104
113
|
picture: string | null;
|
|
114
|
+
locale: string | null;
|
|
115
|
+
zoneinfo: string | null;
|
|
116
|
+
birthdate: string | null;
|
|
105
117
|
blocked: boolean;
|
|
106
118
|
metadata: Record<string, unknown>;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
created_at: string;
|
|
120
|
+
updated_at: string;
|
|
121
|
+
last_login_at: string | null;
|
|
122
|
+
login_count: number;
|
|
110
123
|
}
|
|
111
|
-
/** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are
|
|
124
|
+
/** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ISO 8601 UTC strings. */
|
|
112
125
|
export interface ManagedSession {
|
|
113
126
|
sid: string;
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
user_id: string;
|
|
128
|
+
auth_time: string;
|
|
116
129
|
amr: string[];
|
|
117
130
|
ip: string;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
user_agent: string;
|
|
132
|
+
created_at: string;
|
|
133
|
+
last_seen_at: string;
|
|
134
|
+
expires_at: string;
|
|
135
|
+
}
|
|
136
|
+
/** A linked sign-in of a user (`social:google`, `enterprise:<id>`, `adapter`, …). */
|
|
137
|
+
export interface ManagedIdentity {
|
|
138
|
+
connection: string;
|
|
139
|
+
subject: string;
|
|
140
|
+
profile: Record<string, unknown>;
|
|
141
|
+
created_at: string;
|
|
142
|
+
}
|
|
143
|
+
/** `getUser`: the user with their identities, active sessions and two-factor state. */
|
|
144
|
+
export interface ManagedUserDetail {
|
|
145
|
+
user: ManagedUser;
|
|
146
|
+
identities: ManagedIdentity[];
|
|
147
|
+
sessions: ManagedSession[];
|
|
148
|
+
has_password: boolean;
|
|
149
|
+
mfa: {
|
|
150
|
+
enabled: boolean;
|
|
151
|
+
enabled_at?: string;
|
|
152
|
+
recovery_codes_left?: number;
|
|
153
|
+
};
|
|
123
154
|
}
|
|
124
155
|
/** One record for `importUsers`: the fields of `createUser` plus what an import may carry over. */
|
|
125
156
|
export interface ImportUser {
|
|
126
157
|
/** Keep the id from your old system (letters, digits and | _ . : @ + -, max 128). */
|
|
127
158
|
id?: string;
|
|
128
159
|
email?: string | null;
|
|
129
|
-
|
|
160
|
+
email_verified?: boolean;
|
|
130
161
|
/** E.164, e.g. +447700900000. */
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
phone_number?: string | null;
|
|
163
|
+
phone_number_verified?: boolean;
|
|
133
164
|
username?: string | null;
|
|
134
165
|
name?: string | null;
|
|
135
166
|
given_name?: string | null;
|
|
@@ -140,21 +171,21 @@ export interface ImportUser {
|
|
|
140
171
|
birthdate?: string | null;
|
|
141
172
|
metadata?: Record<string, unknown>;
|
|
142
173
|
blocked?: boolean;
|
|
143
|
-
/** A plain password (at most 50 such records per call), or `
|
|
174
|
+
/** A plain password (at most 50 such records per call), or `password_hash` from your old system. */
|
|
144
175
|
password?: string;
|
|
145
176
|
/** bcrypt, argon2, PBKDF2 or Firebase scrypt hash: users keep their password. */
|
|
146
|
-
|
|
177
|
+
password_hash?: string;
|
|
147
178
|
/** Linked sign-ins, e.g. { connection: "social:google", subject: "1069…" }. */
|
|
148
179
|
identities?: {
|
|
149
180
|
connection: string;
|
|
150
181
|
subject: string;
|
|
151
182
|
}[];
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
183
|
+
/** Original dates, ISO 8601 (`Z` or an offset). */
|
|
184
|
+
created_at?: string;
|
|
185
|
+
last_login_at?: string;
|
|
155
186
|
}
|
|
156
|
-
/** Fields of a new user (`createUser
|
|
157
|
-
export type NewUser = Omit<ImportUser, "id" | "identities" | "
|
|
187
|
+
/** Fields of a new user (`createUser`); `updateUser` takes any of them. */
|
|
188
|
+
export type NewUser = Omit<ImportUser, "id" | "identities" | "created_at" | "last_login_at">;
|
|
158
189
|
export type ImportResult = {
|
|
159
190
|
index: number;
|
|
160
191
|
ok: boolean;
|
|
@@ -185,16 +216,16 @@ export declare class ManagementClient {
|
|
|
185
216
|
limit?: number;
|
|
186
217
|
}): Promise<{
|
|
187
218
|
users: ManagedUser[];
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
getUser(id: string): Promise<{
|
|
191
|
-
user: ManagedUser;
|
|
219
|
+
next_cursor: string | null;
|
|
220
|
+
total: number;
|
|
192
221
|
}>;
|
|
222
|
+
/** The user with their identities, active sessions and two-factor state. */
|
|
223
|
+
getUser(id: string): Promise<ManagedUserDetail>;
|
|
193
224
|
findUserByEmail(email: string): Promise<{
|
|
194
225
|
user: ManagedUser;
|
|
195
226
|
}>;
|
|
196
|
-
/** Looks a user up by email, phone (E.164) or username; throws `not_found` (404) when unknown. */
|
|
197
|
-
findUser(type: "email" | "
|
|
227
|
+
/** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
|
|
228
|
+
findUser(type: "email" | "phone_number" | "username", value: string): Promise<{
|
|
198
229
|
user: ManagedUser;
|
|
199
230
|
}>;
|
|
200
231
|
createUser(input: NewUser): Promise<{
|
|
@@ -211,7 +242,8 @@ export declare class ManagementClient {
|
|
|
211
242
|
}): Promise<{
|
|
212
243
|
results: ImportResult[];
|
|
213
244
|
}>;
|
|
214
|
-
|
|
245
|
+
/** Changes only the fields you send; `metadata` is replaced as a whole. */
|
|
246
|
+
updateUser(id: string, input: Partial<NewUser>): Promise<{
|
|
215
247
|
user: ManagedUser;
|
|
216
248
|
}>;
|
|
217
249
|
deleteUser(id: string): Promise<void>;
|
|
@@ -228,7 +260,7 @@ export declare class ManagementClient {
|
|
|
228
260
|
ttlSeconds?: number;
|
|
229
261
|
}): Promise<{
|
|
230
262
|
url: string;
|
|
231
|
-
expires_at:
|
|
263
|
+
expires_at: string;
|
|
232
264
|
}>;
|
|
233
265
|
/** The user's active sessions, most recently used first. */
|
|
234
266
|
listUserSessions(id: string): Promise<{
|
|
@@ -262,11 +294,20 @@ export interface AdapterUser {
|
|
|
262
294
|
blocked?: boolean | null;
|
|
263
295
|
metadata?: Record<string, unknown> | null;
|
|
264
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* What 9in POSTs to `…/event`. `data.user_id` is your own user id. `otp.requested` carries
|
|
299
|
+
* `user_id, channel, to, code, purpose, expires_at`; `user.updated` and
|
|
300
|
+
* `user.password_changed` carry `user_id, fields`.
|
|
301
|
+
*/
|
|
265
302
|
export interface AdapterEvent {
|
|
266
303
|
id: string;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
304
|
+
event_type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
|
|
305
|
+
/** ISO 8601 UTC. */
|
|
306
|
+
created_at: string;
|
|
307
|
+
data: {
|
|
308
|
+
user_id?: string;
|
|
309
|
+
[field: string]: unknown;
|
|
310
|
+
};
|
|
270
311
|
}
|
|
271
312
|
export interface AdapterHandlers {
|
|
272
313
|
/** Shared secret from the console (Connections → User database). Several allow rotation. */
|
|
@@ -291,14 +332,18 @@ export type WebhookEventType = "user.created" | "user.updated" | "user.deleted"
|
|
|
291
332
|
export interface WebhookEvent<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
292
333
|
/** Event id: the same on every retry and redelivery, so you can ignore duplicates. */
|
|
293
334
|
id: string;
|
|
294
|
-
type
|
|
295
|
-
|
|
335
|
+
/** The event name, also in the `9in-event-type` header. */
|
|
336
|
+
event_type: WebhookEventType;
|
|
337
|
+
/** ISO 8601 UTC. */
|
|
338
|
+
created_at: string;
|
|
296
339
|
/** Tenant id (tnt_…). */
|
|
297
340
|
tenant: string;
|
|
298
341
|
environment: "production" | "sandbox";
|
|
299
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* Event details, plus the `user` the event is about (null when there is none; for
|
|
344
|
+
* `user.deleted`, the user as it was just before the deletion).
|
|
345
|
+
*/
|
|
300
346
|
data: Data & {
|
|
301
|
-
userId?: string | null;
|
|
302
347
|
user?: ManagedUser | null;
|
|
303
348
|
};
|
|
304
349
|
}
|
|
@@ -314,7 +359,7 @@ export interface VerifyWebhookOptions {
|
|
|
314
359
|
* the body, so pass `request.clone()` if you need it afterwards.
|
|
315
360
|
*
|
|
316
361
|
* const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
317
|
-
* if (event.
|
|
362
|
+
* if (event.event_type === "user.created") …
|
|
318
363
|
*/
|
|
319
364
|
export declare function verifyWebhook(secret: string | string[], request: Request, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
|
|
320
365
|
/**
|
package/dist/server.js
CHANGED
|
@@ -221,13 +221,14 @@ export function createTokenVerifier(options) {
|
|
|
221
221
|
},
|
|
222
222
|
handleEvent(event) {
|
|
223
223
|
const data = event.data ?? {};
|
|
224
|
+
const userId = data.user?.id;
|
|
224
225
|
let match = null;
|
|
225
|
-
if (event.
|
|
226
|
+
if (event.event_type === "token.revoked" && data.token_type === "access_token" && data.jti)
|
|
226
227
|
match = (c) => c.jti === data.jti;
|
|
227
|
-
else if (event.
|
|
228
|
+
else if (event.event_type === "session.revoked" && data.sid)
|
|
228
229
|
match = (c) => c.sid === data.sid;
|
|
229
|
-
else if (["user.sessions_revoked", "user.deleted", "user.updated", "user.password_changed"].includes(event.
|
|
230
|
-
match = (c) => c.sub ===
|
|
230
|
+
else if (["user.sessions_revoked", "user.deleted", "user.updated", "user.password_changed"].includes(event.event_type) && typeof userId === "string") {
|
|
231
|
+
match = (c) => c.sub === userId;
|
|
231
232
|
}
|
|
232
233
|
if (!match)
|
|
233
234
|
return 0;
|
|
@@ -289,13 +290,14 @@ export class ManagementClient {
|
|
|
289
290
|
params.set("limit", String(options.limit));
|
|
290
291
|
return this.request("GET", `/users?${params}`);
|
|
291
292
|
}
|
|
293
|
+
/** The user with their identities, active sessions and two-factor state. */
|
|
292
294
|
getUser(id) {
|
|
293
295
|
return this.request("GET", `/users/${encodeURIComponent(id)}`);
|
|
294
296
|
}
|
|
295
297
|
findUserByEmail(email) {
|
|
296
298
|
return this.findUser("email", email);
|
|
297
299
|
}
|
|
298
|
-
/** Looks a user up by email, phone (E.164) or username; throws `not_found` (404) when unknown. */
|
|
300
|
+
/** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
|
|
299
301
|
findUser(type, value) {
|
|
300
302
|
return this.request("GET", `/users/lookup?${type}=${encodeURIComponent(value)}`);
|
|
301
303
|
}
|
|
@@ -314,6 +316,7 @@ export class ManagementClient {
|
|
|
314
316
|
...(options.source ? { source: options.source } : {}),
|
|
315
317
|
});
|
|
316
318
|
}
|
|
319
|
+
/** Changes only the fields you send; `metadata` is replaced as a whole. */
|
|
317
320
|
updateUser(id, input) {
|
|
318
321
|
return this.request("PATCH", `/users/${encodeURIComponent(id)}`, input);
|
|
319
322
|
}
|
|
@@ -386,7 +389,7 @@ export async function signPayload(secret, method, path, body) {
|
|
|
386
389
|
* the body, so pass `request.clone()` if you need it afterwards.
|
|
387
390
|
*
|
|
388
391
|
* const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
389
|
-
* if (event.
|
|
392
|
+
* if (event.event_type === "user.created") …
|
|
390
393
|
*/
|
|
391
394
|
export async function verifyWebhook(secret, request, options = {}) {
|
|
392
395
|
const body = await request.text();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@9in-io/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Official 9in SDK: browser sign-in with PKCE, token verification, Management API client and adapter handler. Zero dependencies; runs in browsers, Node 18+, Cloudflare Workers, Deno and Bun.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|
|
28
|
-
"
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
29
31
|
],
|
|
30
32
|
"keywords": [
|
|
31
33
|
"oauth",
|