@9in-io/sdk 0.1.0 → 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,14 +88,30 @@ 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);
94
+ const { user } = await nine.findUser("phone_number", "+447700900000"); // or "email", "username"
95
+
96
+ // Sessions: list them, end one (its sid), or sign the user out everywhere
97
+ const { sessions } = await nine.listUserSessions(userId);
98
+ await nine.revokeSession(sessions[0].sid);
93
99
  await nine.revokeUserSessions(userId);
94
100
 
95
101
  // Single-use sign-in link (magic links, moving a signed-in user from your app to 9in)
96
102
  const { url } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", ttlSeconds: 600 });
103
+
104
+ // Bulk import (500 per call): old ids, password hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities
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
+ );
97
109
  ```
98
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
+
99
115
  ## Keep users in your own database (adapter)
100
116
 
101
117
  ```ts
@@ -115,7 +131,8 @@ export default {
115
131
  return user ? toAdapterUser(user) : null;
116
132
  },
117
133
  async onEvent(event) {
118
- 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
119
136
  },
120
137
  }),
121
138
  };
@@ -136,8 +153,9 @@ import { verifyWebhook } from "@9in-io/sdk/server";
136
153
  export default {
137
154
  async fetch(request: Request, env: { NINE_WEBHOOK_SECRET: string }) {
138
155
  const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
139
- // event.id is the same on every retry and redelivery: ignore ones you have seen.
140
- 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);
141
159
  return new Response(null, { status: 204 });
142
160
  },
143
161
  };
