@pramen/auth 0.0.8 → 0.0.9

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/dist/index.d.ts CHANGED
@@ -118,8 +118,63 @@ export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
118
118
  };
119
119
  }>;
120
120
  };
121
- /** Admin + self-service handlers over `auth_users`. Spread into your handler map
122
- * alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
121
+ /** Build admin + self-service handlers over a users table (default `auth_users`).
122
+ * Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
123
+ * extra `tenants` column — without renaming it: the handlers manage username/roles/
124
+ * email/active/delete and ignore any extra columns. Spread the result into your
125
+ * handler map and gate it with the matching `authPolicies({ table })`. The table must
126
+ * have a `username` primary key and (for changeEmail/changePassword) `email`/
127
+ * `passwordHash` columns. */
128
+ export declare function createUserHandlers(opts?: {
129
+ table?: string;
130
+ }): {
131
+ /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
132
+ * only the self policy sees just their own row; ungranted callers get a 403. */
133
+ listUsers: import("@pramen/server").Handler<{
134
+ limit?: number;
135
+ offset?: number;
136
+ }, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
137
+ [x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
138
+ }>)[]>;
139
+ /** Admin: replace a user's roles. The ACL admin update policy permits writing
140
+ * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
141
+ setUserRoles: import("@pramen/server").Handler<{
142
+ username: string;
143
+ roles: string[];
144
+ }, Record<string, unknown>>;
145
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
146
+ * token refresh); existing tokens still expire naturally within the TTL. */
147
+ setUserActive: import("@pramen/server").Handler<{
148
+ username: string;
149
+ active: boolean;
150
+ }, Record<string, unknown>>;
151
+ /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
152
+ * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
153
+ deleteUser: import("@pramen/server").Handler<{
154
+ username: string;
155
+ }, {
156
+ ok: boolean;
157
+ }>;
158
+ /** Self-service: change the caller's contact email. The ACL self policy scopes the
159
+ * write to the caller's own row and permits only the `email` field. Email is unique,
160
+ * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
161
+ changeEmail: import("@pramen/server").Handler<{
162
+ email: string;
163
+ }, Record<string, unknown>>;
164
+ /** Self-service: change the caller's password. A credential op — it reads the
165
+ * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
166
+ * password, then writes the new one. Self-scoped by the verified identity, so it
167
+ * never touches another row; passwordless (magic-link) users have no current
168
+ * password and are rejected. */
169
+ changePassword: import("@pramen/server").Handler<{
170
+ currentPassword: string;
171
+ newPassword: string;
172
+ }, {
173
+ ok: boolean;
174
+ }>;
175
+ };
176
+ /** The default user-management handlers over `auth_users`. Equivalent to
177
+ * `createUserHandlers()`; spread alongside `authHandlers`. */
123
178
  export declare const userHandlers: {
124
179
  /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
125
180
  * only the self policy sees just their own row; ungranted callers get a 403. */
@@ -166,18 +221,28 @@ export declare const userHandlers: {
166
221
  ok: boolean;
167
222
  }>;
168
223
  };
