@madauth/server 0.1.0 → 0.2.0-beta.2

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.
@@ -1,29 +1,37 @@
1
1
  import {
2
+ EXPIRY_COOKIE,
3
+ RENEWAL_COOKIE,
4
+ RENEWAL_TYP,
2
5
  SESSION_COOKIE,
3
6
  SESSION_TYP,
4
7
  randomString,
5
8
  readToken,
9
+ signRenewal,
6
10
  signSession,
7
11
  signToken,
8
12
  userFromClaims
9
- } from "./chunk-PRRKLEW7.js";
13
+ } from "./chunk-7CTAB3QV.js";
10
14
  import {
15
+ ADMIN_ROLE,
11
16
  MAX_PASSWORD_LENGTH,
17
+ Roles,
12
18
  Users,
13
19
  checkPasswordPolicy,
14
20
  deriveCodeKey,
15
21
  hashPassword,
16
22
  isValidEmail,
17
23
  normalizeEmail,
24
+ parseRoles,
25
+ sameRoles,
18
26
  verifyAgainstDummy,
19
27
  verifyPassword
20
- } from "./chunk-V2XKGETK.js";
28
+ } from "./chunk-5CKEMC6M.js";
21
29
  import {
22
30
  importSigningKeys
23
31
  } from "./chunk-JDQLSL36.js";
24
32
  import {
25
33
  createWebhookClient
26
- } from "./chunk-AVZE6FFR.js";
34
+ } from "./chunk-6EJDVZCD.js";
27
35
 
28
36
  // src/routes/google.ts
29
37
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
@@ -112,9 +120,9 @@ function googleRoutes(app, ctx, google) {
112
120
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
113
121
  }
114
122
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
115
- await ctx.startSession(c, result.user, ["google"]);
116
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
117
- return c.json({ user: result.user });
123
+ const user = await ctx.startSession(c, result.user, ["google"]);
124
+ await ctx.emit("user.signed_in", { user, method: "google" });
125
+ return c.json({ user });
118
126
  });
119
127
  const requireCodeFlow = async (c, next) => {
120
128
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
@@ -188,8 +196,8 @@ function googleRoutes(app, ctx, google) {
188
196
  keys: config.jwksResolver
189
197
  });
190
198
  if (!result.ok) return back(result.error);
191
- await ctx.startSession(c, result.user, ["google"]);
192
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
199
+ const user = await ctx.startSession(c, result.user, ["google"]);
200
+ await ctx.emit("user.signed_in", { user, method: "google" });
193
201
  return back();
194
202
  });
195
203
  }
@@ -259,12 +267,17 @@ function passwordRoutes(app, ctx, password, users, webhook) {
259
267
  }
260
268
  if (isValidEmail(data.email) && typeof data.code === "string") {
261
269
  const user = await users.findByEmail(normalizeEmail(data.email));
262
- const code = data.code.replace(/\s/g, "");
263
- return { userId: user ? await users.consumeCode(user.id, purpose, code) : null, via: "code" };
270
+ if (!user) return { userId: null, via: "code" };
271
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
264
272
  }
265
273
  return { userId: null, via: "code" };
266
274
  };
267
- const invalidVerification = (c, via) => via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
275
+ const invalidVerification = (c, { via, locked }) => {
276
+ if (locked) {
277
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
278
+ }
279
+ return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
280
+ };
268
281
  app.post("/auth/password/signin", async (c) => {
269
282
  const data = await body(c);
270
283
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
@@ -296,8 +309,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
296
309
  if (!user.emailVerified) {
297
310
  return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
298
311
  }
299
- const result = toMadauthUser(user);
300
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
312
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
301
313
  await ctx.emit("user.signed_in", { user: result, method: "password" });
302
314
  return c.json({ user: result });
303
315
  });
@@ -326,6 +338,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
326
338
  const passwordHash = await hashPassword(data.password);
327
339
  const existing = await users.findByEmail(normalizeEmail(email));
328
340
  if (existing?.emailVerified) {
341
+ if (!webhook.wants("email.already_registered")) return accepted(c);
329
342
  const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
330
343
  return sent ? accepted(c) : unavailable(c);
331
344
  }
@@ -359,12 +372,12 @@ function passwordRoutes(app, ctx, password, users, webhook) {
359
372
  return accepted(c);
360
373
  });
