@lunora/auth 1.0.0-alpha.24 → 1.0.0-alpha.240

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.
Files changed (93) hide show
  1. package/LICENSE.md +32 -0
  2. package/README.md +101 -11
  3. package/dist/adapter.d.mts +4 -43
  4. package/dist/adapter.d.ts +4 -43
  5. package/dist/adapter.mjs +1 -48
  6. package/dist/audit.d.mts +142 -0
  7. package/dist/audit.d.ts +142 -0
  8. package/dist/audit.mjs +12 -0
  9. package/dist/cimd-node.d.mts +1 -0
  10. package/dist/cimd-node.d.ts +1 -0
  11. package/dist/cimd-node.mjs +1 -0
  12. package/dist/cimd-workers.d.mts +64 -0
  13. package/dist/cimd-workers.d.ts +64 -0
  14. package/dist/cimd-workers.mjs +1 -0
  15. package/dist/email-guard.d.mts +143 -0
  16. package/dist/email-guard.d.ts +143 -0
  17. package/dist/email-guard.mjs +1 -0
  18. package/dist/index.d.mts +415 -545
  19. package/dist/index.d.ts +415 -545
  20. package/dist/index.mjs +1 -12
  21. package/dist/middleware.d.mts +165 -155
  22. package/dist/middleware.d.ts +165 -155
  23. package/dist/middleware.mjs +1 -56
  24. package/dist/migrate.d.mts +47 -0
  25. package/dist/migrate.d.ts +47 -0
  26. package/dist/migrate.mjs +1 -0
  27. package/dist/packem_shared/AUTH_DO_AUDIT_PATH-DNM3G5GI.mjs +1 -0
  28. package/dist/packem_shared/DEFAULT_AUTH_BASE_PATH-DneiLGpv.mjs +1 -0
  29. package/dist/packem_shared/LunoraAuthAdminError-uhKG1MgQ.mjs +1 -0
  30. package/dist/packem_shared/adapter-CBZ17jKi.mjs +2 -0
  31. package/dist/packem_shared/adapter.d-CElAPJ1U.d.ts +88 -0
  32. package/dist/packem_shared/adapter.d-CaPwF2v3.d.mts +88 -0
  33. package/dist/packem_shared/auth-do-nKx0bUTe.mjs +1 -0
  34. package/dist/packem_shared/authAuditHook-DPTKdjBe.mjs +1 -0
  35. package/dist/packem_shared/authDiscoveryPaths-B14lM14u.mjs +1 -0
  36. package/dist/packem_shared/authDoColumnAdditions-UwF3Rl41.mjs +1 -0
  37. package/dist/packem_shared/create-auth.d-D1CKOFWc.d.mts +142 -0
  38. package/dist/packem_shared/create-auth.d-D1CKOFWc.d.ts +142 -0
  39. package/dist/packem_shared/createAuth-B-Pq7nmN.mjs +1 -0
  40. package/dist/packem_shared/createDoAuthWiring-az8FcbYw.mjs +1 -0
  41. package/dist/packem_shared/createLunoraAuthClient-DRcFNwp9.mjs +1 -0
  42. package/dist/packem_shared/createSignUpInvitation-DHZL9fNl.mjs +1 -0
  43. package/dist/packem_shared/emailGateDatabaseHooks-BYLUpiN_.mjs +1 -0
  44. package/dist/packem_shared/invite-only.d-CsPJWAwM.d.ts +126 -0
  45. package/dist/packem_shared/invite-only.d-DKyHHkkQ.d.mts +126 -0
  46. package/dist/packem_shared/isDevEnvironment-yLbPngwn.mjs +1 -0
  47. package/dist/packem_shared/legacyIssuerCleanupStatements-D9Kf8OD1.mjs +1 -0
  48. package/dist/packem_shared/mcp-CgX1hTOv.mjs +1 -0
  49. package/dist/packem_shared/on-cloudflare-edge-Dx6gFjBO.mjs +1 -0
  50. package/dist/packem_shared/quote-identifier-CGiYFBvY.mjs +1 -0
  51. package/dist/packem_shared/sessionPresets-C867Mlo4.mjs +1 -0
  52. package/dist/packem_shared/sql-store-uMubPYI9.mjs +1 -0
  53. package/dist/packem_shared/uiConfig-B8pN6pks.mjs +1 -0
  54. package/dist/packem_shared/worker.d-B0mER8nz.d.mts +709 -0
  55. package/dist/packem_shared/worker.d-CGYer33D.d.ts +709 -0
  56. package/dist/plugins-client.d.mts +250 -1
  57. package/dist/plugins-client.d.ts +250 -1
  58. package/dist/plugins-client.mjs +1 -2
  59. package/dist/plugins-enterprise-client.d.mts +13 -0
  60. package/dist/plugins-enterprise-client.d.ts +13 -0
  61. package/dist/plugins-enterprise-client.mjs +1 -0
  62. package/dist/plugins-enterprise.d.mts +1668 -0
  63. package/dist/plugins-enterprise.d.ts +1668 -0
  64. package/dist/plugins-enterprise.mjs +1 -0
  65. package/dist/plugins.d.mts +167 -2
  66. package/dist/plugins.d.ts +167 -2
  67. package/dist/plugins.mjs +1 -22
  68. package/dist/schema.d.mts +39 -39
  69. package/dist/schema.d.ts +39 -39
  70. package/dist/schema.mjs +1 -62
  71. package/dist/sql-store.d.mts +43 -28
  72. package/dist/sql-store.d.ts +43 -28
  73. package/dist/sql-store.mjs +1 -184
  74. package/dist/store.d.mts +65 -47
  75. package/dist/store.d.ts +65 -47
  76. package/dist/store.mjs +1 -183
  77. package/dist/turnstile-middleware.d.mts +73 -55
  78. package/dist/turnstile-middleware.d.ts +73 -55
  79. package/dist/turnstile-middleware.mjs +1 -45
  80. package/dist/turnstile.d.mts +42 -59
  81. package/dist/turnstile.d.ts +42 -59
  82. package/dist/turnstile.mjs +1 -61
  83. package/dist/worker.d.mts +9 -0
  84. package/dist/worker.d.ts +9 -0
  85. package/dist/worker.mjs +1 -0
  86. package/package.json +57 -6
  87. package/dist/packem_shared/DEFAULT_AUTH_BASE_PATH-DjcUWEQl.mjs +0 -11
  88. package/dist/packem_shared/LunoraAuthAdminError-D4L7n6gN.mjs +0 -510
  89. package/dist/packem_shared/compileMigrationsSql-B9bj-mJv.mjs +0 -29
  90. package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.mts +0 -128
  91. package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.ts +0 -128
  92. package/dist/packem_shared/createAuth-BVMMllTm.mjs +0 -87
  93. package/dist/packem_shared/sessionPresets-Dwwd74_J.mjs +0 -38