169
- /** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
170
- * role and `self` into your authenticated-user role:
224
+ /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
225
+ * into your admin role and `self` into your authenticated-user role:
171
226
  *
172
227
  * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
173
228
  * role("user", [...authPolicies().self, ...yourUserPolicies])
174
229
  *
175
230
  * `admin` grants read (projected) + update of roles/email/active on every user.
176
231
  * `self` grants each user read + email-update of ONLY their own row (matched on the
177
- * `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
232
+ * `userId` identity claim). passwordHash is in no policy, so it is never exposed.
233
+ *
234
+ * For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
235
+ * `prefix` (policy names must be unique across roles when you wire more than one
236
+ * instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
237
+ * (e.g. a `tenants` column managed by your own setUserTenants handler). */
178
238
  export declare function authPolicies(opts?: {
179
239
  table?: string;
180
240
  identityPath?: string;
241
+ prefix?: string;
242
+ adminReadFields?: string[];
243
+ adminWriteFields?: string[];
244
+ selfReadFields?: string[];
245
+ selfWriteFields?: string[];
181
246
  }): {
182
247
  admin: Policy[];
183
248
  self: Policy[];
package/dist/index.js CHANGED
@@ -206,27 +206,26 @@ export function createMagicLinkAuth(opts) {
206
206
  // Single-use: consume before issuing the session.
207
207
  await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
208
208
  const email = String(link.email);
209
- // Match on the EMAIL column (not username), so a magic link for an address a
210
- // password user later set via changeEmail logs into THAT account username
211
- // (the JWT sub) stays the user's stable identity. New users key username = email.
212
- const existing = await ctx.db.exec("SELECT username, roles, active FROM auth_users WHERE email = ? LIMIT 1", email);
213
- let username;
209
+ // Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
210
+ // `email` column: a magic-link user's username IS their email address, so this
211
+ // both matches existing users and avoids resolving login by a mutable, unverified
212
+ // field (which would let a changeEmail squat another address and would miss
213
+ // pre-`email`-column users on upgrade, colliding on the username PK).
214
+ const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
214
215
  let roles;
215
216
  if (existing.length > 0) {
216
217
  if (!isActive(existing[0].active))
217
218
  throw new Unauthorized("account is deactivated");
218
- username = String(existing[0].username);
219
219
  roles = JSON.parse(String(existing[0].roles));
220
220
  }
221
221
  else {
222
- username = email;
223
222
  roles = defaultRoles;
224
- await ctx.db.exec(
225
- // Empty passwordHash can never verify the user stays passwordless.
226
- "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", email, "", JSON.stringify(roles), email, Date.now());
223
+ // No email column set: it stays a pure contact attribute (set via changeEmail),
224
+ // so a new passwordless user can never collide with a password user's contact email.
225
+ await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
227
226
  }
228
- const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
229
- return { token, user: { username, roles } };
227
+ const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
228
+ return { token, user: { username: email, roles } };
230
229
  }, { input: parseLinkToken }),
231
230
  };
232
231
  }
@@ -255,115 +254,143 @@ function requireUserId(ctx) {
255
254
  return id;
256
255
  }
257
256
  const usersDb = (ctx) => ctx.db;