361
374
  app.post("/auth/password/verify-email", async (c) => {
362
- const { userId, via } = await consume(await body(c), "verify");
375
+ const consumed = await consume(await body(c), "verify");
376
+ const { userId, via } = consumed;
363
377
  const user = userId ? await users.findById(userId) : null;
364
- if (!user) return invalidVerification(c, via);
365
- await users.updateUser(user.id, { emailVerified: true });
366
- const result = toMadauthUser(user);
367
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
378
+ if (!user) return invalidVerification(c, consumed);
379
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
380
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
368
381
  await ctx.emit("email.verified", { user: result, via });
369
382
  await ctx.emit("user.signed_in", { user: result, method: "password" });
370
383
  return c.json({ user: result });
@@ -384,16 +397,15 @@ function passwordRoutes(app, ctx, password, users, webhook) {
384
397
  const data = await body(c);
385
398
  const policy = checkPasswordPolicy(data.password, minLength);
386
399
  if (policy) return error(c, 400, "weak_password", policy);
387
- const { userId, via } = await consume(data, "reset");
388
- const user = userId ? await users.findById(userId) : null;
400
+ const consumed = await consume(data, "reset");
401
+ const user = consumed.userId ? await users.findById(consumed.userId) : null;
389
402
  const account = user ? await users.passwordAccount(user.id) : null;
390
- if (!user || !account) return invalidVerification(c, via);
403
+ if (!user || !account) return invalidVerification(c, consumed);
391
404
  await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
392
405
  const sessionVersion = user.sessionVersion + 1;
393
- await users.updateUser(user.id, { emailVerified: true, sessionVersion });
406
+ await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
394
407
  await users.clearVerifications(user.id);
395
- const result = toMadauthUser(user);
396
- await ctx.startSession(c, result, ["pwd"], { sv: sessionVersion });
408
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
397
409
  await ctx.emit("password.reset", { user: result });
398
410
  await ctx.emit("user.signed_in", { user: result, method: "password" });
399
411
  return c.json({ user: result });
@@ -403,10 +415,50 @@ function passwordRoutes(app, ctx, password, users, webhook) {
403
415
  // src/app.ts
404
416
  var EVENT_TIMEOUT_MS = 5e3;
405
417
  function createApp(config) {
406
- const { issuer, allowedOrigins, sessionTtlSeconds, cookieDomain } = config;
418
+ const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
407
419
  const keys = importSigningKeys(config.signingKey);
408
420
  const secure = issuer.startsWith("https:");
409
421
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
422
+ const roles = config.password ? new Roles(config.password.store) : void 0;
423
+ const withRoles = async (user) => {
424
+ const { roles: _, ...rest } = user;
425
+ const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
426
+ return current.length ? { ...rest, roles: current } : rest;
427
+ };
428
+ const issueSession = async (c, user, amr, extra) => {
429
+ const signingKeys = await keys;
430
+ const [sessionToken, renewalToken] = await Promise.all([
431
+ signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
432
+ signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
433
+ ]);
434
+ setCookie2(c, SESSION_COOKIE, sessionToken, {
435
+ path: "/",
436
+ domain: cookieDomain,
437
+ httpOnly: true,
438
+ secure,
439
+ sameSite: "Lax",
440
+ maxAge: sessionTtlSeconds
441
+ });
442
+ setCookie2(c, RENEWAL_COOKIE, renewalToken, {
443
+ path: "/auth",
444
+ httpOnly: true,
445
+ secure,
446
+ sameSite: "Lax",
447
+ maxAge: renewalTtlSeconds
448
+ });
449
+ setCookie2(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
450
+ path: "/",
451
+ domain: cookieDomain,
452
+ secure,
453
+ sameSite: "Lax",
454
+ maxAge: renewalTtlSeconds
455
+ });
456
+ };
457
+ const clearSession = (c) => {
458
+ deleteCookie2(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
459
+ deleteCookie2(c, RENEWAL_COOKIE, { path: "/auth", secure });
460
+ deleteCookie2(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
461
+ };
410
462
  const ctx = {
411
463
  config,
412
464
  keys,
@@ -424,15 +476,9 @@ function createApp(config) {
424
476
  return url;
425
477
  },
426
478
  async startSession(c, user, amr, extra) {
427
- const token = await signSession(await keys, issuer, sessionTtlSeconds, user, amr, extra);
428
- setCookie2(c, SESSION_COOKIE, token, {
429
- path: "/",
430
- domain: cookieDomain,
431
- httpOnly: true,
432
- secure,
433
- sameSite: "Lax",
434
- maxAge: sessionTtlSeconds
435
- });
479
+ const signedIn = await withRoles(user);
480
+ await issueSession(c, signedIn, amr, extra);
481
+ return signedIn;
436
482
  },
437
483
  users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
438
484
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
@@ -474,21 +520,95 @@ function createApp(config) {
474
520
  );
475
521
  if (config.google) googleRoutes(app, ctx, config.google);
476
522
  if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
523
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie2(c, cookie));
524
+ const hasEnded = async (claims) => {
525
+ if (!claims.sub.startsWith("usr_")) return false;
526
+ const stored = await ctx.users?.findById(claims.sub);
527
+ return !stored || stored.sessionVersion !== claims.sv;
528
+ };
529
+ const checked = async (claims) => {
530
+ const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
531
+ return ended ? null : user;
532
+ };
533
+ const currentSession = async (c) => {
534
+ const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
535
+ const user = session ? await checked(session) : null;
536
+ const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
537
+ if (upToDate) return user;
538
+ const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
539
+ const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
540
+ const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
541
+ if (renewal && renewed) {
542
+ await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
543
+ return renewed;
544
+ }
545
+ return user;
546
+ };
477
547
  app.get("/auth/session", async (c) => {
478
- const claims = await readToken(await keys, issuer, SESSION_TYP, getCookie2(c, SESSION_COOKIE));
479
- if (!claims) return c.json({ error: "no_session" }, 401);
480
- if (claims.sub.startsWith("usr_")) {
481
- const stored = await ctx.users?.findById(claims.sub);
482
- if (!stored || stored.sessionVersion !== claims.sv) return c.json({ error: "no_session" }, 401);
548
+ const user = await currentSession(c);
549
+ if (!user) {
550
+ clearSession(c);
551
+ return c.json({ error: "no_session" }, 401);
483
552
  }
484
- const user = userFromClaims(claims);
485
- if (Date.now() / 1e3 - claims.iat > sessionTtlSeconds / 2) await ctx.startSession(c, user, claims.amr, { sv: claims.sv });
486
553
  return c.json({ user });
487
554
  });
488
555
  app.post("/auth/logout", (c) => {
489
- deleteCookie2(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
556
+ clearSession(c);
490
557
  return c.body(null, 204);
491
558
  });
559
+ app.post("/auth/account/delete", async (c) => {
560
+ const user = await currentSession(c);
561
+ if (!user) return c.json({ error: "no_session" }, 401);
562
+ if (roles && user.email) await roles.remove(normalizeEmail(user.email));
563
+ const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
564
+ clearSession(c);
565
+ await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
566
+ return c.body(null, 204);
567
+ });
568
+ if (roles) {
569
+ const admin = async (c) => {
570
+ const user = await currentSession(c);
571
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
572
+ const email = user.email ? normalizeEmail(user.email) : void 0;
573
+ if (!email || !user.roles?.includes(ADMIN_ROLE)) {
574
+ return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
575
+ }
576
+ return { email };
577
+ };
578
+ const body = async (c) => {
579
+ const data = await c.req.json().catch(() => null);
580
+ return data && typeof data === "object" ? data : {};
581
+ };
582
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
583
+ app.post("/auth/admin/roles/get", async (c) => {
584
+ const caller = await admin(c);
585
+ if ("answer" in caller) return caller.answer;
586
+ const data = await body(c);
587
+ if (!isValidEmail(data.email)) return invalidEmail(c);
588
+ const email = normalizeEmail(data.email);
589
+ return c.json({ email, roles: await roles.get(email) });
590
+ });
591
+ app.post("/auth/admin/roles/set", async (c) => {
592
+ const caller = await admin(c);
593
+ if ("answer" in caller) return caller.answer;
594
+ const data = await body(c);
595
+ if (!isValidEmail(data.email)) return invalidEmail(c);
596
+ const names = parseRoles(data.roles);
597
+ if (!names) {
598
+ return c.json(
599
+ {
600
+ error: "invalid_roles",
601
+ message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
602
+ },
603
+ 400
604
+ );
605
+ }
606
+ const email = normalizeEmail(data.email);
607
+ await roles.set(email, names, caller.email);
608
+ await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
609
+ return c.json({ email, roles: names });
610
+ });
611
+ }
492
612
  return app;
493
613
  }
494
614
 
@@ -1,6 +1,6 @@
1
1
  import { JWK, JWTVerifyGetKey } from 'jose';
2
- import { S as StoreAdapter } from './schema-Dotc6LU7.js';
3
- import { W as WebhookSettings } from './webhook-CT80emTQ.js';
2
+ import { S as StoreAdapter } from './schema-BSgYVRpd.js';
3
+ import { W as WebhookSettings } from './webhook-ltwulgcB.js';
4
4
 
5
5
  interface MadauthConfig {
6
6
  /** Public base URL of the server (MADAUTH_ISSUER). */
@@ -9,8 +9,10 @@ interface MadauthConfig {
9
9
  signingKey: JWK;
10
10
  /** App origins allowed to call the server (ALLOWED_ORIGINS). */
11
11
  allowedOrigins: string[];
12
- /** Session lifetime in seconds (SESSION_TTL). */
12
+ /** Lifetime of a session token in seconds (SESSION_TTL): how long backends accept it. */
13
13
  sessionTtlSeconds: number;
14
+ /** How long a session can be renewed without signing in again, counted from its last renewal (SESSION_RENEWAL_TTL). */
15
+ renewalTtlSeconds: number;
14
16
  /** Cookie domain, e.g. `.example.com` (COOKIE_DOMAIN). */
15
17
  cookieDomain?: string;
16
18
  /** Google sign-in; off when GOOGLE_CLIENT_ID is not set. */
@@ -46,6 +48,7 @@ declare const envVars: {
46
48
  readonly WEBHOOK_SECRET: false;
47
49
  readonly WEBHOOK_EVENTS: false;
48
50
  readonly SESSION_TTL: false;
51
+ readonly SESSION_RENEWAL_TTL: false;
49
52
  readonly COOKIE_DOMAIN: false;
50
53
  };
51
54
  /** Instead of environment variables: your own store adapter. */
@@ -53,6 +56,15 @@ interface ConfigOverrides {
53
56
  /** Stores users; replaces DATABASE_URL. */
54
57
  store?: StoreAdapter;
55
58
  }
59
+ type Env = Record<string, string | undefined>;
60
+ /** Options of the hosting entry points: `start` (Node), `createHandler` (AWS Lambda) and `register` (Azure). */
61
+ interface EntryOptions extends ConfigOverrides {
62
+ /**
63
+ * The environment variables to read, or a function that loads them, e.g. to add secrets from a
64
+ * parameter store. The function runs once, before the first request. Default: `process.env`.
65
+ */
66
+ env?: Env | (() => Env | Promise<Env>);
67
+ }
56
68
  declare class ConfigError extends Error {
57
69
  name: string;
58
70
  }
@@ -62,4 +74,4 @@ declare class ConfigError extends Error {
62
74
  */
63
75
  declare function loadConfig(env: Record<string, string | undefined>, overrides?: ConfigOverrides): Promise<MadauthConfig>;
64
76
 
65
- export { type ConfigOverrides as C, type MadauthConfig as M, ConfigError as a, envVars as e, loadConfig as l };
77
+ export { ConfigError as C, type EntryOptions as E, type MadauthConfig as M, type ConfigOverrides as a, envVars as e, loadConfig as l };
@@ -0,0 +1,244 @@
1
+ import {
2
+ madauthSchema
3
+ } from "./chunk-XNBWZZHT.js";
4
+
5
+ // src/store/dynamodb.ts
6
+ var SORT_KEY = "#";
7
+ var MAX_RETRIES = 8;
8
+ var RETRYABLE_REASONS = ["TransactionConflict", "ThrottlingError", "ProvisionedThroughputExceeded"];
9
+ var RETRYABLE_ERRORS = ["TransactionConflictException", "TransactionInProgressException"];
10
+ var recordKey = (model, id) => ({ pk: { S: `r|${model}|${id}` }, sk: { S: SORT_KEY } });
11
+ var uniqueKey = (model, field, value) => ({
12
+ pk: { S: `u|${model}|${field}|${String(value)}` },
13
+ sk: { S: SORT_KEY }
14
+ });
15
+ var indexPartition = (model, field, value) => `i|${model}|${field}|${String(value)}`;
16
+ var indexKey = (model, field, value, id) => ({
17
+ pk: { S: indexPartition(model, field, value) },
18
+ sk: { S: id }
19
+ });
20
+ function toAttribute(value) {
21
+ if (value === null) return { NULL: true };
22
+ if (typeof value === "string") return { S: value };
23
+ if (typeof value === "number") return { N: String(value) };
24
+ return { BOOL: value };
25
+ }
26
+ function fromAttribute(attribute) {
27
+ if (attribute?.S !== void 0) return attribute.S;
28
+ if (attribute?.N !== void 0) return Number(attribute.N);
29
+ if (attribute?.BOOL !== void 0) return attribute.BOOL;
30
+ return null;
31
+ }
32
+ function fieldsOf(model) {
33
+ const fields = madauthSchema.models[model]?.fields;
34
+ if (!fields) throw new Error(`Unknown model "${model}"`);
35
+ return fields;
36
+ }
37
+ function checkFields(fields, names) {
38
+ for (const name of names) if (!fields[name]) throw new Error(`Unknown field "${name}"`);
39
+ }
40
+ function primaryKeyOf(fields) {
41
+ return Object.keys(fields).find((name) => fields[name].primaryKey);
42
+ }
43
+ function toRow(fields, item) {
44
+ return Object.fromEntries(Object.keys(fields).map((name) => [name, fromAttribute(item[name])]));
45
+ }
46
+ function matches(row, where) {
47
+ return Object.entries(where).every(([field, value]) => row[field] === value);
48
+ }
49
+ function stillMatches(where) {
50
+ const names = {};
51
+ const values = {};
52
+ const parts = ["attribute_exists(pk)"];
53
+ Object.entries(where).forEach(([field, value], i) => {
54
+ names[`#w${i}`] = field;
55
+ values[`:w${i}`] = toAttribute(value);
56
+ parts.push(value === null ? `(attribute_not_exists(#w${i}) OR #w${i} = :w${i})` : `#w${i} = :w${i}`);
57
+ });
58
+ return { text: parts.join(" AND "), names, values };
59
+ }
60
+ function failedWrite(error) {
61
+ const { name, CancellationReasons } = error;
62
+ if (name === "ConditionalCheckFailedException") return "refused";
63
+ if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
64
+ if (name === "TransactionCanceledException") {
65
+ const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
66
+ if (reasons.includes("ConditionalCheckFailed")) return "refused";
67
+ if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
68
+ }
69
+ throw error;
70
+ }
71
+ var pause = (attempt) => new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(25 * 2 ** attempt, 400)));
72
+ function createDynamoDbAdapter(options) {
73
+ const { tableName } = options;
74
+ let loading;
75
+ const aws = () => loading ??= import("@aws-sdk/client-dynamodb").then((sdk) => ({
76
+ sdk,
77
+ client: options.client ?? new sdk.DynamoDBClient({})
78
+ }));
79
+ const write = async (send) => {
80
+ for (let attempt = 0; ; attempt++) {
81
+ const outcome = await send().then(() => "written", failedWrite);
82
+ if (outcome === "written") return true;
83
+ if (outcome === "refused") return false;
84
+ if (attempt === MAX_RETRIES) throw outcome.retry;
85
+ await pause(attempt);
86
+ }
87
+ };
88
+ const transact = async (items) => {
89
+ const { sdk, client } = await aws();
90
+ return write(() => client.send(new sdk.TransactWriteItemsCommand({ TransactItems: items })));
91
+ };
92
+ const getItem = async (key) => {
93
+ const { sdk, client } = await aws();
94
+ return (await client.send(new sdk.GetItemCommand({ TableName: tableName, Key: key, ConsistentRead: true }))).Item;
95
+ };
96
+ const getRecord = async (model, fields, id) => {
97
+ const item = await getItem(recordKey(model, id));
98
+ return item ? toRow(fields, item) : null;
99
+ };
100
+ const idsOfIndex = async (model, field, value) => {
101
+ const { sdk, client } = await aws();
102
+ const ids = [];
103
+ let next;
104
+ do {
105
+ const page = await client.send(
106
+ new sdk.QueryCommand({
107
+ TableName: tableName,
108
+ KeyConditionExpression: "pk = :pk",
109
+ ExpressionAttributeValues: { ":pk": { S: indexPartition(model, field, value) } },
110
+ ProjectionExpression: "sk",
111
+ ConsistentRead: true,
112
+ ExclusiveStartKey: next
113
+ })
114
+ );
115
+ for (const item of page.Items ?? []) if (item.sk?.S) ids.push(item.sk.S);
116
+ next = page.LastEvaluatedKey;
117
+ } while (next);
118
+ return ids;
119
+ };
120
+ const find = async (model, where) => {
121
+ const fields = fieldsOf(model);
122
+ checkFields(fields, Object.keys(where));
123
+ const given = (name) => where[name] !== void 0 && where[name] !== null;
124
+ const primaryKey = primaryKeyOf(fields);
125
+ const unique = Object.keys(fields).find((name) => fields[name].unique && given(name));
126
+ const indexed = Object.keys(fields).find((name) => fields[name].index && given(name));
127
+ let ids;
128
+ if (given(primaryKey)) {
129
+ ids = [String(where[primaryKey])];
130
+ } else if (unique) {
131
+ const ref = (await getItem(uniqueKey(model, unique, where[unique])))?.ref?.S;
132
+ ids = ref ? [ref] : [];
133
+ } else if (indexed) {
134
+ ids = await idsOfIndex(model, indexed, where[indexed]);
135
+ } else {
136
+ throw new Error(
137
+ `The DynamoDB adapter can't find "${model}" records by ${Object.keys(where).join(", ") || "nothing"}: the filter needs the id, a unique field or an indexed field.`
138
+ );
139
+ }
140
+ const rows = await Promise.all(ids.map((id) => getRecord(model, fields, id)));
141
+ return rows.filter((row) => row !== null && matches(row, where));
142
+ };
143
+ const derivedKeys = (model, fields, row, id) => Object.keys(fields).flatMap((name) => {
144
+ const value = row[name];
145
+ if (value === void 0 || value === null) return [];
146
+ return [
147
+ ...fields[name].unique ? [uniqueKey(model, name, value)] : [],
148
+ ...fields[name].index ? [indexKey(model, name, value, id)] : []
149
+ ];
150
+ });
151
+ return {
152
+ async create(model, data) {
153
+ const fields = fieldsOf(model);
154
+ checkFields(fields, Object.keys(data));
155
+ const primaryKey = primaryKeyOf(fields);
156
+ const id = data[primaryKey];
157
+ if (typeof id !== "string" || !id) throw new Error(`A "${model}" record needs a "${primaryKey}"`);
158
+ const record = { ...recordKey(model, id) };
159
+ for (const [field, value] of Object.entries(data)) record[field] = toAttribute(value);
160
+ const unused = "attribute_not_exists(pk)";
161
+ return transact([
162
+ { Put: { TableName: tableName, Item: record, ConditionExpression: unused } },
163
+ ...derivedKeys(model, fields, data, id).map(
164
+ (key) => (
165
+ // A unique value refers to its record and may not be taken yet; an index entry is only a key.
166
+ key.pk.S.startsWith("u|") ? { Put: { TableName: tableName, Item: { ...key, ref: { S: id } }, ConditionExpression: unused } } : { Put: { TableName: tableName, Item: key } }
167
+ )
168
+ )
169
+ ]);
170
+ },
171
+ async findOne(model, where) {
172
+ return (await find(model, where))[0] ?? null;
173
+ },
174
+ async findMany(model, where, opts = {}) {
175
+ const rows = await find(model, where);
176
+ return opts.limit === void 0 ? rows : rows.slice(0, Math.max(0, Math.floor(opts.limit)));
177
+ },
178
+ async update(model, where, patch) {
179
+ const fields = fieldsOf(model);
180
+ const changes = Object.entries(patch);
181
+ checkFields(fields, [...Object.keys(where), ...Object.keys(patch)]);
182
+ for (const [field] of changes) {
183
+ if (fields[field].primaryKey || fields[field].unique || fields[field].index) {
184
+ throw new Error(`The DynamoDB adapter can't change "${field}" of a "${model}" record: it is a key.`);
185
+ }
186
+ }
187
+ if (!changes.length) return 0;
188
+ const primaryKey = primaryKeyOf(fields);
189
+ const condition = stillMatches(where);
190
+ const names = { ...condition.names };
191
+ const values = { ...condition.values };
192
+ const assignments = changes.map(([field, value], i) => {
193
+ names[`#p${i}`] = field;
194
+ values[`:p${i}`] = toAttribute(value);
195
+ return `#p${i} = :p${i}`;
196
+ });
197
+ const byId = where[primaryKey];
198
+ const ids = byId !== void 0 && byId !== null ? [String(byId)] : (await find(model, where)).map((row) => String(row[primaryKey]));
199
+ const written = await Promise.all(
200
+ ids.map(
201
+ (id) => transact([
202
+ {
203
+ Update: {
204
+ TableName: tableName,
205
+ Key: recordKey(model, id),
206
+ UpdateExpression: `SET ${assignments.join(", ")}`,
207
+ ConditionExpression: condition.text,
208
+ ExpressionAttributeNames: names,
209
+ ExpressionAttributeValues: values
210
+ }
211
+ }
212
+ ])
213
+ )
214
+ );
215
+ return written.filter(Boolean).length;
216
+ },
217
+ async delete(model, where) {
218
+ const fields = fieldsOf(model);
219
+ const primaryKey = primaryKeyOf(fields);
220
+ const condition = stillMatches(where);
221
+ const deleted = await Promise.all(
222
+ (await find(model, where)).map((row) => {
223
+ const id = String(row[primaryKey]);
224
+ return transact([
225
+ {
226
+ Delete: {
227
+ TableName: tableName,
228
+ Key: recordKey(model, id),
229
+ ConditionExpression: condition.text,
230
+ ExpressionAttributeNames: condition.names,
231
+ ExpressionAttributeValues: condition.values
232
+ }
233
+ },
234
+ ...derivedKeys(model, fields, row, id).map((key) => ({ Delete: { TableName: tableName, Key: key } }))
235
+ ]);
236
+ })
237
+ );
238
+ return deleted.filter(Boolean).length;
239
+ }
240
+ };
241
+ }
242
+ export {
243
+ createDynamoDbAdapter
244
+ };
@@ -1,13 +1,13 @@
1
- import { C as ConfigOverrides } from '../config-KQvE2imU.js';
1
+ import { E as EntryOptions } from '../config-BkxADgtp.js';
2
2
  import 'jose';
3
- import '../schema-Dotc6LU7.js';
4
- import '../webhook-CT80emTQ.js';
3
+ import '../schema-BSgYVRpd.js';
4
+ import '../webhook-ltwulgcB.js';
5
5
 
6
6
  /**
7
7
  * Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
8
- * own store adapter.
8
+ * own store adapter, and `env` to load settings from elsewhere, e.g. secrets from Key Vault.
9
9
  * Set `"routePrefix": ""` in host.json so the routes are served at /auth/... (see docs/server.md).
10
10
  */
11
- declare function register(overrides?: ConfigOverrides): void;
11
+ declare function register(options?: EntryOptions): void;
12
12
 
13
13
  export { register };
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-TEMZRMFF.js";
4
- import "../chunk-PRRKLEW7.js";
3
+ } from "../chunk-YALS7OMV.js";
4
+ import "../chunk-7CTAB3QV.js";
5
5
  import {
6
- loadConfig
7
- } from "../chunk-V2XKGETK.js";
6
+ loadConfig,
7
+ resolveEnv
8
+ } from "../chunk-5CKEMC6M.js";
8
9
  import "../chunk-JDQLSL36.js";
9
- import "../chunk-AVZE6FFR.js";
10
+ import "../chunk-6EJDVZCD.js";
10
11
 
11
12
  // src/entry/azure.ts
12
13
  import { app } from "@azure/functions";
@@ -66,14 +67,15 @@ async function handleAzureRequest(app2, request) {
66
67
  }
67
68
 
68
69
  // src/entry/azure.ts
69
- function register(overrides = {}) {
70
+ function register(options = {}) {
71
+ const { env, ...overrides } = options;
70
72
  let appPromise;
71
73
  app.http("madauth", {
72
74
  route: "{*path}",
73
75
  methods: ["GET", "POST", "OPTIONS"],
74
76
  authLevel: "anonymous",
75
77
  handler: async (request) => {
76
- appPromise ??= loadConfig(process.env, overrides).then(createApp);
78
+ appPromise ??= resolveEnv(env).then((loaded) => loadConfig(loaded, overrides)).then(createApp);
77
79
  appPromise.catch(() => appPromise = void 0);
78
80
  return handleAzureRequest(await appPromise, request);
79
81
  }