@pramen/auth 0.0.7 → 0.0.8

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
@@ -1,4 +1,4 @@
1
- import type { HandlerContext } from "@pramen/server";
1
+ import type { HandlerContext, Policy } from "@pramen/server";
2
2
  export declare const authSchema: {
3
3
  auth_users: import("@pramen/server").EntityDef<{
4
4
  username: {
@@ -8,10 +8,22 @@ export declare const authSchema: {
8
8
  };
9
9
  passwordHash: {
10
10
  readonly type: "text";
11
+ } & {
12
+ readonly hidden: true;
11
13
  };
12
14
  roles: {
13
15
  readonly type: "json";
14
16
  };
17
+ email: {
18
+ readonly type: "text";
19
+ } & {
20
+ readonly unique: true;
21
+ };
22
+ active: {
23
+ readonly type: "boolean";
24
+ } & {
25
+ readonly default: true;
26
+ };
15
27
  createdAt: {
16
28
  readonly type: "integer";
17
29
  };
@@ -106,3 +118,67 @@ export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
106
118
  };
107
119
  }>;
108
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. */
123
+ export declare const userHandlers: {
124
+ /** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
125
+ * only the self policy sees just their own row; ungranted callers get a 403. */
126
+ listUsers: import("@pramen/server").Handler<{
127
+ limit?: number;
128
+ offset?: number;
129
+ }, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
130
+ [x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
131
+ }>)[]>;
132
+ /** Admin: replace a user's roles. The ACL admin update policy permits writing
133
+ * `roles`; a self-only caller can't (so this is admin-gated declaratively). */
134
+ setUserRoles: import("@pramen/server").Handler<{
135
+ username: string;
136
+ roles: string[];
137
+ }, Record<string, unknown>>;
138
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
139
+ * token refresh); existing tokens still expire naturally within the TTL. */
140
+ setUserActive: import("@pramen/server").Handler<{
141
+ username: string;
142
+ active: boolean;
143
+ }, Record<string, unknown>>;
144
+ /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
145
+ * cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
146
+ deleteUser: import("@pramen/server").Handler<{
147
+ username: string;
148
+ }, {
149
+ ok: boolean;
150
+ }>;
151
+ /** Self-service: change the caller's contact email. The ACL self policy scopes the
152
+ * write to the caller's own row and permits only the `email` field. Email is unique,
153
+ * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
154
+ changeEmail: import("@pramen/server").Handler<{
155
+ email: string;
156
+ }, Record<string, unknown>>;
157
+ /** Self-service: change the caller's password. A credential op — it reads the
158
+ * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
159
+ * password, then writes the new one. Self-scoped by the verified identity, so it
160
+ * never touches another row; passwordless (magic-link) users have no current
161
+ * password and are rejected. */
162
+ changePassword: import("@pramen/server").Handler<{
163
+ currentPassword: string;
164
+ newPassword: string;
165
+ }, {
166
+ ok: boolean;
167
+ }>;
168
+ };
169
+ /** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
170
+ * role and `self` into your authenticated-user role:
171
+ *
172
+ * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
173
+ * role("user", [...authPolicies().self, ...yourUserPolicies])
174
+ *
175
+ * `admin` grants read (projected) + update of roles/email/active on every user.
176
+ * `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. */
178
+ export declare function authPolicies(opts?: {
179
+ table?: string;
180
+ identityPath?: string;
181
+ }): {
182
+ admin: Policy[];
183
+ self: Policy[];
184
+ };
package/dist/index.js CHANGED
@@ -15,13 +15,15 @@
15
15
  // Passwordless magic-link login is also available via createMagicLinkAuth (spread
16
16
  // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
17
  // the token lifecycle. See createMagicLinkAuth below.
18
- import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
18
+ import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
19
19
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
20
20
  export const authSchema = {
21
21
  auth_users: Entity((t) => ({
22
- username: t.textId(),
23
- passwordHash: t.text(),
22
+ username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
23
+ passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
24
24
  roles: t.json(), // string[]
25
+ email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
26
+ active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
25
27
  createdAt: t.int(),
26
28
  })),
27
29
  };
@@ -86,6 +88,12 @@ function secretOf(ctx) {
86
88
  }
87
89
  const DEFAULT_ROLES = ["user"];
88
90
  const TOKEN_TTL_SECONDS = 3600;
91
+ /** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
92
+ * shorten it to tighten the deactivation/role-change window), else 1h. */
93
+ function sessionTtlOf(ctx) {
94
+ const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
95
+ return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
96
+ }
89
97
  function parseCreds(raw) {
90
98
  const o = (raw ?? {});
91
99
  if (typeof o.username !== "string" || o.username.length === 0)
@@ -103,17 +111,21 @@ export const authHandlers = {
103
111
  throw new BadRequest("username is taken");
104
112
  const roles = DEFAULT_ROLES;
105
113
  await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, await hashPassword(input.password), JSON.stringify(roles), Date.now());
106
- const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
114
+ const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
107
115
  return { token, user: { username: input.username, roles } };
108
116
  }, { input: parseCreds }),