258
- /** Admin + self-service handlers over `auth_users`. Spread into your handler map
259
- * alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
260
- export const userHandlers = {
261
- /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
262
- * only the self policy sees just their own row; ungranted callers get a 403. */
263
- listUsers: query(async (ctx, input) => {
264
- const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
265
- const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
266
- return ctx.db.find({ from: "auth_users", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
267
- }),
268
- /** Admin: replace a user's roles. The ACL admin update policy permits writing
269
- * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
270
- setUserRoles: mutation(async (ctx, input) => {
271
- if (typeof input?.username !== "string" || input.username.length === 0)
272
- throw new BadRequest("username is required");
273
- if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
274
- throw new BadRequest("roles must be a non-empty string[]");
275
- }
276
- const updated = await usersDb(ctx).update("auth_users", input.username, { roles: input.roles });
277
- if (!updated)
278
- throw new BadRequest("user not found"); // (or out of the caller's update scope)
279
- return updated;
280
- }),
281
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
282
- * token refresh); existing tokens still expire naturally within the TTL. */
283
- setUserActive: mutation(async (ctx, input) => {
284
- if (typeof input?.username !== "string" || input.username.length === 0)
285
- throw new BadRequest("username is required");
286
- if (typeof input?.active !== "boolean")
287
- throw new BadRequest("active must be a boolean");
288
- if (input.active === false && input.username === ctx.identity?.userId) {
289
- throw new BadRequest("cannot deactivate your own account");
290
- }
291
- const updated = await usersDb(ctx).update("auth_users", input.username, { active: input.active });
292
- if (!updated)
293
- throw new BadRequest("user not found");
294
- return updated;
295
- }),
296
- /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
297
- * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
298
- deleteUser: mutation(async (ctx, input) => {
299
- if (typeof input?.username !== "string" || input.username.length === 0)
300
- throw new BadRequest("username is required");
301
- if (input.username === ctx.identity?.userId)
302
- throw new BadRequest("cannot delete your own account");
303
- const deleted = await usersDb(ctx).delete("auth_users", input.username);
304
- if (!deleted)
305
- throw new BadRequest("user not found");
306
- return { ok: true };
307
- }),
308
- /** Self-service: change the caller's contact email. The ACL self policy scopes the
309
- * write to the caller's own row and permits only the `email` field. Email is unique,
310
- * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
311
- changeEmail: mutation(async (ctx, input) => {
312
- const userId = requireUserId(ctx);
313
- const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
314
- const taken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? AND username != ? LIMIT 1", email, userId);
315
- if (taken.length > 0)
316
- throw new BadRequest("email already in use");
317
- const updated = await usersDb(ctx).update("auth_users", userId, { email });
318
- if (!updated)
319
- throw new Unauthorized("authentication required");
320
- return updated;
321
- }),
322
- /** Self-service: change the caller's password. A credential op it reads the
323
- * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
324
- * password, then writes the new one. Self-scoped by the verified identity, so it
325
- * never touches another row; passwordless (magic-link) users have no current
326
- * password and are rejected. */
327
- changePassword: mutation(async (ctx, input) => {
328
- const userId = requireUserId(ctx);
329
- const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
330
- const next = typeof input?.newPassword === "string" ? input.newPassword : "";
331
- if (next.length < 8)
332
- throw new BadRequest("newPassword must be at least 8 characters");
333
- const rows = await ctx.db.exec("SELECT passwordHash FROM auth_users WHERE username = ? LIMIT 1", userId);
334
- const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
335
- if (stored === "" || !(await verifyPassword(current, stored))) {
336
- throw new Unauthorized("current password is incorrect");
337
- }
338
- await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(next), userId);
339
- return { ok: true };
340
- }),
341
- };
257
+ // The users table must be a valid SQL identifier (it's interpolated into the raw exec
258
+ // strings below). It's app config, never request input, but guard it anyway.
259
+ function assertIdentifier(table) {
260
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
261
+ throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
262
+ return table;
263
+ }
264
+ /** Build admin + self-service handlers over a users table (default `auth_users`).
265
+ * Pass `table` to operate over your OWN authSchema-shaped table e.g. one with an
266
+ * extra `tenants` column — without renaming it: the handlers manage username/roles/
267
+ * email/active/delete and ignore any extra columns. Spread the result into your
268
+ * handler map and gate it with the matching `authPolicies({ table })`. The table must
269
+ * have a `username` primary key and (for changeEmail/changePassword) `email`/
270
+ * `passwordHash` columns. */
271
+ export function createUserHandlers(opts = {}) {
272
+ const table = assertIdentifier(opts.table ?? "auth_users");
273
+ return {
274
+ /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
275
+ * only the self policy sees just their own row; ungranted callers get a 403. */
276
+ listUsers: query(async (ctx, input) => {
277
+ const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
278
+ const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
279
+ return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
280
+ }),
281
+ /** Admin: replace a user's roles. The ACL admin update policy permits writing
282
+ * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
283
+ setUserRoles: mutation(async (ctx, input) => {
284
+ if (typeof input?.username !== "string" || input.username.length === 0)
285
+ throw new BadRequest("username is required");
286
+ if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
287
+ throw new BadRequest("roles must be a non-empty string[]");
288
+ }
289
+ const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
290
+ if (!updated)
291
+ throw new BadRequest("user not found"); // (or out of the caller's update scope)
292
+ return updated;
293
+ }),
294
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
295
+ * token refresh); existing tokens still expire naturally within the TTL. */
296
+ setUserActive: mutation(async (ctx, input) => {
297
+ if (typeof input?.username !== "string" || input.username.length === 0)
298
+ throw new BadRequest("username is required");
299
+ if (typeof input?.active !== "boolean")
300
+ throw new BadRequest("active must be a boolean");
301
+ if (input.active === false && input.username === ctx.identity?.userId) {
302
+ throw new BadRequest("cannot deactivate your own account");
303
+ }
304
+ const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
305
+ if (!updated)
306
+ throw new BadRequest("user not found");
307
+ return updated;
308
+ }),
309
+ /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
310
+ * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
311
+ deleteUser: mutation(async (ctx, input) => {
312
+ if (typeof input?.username !== "string" || input.username.length === 0)
313
+ throw new BadRequest("username is required");
314
+ if (input.username === ctx.identity?.userId)
315
+ throw new BadRequest("cannot delete your own account");
316
+ const deleted = await usersDb(ctx).delete(table, input.username);
317
+ if (!deleted)
318
+ throw new BadRequest("user not found");
319
+ return { ok: true };
320
+ }),
321
+ /** Self-service: change the caller's contact email. The ACL self policy scopes the
322
+ * write to the caller's own row and permits only the `email` field. Email is unique,
323
+ * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
324
+ changeEmail: mutation(async (ctx, input) => {
325
+ const userId = requireUserId(ctx);
326
+ const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
327
+ const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
328
+ if (taken.length > 0)
329
+ throw new BadRequest("email already in use");
330
+ const updated = await usersDb(ctx).update(table, userId, { email });
331
+ if (!updated)
332
+ throw new Unauthorized("authentication required");
333
+ return updated;
334
+ }),
335
+ /** Self-service: change the caller's password. A credential op — it reads the
336
+ * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
337
+ * password, then writes the new one. Self-scoped by the verified identity, so it
338
+ * never touches another row; passwordless (magic-link) users have no current
339
+ * password and are rejected. */
340
+ changePassword: mutation(async (ctx, input) => {
341
+ const userId = requireUserId(ctx);
342
+ const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
343
+ const next = typeof input?.newPassword === "string" ? input.newPassword : "";
344
+ if (next.length < 8)
345
+ throw new BadRequest("newPassword must be at least 8 characters");
346
+ const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
347
+ const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
348
+ if (stored === "" || !(await verifyPassword(current, stored))) {
349
+ throw new Unauthorized("current password is incorrect");
350
+ }
351
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
352
+ return { ok: true };
353
+ }),
354
+ };
355
+ }
356
+ /** The default user-management handlers over `auth_users`. Equivalent to
357
+ * `createUserHandlers()`; spread alongside `authHandlers`. */
358
+ export const userHandlers = createUserHandlers();
342
359
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
343
360
  const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
