@pramen/auth 0.0.17 → 0.0.19

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, Policy } from "@pramen/server";
1
+ import type { AppTaskMap, HandlerContext, HandlerMap, Policy } from "@pramen/server";
2
2
  export declare const authSchema: {
3
3
  auth_users: import("@pramen/server").EntityDef<{
4
4
  username: {
@@ -86,8 +86,11 @@ export interface MagicLinkOptions {
86
86
  * On Cloudflare the recommended transport is Cloudflare Email Sending — a
87
87
  * `send_email` binding (no API keys), e.g.
88
88
  * `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
89
- * (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
90
- * failure leaves no orphan token and surfaces to the caller to retry. */
89
+ * (see example/app.ts + oblaka.ts). Called from the `sendMagicLinkEmail` TASK, not
90
+ * inline in the mutation a slow SMTP/API call can't hold the mutation's storage
91
+ * transaction open and time the store out. Retries follow the outbox retry policy;
92
+ * a permanent failure dead-letters the task and the token expires unused (users just
93
+ * request a new link). */
91
94
  sendEmail: (ctx: HandlerContext, args: {
92
95
  email: string;
93
96
  token: string;
@@ -99,24 +102,27 @@ export interface MagicLinkOptions {
99
102
  /** Roles assigned when a magic-link login first creates the user. Default `["user"]`. */
100
103
  defaultRoles?: string[];
101
104
  }
102
- /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
103
- * result into your handler map (and `magicLinkSchema` into your schema). Both are
104
- * anonymous — gate nothing; the token is the capability. */
105
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair, plus the
106
+ * `sendMagicLinkEmail` task handler that actually invokes `opts.sendEmail`.
107
+ *
108
+ * ```
109
+ * const magicLink = createMagicLinkAuth({ sendEmail: ... });
110
+ * const handlers = { ...cmsHandlers, ...authHandlers, ...magicLink.handlers };
111
+ * const tasks = { ...cmsTasks, ...magicLink.tasks };
112
+ * ```
113
+ *
114
+ * The `requestMagicLink` handler writes the token and ENQUEUES a task (atomic with
115
+ * the write via the transactional outbox); the drainer runs `sendMagicLinkEmail`
116
+ * AFTER commit, outside the mutation's storage transaction. This avoids the class
117
+ * of failure where a slow SMTP/API call holds the storage lock long enough for the
118
+ * store to time out and reset the underlying object.
119
+ *
120
+ * You MUST spread `magicLink.tasks` into your app's task map — without it, tokens
121
+ * get written but the email never sends (the drainer retries then dead-letters).
122
+ * Both handlers are anonymous — gate nothing; the token is the capability. */
105
123
  export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
106
- requestMagicLink: import("@pramen/server").Handler<{
107
- email: string;
108
- }, {
109
- ok: boolean;
110
- }>;
111
- loginWithMagicLink: import("@pramen/server").Handler<{
112
- token: string;
113
- }, {
114
- token: string;
115
- user: {
116
- username: string;
117
- roles: string[];
118
- };
119
- }>;
124
+ handlers: HandlerMap;
125
+ tasks: AppTaskMap;
120
126
  };
121
127
  /** Build admin + self-service handlers over a users table (default `auth_users`).
122
128
  * Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
package/dist/index.js CHANGED
@@ -227,14 +227,29 @@ function parseLinkToken(raw) {
227
227
  throw new BadRequest("token is required");
228
228
  return { token: o.token };
229
229
  }
230
- /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
231
- * result into your handler map (and `magicLinkSchema` into your schema). Both are
232
- * anonymous — gate nothing; the token is the capability. */
230
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair, plus the
231
+ * `sendMagicLinkEmail` task handler that actually invokes `opts.sendEmail`.
232
+ *
233
+ * ```
234
+ * const magicLink = createMagicLinkAuth({ sendEmail: ... });
235
+ * const handlers = { ...cmsHandlers, ...authHandlers, ...magicLink.handlers };
236
+ * const tasks = { ...cmsTasks, ...magicLink.tasks };
237
+ * ```
238
+ *
239
+ * The `requestMagicLink` handler writes the token and ENQUEUES a task (atomic with
240
+ * the write via the transactional outbox); the drainer runs `sendMagicLinkEmail`
241
+ * AFTER commit, outside the mutation's storage transaction. This avoids the class
242
+ * of failure where a slow SMTP/API call holds the storage lock long enough for the
243
+ * store to time out and reset the underlying object.
244
+ *
245
+ * You MUST spread `magicLink.tasks` into your app's task map — without it, tokens
246
+ * get written but the email never sends (the drainer retries then dead-letters).
247
+ * Both handlers are anonymous — gate nothing; the token is the capability. */
233
248
  export function createMagicLinkAuth(opts) {
234
249
  const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
235
250
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
236
251
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
237
- return {
252
+ const handlers = {
238
253
  requestMagicLink: mutation(async (ctx, input) => {
239
254
  const token = mintToken();
240
255
  const tokenHash = await sha256Hex(token);
@@ -242,8 +257,11 @@ export function createMagicLinkAuth(opts) {
242
257
  // Invalidate any prior pending links for this email — only the latest works.
243
258
  await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", input.email);
244
259
  await ctx.db.exec("INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)", tokenHash, input.email, now + linkTtlMs, now);
245
- // Inside the mutation transaction: a throw here rolls the token back.
246
- await opts.sendEmail(ctx, { email: input.email, token });
260
+ // Defer the actual email send. Enqueue is a DB write into the outbox, so it
261
+ // commits atomically with the token insert. If commit fails, the task never
262
+ // runs. If the task fails, retries + eventual dead-letter; the token expires
263
+ // (linkTtl) and the user re-requests.
264
+ await ctx.tasks.enqueue({ kind: "sendMagicLinkEmail", payload: { email: input.email, token } });
247
265
  return { ok: true };
248
266
  }, { input: parseEmail }),
249
267
  loginWithMagicLink: mutation(async (ctx, input) => {
@@ -278,6 +296,13 @@ export function createMagicLinkAuth(opts) {
278
296
  return { token, user: { username: email, roles } };
279
297
  }, { input: parseLinkToken }),
280
298
  };
299
+ const tasks = {
300
+ sendMagicLinkEmail: async (ctx, payload) => {
301
+ const p = payload;
302
+ await opts.sendEmail(ctx, { email: p.email, token: p.token });
303
+ },
304
+ };
305
+ return { handlers, tasks };
281
306
  }
282
307
  // --- user management ---------------------------------------------------------
283
308
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
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.17"
37
+ "@pramen/server": "0.0.19"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // the token lifecycle. See createMagicLinkAuth below.
18
18
 
19
19
  import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
20
- import type { HandlerContext, Policy } from "@pramen/server";
20
+ import type { AppTaskMap, HandlerContext, HandlerMap, Policy } from "@pramen/server";
21
21
 
22
22
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
23
23
 
@@ -277,8 +277,11 @@ export interface MagicLinkOptions {
277
277
  * On Cloudflare the recommended transport is Cloudflare Email Sending — a
278
278
  * `send_email` binding (no API keys), e.g.
279
279
  * `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
280
- * (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
281
- * failure leaves no orphan token and surfaces to the caller to retry. */
280
+ * (see example/app.ts + oblaka.ts). Called from the `sendMagicLinkEmail` TASK, not
281
+ * inline in the mutation a slow SMTP/API call can't hold the mutation's storage
282
+ * transaction open and time the store out. Retries follow the outbox retry policy;
283
+ * a permanent failure dead-letters the task and the token expires unused (users just
284
+ * request a new link). */
282
285
  sendEmail: (ctx: HandlerContext, args: { email: string; token: string }) => void | Promise<void>;
283
286
  /** How long the emailed link stays valid, in seconds. Default 900 (15 min). */
284
287
  linkTtlSeconds?: number;
@@ -288,15 +291,30 @@ export interface MagicLinkOptions {
288
291
  defaultRoles?: string[];
289
292
  }
290
293
 
291
- /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
292
- * result into your handler map (and `magicLinkSchema` into your schema). Both are
293
- * anonymous — gate nothing; the token is the capability. */
294
- export function createMagicLinkAuth(opts: MagicLinkOptions) {
294
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair, plus the
295
+ * `sendMagicLinkEmail` task handler that actually invokes `opts.sendEmail`.
296
+ *
297
+ * ```
298
+ * const magicLink = createMagicLinkAuth({ sendEmail: ... });
299
+ * const handlers = { ...cmsHandlers, ...authHandlers, ...magicLink.handlers };
300
+ * const tasks = { ...cmsTasks, ...magicLink.tasks };
301
+ * ```
302
+ *
303
+ * The `requestMagicLink` handler writes the token and ENQUEUES a task (atomic with
304
+ * the write via the transactional outbox); the drainer runs `sendMagicLinkEmail`
305
+ * AFTER commit, outside the mutation's storage transaction. This avoids the class
306
+ * of failure where a slow SMTP/API call holds the storage lock long enough for the
307
+ * store to time out and reset the underlying object.
308
+ *
309
+ * You MUST spread `magicLink.tasks` into your app's task map — without it, tokens
310
+ * get written but the email never sends (the drainer retries then dead-letters).
311
+ * Both handlers are anonymous — gate nothing; the token is the capability. */
312
+ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: HandlerMap; tasks: AppTaskMap } {
295
313
  const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
296
314
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
297
315
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
298
316
 
299
- return {
317
+ const handlers: HandlerMap = {
300
318
  requestMagicLink: mutation(
301
319
  async (ctx, input: { email: string }) => {
302
320
  const token = mintToken();
@@ -311,8 +329,11 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
311
329
  now + linkTtlMs,
312
330
  now,
313
331
  );
314
- // Inside the mutation transaction: a throw here rolls the token back.
315
- await opts.sendEmail(ctx, { email: input.email, token });
332
+ // Defer the actual email send. Enqueue is a DB write into the outbox, so it
333
+ // commits atomically with the token insert. If commit fails, the task never
334
+ // runs. If the task fails, retries + eventual dead-letter; the token expires
335
+ // (linkTtl) and the user re-requests.
336
+ await ctx.tasks.enqueue({ kind: "sendMagicLinkEmail", payload: { email: input.email, token } });
316
337
  return { ok: true };
317
338
  },
318
339
  { input: parseEmail },
@@ -361,6 +382,15 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
361
382
  { input: parseLinkToken },
362
383
  ),
363
384
  };
385
+
386
+ const tasks: AppTaskMap = {
387
+ sendMagicLinkEmail: async (ctx, payload) => {
388
+ const p = payload as { email: string; token: string };
389
+ await opts.sendEmail(ctx, { email: p.email, token: p.token });
390
+ },
391
+ };
392
+
393
+ return { handlers, tasks };
364
394
  }
365
395
 
366
396
  // --- user management ---------------------------------------------------------