109
117
  login: mutation(async (ctx, input) => {
110
- const rows = await ctx.db.exec("SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1", input.username);
118
+ const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
111
119
  const u = rows[0];
112
120
  if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
113
121
  throw new Unauthorized("invalid username or password");
114
122
  }
123
+ // Only after the password verifies (so this can't enumerate accounts): a
124
+ // deactivated user gets no new token. Existing tokens expire within the TTL.
125
+ if (!isActive(u.active))
126
+ throw new Unauthorized("account is deactivated");
115
127
  const roles = JSON.parse(String(u.roles));
116
- const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
128
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
117
129
  return { token, user: { username: String(u.username), roles } };
118
130
  }, { input: parseCreds }),
119
131
  me: query((ctx) => ctx.identity),
@@ -194,19 +206,164 @@ export function createMagicLinkAuth(opts) {
194
206
  // Single-use: consume before issuing the session.
195
207
  await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
196
208
  const email = String(link.email);
197
- const existing = await ctx.db.exec("SELECT roles FROM auth_users WHERE username = ? LIMIT 1", 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;
198
214
  let roles;
199
215
  if (existing.length > 0) {
216
+ if (!isActive(existing[0].active))
217
+ throw new Unauthorized("account is deactivated");
218
+ username = String(existing[0].username);
200
219
  roles = JSON.parse(String(existing[0].roles));
201
220
  }
202
221
  else {
222
+ username = email;
203
223
  roles = defaultRoles;
204
224
  await ctx.db.exec(
205
225
  // Empty passwordHash can never verify → the user stays passwordless.
206
- "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
226
+ "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", email, "", JSON.stringify(roles), email, Date.now());
207
227
  }
208
- const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
209
- return { token, user: { username: email, roles } };
228
+ const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
229
+ return { token, user: { username, roles } };
210
230
  }, { input: parseLinkToken }),
211
231
  };
212
232
  }