344
361
  // Fields an admin may see of any user (never passwordHash).
345
362
  const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
346
- /** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
347
- * role and `self` into your authenticated-user role:
363
+ /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
364
+ * into your admin role and `self` into your authenticated-user role:
348
365
  *
349
366
  * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
350
367
  * role("user", [...authPolicies().self, ...yourUserPolicies])
351
368
  *
352
369
  * `admin` grants read (projected) + update of roles/email/active on every user.
353
370
  * `self` grants each user read + email-update of ONLY their own row (matched on the
354
- * `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
371
+ * `userId` identity claim). passwordHash is in no policy, so it is never exposed.
372
+ *
373
+ * For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
374
+ * `prefix` (policy names must be unique across roles when you wire more than one
375
+ * instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
376
+ * (e.g. a `tenants` column managed by your own setUserTenants handler). */
355
377
  export function authPolicies(opts = {}) {
356
378
  const table = opts.table ?? "auth_users";
357
379
  const idPath = opts.identityPath ?? "userId";
380
+ const p = opts.prefix ?? "auth";
381
+ const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
382
+ const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
383
+ const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
384
+ const selfWrite = opts.selfWriteFields ?? ["email"];
358
385
  return {
359
386
  admin: [
360
- policy("auth:admin:read", table, "read", { fields: ADMIN_READ_FIELDS }),
361
- policy("auth:admin:update", table, "update", { fields: ["roles", "email", "active"] }),
362
- policy("auth:admin:delete", table, "delete", allow()),
387
+ policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
388
+ policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
389
+ policy(`${p}:admin:delete`, table, "delete", allow()),
363
390
  ],
364
391
  self: [
365
- policy("auth:self:read", table, "read", { where: { username: $identity(idPath) }, fields: SELF_READ_FIELDS }),
366
- policy("auth:self:update", table, "update", { where: { username: $identity(idPath) }, fields: ["email"] }),
392
+ policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
393
+ policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
367
394
  ],
368
395
  };
369
396
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,6 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pramen/server": "0.0.8"
37
+ "@pramen/server": "0.0.9"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -282,34 +282,30 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
282
282
  await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
283
283
 
284
284
  const email = String(link.email);
285
- // Match on the EMAIL column (not username), so a magic link for an address a
286
- // password user later set via changeEmail logs into THAT account username
287
- // (the JWT sub) stays the user's stable identity. New users key username = email.
288
- const existing = await ctx.db.exec(
289
- "SELECT username, roles, active FROM auth_users WHERE email = ? LIMIT 1",
290
- email,
291
- );
292
- let username: string;
285
+ // Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
286
+ // `email` column: a magic-link user's username IS their email address, so this
287
+ // both matches existing users and avoids resolving login by a mutable, unverified
288
+ // field (which would let a changeEmail squat another address — and would miss
289
+ // pre-`email`-column users on upgrade, colliding on the username PK).
290
+ const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
293
291
  let roles: string[];
294
292
  if (existing.length > 0) {
295
293
  if (!isActive(existing[0].active)) throw new Unauthorized("account is deactivated");
296
- username = String(existing[0].username);
297
294
  roles = JSON.parse(String(existing[0].roles)) as string[];
298
295
  } else {
299
- username = email;
300
296
  roles = defaultRoles;
297
+ // No email column set: it stays a pure contact attribute (set via changeEmail),
298
+ // so a new passwordless user can never collide with a password user's contact email.
301
299
  await ctx.db.exec(
302
- // Empty passwordHash can never verify the user stays passwordless.
303
- "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
300
+ "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
304
301
  email,
305
302
  "",
306
303
  JSON.stringify(roles),
307
- email,
308
304
  Date.now(),
309
305
  );
310
306
  }
311
- const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
312
- return { token, user: { username, roles } };
307
+ const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
308
+ return { token, user: { username: email, roles } };
313
309
  },
