@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 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: "…", emailVerified: true });
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("phone", "+447700900000"); // or "email", "username"
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([{ id: "auth0|42", email: "jane@acme.com", passwordHash: "$2b$10$…" }], { onConflict: "skip", source: "auth0" });
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
- if (event.type === "otp.requested") await sendCode(event.data);
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 retry and redelivery: ignore ones you have seen.
148
- if (event.type === "user.created") await addToCrm(event.data.user);
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
- type: string;
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
- createdAt: number;
108
- lastLoginAt: number | null;
109
- [field: string]: unknown;
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 ms since epoch. */
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
- userId: string;
115
- authTime: number;
127
+ user_id: string;
128
+ auth_time: string;
116
129
  amr: string[];
117
130
  ip: string;
118
- userAgent: string;
119
- createdAt: number;
120
- lastSeenAt: number;
121
- expiresAt: number;
122
- revokedAt: number | null;
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
- emailVerified?: boolean;
160
+ email_verified?: boolean;
130
161
  /** E.164, e.g. +447700900000. */
131
- phone?: string | null;
132
- phoneVerified?: boolean;
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 `passwordHash` from your old system. */
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
- passwordHash?: string;
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
- /** ms since epoch */
153
- createdAt?: number;
154
- lastLoginAt?: number;
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`, `updateUser`). */
157
- export type NewUser = Omit<ImportUser, "id" | "identities" | "createdAt" | "lastLoginAt">;
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
- nextCursor: string | null;
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" | "phone" | "username", value: string): Promise<{
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
- updateUser(id: string, input: Record<string, unknown>): Promise<{
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: number;
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
- type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
268
- createdAt: string;
269
- data: Record<string, unknown>;
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: WebhookEventType;
295
- createdAt: string;
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
- /** Event details, plus `userId` and a `user` snapshot (null once deleted) for user events. */
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.type === "user.created") …
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.type === "token.revoked" && data.type === "access_token" && data.jti)
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.type === "session.revoked" && data.sid)
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.type) && data.userId) {
230
- match = (c) => c.sub === data.userId;
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.type === "user.created") …
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.1.1",
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
- "README.md"
28
+ "src",
29
+ "README.md",
30
+ "CHANGELOG.md"
29
31
  ],
30
32
  "keywords": [
31
33
  "oauth",