package/dist/index.js CHANGED
@@ -107,17 +107,24 @@ export class NineAuth {
107
107
  throw new NineError("invalid_issuer", `unexpected issuer ${iss}`);
108
108
  const config = await discover(this.issuer, this.fetcher);
109
109
  const tokens = await postForm(config.token_endpoint, { grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, client_id: this.options.clientId, code_verifier: pending.verifier }, {}, this.fetcher);
110
- const set = this.store(tokens);
111
- if (set.idToken) {
110
+ if (typeof tokens.id_token === "string") {
112
111
  // The ID token came straight from the token endpoint over TLS (OIDC Core 3.1.3.7):
113
112
  // its claims are checked; the signature check is for servers (verifyAccessToken).
114
- const { payload } = decodeJwt(set.idToken);
113
+ // Checked before anything is stored, so a refused sign-in leaves no tokens behind.
114
+ let payload;
115
+ try {
116
+ payload = decodeJwt(tokens.id_token).payload;
117
+ }
118
+ catch {
119
+ throw new NineError("invalid_id_token", "malformed ID token");
120
+ }
115
121
  const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
116
122
  if (!aud.includes(this.options.clientId))
117
123
  throw new NineError("invalid_id_token", "audience mismatch");
118
124
  if (payload.nonce !== pending.nonce)
119
125
  throw new NineError("invalid_id_token", "nonce mismatch");
120
126
  }
127
+ this.store(tokens);
121
128
  if (typeof window !== "undefined" && window.history && url === window.location.href) {
122
129
  for (const p of ["code", "state", "iss", "session_state"])
123
130
  current.searchParams.delete(p);
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,20 +94,113 @@ 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
  }
124
+ /** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ISO 8601 UTC strings. */
125
+ export interface ManagedSession {
126
+ sid: string;
127
+ user_id: string;
128
+ auth_time: string;
129
+ amr: string[];
130
+ ip: string;
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
+ };
154
+ }
155
+ /** One record for `importUsers`: the fields of `createUser` plus what an import may carry over. */
156
+ export interface ImportUser {
157
+ /** Keep the id from your old system (letters, digits and | _ . : @ + -, max 128). */
158
+ id?: string;
159
+ email?: string | null;
160
+ email_verified?: boolean;
161
+ /** E.164, e.g. +447700900000. */
162
+ phone_number?: string | null;
163
+ phone_number_verified?: boolean;
164
+ username?: string | null;
165
+ name?: string | null;
166
+ given_name?: string | null;
167
+ family_name?: string | null;
168
+ picture?: string | null;
169
+ locale?: string | null;
170
+ zoneinfo?: string | null;
171
+ birthdate?: string | null;
172
+ metadata?: Record<string, unknown>;
173
+ blocked?: boolean;
174
+ /** A plain password (at most 50 such records per call), or `password_hash` from your old system. */
175
+ password?: string;
176
+ /** bcrypt, argon2, PBKDF2 or Firebase scrypt hash: users keep their password. */
177
+ password_hash?: string;
178
+ /** Linked sign-ins, e.g. { connection: "social:google", subject: "1069…" }. */
179
+ identities?: {
180
+ connection: string;
181
+ subject: string;
182
+ }[];
183
+ /** Original dates, ISO 8601 (`Z` or an offset). */
184
+ created_at?: string;
185
+ last_login_at?: string;
186
+ }
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">;
189
+ export type ImportResult = {
190
+ index: number;
191
+ ok: boolean;
192
+ } & ({
193
+ status: "created" | "updated";
194
+ id: string;
195
+ } | {
196
+ status: "skipped";
197
+ id: string;
198
+ reason: string;
199
+ } | {
200
+ status: "failed";
201
+ error: string;
202
+ details?: unknown;
203
+ });
111
204
  export declare class ManagementClient {
112
205
  private readonly options;
113
206
  /** One token per scope: each call asks only for what it needs, so an app granted some scopes works. */
@@ -123,26 +216,34 @@ export declare class ManagementClient {
123
216
  limit?: number;
124
217
  }): Promise<{
125
218
  users: ManagedUser[];
126
- nextCursor: string | null;
219
+ next_cursor: string | null;
220
+ total: number;
127
221
  }>;
128
- getUser(id: string): Promise<{
222
+ /** The user with their identities, active sessions and two-factor state. */
223
+ getUser(id: string): Promise<ManagedUserDetail>;
224
+ findUserByEmail(email: string): Promise<{
129
225
  user: ManagedUser;
130
226
  }>;
131
- findUserByEmail(email: 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<{
132
229
  user: ManagedUser;
133
230
  }>;
134
- createUser(input: {
135
- email?: string;
136
- phone?: string;
137
- username?: string;
138
- password?: string;
139
- name?: string;
140
- emailVerified?: boolean;
141
- metadata?: Record<string, unknown>;
142
- }): Promise<{
231
+ createUser(input: NewUser): Promise<{
143
232
  user: ManagedUser;
144
233
  }>;
145
- updateUser(id: string, input: Record<string, unknown>): Promise<{
234
+ /**
235
+ * Bulk import, up to 500 users per call. Records may keep their old id and carry password
236
+ * hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities and original dates.
237
+ * Results are per record, in order; one bad record does not stop the others.
238
+ */
239
+ importUsers(users: ImportUser[], options?: {
240
+ onConflict?: "skip" | "update";
241
+ source?: string;
242
+ }): Promise<{
243
+ results: ImportResult[];
244
+ }>;
245
+ /** Changes only the fields you send; `metadata` is replaced as a whole. */
246
+ updateUser(id: string, input: Partial<NewUser>): Promise<{
146
247
  user: ManagedUser;
147
248
  }>;
148
249
  deleteUser(id: string): Promise<void>;
@@ -159,11 +260,18 @@ export declare class ManagementClient {
159
260
  ttlSeconds?: number;
160
261
  }): Promise<{
161
262
  url: string;
162
- expires_at: number;
263
+ expires_at: string;
163
264
  }>;
265
+ /** The user's active sessions, most recently used first. */
266
+ listUserSessions(id: string): Promise<{
267
+ sessions: ManagedSession[];
268
+ }>;
269
+ /** Signs the user out everywhere; returns how many sessions ended. */
164
270
  revokeUserSessions(id: string): Promise<{
165
271
  revoked: number;
166
272
  }>;
273
+ /** Ends one session (its `sid`) and every token issued under it. */
274
+ revokeSession(sid: string): Promise<void>;
167
275
  }
168
276
  export interface AdapterIdentifier {
169
277
  type: "email" | "username" | "phone";
@@ -186,11 +294,20 @@ export interface AdapterUser {
186
294
  blocked?: boolean | null;
187
295
  metadata?: Record<string, unknown> | null;
188
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
+ */
189
302
  export interface AdapterEvent {
190
303
  id: string;
191
- type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
192
- createdAt: string;
193
- 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
+ };
194
311
  }
195
312
  export interface AdapterHandlers {
196
313
  /** Shared secret from the console (Connections → User database). Several allow rotation. */
@@ -215,14 +332,18 @@ export type WebhookEventType = "user.created" | "user.updated" | "user.deleted"
215
332
  export interface WebhookEvent<Data extends Record<string, unknown> = Record<string, unknown>> {
216
333
  /** Event id: the same on every retry and redelivery, so you can ignore duplicates. */
217
334
  id: string;
218
- type: WebhookEventType;
219
- 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;
220
339
  /** Tenant id (tnt_…). */
221
340
  tenant: string;
222
341
  environment: "production" | "sandbox";
223
- /** 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
+ */
224
346
  data: Data & {
225
- userId?: string | null;
226
347
  user?: ManagedUser | null;
227
348
  };
228
349
  }
@@ -238,7 +359,7 @@ export interface VerifyWebhookOptions {
238
359
  * the body, so pass `request.clone()` if you need it afterwards.
239
360
  *
240
361
  * const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
241
- * if (event.type === "user.created") …
362
+ * if (event.event_type === "user.created") …
242
363
  */
243
364
  export declare function verifyWebhook(secret: string | string[], request: Request, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
244
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,15 +290,33 @@ 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
- return this.request("GET", `/users/lookup?email=${encodeURIComponent(email)}`);
298
+ return this.findUser("email", email);
299
+ }
300
+ /** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
301
+ findUser(type, value) {
302
+ return this.request("GET", `/users/lookup?${type}=${encodeURIComponent(value)}`);
297
303
  }
298
304
  createUser(input) {
299
305
  return this.request("POST", "/users", input);
300
306
  }
307
+ /**
308
+ * Bulk import, up to 500 users per call. Records may keep their old id and carry password
309
+ * hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities and original dates.
310
+ * Results are per record, in order; one bad record does not stop the others.
311
+ */
312
+ importUsers(users, options = {}) {
313
+ return this.request("POST", "/users/import", {
314
+ users,
315
+ ...(options.onConflict ? { on_conflict: options.onConflict } : {}),
316
+ ...(options.source ? { source: options.source } : {}),
317
+ });
318
+ }
319
+ /** Changes only the fields you send; `metadata` is replaced as a whole. */
301
320
  updateUser(id, input) {
302
321
  return this.request("PATCH", `/users/${encodeURIComponent(id)}`, input);
303
322
  }
@@ -318,9 +337,18 @@ export class ManagementClient {
318
337
  ...(options.ttlSeconds ? { ttl_seconds: options.ttlSeconds } : {}),
319
338
  });
320
339
  }
340
+ /** The user's active sessions, most recently used first. */
341
+ listUserSessions(id) {
342
+ return this.request("GET", `/users/${encodeURIComponent(id)}/sessions`);
343
+ }
344
+ /** Signs the user out everywhere; returns how many sessions ended. */
321
345
  revokeUserSessions(id) {
322
346
  return this.request("DELETE", `/users/${encodeURIComponent(id)}/sessions`);
323
347
  }
348
+ /** Ends one session (its `sid`) and every token issued under it. */
349
+ revokeSession(sid) {
350
+ return this.request("DELETE", `/sessions/${encodeURIComponent(sid)}`);
351
+ }
324
352
  }
325
353
  const SIGNATURE_HEADER = "9in-signature";
326
354
  async function hmac(secret, payload) {
@@ -361,7 +389,7 @@ export async function signPayload(secret, method, path, body) {
361
389
  * the body, so pass `request.clone()` if you need it afterwards.
362
390
  *
363
391
  * const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
364
- * if (event.type === "user.created") …
392
+ * if (event.event_type === "user.created") …
365
393
  */
366
394
  export async function verifyWebhook(secret, request, options = {}) {
367
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.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
- "README.md"
28
+ "src",
29
+ "README.md",
30
+ "CHANGELOG.md"
29
31
  ],
30
32
  "keywords": [
31
33
  "oauth",