314
310
  { input: parseLinkToken },
315
311
  ),
@@ -343,119 +339,156 @@ function requireUserId(ctx: HandlerContext): string {
343
339
  }
344
340
 
345
341
  // `ctx.db` is schema-typed against the *app's* composed schema, which this package
346
- // can't import — so address auth_users through a minimal structural view of the ACL'd
347
- // Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
348
- interface AuthUsersDb {
349
- update(table: "auth_users", id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
350
- delete(table: "auth_users", id: string): Promise<boolean>;
342
+ // can't import — so address the users table through a minimal structural view of the
343
+ // ACL'd Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
344
+ interface UsersDb {
345
+ update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
346
+ delete(table: string, id: string): Promise<boolean>;
351
347
  }
352
- const usersDb = (ctx: HandlerContext): AuthUsersDb => ctx.db as unknown as AuthUsersDb;
353
-
354
- /** Admin + self-service handlers over `auth_users`. Spread into your handler map
355
- * alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
356
- export const userHandlers = {
357
- /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
358
- * only the self policy sees just their own row; ungranted callers get a 403. */
359
- listUsers: query(async (ctx, input: { limit?: number; offset?: number }) => {
360
- const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
361
- const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
362
- return ctx.db.find({ from: "auth_users", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
363
- }),
364
-
365
- /** Admin: replace a user's roles. The ACL admin update policy permits writing
366
- * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
367
- setUserRoles: mutation(async (ctx, input: { username: string; roles: string[] }) => {
368
- if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
369
- if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
370
- throw new BadRequest("roles must be a non-empty string[]");
371
- }
372
- const updated = await usersDb(ctx).update("auth_users", input.username, { roles: input.roles });
373
- if (!updated) throw new BadRequest("user not found"); // (or out of the caller's update scope)
374
- return updated;
375
- }),
376
-
377
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
378
- * token refresh); existing tokens still expire naturally within the TTL. */
379
- setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
380
- if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
381
- if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
382
- if (input.active === false && input.username === ctx.identity?.userId) {
383
- throw new BadRequest("cannot deactivate your own account");
384
- }
385
- const updated = await usersDb(ctx).update("auth_users", input.username, { active: input.active });
386
- if (!updated) throw new BadRequest("user not found");
387
- return updated;
388
- }),
389
-
390
- /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
391
- * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
392
- deleteUser: mutation(async (ctx, input: { username: string }) => {
393
- if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
394
- if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
395
- const deleted = await usersDb(ctx).delete("auth_users", input.username);
396
- if (!deleted) throw new BadRequest("user not found");
397
- return { ok: true };
398
- }),
399
-
400
- /** Self-service: change the caller's contact email. The ACL self policy scopes the
401
- * write to the caller's own row and permits only the `email` field. Email is unique,
402
- * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
403
- changeEmail: mutation(async (ctx, input: { email: string }) => {
404
- const userId = requireUserId(ctx);
405
- const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
406
- const taken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? AND username != ? LIMIT 1", email, userId);
407
- if (taken.length > 0) throw new BadRequest("email already in use");
408
- const updated = await usersDb(ctx).update("auth_users", userId, { email });
409
- if (!updated) throw new Unauthorized("authentication required");
410
- return updated;
411
- }),
412
-
413
- /** Self-service: change the caller's password. A credential op — it reads the
414
- * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
415
- * password, then writes the new one. Self-scoped by the verified identity, so it
416
- * never touches another row; passwordless (magic-link) users have no current
417
- * password and are rejected. */
418
- changePassword: mutation(async (ctx, input: { currentPassword: string; newPassword: string }) => {
419
- const userId = requireUserId(ctx);
420
- const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
421
- const next = typeof input?.newPassword === "string" ? input.newPassword : "";
422
- if (next.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
423
- const rows = await ctx.db.exec("SELECT passwordHash FROM auth_users WHERE username = ? LIMIT 1", userId);
424
- const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
425
- if (stored === "" || !(await verifyPassword(current, stored))) {
426
- throw new Unauthorized("current password is incorrect");
427
- }
428
- await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(next), userId);
429
- return { ok: true };
430
- }),
431
- };
348
+ const usersDb = (ctx: HandlerContext): UsersDb => ctx.db as unknown as UsersDb;
349
+
350
+ // The users table must be a valid SQL identifier (it's interpolated into the raw exec
351
+ // strings below). It's app config, never request input, but guard it anyway.
352
+ function assertIdentifier(table: string): string {
353
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
354
+ return table;
355
+ }
356
+
357
+ /** Build admin + self-service handlers over a users table (default `auth_users`).
358
+ * Pass `table` to operate over your OWN authSchema-shaped table e.g. one with an
359
+ * extra `tenants` column — without renaming it: the handlers manage username/roles/
360
+ * email/active/delete and ignore any extra columns. Spread the result into your
361
+ * handler map and gate it with the matching `authPolicies({ table })`. The table must
362
+ * have a `username` primary key and (for changeEmail/changePassword) `email`/
363
+ * `passwordHash` columns. */
364
+ export function createUserHandlers(opts: { table?: string } = {}) {
365
+ const table = assertIdentifier(opts.table ?? "auth_users");
366
+ return {
367
+ /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
368
+ * only the self policy sees just their own row; ungranted callers get a 403. */
369
+ listUsers: query(async (ctx, input: { limit?: number; offset?: number }) => {
370
+ const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
371
+ const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
372
+ return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
373
+ }),
374
+
375
+ /** Admin: replace a user's roles. The ACL admin update policy permits writing
376
+ * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
377
+ setUserRoles: mutation(async (ctx, input: { username: string; roles: string[] }) => {
378
+ if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
379
+ if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
380
+ throw new BadRequest("roles must be a non-empty string[]");
381
+ }
382
+ const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
383
+ if (!updated) throw new BadRequest("user not found"); // (or out of the caller's update scope)
384
+ return updated;
385
+ }),
386
+
387
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
388
+ * token refresh); existing tokens still expire naturally within the TTL. */
389
+ setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
390
+ if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
391
+ if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
392
+ if (input.active === false && input.username === ctx.identity?.userId) {
393
+ throw new BadRequest("cannot deactivate your own account");
394
+ }
395
+ const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
396
+ if (!updated) throw new BadRequest("user not found");
397
+ return updated;
398
+ }),
399
+
400
+ /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
401
+ * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
402
+ deleteUser: mutation(async (ctx, input: { username: string }) => {
403
+ if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
404
+ if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
405
+ const deleted = await usersDb(ctx).delete(table, input.username);
406
+ if (!deleted) throw new BadRequest("user not found");
407
+ return { ok: true };
408
+ }),
409
+
410
+ /** Self-service: change the caller's contact email. The ACL self policy scopes the
411
+ * write to the caller's own row and permits only the `email` field. Email is unique,
412
+ * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
413
+ changeEmail: mutation(async (ctx, input: { email: string }) => {
414
+ const userId = requireUserId(ctx);
415
+ const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
416
+ const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
417
+ if (taken.length > 0) throw new BadRequest("email already in use");
418
+ const updated = await usersDb(ctx).update(table, userId, { email });
419
+ if (!updated) throw new Unauthorized("authentication required");
420
+ return updated;
421
+ }),
422
+
423
+ /** Self-service: change the caller's password. A credential op — it reads the
424
+ * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
425
+ * password, then writes the new one. Self-scoped by the verified identity, so it
426
+ * never touches another row; passwordless (magic-link) users have no current
427
+ * password and are rejected. */
428
+ changePassword: mutation(async (ctx, input: { currentPassword: string; newPassword: string }) => {
429
+ const userId = requireUserId(ctx);
430
+ const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
431
+ const next = typeof input?.newPassword === "string" ? input.newPassword : "";
432
+ if (next.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
433
+ const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
434
+ const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
435
+ if (stored === "" || !(await verifyPassword(current, stored))) {
436
+ throw new Unauthorized("current password is incorrect");
437
+ }
438
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
439
+ return { ok: true };
440
+ }),
441
+ };
442
+ }
443
+
444
+ /** The default user-management handlers over `auth_users`. Equivalent to
445
+ * `createUserHandlers()`; spread alongside `authHandlers`. */
446
+ export const userHandlers = createUserHandlers();
432
447
 