233
+ // --- user management ---------------------------------------------------------
234
+ //
235
+ // Admin + self-service operations over `auth_users`, built the pramen way: ordinary
236
+ // handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
237
+ // checks. They are inert until you grant access — spread `authPolicies()` into your
238
+ // roles (admin manages everyone; the authenticated user manages only itself). Because
239
+ // the admin read policy restricts `fields`, `passwordHash` is never projected back.
240
+ //
241
+ // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
242
+ // take effect on the user's NEXT login — not instantly. That lag is the cost of the
243
+ // stateless, verify-only core (no session store, by design). Tune the revocation window
244
+ // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
245
+ // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
246
+ // deliberately left to the app rather than building a session store into the core.
247
+ /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
248
+ function isActive(v) {
249
+ return v == null || Number(v) !== 0;
250
+ }
251
+ function requireUserId(ctx) {
252
+ const id = ctx.identity?.userId;
253
+ if (typeof id !== "string" || id.length === 0)
254
+ throw new Unauthorized("authentication required");
255
+ return id;
256
+ }
257
+ 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
+ };
342
+ // Fields a self-service caller may see of their own row (never passwordHash/roles).
343
+ const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
344
+ // Fields an admin may see of any user (never passwordHash).
345
+ 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:
348
+ *
349
+ * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
350
+ * role("user", [...authPolicies().self, ...yourUserPolicies])
351
+ *
352
+ * `admin` grants read (projected) + update of roles/email/active on every user.
353
+ * `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. */
355
+ export function authPolicies(opts = {}) {
356
+ const table = opts.table ?? "auth_users";
357
+ const idPath = opts.identityPath ?? "userId";
358
+ return {
359
+ 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()),
363
+ ],
364
+ 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"] }),
367
+ ],
368
+ };
369
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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.7"
37
+ "@pramen/server": "0.0.8"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -16,16 +16,18 @@
16
16
  // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
17
  // the token lifecycle. See createMagicLinkAuth below.
18
18
 
19
- import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
20
- import type { HandlerContext } from "@pramen/server";
19
+ import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
20
+ import type { HandlerContext, Policy } from "@pramen/server";
21
21
 
22
22
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
23
23
 
24
24
  export const authSchema = {
25
25
  auth_users: Entity((t) => ({
26
- username: t.textId(),
27
- passwordHash: t.text(),
26
+ username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
27
+ passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
28
28
  roles: t.json(), // string[]
29
+ email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
30
+ active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
29
31
  createdAt: t.int(),
30
32
  })),
31
33
  };
@@ -109,6 +111,13 @@ function secretOf(ctx: HandlerContext): string {
109
111
  const DEFAULT_ROLES = ["user"];
110
112
  const TOKEN_TTL_SECONDS = 3600;
111
113
 
114
+ /** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
115
+ * shorten it to tighten the deactivation/role-change window), else 1h. */
116
+ function sessionTtlOf(ctx: HandlerContext): number {
117
+ const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
118
+ return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
119
+ }
120
+
112
121
  function parseCreds(raw: unknown): { username: string; password: string } {
113
122
  const o = (raw ?? {}) as Record<string, unknown>;
114
123
  if (typeof o.username !== "string" || o.username.length === 0) throw new Error("username is required");
@@ -131,7 +140,7 @@ export const authHandlers = {
131
140
  JSON.stringify(roles),
132
141
  Date.now(),
133
142
  );
134
- const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
143
+ const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
135
144
  return { token, user: { username: input.username, roles } };
136
145
  },
137
146
  { input: parseCreds },
@@ -140,15 +149,18 @@ export const authHandlers = {
140
149
  login: mutation(
141
150
  async (ctx, input: { username: string; password: string }) => {
142
151
  const rows = await ctx.db.exec(
143
- "SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1",
152
+ "SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1",
144
153
  input.username,
145
154
  );
146
155
  const u = rows[0];
147
156
  if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
148
157
  throw new Unauthorized("invalid username or password");
149
158
  }
159
+ // Only after the password verifies (so this can't enumerate accounts): a
160
+ // deactivated user gets no new token. Existing tokens expire within the TTL.
161
+ if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
150
162
  const roles = JSON.parse(String(u.roles)) as string[];
151
- const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: TOKEN_TTL_SECONDS });
163
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
152
164
  return { token, user: { username: String(u.username), roles } };
153
165
  },
154
166
  { input: parseCreds },
@@ -270,25 +282,180 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
270
282
  await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
271
283
 
272
284
  const email = String(link.email);
273
- const existing = await ctx.db.exec("SELECT roles FROM auth_users WHERE username = ? LIMIT 1", 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;
274
293
  let roles: string[];
275
294
  if (existing.length > 0) {
295
+ if (!isActive(existing[0].active)) throw new Unauthorized("account is deactivated");
296
+ username = String(existing[0].username);
276
297
  roles = JSON.parse(String(existing[0].roles)) as string[];
277
298
  } else {
299
+ username = email;
278
300
  roles = defaultRoles;
279
301
  await ctx.db.exec(
280
302
  // Empty passwordHash can never verify → the user stays passwordless.
281
- "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
303
+ "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
282
304
  email,
283
305
  "",
284
306
  JSON.stringify(roles),
307
+ email,
285
308
  Date.now(),
286
309
  );
287
310
  }
288
- const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
289
- return { token, user: { username: email, roles } };
311
+ const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
312
+ return { token, user: { username, roles } };
290
313
  },
291
314
  { input: parseLinkToken },
292
315
  ),
293
316
  };
294
317
  }
318
+
319
+ // --- user management ---------------------------------------------------------
320
+ //
321
+ // Admin + self-service operations over `auth_users`, built the pramen way: ordinary
322
+ // handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
323
+ // checks. They are inert until you grant access — spread `authPolicies()` into your
324
+ // roles (admin manages everyone; the authenticated user manages only itself). Because
325
+ // the admin read policy restricts `fields`, `passwordHash` is never projected back.
326
+ //
327
+ // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
328
+ // take effect on the user's NEXT login — not instantly. That lag is the cost of the
329
+ // stateless, verify-only core (no session store, by design). Tune the revocation window
330
+ // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
331
+ // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
332
+ // deliberately left to the app rather than building a session store into the core.
333
+
334
+ /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
335
+ function isActive(v: unknown): boolean {
336
+ return v == null || Number(v) !== 0;
337
+ }
338
+
339
+ function requireUserId(ctx: HandlerContext): string {
340
+ const id = ctx.identity?.userId;
341
+ if (typeof id !== "string" || id.length === 0) throw new Unauthorized("authentication required");
342
+ return id;
343
+ }
344
+
345
+ // `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>;
351
+ }
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
+ };
432
+
433
+ // Fields a self-service caller may see of their own row (never passwordHash/roles).
434
+ const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
435
+ // Fields an admin may see of any user (never passwordHash).
436
+ const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
437
+
438
+ /** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
439
+ * role and `self` into your authenticated-user role:
440
+ *
441
+ * role("admin", [...authPolicies().admin, ...yourAdminPolicies])
442
+ * role("user", [...authPolicies().self, ...yourUserPolicies])
443
+ *
444
+ * `admin` grants read (projected) + update of roles/email/active on every user.
445
+ * `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[] } {
448
+ const table = opts.table ?? "auth_users";
449
+ const idPath = opts.identityPath ?? "userId";
450
+ return {
451
+ 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()),
455
+ ],
456
+ 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"] }),
459
+ ],
460
+ };
461
+ }