@@ -0,0 +1 @@
1
+ export { fetchClientMetadataResource as default } from '@better-auth/cimd/node';
@@ -0,0 +1 @@
1
+ import{fetchClientMetadataResource as a}from"@better-auth/cimd/node";export{a as default};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The Cloudflare Workers transport for `cimd()` (Client ID Metadata Documents).
3
+ *
4
+ * `@better-auth/cimd` fetches a URL the client hands it — the `client_id` itself,
5
+ * and any `jwks_uri` the document names — so the fetch is server-side request
6
+ * forgery waiting to happen unless the transport keeps it on the public internet.
7
+ * The plugin therefore makes the application supply that transport, and asks it to
8
+ * resolve the host once, reject special-use addresses, pin the connection and
9
+ * refuse redirects.
10
+ *
11
+ * A Worker cannot pin a connection or see the resolved address. What it can do is
12
+ * run with the global_fetch_strictly_public compatibility flag, under which every
13
+ * global `fetch` goes out to the public internet and never to a private or
14
+ * special-use address, the Worker's own zone included. That is the guarantee
15
+ * Cloudflare's own `@cloudflare/workers-oauth-provider` builds its CIMD support on,
16
+ * and the one this transport requires: {@link workersCimdFetch} refuses to build
17
+ * without the flag, so a misconfigured deploy fails at startup instead of quietly
18
+ * fetching attacker-chosen internal URLs.
19
+ *
20
+ * It is weaker than the letter of cimd's contract — there is no DNS pinning, so a
21
+ * rebinding host still reaches *some* public address. An origin allowlist (the
22
+ * isMetadataDocumentUrlAllowed option) narrows that to hosts you trust, but it only
23
+ * gates the `client_id` fetch: oauth-provider fetches a document's `jwks_uri`
24
+ * through this same transport without consulting it. Add `jwks_uri` to cimd's
25
+ * originBoundFields so it must share the allowlisted `client_id` origin.
26
+ *
27
+ * On Node, use the default export of `@lunora/auth/cimd/node`, which
28
+ * does pin the connection.
29
+ *
30
+ * Kept off the package root (and out of `./plugins`) because it is Workers-only:
31
+ * the flag probe means nothing on any other runtime.
32
+ */
33
+ import { ClientMetadataResourceFetch } from '@better-auth/oauth-provider';
34
+ /**
35
+ * Build the `fetchClientMetadataResource` transport for `cimd()` on Cloudflare
36
+ * Workers.
37
+ *
38
+ * ```ts
39
+ * import { cimd, jwt, mcp } from "@lunora/auth/plugins";
40
+ * import workersCimdFetch from "@lunora/auth/cimd/workers";
41
+ *
42
+ * plugins: [
43
+ * jwt(),
44
+ * mcp({ … }),
45
+ * cimd({
46
+ * fetchClientMetadataResource: workersCimdFetch(),
47
+ * metadataProfile: "mcp-2026-07-28",
48
+ * isMetadataDocumentUrlAllowed: (url) => new URL(url).origin === "https://claude.ai",
49
+ * // The allowlist gates only the `client_id` fetch; bind `jwks_uri` to it too.
50
+ * originBoundFields: ["post_logout_redirect_uris", "client_uri", "jwks_uri"],
51
+ * }),
52
+ * ];
53
+ * ```
54
+ *
55
+ * The returned transport only issues `GET` / `HEAD` to `https:` URLs without
56
+ * credentials, and never follows a redirect: it fetches with `redirect: "manual"`
57
+ * (workerd does not implement `"error"`) and throws on any redirect status, which
58
+ * cimd reports as `invalid_client`. A `304` from a conditional revalidation passes
59
+ * through.
60
+ * @throws LunoraError `AUTH_CIMD_FETCH_NOT_PUBLIC` when the Worker runs without the
61
+ * `global_fetch_strictly_public` compatibility flag.
62
+ */
63
+ declare const workersCimdFetch: () => ClientMetadataResourceFetch;
64
+ export { workersCimdFetch as default };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The Cloudflare Workers transport for `cimd()` (Client ID Metadata Documents).
3
+ *
4
+ * `@better-auth/cimd` fetches a URL the client hands it — the `client_id` itself,
5
+ * and any `jwks_uri` the document names — so the fetch is server-side request
6
+ * forgery waiting to happen unless the transport keeps it on the public internet.
7
+ * The plugin therefore makes the application supply that transport, and asks it to
8
+ * resolve the host once, reject special-use addresses, pin the connection and
9
+ * refuse redirects.
10
+ *
11
+ * A Worker cannot pin a connection or see the resolved address. What it can do is
12
+ * run with the global_fetch_strictly_public compatibility flag, under which every
13
+ * global `fetch` goes out to the public internet and never to a private or
14
+ * special-use address, the Worker's own zone included. That is the guarantee
15
+ * Cloudflare's own `@cloudflare/workers-oauth-provider` builds its CIMD support on,
16
+ * and the one this transport requires: {@link workersCimdFetch} refuses to build
17
+ * without the flag, so a misconfigured deploy fails at startup instead of quietly
18
+ * fetching attacker-chosen internal URLs.
19
+ *
20
+ * It is weaker than the letter of cimd's contract — there is no DNS pinning, so a
21
+ * rebinding host still reaches *some* public address. An origin allowlist (the
22
+ * isMetadataDocumentUrlAllowed option) narrows that to hosts you trust, but it only
23
+ * gates the `client_id` fetch: oauth-provider fetches a document's `jwks_uri`
24
+ * through this same transport without consulting it. Add `jwks_uri` to cimd's
25
+ * originBoundFields so it must share the allowlisted `client_id` origin.
26
+ *
27
+ * On Node, use the default export of `@lunora/auth/cimd/node`, which
28
+ * does pin the connection.
29
+ *
30
+ * Kept off the package root (and out of `./plugins`) because it is Workers-only:
31
+ * the flag probe means nothing on any other runtime.
32
+ */
33
+ import { ClientMetadataResourceFetch } from '@better-auth/oauth-provider';
34
+ /**
35
+ * Build the `fetchClientMetadataResource` transport for `cimd()` on Cloudflare
36
+ * Workers.
37
+ *
38
+ * ```ts
39
+ * import { cimd, jwt, mcp } from "@lunora/auth/plugins";
40
+ * import workersCimdFetch from "@lunora/auth/cimd/workers";
41
+ *
42
+ * plugins: [
43
+ * jwt(),
44
+ * mcp({ … }),
45
+ * cimd({
46
+ * fetchClientMetadataResource: workersCimdFetch(),
47
+ * metadataProfile: "mcp-2026-07-28",
48
+ * isMetadataDocumentUrlAllowed: (url) => new URL(url).origin === "https://claude.ai",
49
+ * // The allowlist gates only the `client_id` fetch; bind `jwks_uri` to it too.
50
+ * originBoundFields: ["post_logout_redirect_uris", "client_uri", "jwks_uri"],
51
+ * }),
52
+ * ];
53
+ * ```
54
+ *
55
+ * The returned transport only issues `GET` / `HEAD` to `https:` URLs without
56
+ * credentials, and never follows a redirect: it fetches with `redirect: "manual"`
57
+ * (workerd does not implement `"error"`) and throws on any redirect status, which
58
+ * cimd reports as `invalid_client`. A `304` from a conditional revalidation passes
59
+ * through.
60
+ * @throws LunoraError `AUTH_CIMD_FETCH_NOT_PUBLIC` when the Worker runs without the
61
+ * `global_fetch_strictly_public` compatibility flag.
62
+ */
63
+ declare const workersCimdFetch: () => ClientMetadataResourceFetch;
64
+ export { workersCimdFetch as default };
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";const a="global_fetch_strictly_public",i=()=>{const{Cloudflare:t}=globalThis;return t?.compatibilityFlags?.[a]===!0},n=new Set([301,302,303,307,308]),l=t=>n.has(t.status)||t.type==="opaqueredirect",d=()=>{if(!i())throw new c("AUTH_CIMD_FETCH_NOT_PUBLIC",`CIMD needs the "${a}" compatibility flag, so metadata fetches cannot reach private addresses. Add it to "compatibility_flags" in wrangler.jsonc.`);return async(t,s)=>{const e=new Request(t,{...s,redirect:"manual"}),r=new URL(e.url);if(r.protocol!=="https:")throw new TypeError(`refusing to fetch a client metadata resource over ${r.protocol}`);if(r.username!==""||r.password!=="")throw new TypeError("refusing to fetch a client metadata resource URL that carries credentials");if(e.method!=="GET"&&e.method!=="HEAD")throw new TypeError(`refusing to ${e.method} a client metadata resource`);const o=await fetch(e);if(l(o))throw new TypeError("client metadata resource fetches must not follow redirects");return o}};export{d as default};
@@ -0,0 +1,143 @@
1
+ import { Middleware } from '@lunora/server';
2
+ /**
3
+ * The resolved trust class of an email address' domain: `disposable` (a
4
+ * throwaway/temporary provider, or a caller `denyDomains` hit), `free` (a free
5
+ * consumer provider like Gmail — deliverable but not B2B), or `business`
6
+ * (anything else — a custom/company domain, or an `allowDomains` hit).
7
+ */
8
+ type EmailClass = "business" | "disposable" | "free";
9
+ /** Result of {@link classifyEmail} — the resolved class plus the normalized domain. */
10
+ interface EmailClassification {
11
+ /** The normalized (lowercased, trimmed) domain, or `undefined` for a structurally invalid address. */
12
+ domain: string | undefined;
13
+ /** The resolved trust class. A structurally invalid address resolves to `business` with `domain: undefined`. */
14
+ emailClass: EmailClass;
15
+ }
16
+ /** Configuration for {@link classifyEmail} / {@link assertEmailAllowed} / {@link emailGateMiddleware}. */
17
+ interface EmailGateConfig {
18
+ /**
19
+ * Domains that are always allowed and always classified `business`, even if
20
+ * they appear on the disposable or free lists. Wildcard/subdomain aware
21
+ * (`example.com` also allows `mail.example.com`). Your own hosted domains go
22
+ * here.
23
+ */
24
+ allowDomains?: ReadonlyArray<string>;
25
+ /**
26
+ * Reject disposable/throwaway signups. Defaults to `true` — the whole point
27
+ * of the gate. Set `false` to classify-only (surface `emailClass` without
28
+ * blocking).
29
+ */
30
+ blockDisposable?: boolean;
31
+ /**
32
+ * Extra domains to treat as disposable (blocked when `blockDisposable`), on
33
+ * top of the built-in list. Wildcard/subdomain aware.
34
+ */
35
+ denyDomains?: ReadonlyArray<string>;
36
+ /**
37
+ * Which built-in lists {@link assertEmailAllowed} loads (both by default, so the
38
+ * returned class is complete). The disposable list is ~2.7 MB and the free list
39
+ * ~143 KB of bundle; `emailGateMiddleware` and `emailGateDatabaseHooks` set this
40
+ * for you: the free list loads only with `onClassify`, the disposable list only
41
+ * with `blockDisposable !== false` or `onClassify`.
42
+ */
43
+ lists?: {
44
+ disposable?: boolean;
45
+ free?: boolean;
46
+ };
47
+ /**
48
+ * Opt-in MX deliverability verification. Off by default because it needs DNS
49
+ * (`@visulima/email-verifier/checks/mx` → `node:dns`), which is not available
50
+ * on the default workerd path. When `true`, {@link assertEmailAllowed} rejects
51
+ * an address whose domain publishes no MX (or fallback A/AAAA) records with
52
+ * `EMAIL_UNDELIVERABLE`. The check is loaded via a dynamic import, so leaving
53
+ * this off keeps the DNS module out of the bundle entirely.
54
+ */
55
+ mx?: boolean;
56
+ /**
57
+ * Reject structurally invalid addresses up front (via
58
+ * `@visulima/email-verifier/checks/syntax`, pure-data/edge-safe). Defaults to
59
+ * `true`. When `false`, an unparseable address classifies as `business` with
60
+ * `domain: undefined` and is not rejected on syntax alone.
61
+ */
62
+ requireValidSyntax?: boolean;
63
+ }
64
+ /** Options for {@link emailGateMiddleware}: the base gate config plus how to read the email from `ctx`. */
65
+ interface EmailGateMiddlewareOptions<Context> extends EmailGateConfig {
66
+ /**
67
+ * Selector that pulls the signup email off `ctx`. The procedure context
68
+ * carries only the resolved identity, not the raw request body, so the email
69
+ * travels in the function `args` — which the builder surfaces to middleware
70
+ * as `ctx.args` (validated, frozen). Mirrors `verifyTurnstileMiddleware`'s
71
+ * `token` selector:
72
+ *
73
+ * ```ts
74
+ * export const signUp = mutation
75
+ * .input({ email: v.string() })
76
+ * .use(emailGateMiddleware({ email: (ctx) => ctx.args.email }))
77
+ * .mutation(async ({ args, ctx }) => { … });
78
+ * ```
79
+ */
80
+ email: (context: Context) => string | undefined;
81
+ /**
82
+ * Called with the resolved classification once the gate passes, so app policy
83
+ * can branch on `free` vs `business` (e.g. gate a plan behind a business
84
+ * email). Never fires when the gate rejects.
85
+ */
86
+ onClassify?: (classification: EmailClassification, context: Context) => void;
87
+ }
88
+ /**
89
+ * Inject the built-in domain lists into the lookup packages, once each. Call this
90
+ * at worker init on workerd (where the packages' `node:fs` loader is unavailable)
91
+ * so {@link classifyEmail} has data to match against; {@link assertEmailAllowed} /
92
+ * {@link emailGateMiddleware} await it for you, loading only what the config needs.
93
+ * Both lists (~2.7 MB disposable, ~143 KB free) load by default; pass
94
+ * `{ free: false }` or `{ disposable: false }` to skip one. Idempotent — repeat
95
+ * calls share one load per list.
96
+ */
97
+ declare const loadEmailDomainLists: ({ disposable, free }?: {
98
+ disposable?: boolean;
99
+ free?: boolean;
100
+ }) => Promise<void>;
101
+ /**
102
+ * Classify an email address' domain as `disposable` / `free` / `business`,
103
+ * pure-data and edge-safe (no DNS, no filesystem). A structurally invalid
104
+ * address resolves to `{ domain: undefined, emailClass: "business" }` — use
105
+ * {@link assertEmailAllowed} (which can reject on syntax) for the gating path.
106
+ * `allowDomains` wins over both lists; `denyDomains` adds to the disposable list.
107
+ *
108
+ * The lookup relies on the built-in domain lists being loaded. On Node they
109
+ * auto-load from disk; on workerd, `await loadEmailDomainLists()` first (or use
110
+ * {@link assertEmailAllowed} / {@link emailGateMiddleware}, which await it).
111
+ *
112
+ * The returned/compared domain is IDNA-normalized (see {@link toAsciiDomain}) so
113
+ * a Unicode-form internationalized domain matches its `xn--` blocklist entry.
114
+ */
115
+ declare const classifyEmail: (email: string, config?: EmailGateConfig) => EmailClassification;
116
+ /**
117
+ * Classify `email` and enforce the gate, throwing a coded {@link LunoraError}
118
+ * when it fails: `VALIDATION_ERROR` (structurally invalid, only when
119
+ * `requireValidSyntax`, the default), `EMAIL_DOMAIN_BLOCKED` (disposable or
120
+ * deny-listed, only when `blockDisposable`, the default), or `EMAIL_UNDELIVERABLE`
121
+ * (no MX records, only when `mx: true`).
122
+ *
123
+ * Returns the {@link EmailClassification} on success so callers can branch on
124
+ * `free` vs `business`. Async only because of the opt-in MX step; with `mx` off
125
+ * it resolves without any network I/O.
126
+ */
127
+ declare const assertEmailAllowed: (email: string, config?: EmailGateConfig) => Promise<EmailClassification>;
128
+ /**
129
+ * Lunora procedure middleware that gates a non-auth, signup-shaped
130
+ * `mutation`/`action` on the email-domain policy. Attach it with `.use()`; it
131
+ * reads the email from `ctx` via the `email` selector (declare it with
132
+ * `.input(...)` and read it back as `ctx.args.email`)
133
+ * and runs {@link assertEmailAllowed}, which throws a coded {@link LunoraError}
134
+ * (`EMAIL_DOMAIN_BLOCKED` / `EMAIL_UNDELIVERABLE` / `VALIDATION_ERROR`) the
135
+ * runtime maps to the matching status.
136
+ *
137
+ * To gate better-auth's native `/sign-up/email` endpoint instead, use
138
+ * `emailGateDatabaseHooks` / `withEmailGate` from `@lunora/auth` — those hook
139
+ * better-auth's own user-create path. The `Middleware` import is type-only, so
140
+ * this stays free of any runtime `@lunora/server` dependency.
141
+ */
142
+ declare const emailGateMiddleware: <Context>(options: EmailGateMiddlewareOptions<Context>) => Middleware<Context, Context>;
143
+ export { type EmailClass, type EmailClassification, type EmailGateConfig, type EmailGateMiddlewareOptions, assertEmailAllowed, classifyEmail, emailGateMiddleware, loadEmailDomainLists };
@@ -0,0 +1,143 @@
1
+ import { Middleware } from '@lunora/server';
2
+ /**
3
+ * The resolved trust class of an email address' domain: `disposable` (a
4
+ * throwaway/temporary provider, or a caller `denyDomains` hit), `free` (a free
5
+ * consumer provider like Gmail — deliverable but not B2B), or `business`
6
+ * (anything else — a custom/company domain, or an `allowDomains` hit).
7
+ */
8
+ type EmailClass = "business" | "disposable" | "free";
9
+ /** Result of {@link classifyEmail} — the resolved class plus the normalized domain. */
10
+ interface EmailClassification {
11
+ /** The normalized (lowercased, trimmed) domain, or `undefined` for a structurally invalid address. */
12
+ domain: string | undefined;
13
+ /** The resolved trust class. A structurally invalid address resolves to `business` with `domain: undefined`. */
14
+ emailClass: EmailClass;
15
+ }
16
+ /** Configuration for {@link classifyEmail} / {@link assertEmailAllowed} / {@link emailGateMiddleware}. */
17
+ interface EmailGateConfig {
18
+ /**
19
+ * Domains that are always allowed and always classified `business`, even if
20
+ * they appear on the disposable or free lists. Wildcard/subdomain aware
21
+ * (`example.com` also allows `mail.example.com`). Your own hosted domains go
22
+ * here.
23
+ */
24
+ allowDomains?: ReadonlyArray<string>;
25
+ /**
26
+ * Reject disposable/throwaway signups. Defaults to `true` — the whole point
27
+ * of the gate. Set `false` to classify-only (surface `emailClass` without
28
+ * blocking).
29
+ */
30
+ blockDisposable?: boolean;
31
+ /**
32
+ * Extra domains to treat as disposable (blocked when `blockDisposable`), on
33
+ * top of the built-in list. Wildcard/subdomain aware.
34
+ */
35
+ denyDomains?: ReadonlyArray<string>;
36
+ /**
37
+ * Which built-in lists {@link assertEmailAllowed} loads (both by default, so the
38
+ * returned class is complete). The disposable list is ~2.7 MB and the free list
39
+ * ~143 KB of bundle; `emailGateMiddleware` and `emailGateDatabaseHooks` set this
40
+ * for you: the free list loads only with `onClassify`, the disposable list only
41
+ * with `blockDisposable !== false` or `onClassify`.
42
+ */
43
+ lists?: {
44
+ disposable?: boolean;
45
+ free?: boolean;
46
+ };
47
+ /**
48
+ * Opt-in MX deliverability verification. Off by default because it needs DNS
49
+ * (`@visulima/email-verifier/checks/mx` → `node:dns`), which is not available
50
+ * on the default workerd path. When `true`, {@link assertEmailAllowed} rejects
51
+ * an address whose domain publishes no MX (or fallback A/AAAA) records with
52
+ * `EMAIL_UNDELIVERABLE`. The check is loaded via a dynamic import, so leaving
53
+ * this off keeps the DNS module out of the bundle entirely.
54
+ */
55
+ mx?: boolean;
56
+ /**
57
+ * Reject structurally invalid addresses up front (via
58
+ * `@visulima/email-verifier/checks/syntax`, pure-data/edge-safe). Defaults to
59
+ * `true`. When `false`, an unparseable address classifies as `business` with
60
+ * `domain: undefined` and is not rejected on syntax alone.
61
+ */
62
+ requireValidSyntax?: boolean;
63
+ }
64
+ /** Options for {@link emailGateMiddleware}: the base gate config plus how to read the email from `ctx`. */
65
+ interface EmailGateMiddlewareOptions<Context> extends EmailGateConfig {
66
+ /**
67
+ * Selector that pulls the signup email off `ctx`. The procedure context
68
+ * carries only the resolved identity, not the raw request body, so the email
69
+ * travels in the function `args` — which the builder surfaces to middleware
70
+ * as `ctx.args` (validated, frozen). Mirrors `verifyTurnstileMiddleware`'s
71
+ * `token` selector:
72
+ *
73
+ * ```ts
74
+ * export const signUp = mutation
75
+ * .input({ email: v.string() })
76
+ * .use(emailGateMiddleware({ email: (ctx) => ctx.args.email }))
77
+ * .mutation(async ({ args, ctx }) => { … });
78
+ * ```
79
+ */
80
+ email: (context: Context) => string | undefined;
81
+ /**
82
+ * Called with the resolved classification once the gate passes, so app policy
83
+ * can branch on `free` vs `business` (e.g. gate a plan behind a business
84
+ * email). Never fires when the gate rejects.
85
+ */
86
+ onClassify?: (classification: EmailClassification, context: Context) => void;
87
+ }
88
+ /**
89
+ * Inject the built-in domain lists into the lookup packages, once each. Call this
90
+ * at worker init on workerd (where the packages' `node:fs` loader is unavailable)
91
+ * so {@link classifyEmail} has data to match against; {@link assertEmailAllowed} /
92
+ * {@link emailGateMiddleware} await it for you, loading only what the config needs.
93
+ * Both lists (~2.7 MB disposable, ~143 KB free) load by default; pass
94
+ * `{ free: false }` or `{ disposable: false }` to skip one. Idempotent — repeat
95
+ * calls share one load per list.
96
+ */
97
+ declare const loadEmailDomainLists: ({ disposable, free }?: {
98
+ disposable?: boolean;
99
+ free?: boolean;
100
+ }) => Promise<void>;
101
+ /**
102
+ * Classify an email address' domain as `disposable` / `free` / `business`,
103
+ * pure-data and edge-safe (no DNS, no filesystem). A structurally invalid
104
+ * address resolves to `{ domain: undefined, emailClass: "business" }` — use
105
+ * {@link assertEmailAllowed} (which can reject on syntax) for the gating path.
106
+ * `allowDomains` wins over both lists; `denyDomains` adds to the disposable list.
107
+ *
108
+ * The lookup relies on the built-in domain lists being loaded. On Node they
109
+ * auto-load from disk; on workerd, `await loadEmailDomainLists()` first (or use
110
+ * {@link assertEmailAllowed} / {@link emailGateMiddleware}, which await it).
111
+ *
112
+ * The returned/compared domain is IDNA-normalized (see {@link toAsciiDomain}) so
113
+ * a Unicode-form internationalized domain matches its `xn--` blocklist entry.
114
+ */
115
+ declare const classifyEmail: (email: string, config?: EmailGateConfig) => EmailClassification;
116
+ /**
117
+ * Classify `email` and enforce the gate, throwing a coded {@link LunoraError}
118
+ * when it fails: `VALIDATION_ERROR` (structurally invalid, only when
119
+ * `requireValidSyntax`, the default), `EMAIL_DOMAIN_BLOCKED` (disposable or
120
+ * deny-listed, only when `blockDisposable`, the default), or `EMAIL_UNDELIVERABLE`
121
+ * (no MX records, only when `mx: true`).
122
+ *
123
+ * Returns the {@link EmailClassification} on success so callers can branch on
124
+ * `free` vs `business`. Async only because of the opt-in MX step; with `mx` off
125
+ * it resolves without any network I/O.
126
+ */
127
+ declare const assertEmailAllowed: (email: string, config?: EmailGateConfig) => Promise<EmailClassification>;
128
+ /**
129
+ * Lunora procedure middleware that gates a non-auth, signup-shaped
130
+ * `mutation`/`action` on the email-domain policy. Attach it with `.use()`; it
131
+ * reads the email from `ctx` via the `email` selector (declare it with
132
+ * `.input(...)` and read it back as `ctx.args.email`)
133
+ * and runs {@link assertEmailAllowed}, which throws a coded {@link LunoraError}
134
+ * (`EMAIL_DOMAIN_BLOCKED` / `EMAIL_UNDELIVERABLE` / `VALIDATION_ERROR`) the
135
+ * runtime maps to the matching status.
136
+ *
137
+ * To gate better-auth's native `/sign-up/email` endpoint instead, use
138
+ * `emailGateDatabaseHooks` / `withEmailGate` from `@lunora/auth` — those hook
139
+ * better-auth's own user-create path. The `Middleware` import is type-only, so
140
+ * this stays free of any runtime `@lunora/server` dependency.
141
+ */
142
+ declare const emailGateMiddleware: <Context>(options: EmailGateMiddlewareOptions<Context>) => Middleware<Context, Context>;
143
+ export { type EmailClass, type EmailClassification, type EmailGateConfig, type EmailGateMiddlewareOptions, assertEmailAllowed, classifyEmail, emailGateMiddleware, loadEmailDomainLists };
@@ -0,0 +1 @@
1
+ import{LunoraError as l}from"@lunora/errors";import{assertListLoaded as D,isDisposableDomain as v,setDomains as y}from"@visulima/disposable-email-domains";import{extractDomain as w,assertListLoaded as L,setDomains as b,isFreeDomain as p}from"@visulima/free-email-domains";const c=a=>{const s=a.default??a;return Array.isArray(s)?s:[]};let r,n,d=!1;const u=(a,s,t)=>{if(a)return a;const e=s();return e.catch(t),e},E=async({disposable:a=!0,free:s=!0}={})=>{a&&(r=u(r,async()=>{y(c(await import("@visulima/disposable-email-domains/domains")))},()=>{r=void 0})),s&&(n=u(n,async()=>{b(c(await import("@visulima/free-email-domains/domains"))),d=!0},()=>{n=void 0})),await Promise.all([a?r:void 0,s?n:void 0])},m=a=>{try{return new URL(`https://${a}`).hostname}catch{return a.toLowerCase()}},f=a=>a&&a.length>0?new Set(a.map(s=>m(s))):void 0,A=(a,s={})=>{const t=w(a);if(t===void 0)return{domain:void 0,emailClass:"business"};try{D(),d&&L()}catch(i){throw new Error("@lunora/auth: the email domain lists are not loaded; `await loadEmailDomainLists()` before calling classifyEmail (assertEmailAllowed does this for you).",{cause:i})}const e=m(t),o=f(s.allowDomains);return v(e,{allowDomains:o,customDomains:f(s.denyDomains)})?{domain:e,emailClass:"disposable"}:d&&p(e,{allowDomains:o})?{domain:e,emailClass:"free"}:{domain:e,emailClass:"business"}},C=async a=>{const{checkMxRecords:s}=await import("@visulima/email-verifier/checks/mx"),t=await s(a);return t.valid||t.deferred===!0},M=async(a,s={})=>{if(s.requireValidSyntax!==!1){const{validateSyntax:h}=await import("@visulima/email-verifier/checks/syntax");if(!h(a))throw new l("VALIDATION_ERROR",`@lunora/auth: "${a}" is not a valid email address.`)}const t=s.lists??{},e=t.disposable!==!1;await E({disposable:e,free:t.free!==!1});const o=w(a),i=e?A(a,s):{domain:o===void 0?void 0:m(o),emailClass:"business"};if(i.emailClass==="disposable"&&s.blockDisposable!==!1)throw new l("EMAIL_DOMAIN_BLOCKED",`@lunora/auth: signups from the disposable/throwaway domain "${i.domain??a}" are not allowed.`);if(s.mx===!0&&i.domain!==void 0&&!await C(i.domain))throw new l("EMAIL_UNDELIVERABLE",`@lunora/auth: the domain "${i.domain}" publishes no MX records, so mail to it cannot be delivered.`);return i},O=a=>async({ctx:s,next:t})=>{const e=a.email(s);if(e===void 0||e==="")throw new l("VALIDATION_ERROR","@lunora/auth: emailGateMiddleware received no email to check.");const o=a.onClassify!==void 0,i=await M(e,{...a,lists:{disposable:a.blockDisposable!==!1||o,free:o}});return a.onClassify?.(i,s),t()};export{M as assertEmailAllowed,A as classifyEmail,O as emailGateMiddleware,E as loadEmailDomainLists};