433
448
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
434
449
  const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
435
450
  // Fields an admin may see of any user (never passwordHash).
436
451
  const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
437
452
 
438
- /** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
439
- * role and `self` into your authenticated-user role:
453
+ /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
454
+ * into your admin role and `self` into your authenticated-user role:
440
455
  *
441
456
  * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
442
457
  * role("user", [...authPolicies().self, ...yourUserPolicies])
443
458
  *
444
459
  * `admin` grants read (projected) + update of roles/email/active on every user.
445
460
  * `self` grants each user read + email-update of ONLY their own row (matched on the
446
- * `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
447
- export function authPolicies(opts: { table?: string; identityPath?: string } = {}): { admin: Policy[]; self: Policy[] } {
461
+ * `userId` identity claim). passwordHash is in no policy, so it is never exposed.
462
+ *
463
+ * For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
464
+ * `prefix` (policy names must be unique across roles when you wire more than one
465
+ * instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
466
+ * (e.g. a `tenants` column managed by your own setUserTenants handler). */
467
+ export function authPolicies(opts: {
468
+ table?: string;
469
+ identityPath?: string;
470
+ prefix?: string;
471
+ adminReadFields?: string[];
472
+ adminWriteFields?: string[];
473
+ selfReadFields?: string[];
474
+ selfWriteFields?: string[];
475
+ } = {}): { admin: Policy[]; self: Policy[] } {
448
476
  const table = opts.table ?? "auth_users";
449
477
  const idPath = opts.identityPath ?? "userId";
478
+ const p = opts.prefix ?? "auth";
479
+ const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
480
+ const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
481
+ const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
482
+ const selfWrite = opts.selfWriteFields ?? ["email"];
450
483
  return {
451
484
  admin: [
452
- policy("auth:admin:read", table, "read", { fields: ADMIN_READ_FIELDS }),
453
- policy("auth:admin:update", table, "update", { fields: ["roles", "email", "active"] }),
454
- policy("auth:admin:delete", table, "delete", allow()),
485
+ policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
486
+ policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
487
+ policy(`${p}:admin:delete`, table, "delete", allow()),
455
488
  ],
456
489
  self: [
457
- policy("auth:self:read", table, "read", { where: { username: $identity(idPath) }, fields: SELF_READ_FIELDS }),
458
- policy("auth:self:update", table, "update", { where: { username: $identity(idPath) }, fields: ["email"] }),
490
+ policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
491
+ policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
459
492
  ],
460
493
  };
461
494
  }