@lunora/auth 1.0.0-alpha.14 → 1.0.0-alpha.141

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 (76) hide show
  1. package/LICENSE.md +38 -0
  2. package/README.md +99 -10
  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 +131 -0
  7. package/dist/audit.d.ts +131 -0
  8. package/dist/audit.mjs +12 -0
  9. package/dist/email-guard.d.mts +127 -0
  10. package/dist/email-guard.d.ts +127 -0
  11. package/dist/email-guard.mjs +1 -0
  12. package/dist/index.d.mts +801 -157
  13. package/dist/index.d.ts +801 -157
  14. package/dist/index.mjs +1 -12
  15. package/dist/middleware.d.mts +167 -156
  16. package/dist/middleware.d.ts +167 -156
  17. package/dist/middleware.mjs +1 -53
  18. package/dist/packem_shared/AUTH_DO_AUDIT_PATH-th2-Hb30.mjs +1 -0
  19. package/dist/packem_shared/DEFAULT_AUTH_BASE_PATH-DneiLGpv.mjs +1 -0
  20. package/dist/packem_shared/LunoraAuthAdminError-4r29Kj_y.mjs +1 -0
  21. package/dist/packem_shared/adapter-CG0Yv85f.mjs +2 -0
  22. package/dist/packem_shared/adapter.d-BW_ovHul.d.mts +89 -0
  23. package/dist/packem_shared/adapter.d-BWw-tC_U.d.ts +89 -0
  24. package/dist/packem_shared/authAuditHook-C9Gt2iYp.mjs +1 -0
  25. package/dist/packem_shared/authDoColumnAdditions-UwF3Rl41.mjs +1 -0
  26. package/dist/packem_shared/compileMigrationsSql-Ds2snsWg.mjs +1 -0
  27. package/dist/packem_shared/create-auth.d-D1CKOFWc.d.mts +142 -0
  28. package/dist/packem_shared/create-auth.d-D1CKOFWc.d.ts +142 -0
  29. package/dist/packem_shared/createAuth-8aUw8v8D.mjs +1 -0
  30. package/dist/packem_shared/createDoAuthWiring-C8BRUjf6.mjs +1 -0
  31. package/dist/packem_shared/createLunoraAuthClient-DhjhCwmM.mjs +1 -0
  32. package/dist/packem_shared/createSignUpInvitation-ClvskwuB.mjs +1 -0
  33. package/dist/packem_shared/emailGateDatabaseHooks-CAygR3iq.mjs +1 -0
  34. package/dist/packem_shared/invite-only.d-CsPJWAwM.d.ts +126 -0
  35. package/dist/packem_shared/invite-only.d-DKyHHkkQ.d.mts +126 -0
  36. package/dist/packem_shared/legacyIssuerCleanupStatements-D9Kf8OD1.mjs +1 -0
  37. package/dist/packem_shared/on-cloudflare-edge-Dx6gFjBO.mjs +1 -0
  38. package/dist/packem_shared/quote-identifier-CGiYFBvY.mjs +1 -0
  39. package/dist/packem_shared/sessionPresets-C867Mlo4.mjs +1 -0
  40. package/dist/packem_shared/sql-store-CYa4HJsO.mjs +1 -0
  41. package/dist/packem_shared/uiConfig-B8pN6pks.mjs +1 -0
  42. package/dist/plugins-client.d.mts +165 -1
  43. package/dist/plugins-client.d.ts +165 -1
  44. package/dist/plugins-client.mjs +1 -2
  45. package/dist/plugins-enterprise-client.d.mts +15 -0
  46. package/dist/plugins-enterprise-client.d.ts +15 -0
  47. package/dist/plugins-enterprise-client.mjs +1 -0
  48. package/dist/plugins-enterprise.d.mts +1670 -0
  49. package/dist/plugins-enterprise.d.ts +1670 -0
  50. package/dist/plugins-enterprise.mjs +1 -0
  51. package/dist/plugins.d.mts +125 -2
  52. package/dist/plugins.d.ts +125 -2
  53. package/dist/plugins.mjs +1 -22
  54. package/dist/schema.d.mts +39 -39
  55. package/dist/schema.d.ts +39 -39
  56. package/dist/schema.mjs +1 -62
  57. package/dist/sql-store.d.mts +28 -28
  58. package/dist/sql-store.d.ts +28 -28
  59. package/dist/sql-store.mjs +1 -184
  60. package/dist/store.d.mts +65 -47
  61. package/dist/store.d.ts +65 -47
  62. package/dist/store.mjs +1 -183
  63. package/dist/turnstile-middleware.d.mts +73 -55
  64. package/dist/turnstile-middleware.d.ts +73 -55
  65. package/dist/turnstile-middleware.mjs +1 -45
  66. package/dist/turnstile.d.mts +42 -59
  67. package/dist/turnstile.d.ts +42 -59
  68. package/dist/turnstile.mjs +1 -61
  69. package/package.json +39 -5
  70. package/dist/packem_shared/DEFAULT_AUTH_BASE_PATH-DjcUWEQl.mjs +0 -11
  71. package/dist/packem_shared/LunoraAuthAdminError-BxrfEeA_.mjs +0 -249
  72. package/dist/packem_shared/compileMigrationsSql-cyE5U18R.mjs +0 -29
  73. package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.mts +0 -128
  74. package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.ts +0 -128
  75. package/dist/packem_shared/createAuth-hRGxUJ9w.mjs +0 -85
  76. package/dist/packem_shared/sessionPresets-Dwwd74_J.mjs +0 -38
@@ -0,0 +1,89 @@
1
+ import { createAdapterFactory } from 'better-auth/adapters';
2
+ import { d1Executor } from "../sql-store.js";
3
+ import { AuthStore } from "../store.js";
4
+ /**
5
+ * The slice of `DurableObjectStorage` this module uses.
6
+ *
7
+ * Structural on purpose: it keeps `@lunora/auth` free of a `@lunora/do` dependency
8
+ * and lets tests supply a double whose `transaction` has the same semantics.
9
+ */
10
+ interface DoStorageLike {
11
+ /** Synchronous SQL over the object's SQLite. */
12
+ sql: {
13
+ exec: (query: string, ...bindings: unknown[]) => Iterable<Record<string, unknown>>;
14
+ };
15
+ /**
16
+ * The platform's async transaction primitive. Everything the closure executes
17
+ * against `sql` joins the transaction — it is connection-scoped, so no handle is
18
+ * threaded through — and a throw rolls the whole thing back.
19
+ */
20
+ transaction: <R>(closure: () => Promise<R>) => Promise<R>;
21
+ }
22
+ /**
23
+ * Runs `closure` atomically. Supplied by stores whose backend has real
24
+ * transactions — a Durable Object's storage does; D1 does not.
25
+ */
26
+ type TransactionRunner = <R>(closure: () => Promise<R>) => Promise<R>;
27
+ /**
28
+ * A better-auth database adapter backed by an {@link AuthStore} — the bridge
29
+ * that routes better-auth's reads and writes through Lunora's data layer
30
+ * instead of better-auth's built-in D1/Kysely adapter. Pass the result as
31
+ * `createAuth({ database: lunoraAuthAdapter(store) })`; better-auth's
32
+ * `createAdapterFactory` handles id generation, default values, field-name
33
+ * mapping and output shaping, so this only translates the cleaned CRUD calls
34
+ * onto the store.
35
+ *
36
+ * ```ts
37
+ * const auth = createAuth({
38
+ * secret: env.AUTH_SECRET,
39
+ * emailAndPassword: { enabled: true },
40
+ * database: lunoraAuthAdapter(lunoraStore), // lunoraStore writes via ctx.db
41
+ * });
42
+ * ```
43
+ *
44
+ * Scope: the {@link AuthStore} interface is single-table CRUD. better-auth's
45
+ * relational `join` reads (an advanced opt-in) are not handled — pair the
46
+ * adapter with `disableJoins` or let better-auth fall back to per-table reads.
47
+ *
48
+ * No schema check either: an {@link AuthStore} is opaque CRUD with nothing to
49
+ * introspect, so better-auth logs that validation is unavailable for this adapter
50
+ * and proceeds. {@link lunoraD1Adapter} and {@link lunoraDoAdapter} know which
51
+ * SQLite backend they sit on and register one — see `schema-check.ts`.
52
+ */
53
+ declare const lunoraAuthAdapter: (store: AuthStore, runInTransaction?: TransactionRunner) => ReturnType<typeof createAdapterFactory>;
54
+ /**
55
+ * One-liner for the common case: a better-auth `database` backed by a Cloudflare
56
+ * D1 binding, via Lunora's SQL store — equivalent to
57
+ * `lunoraAuthAdapter(createSqlAuthStore(d1Executor(d1)))`.
58
+ *
59
+ * Prefer this over passing the raw `env.DB` as `database`. With raw D1,
60
+ * better-auth resolves its Kysely adapter through a runtime `await import(...)`
61
+ * inside `auth.$context`, and that dynamic import never settles under
62
+ * `@cloudflare/vite-plugin`'s worker runner — so it hangs *every* auth request
63
+ * in `pnpm dev` (a standalone `wrangler dev` or a deployed worker bundle it
64
+ * up-front, so they're unaffected — which makes the hang baffling to debug).
65
+ * This explicit adapter skips that import entirely, so dev and prod behave the
66
+ * same. The migration instance is the one exception — it wants raw `env.DB` so
67
+ * `ensureMigrated`'s Kysely migrator can create the tables (its `$context` is
68
+ * never resolved, so the hang doesn't apply there).
69
+ *
70
+ * Carries a schema check: before the first write — better-auth awaits it in the
71
+ * router ahead of the rate limiter, which is the first thing that writes — the
72
+ * tables this configuration needs are compared against the ones D1 holds, and a
73
+ * mismatch fails with the table and column named instead of `no such table: …`
74
+ * inside an empty 500. See `schema-check.ts`.
75
+ */
76
+ declare const lunoraD1Adapter: (d1: Parameters<typeof d1Executor>[0]) => ReturnType<typeof lunoraAuthAdapter>;
77
+ /**
78
+ * A better-auth `database` backed by a Durable Object's own SQLite —
79
+ * `lunoraAuthAdapter(createSqlAuthStore(doExecutor(storage)), doTransactionRunner(storage))`.
80
+ *
81
+ * Unlike `lunoraD1Adapter` this one exposes real transactions, so plugins that
82
+ * demand them (`@better-auth/scim`) accept it. Read the trade-offs in `do-store.ts`
83
+ * before reaching for it: the auth tables then live inside a single Durable Object.
84
+ *
85
+ * Schema-checked like {@link lunoraD1Adapter}, against the object's own SQLite.
86
+ * @experimental
87
+ */
88
+ declare const lunoraDoAdapter: (storage: DoStorageLike) => ReturnType<typeof lunoraAuthAdapter>;
89
+ export { DoStorageLike as D, lunoraD1Adapter as a, lunoraDoAdapter as b, lunoraAuthAdapter as l };
@@ -0,0 +1 @@
1
+ import{createAuthMiddleware as d}from"better-auth/api";import{o as c}from"./on-cloudflare-edge-Dx6gFjBO.mjs";import{appendAuthAuditEntry as l}from"../audit.mjs";const f=r=>{const t=r.toLowerCase(),e=i=>t===i||t.endsWith(i);if(e("/sign-up/email")||e("/sign-up"))return"sign-up";if(e("/sign-in/social")||e("/sign-in/magic-link"))return"sign-in-initiated";if(t.includes("/sign-in/")||t.includes("/callback/")||e("/magic-link/verify")||t.includes("/two-factor/verify-"))return"sign-in";if(e("/sign-out"))return"sign-out";if(e("/change-password")||e("/set-password"))return"password-change";if(e("/reset-password")||e("/request-password-reset")||e("/forget-password"))return"password-reset";if(e("/verify-email"))return"email-verification";if(t.includes("/two-factor/enable")||t.includes("/totp/enable"))return"mfa-enable";if(t.includes("/two-factor/disable")||t.includes("/totp/disable"))return"mfa-disable";if(e("/refresh-token")||e("/token"))return"token-refresh";if(e("/revoke-session")||e("/revoke-sessions")||e("/revoke-other-sessions"))return"session-revoke";if(e("/link-social"))return"account-link";if(e("/unlink-account"))return"account-unlink"},a=(r,t)=>r.headers?.get(t)??r.request?.headers.get(t)??void 0,v=(r,t)=>{if(c()){const e=a(r,"cf-connecting-ip");if(e!==void 0)return e}if(t===!0)return a(r,"x-forwarded-for")?.split(",")[0]?.trim()},p=r=>{const t=r.context?.newSession??r.context?.session,e=t?.user?.id??t?.session?.userId,i=t?.user?.email;return{...e===void 0?{}:{actorId:e},...i===void 0?{}:{actorEmail:i}}},h=r=>{const t=r.context?.returned;if(t instanceof Error)return"failure";if(typeof t=="object"&&t!==null&&"status"in t){const e=Number(t.status);if(Number.isFinite(e)&&e>=400)return"failure"}return"success"},u=320,g=(r,t)=>{if(t!=="sign-in"&&t!=="sign-in-initiated")return;const e=r.body?.email??r.body?.username;if(!(typeof e!="string"||e.length===0))return e.length>u?e.slice(0,u):e},m=(r,{now:t=Date.now(),trustProxyHeaders:e}={})=>{const i=r.path===void 0?void 0:f(r.path);if(i===void 0)return;const s=v(r,e),n=a(r,"user-agent"),o=g(r,i);return{...p(r),event:i,outcome:h(r),ts:t,...s===void 0?{}:{ip:s},...o===void 0?{}:{targetEmail:o},...n===void 0?{}:{userAgent:n},detail:{path:r.path}}},w=r=>d(async t=>{try{const e=m(t,{trustProxyHeaders:r.trustProxyHeaders});if(e!==void 0){const i=await l(r.executor,e,{redactDetail:r.redactDetail,retention:r.retention});r.onRecord!==void 0&&await r.onRecord(i)}}catch(e){console.error("@lunora/auth: audit hook failed to record event",e)}}),A=(r,t)=>{const e=w(t),i=r.hooks?.after,s=i?async n=>{const o=await i(n);return await e(n),o}:e;return{...r,hooks:{...r.hooks,after:s}}};export{w as authAuditHook,m as buildAuditEntry,f as eventForPath,A as withAuthAudit};
@@ -0,0 +1 @@
1
+ import{getAuthTablesWithResolvedIndexes as l,getDatabaseFieldIndexName as N}from"@better-auth/core/db/internal";import{q as r}from"./quote-identifier-CGiYFBvY.mjs";const f=(n,e)=>{if(n==="id"||e.references?.field==="id")return"text";const{type:t}=e;if(Array.isArray(t))return"text";switch(t){case"boolean":return"integer";case"date":return"date";case"number":return"bigint"in e&&e.bigint===!0?"bigint":"integer";default:return"text"}},b=n=>{const{defaultValue:e,type:t,unique:o}=n;if(!(o===!0&&n.required===!1)&&!(e==null||typeof e=="function")){if(t==="boolean")return e===!0?"1":"0";if(t==="number"&&typeof e=="number"&&Number.isFinite(e))return String(e);if(t==="string"&&typeof e=="string")return`'${e.replaceAll("'","''")}'`}},p=(n,e)=>{const t=[];for(const[o,i]of Object.entries(e)){const s=i.unique===!0;if(!s&&i.index!==!0)continue;const c=i.fieldName??o,u=N(n,c,s);t.push(`CREATE ${s?"UNIQUE ":""}INDEX IF NOT EXISTS ${r(u)} ON ${r(n)} (${r(c)})`)}return t},T=(n,e)=>{const t=[r(n),f(n,e)],o=b(e);return e.required!==!1&&o!==void 0&&t.push("NOT NULL"),o!==void 0&&t.push(`DEFAULT ${o}`),t.join(" ")},E=(n,e)=>{const t=[r(n),f(n,e)];return e.required!==!1&&t.push("NOT NULL"),t.join(" ")},$=n=>{const{indexesByTable:e,tables:t}=l(n),o=[],i=[];for(const s of Object.values(t)){if(s.disableMigrations===!0)continue;const c=[`${r("id")} text NOT NULL PRIMARY KEY`,...Object.entries(s.fields).map(([u,a])=>E(a.fieldName??u,a))];o.push(`CREATE TABLE IF NOT EXISTS ${r(s.modelName)} (${c.join(", ")})`),i.push(...p(s.modelName,s.fields));for(const u of e.get(s.modelName)??[]){const a=u.unique===!0?"UNIQUE ":"",m=u.columns.map(d=>r(d)).join(", ");i.push(`CREATE ${a}INDEX IF NOT EXISTS ${r(u.name)} ON ${r(s.modelName)} (${m})`)}}return[...o,...i]},A=(n,e)=>{const{tables:t}=l(n),o=[];for(const i of Object.values(t)){if(i.disableMigrations===!0)continue;const s=new Set(e(i.modelName));if(s.size!==0)for(const[c,u]of Object.entries(i.fields)){const a=u.fieldName??c;s.has(a)||o.push(`ALTER TABLE ${r(i.modelName)} ADD COLUMN ${T(a,u)}`)}}return o};export{A as authDoColumnAdditions,$ as authDoSchemaStatements};
@@ -0,0 +1 @@
1
+ import{getAuthTablesWithResolvedIndexes as w,invalidateSchemaChecks as y}from"@better-auth/core/db/internal";import{LunoraError as R}from"@lunora/errors";import{getMigrations as l}from"better-auth/db/migration";import{q as u}from"./quote-identifier-CGiYFBvY.mjs";import{resolveAuthOptions as _}from"./createAuth-8aUw8v8D.mjs";import{schemaDeclaresIssuer as x,legacyIssuerCleanupStatements as M,indexesReferencingIssuer as T}from"./legacyIssuerCleanupStatements-D9Kf8OD1.mjs";const N=/pragma_index_list\s*\(/iu,A=/sqlite_master/iu,C=/^["`[]|["\]`]$/gu,D=/\s+/u,S=/create\s+unique\s+index/iu,O=/\bwhere\b/iu,P=e=>N.test(e)&&A.test(e),L=e=>(e.trim().split(D)[0]??"").replaceAll(C,"").trim(),U=e=>{const t=e.indexOf("(");if(t===-1)return{columns:[],tail:""};let a=0;for(let n=t;n<e.length;n+=1){const s=e[n];if(s==="(")a+=1;else if(s===")"&&(a-=1,a===0))return{columns:e.slice(t+1,n).split(",").map(o=>L(o)).filter(o=>o!==""),tail:e.slice(n+1)}}return{columns:[],tail:""}},q=async e=>{const t=e.prepare("SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index'"),{results:a}=await t.all(),n=[];for(const s of a??[]){const o=s.name,r=s.tbl_name,i=s.sql;if(typeof o!="string"||typeof r!="string")continue;if(typeof i!="string"){n.push({columnPosition:0,indexName:o,isPartial:0,isUnique:1,tableName:r});continue}const f=S.test(i)?1:0,{columns:h,tail:b}=U(i),E=O.test(b)?1:0;for(const[I,g]of h.entries())n.push({columnName:g,columnPosition:I,indexName:o,isPartial:E,isUnique:f,tableName:r})}return n},v=e=>{const t=async()=>{const n=await q(e);return{meta:{},results:n,success:!0}},a={all:t,bind:()=>a,run:t};return a},W=e=>new Proxy(e,{get(t,a,n){if(a==="prepare")return o=>P(o)?v(t):t.prepare(o);const s=Reflect.get(t,a,n);return typeof s=="function"?s.bind(t):s}}),m=e=>typeof e=="object"&&e!==null&&typeof e.prepare=="function"&&typeof e.batch=="function",p=e=>{const{database:t}=e;if(!(t&&typeof t!="function"))throw new R("AUTH_MIGRATOR_UNSUPPORTED",t?"@lunora/auth: this auth instance's `database` is a custom adapter, which better-auth's migrator cannot drive.":"@lunora/auth: this auth instance has no `database`, so better-auth's migrator has nothing to introspect.")},d=e=>m(e.database)?{...e,database:W(e.database)}:e,F=async(e,t)=>{try{return await e.prepare(`SELECT ${u("issuer")} FROM ${u(t)} LIMIT 0`).run(),!0}catch{return!1}},H=async e=>{const{database:t}=e,{tables:a}=w(e),{account:n}=a;if(n!==void 0&&!x(Object.entries(n.fields).map(([s,o])=>o.fieldName??s))&&m(t)&&await F(t,n.modelName))try{const{results:s}=await t.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?").bind(n.modelName).all();await t.batch(M(n.modelName,T(s)).map(o=>t.prepare(o)))}catch(s){console.error("@lunora/auth: could not drop the reverted `account.issuer` column; sign-ups will fail until it is removed.",s)}},Q=e=>{const{database:t}=e;typeof t=="object"&&t!==null&&y(t)},c=new WeakMap,Y=async e=>{const{options:t}=e;p(t);const a=c.get(t);if(a){await a;return}const n=(async()=>{const{runMigrations:s}=await l(d(t));await s(),await H(t),Q(t)})();c.set(t,n);try{await n}catch(s){throw c.delete(t),s}},z=async e=>{const t=_(e);p(t);const{compileMigrations:a}=await l(d(t));return a()};export{z as compileMigrationsSql,Y as ensureMigrated};
@@ -0,0 +1,142 @@
1
+ import { betterAuth, BetterAuthOptions } from 'better-auth';
2
+ /**
3
+ * Lunora's options pass straight through to better-auth — the only thing we add
4
+ * is requiring `secret` up front so a misconfigured deployment fails loudly
5
+ * instead of at the first sign-in.
6
+ *
7
+ * For `database`, prefer `lunoraD1Adapter` (`database: lunoraD1Adapter(env.DB)`)
8
+ * over passing the raw `env.DB`. better-auth *does* accept a D1Database directly,
9
+ * but it then resolves its Kysely adapter via a runtime `await import(...)` inside
10
+ * `auth.$context` — and that import never settles under `@cloudflare/vite-plugin`'s
11
+ * worker runner, hanging every auth request in `pnpm dev`. The explicit adapter
12
+ * skips it, so dev and prod behave the same. (Raw `env.DB` is still correct for
13
+ * the migration-only instance — see `lunoraD1Adapter`'s note.)
14
+ *
15
+ * Session rotation / richer session policies are configured via the `session`
16
+ * field (a `SessionPolicy`); Lunora validates it for obviously-broken
17
+ * durations and forwards it verbatim to better-auth. See `sessionPresets`
18
+ * for ready-made rotation/expiry trade-offs.
19
+ *
20
+ * ## Serverless background tasks (Cloudflare Workers)
21
+ *
22
+ * better-auth runs some work *after* sending the response — most importantly the
23
+ * password-reset email, whose background send is what keeps reset responses
24
+ * constant-time (a timing-attack defence: the response doesn't reveal whether
25
+ * the account exists). On Cloudflare Workers a promise that isn't handed to
26
+ * `ctx.waitUntil` can be cancelled the moment the response returns, dropping
27
+ * that send and weakening the guarantee. Wire your request's `ctx.waitUntil`
28
+ * into better-auth's background handler so the work survives:
29
+ *
30
+ * ```ts
31
+ * // in your worker fetch handler, where `ctx: ExecutionContext` is in scope
32
+ * const auth = createAuth({
33
+ * secret: env.AUTH_SECRET,
34
+ * database: lunoraD1Adapter(env.DB),
35
+ * advanced: {
36
+ * backgroundTasks: { handler: (promise) => ctx.waitUntil(promise) },
37
+ * },
38
+ * });
39
+ * ```
40
+ *
41
+ * (Lunora can't set this for you — `ctx.waitUntil` is per-request, but
42
+ * `createAuth` runs once at worker setup.)
43
+ *
44
+ * `databaseHooks` never receives a Lunora `MutationCtx` — the hook runs inside
45
+ * better-auth's own write path. A side effect on an app table is therefore a
46
+ * separate write and has to be idempotent; see `concepts/authentication`.
47
+ */
48
+ type LunoraAuthOptions = BetterAuthOptions;
49
+ /**
50
+ * The full better-auth instance: `auth.handler` accepts a `Request` and
51
+ * returns a `Response` (used by `handleAuthRequest`); `auth.api`
52
+ * exposes the typed endpoint surface for server-side calls (e.g.
53
+ * `auth.api.getSession({ headers })` inside a query/mutation).
54
+ */
55
+ type LunoraAuth = ReturnType<typeof betterAuth>;
56
+ /**
57
+ * Resolve the caller's options into the exact shape `createAuth` hands to
58
+ * `betterAuth` — the hardened, default-filled options the running worker uses.
59
+ * Exported (and pure) so the migration path can compile the schema from the
60
+ * same resolved options: `compileMigrationsSql` routes through here, so the
61
+ * `rateLimit` table the worker's durable limiter writes to is included in the
62
+ * migration rather than silently omitted (it would be, if migrations saw the
63
+ * raw options while the worker ran the resolved ones).
64
+ *
65
+ * ## What it fills (each gated independently on caller silence)
66
+ *
67
+ * Secure-by-default cookies + secret-strength warning via {@link hardenAuthOptions},
68
+ * applied first so all hardening composes onto one options object.
69
+ *
70
+ * Rate limiting is ON by default for `/api/auth/*`.
71
+ *
72
+ * better-auth's own default is `rateLimit.enabled ?? isProduction`, and its
73
+ * `isProduction` is `"production" === "production"` resolved at
74
+ * module-load time. On Cloudflare Workers that check is unreliable: the
75
+ * runtime has no Node `process.env` (absent entirely without
76
+ * `nodejs_compat`, and even with it `NODE_ENV` is rarely `"production"` at
77
+ * request time). So better-auth would silently leave auth endpoints
78
+ * _unthrottled_ on a real deployment — the surprise we refuse to ship.
79
+ *
80
+ * We therefore default `enabled: true` whenever the caller hasn't made an
81
+ * explicit choice. We only fill the `enabled` flag and otherwise forward
82
+ * the caller's `rateLimit` verbatim, so better-auth's `window` (10s) / `max`
83
+ * (100) defaults and any custom rules still apply. Callers who genuinely
84
+ * want it off can pass `rateLimit: { enabled: false }` (e.g. when fronting
85
+ * auth with their own limiter), and any explicit `enabled` value wins.
86
+ *
87
+ * We also default `storage: "database"` — but only when rate limiting is not
88
+ * explicitly disabled (`enabled !== false`). Filling storage under a disabled
89
+ * limiter is harmless at runtime but makes `getAuthTables` emit an unused
90
+ * `rateLimit` table, so we skip it there.
91
+ *
92
+ * better-auth's own default is `storage: "memory"` — a per-isolate,
93
+ * non-durable counter. On Cloudflare Workers that means each isolate keeps
94
+ * its own tally, counters vanish on isolate recycle, and traffic spread
95
+ * across isolates never sums to the configured `max` — a limiter that
96
+ * reports "enabled" while never enforcing a global limit (the exact
97
+ * brute-force / credential-stuffing protection on `/sign-in`, OTP, and
98
+ * password-reset it is meant to buy). `storage: "database"` rides the counter
99
+ * through the configured `database` adapter — Lunora's store over the D1 auth
100
+ * tables — so the limit is durable *and* atomic (the store's native
101
+ * `incrementOne` gives a one-winner guarantee across isolates). Callers with
102
+ * their own durable store can pass an explicit `rateLimit: { storage: … }`
103
+ * (or `customStorage`), and any explicit value wins.
104
+ *
105
+ * The limiter's *bucket key* is filled here too, and it is the half that makes
106
+ * the rest of the paragraph above true. A limit is only brute-force protection
107
+ * if the key identifies the client, so {@link defaultIpAddressHeaders} points
108
+ * better-auth at `cf-connecting-ip` instead of its own `x-forwarded-for`
109
+ * default, and {@link UNRESOLVED_IP_BUCKET_FACTOR} re-sizes the shared bucket
110
+ * for the deployments where no client IP can be resolved at all. Both are read
111
+ * off `advanced.ipAddress`, so a caller who configures that keeps their own
112
+ * policy end to end. The catch-all `customRules` entry is merged *after* the
113
+ * caller's own rules so theirs match first.
114
+ *
115
+ * Session cookie cache is ON by default too.
116
+ *
117
+ * Every authenticated call resolves identity through better-auth's
118
+ * `getSession`, which — without a cache — is a DB (D1) read on the hot path
119
+ * of every query/mutation/action that reads `ctx.auth` and of the WebSocket
120
+ * upgrade. better-auth's `session.cookieCache` carries the session payload in
121
+ * a short-lived signed cookie so `getSession` can answer without hitting the
122
+ * database until the cache window elapses. We default it on with a
123
+ * deliberately short 60s `maxAge` (better-auth's own default is 300s): long
124
+ * enough to erase the per-request read for a burst of calls, short enough
125
+ * that a revoked or role-changed session self-corrects within a minute.
126
+ * The one tradeoff — a revoked session stays valid until the cache expires —
127
+ * is bounded by that TTL; callers who need immediate revocation opt out with
128
+ * `session: { cookieCache: { enabled: false } }` (or the `strict` preset).
129
+ *
130
+ * Every explicit caller value is forwarded verbatim. The `rateLimit` fills all
131
+ * merge into a single `rateLimit` object so none clobbers the others.
132
+ */
133
+ declare const resolveAuthOptions: (options: LunoraAuthOptions) => LunoraAuthOptions;
134
+ /**
135
+ * Create the auth instance. Thin wrapper around `betterAuth` that enforces
136
+ * the `secret` requirement at construction time so misconfigured deployments
137
+ * fail loudly at the first fetch rather than the first sign-in attempt, then
138
+ * hands {@link resolveAuthOptions}'s hardened, default-filled options to
139
+ * better-auth.
140
+ */
141
+ declare const createAuth: (options: LunoraAuthOptions) => LunoraAuth;
142
+ export { LunoraAuth as L, LunoraAuthOptions as a, createAuth as c, resolveAuthOptions as r };
@@ -0,0 +1,142 @@
1
+ import { betterAuth, BetterAuthOptions } from 'better-auth';
2
+ /**
3
+ * Lunora's options pass straight through to better-auth — the only thing we add
4
+ * is requiring `secret` up front so a misconfigured deployment fails loudly
5
+ * instead of at the first sign-in.
6
+ *
7
+ * For `database`, prefer `lunoraD1Adapter` (`database: lunoraD1Adapter(env.DB)`)
8
+ * over passing the raw `env.DB`. better-auth *does* accept a D1Database directly,
9
+ * but it then resolves its Kysely adapter via a runtime `await import(...)` inside
10
+ * `auth.$context` — and that import never settles under `@cloudflare/vite-plugin`'s
11
+ * worker runner, hanging every auth request in `pnpm dev`. The explicit adapter
12
+ * skips it, so dev and prod behave the same. (Raw `env.DB` is still correct for
13
+ * the migration-only instance — see `lunoraD1Adapter`'s note.)
14
+ *
15
+ * Session rotation / richer session policies are configured via the `session`
16
+ * field (a `SessionPolicy`); Lunora validates it for obviously-broken
17
+ * durations and forwards it verbatim to better-auth. See `sessionPresets`
18
+ * for ready-made rotation/expiry trade-offs.
19
+ *
20
+ * ## Serverless background tasks (Cloudflare Workers)
21
+ *
22
+ * better-auth runs some work *after* sending the response — most importantly the
23
+ * password-reset email, whose background send is what keeps reset responses
24
+ * constant-time (a timing-attack defence: the response doesn't reveal whether
25
+ * the account exists). On Cloudflare Workers a promise that isn't handed to
26
+ * `ctx.waitUntil` can be cancelled the moment the response returns, dropping
27
+ * that send and weakening the guarantee. Wire your request's `ctx.waitUntil`
28
+ * into better-auth's background handler so the work survives:
29
+ *
30
+ * ```ts
31
+ * // in your worker fetch handler, where `ctx: ExecutionContext` is in scope
32
+ * const auth = createAuth({
33
+ * secret: env.AUTH_SECRET,
34
+ * database: lunoraD1Adapter(env.DB),
35
+ * advanced: {
36
+ * backgroundTasks: { handler: (promise) => ctx.waitUntil(promise) },
37
+ * },
38
+ * });
39
+ * ```
40
+ *
41
+ * (Lunora can't set this for you — `ctx.waitUntil` is per-request, but
42
+ * `createAuth` runs once at worker setup.)
43
+ *
44
+ * `databaseHooks` never receives a Lunora `MutationCtx` — the hook runs inside
45
+ * better-auth's own write path. A side effect on an app table is therefore a
46
+ * separate write and has to be idempotent; see `concepts/authentication`.
47
+ */
48
+ type LunoraAuthOptions = BetterAuthOptions;
49
+ /**
50
+ * The full better-auth instance: `auth.handler` accepts a `Request` and
51
+ * returns a `Response` (used by `handleAuthRequest`); `auth.api`
52
+ * exposes the typed endpoint surface for server-side calls (e.g.
53
+ * `auth.api.getSession({ headers })` inside a query/mutation).
54
+ */
55
+ type LunoraAuth = ReturnType<typeof betterAuth>;
56
+ /**
57
+ * Resolve the caller's options into the exact shape `createAuth` hands to
58
+ * `betterAuth` — the hardened, default-filled options the running worker uses.
59
+ * Exported (and pure) so the migration path can compile the schema from the
60
+ * same resolved options: `compileMigrationsSql` routes through here, so the
61
+ * `rateLimit` table the worker's durable limiter writes to is included in the
62
+ * migration rather than silently omitted (it would be, if migrations saw the
63
+ * raw options while the worker ran the resolved ones).
64
+ *
65
+ * ## What it fills (each gated independently on caller silence)
66
+ *
67
+ * Secure-by-default cookies + secret-strength warning via {@link hardenAuthOptions},
68
+ * applied first so all hardening composes onto one options object.
69
+ *
70
+ * Rate limiting is ON by default for `/api/auth/*`.
71
+ *
72
+ * better-auth's own default is `rateLimit.enabled ?? isProduction`, and its
73
+ * `isProduction` is `"production" === "production"` resolved at
74
+ * module-load time. On Cloudflare Workers that check is unreliable: the
75
+ * runtime has no Node `process.env` (absent entirely without
76
+ * `nodejs_compat`, and even with it `NODE_ENV` is rarely `"production"` at
77
+ * request time). So better-auth would silently leave auth endpoints
78
+ * _unthrottled_ on a real deployment — the surprise we refuse to ship.
79
+ *
80
+ * We therefore default `enabled: true` whenever the caller hasn't made an
81
+ * explicit choice. We only fill the `enabled` flag and otherwise forward
82
+ * the caller's `rateLimit` verbatim, so better-auth's `window` (10s) / `max`
83
+ * (100) defaults and any custom rules still apply. Callers who genuinely
84
+ * want it off can pass `rateLimit: { enabled: false }` (e.g. when fronting
85
+ * auth with their own limiter), and any explicit `enabled` value wins.
86
+ *
87
+ * We also default `storage: "database"` — but only when rate limiting is not
88
+ * explicitly disabled (`enabled !== false`). Filling storage under a disabled
89
+ * limiter is harmless at runtime but makes `getAuthTables` emit an unused
90
+ * `rateLimit` table, so we skip it there.
91
+ *
92
+ * better-auth's own default is `storage: "memory"` — a per-isolate,
93
+ * non-durable counter. On Cloudflare Workers that means each isolate keeps
94
+ * its own tally, counters vanish on isolate recycle, and traffic spread
95
+ * across isolates never sums to the configured `max` — a limiter that
96
+ * reports "enabled" while never enforcing a global limit (the exact
97
+ * brute-force / credential-stuffing protection on `/sign-in`, OTP, and
98
+ * password-reset it is meant to buy). `storage: "database"` rides the counter
99
+ * through the configured `database` adapter — Lunora's store over the D1 auth
100
+ * tables — so the limit is durable *and* atomic (the store's native
101
+ * `incrementOne` gives a one-winner guarantee across isolates). Callers with
102
+ * their own durable store can pass an explicit `rateLimit: { storage: … }`
103
+ * (or `customStorage`), and any explicit value wins.
104
+ *
105
+ * The limiter's *bucket key* is filled here too, and it is the half that makes
106
+ * the rest of the paragraph above true. A limit is only brute-force protection
107
+ * if the key identifies the client, so {@link defaultIpAddressHeaders} points
108
+ * better-auth at `cf-connecting-ip` instead of its own `x-forwarded-for`
109
+ * default, and {@link UNRESOLVED_IP_BUCKET_FACTOR} re-sizes the shared bucket
110
+ * for the deployments where no client IP can be resolved at all. Both are read
111
+ * off `advanced.ipAddress`, so a caller who configures that keeps their own
112
+ * policy end to end. The catch-all `customRules` entry is merged *after* the
113
+ * caller's own rules so theirs match first.
114
+ *
115
+ * Session cookie cache is ON by default too.
116
+ *
117
+ * Every authenticated call resolves identity through better-auth's
118
+ * `getSession`, which — without a cache — is a DB (D1) read on the hot path
119
+ * of every query/mutation/action that reads `ctx.auth` and of the WebSocket
120
+ * upgrade. better-auth's `session.cookieCache` carries the session payload in
121
+ * a short-lived signed cookie so `getSession` can answer without hitting the
122
+ * database until the cache window elapses. We default it on with a
123
+ * deliberately short 60s `maxAge` (better-auth's own default is 300s): long
124
+ * enough to erase the per-request read for a burst of calls, short enough
125
+ * that a revoked or role-changed session self-corrects within a minute.
126
+ * The one tradeoff — a revoked session stays valid until the cache expires —
127
+ * is bounded by that TTL; callers who need immediate revocation opt out with
128
+ * `session: { cookieCache: { enabled: false } }` (or the `strict` preset).
129
+ *
130
+ * Every explicit caller value is forwarded verbatim. The `rateLimit` fills all
131
+ * merge into a single `rateLimit` object so none clobbers the others.
132
+ */
133
+ declare const resolveAuthOptions: (options: LunoraAuthOptions) => LunoraAuthOptions;
134
+ /**
135
+ * Create the auth instance. Thin wrapper around `betterAuth` that enforces
136
+ * the `secret` requirement at construction time so misconfigured deployments
137
+ * fail loudly at the first fetch rather than the first sign-in attempt, then
138
+ * hands {@link resolveAuthOptions}'s hardened, default-filled options to
139
+ * better-auth.
140
+ */
141
+ declare const createAuth: (options: LunoraAuthOptions) => LunoraAuth;
142
+ export { LunoraAuth as L, LunoraAuthOptions as a, createAuth as c, resolveAuthOptions as r };
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";import{betterAuth as l}from"better-auth";import{getIP as u}from"better-auth/api";import{o as f}from"./on-cloudflare-edge-Dx6gFjBO.mjs";import{validateSessionPolicy as h}from"./sessionPresets-C867Mlo4.mjs";const n="cf-connecting-ip",m=e=>{const t=e!==void 0&&e.length>0;return f()?t?[n,"x-forwarded-for"]:[n]:t?["x-forwarded-for"]:[]},a="/**",p=100,A=e=>{const t=e.rateLimit?.customRules??{};return t[a]!==void 0?t:{...t,[a]:(o,r)=>u(o,e)===null?{max:r.max*p,window:r.window}:r}},d=32,E=e=>{const t=typeof e=="string"?e.trim().length:0;return t>0&&t<d},i=e=>typeof e=="string"?e.toLowerCase().startsWith("http://"):e&&typeof e=="object"?e.protocol==="http"?!0:e.protocol==="https"?!1:typeof e.fallback=="string"&&e.fallback.toLowerCase().startsWith("http://"):!1,L=new Set(["127.0.0.1","localhost"]),g=e=>{if(typeof e!="string")return;let t;try{t=new URL(e)}catch{return}if(!(t.protocol!=="http:"||!L.has(t.hostname)))return{allowedHosts:[t.hostname,`${t.hostname}:*`],fallback:e,protocol:"http"}},w=e=>{const t=g(e.baseURL)??e.baseURL;if(E(e.secret)){const s=`@lunora/auth: AUTH_SECRET is only ${String(e.secret?.trim().length)} characters. Use at least ${String(d)} for a brute-force-resistant secret — generate one with \`openssl rand -hex 32\`.`;if(!i(t))throw new c("INTERNAL",s);console.warn(s)}const o=e.advanced??{},r=o.ipAddress??{};return{...e,advanced:{...o,defaultCookieAttributes:o.defaultCookieAttributes??{httpOnly:!0,path:"/",sameSite:"lax"},ipAddress:{...r,...r.ipAddressHeaders===void 0?{ipAddressHeaders:m(r.trustedProxies)}:{}},...o.useSecureCookies===void 0?{useSecureCookies:!i(t)}:{}},baseURL:t}},C=e=>{const t=w(e),o=t.rateLimit?.enabled===void 0,r=t.rateLimit?.storage===void 0&&t.rateLimit?.enabled!==!1,s=t.rateLimit?.enabled===!1?{}:{rateLimit:{...t.rateLimit,...o?{enabled:!0}:{},...r?{storage:"database"}:{},customRules:A(t)}};return{...t,...s,...t.session?.cookieCache===void 0?{session:{...t.session,cookieCache:{enabled:!0,maxAge:60}}}:{}}},x=e=>{if(!e.secret||e.secret.trim()==="")throw new c("INTERNAL",'@lunora/auth: `secret` is required. Set AUTH_SECRET locally in .dev.vars (`lunora env set AUTH_SECRET "$(openssl rand -hex 32)"`), and in production with `wrangler secret put AUTH_SECRET`.');return e.session&&h(e.session),l(C(e))};export{x as createAuth,C as resolveAuthOptions};
@@ -0,0 +1 @@
1
+ import{INTERNAL_SECRET_HEADER as u,RESOLVE_SESSION_PATH as p,READ_AUDIT_PATH as f}from"./AUTH_DO_AUDIT_PATH-th2-Hb30.mjs";import{DEFAULT_AUTH_BASE_PATH as h,isAuthRoutePath as A}from"./DEFAULT_AUTH_BASE_PATH-DneiLGpv.mjs";const g=l=>{const{basePath:c=h,internalSecret:a,namespace:o,objectName:d="auth"}=l,i=()=>{if(o)return o.get(o.idFromName(d))},m=async(t,n,r)=>{if(!a)return;const s=i();if(!s)return;const e=await s.fetch(new Request(new URL(t,n),{body:JSON.stringify(r),headers:{"content-type":"application/json",[u]:a},method:"POST"}));return e.ok?e:void 0};return{auditReader:{read:async t=>{const n=await m(f,"https://auth-do.invalid",t);return n?(await n.json())?.entries??[]:[]}},authHandler:async t=>{if(A(new URL(t.url).pathname,c))return i()?.fetch(t)},resolveIdentity:async t=>{if(!a)return null;const n=i();if(!n)return null;const r=new Headers(t.headers);r.set(u,a);const s=await n.fetch(new Request(new URL(p,t.url),{headers:r}));if(!s.ok)return null;const e=await s.json();return e?.userId?{...typeof e.email=="string"&&e.email.length>0?{email:e.email}:{},...typeof e.expiresAtMs=="number"&&Number.isFinite(e.expiresAtMs)?{expiresAtMs:e.expiresAtMs}:{},...typeof e.name=="string"&&e.name.length>0?{name:e.name}:{},...typeof e.role=="string"&&e.role.length>0?{role:e.role}:{},userId:e.userId}:null}}};export{g as createDoAuthWiring};
@@ -0,0 +1 @@
1
+ import{oauthProviderClient as u}from"@better-auth/oauth-provider/client";import{passkeyClient as m}from"@better-auth/passkey/client";import{oneTapClient as c,lastLoginMethodClient as C,deviceAuthorizationClient as p,anonymousClient as g,multiSessionClient as h,phoneNumberClient as d,usernameClient as L,adminClient as b,emailOTPClient as f,magicLinkClient as k,twoFactorClient as y,organizationClient as P}from"better-auth/client/plugins";const o={organization:P,twoFactor:y,passkey:m,magicLink:k,emailOtp:f,admin:b,username:L,phoneNumber:d,multiSession:h,anonymous:g,deviceAuthorization:p,lastLoginMethod:C,oauthProvider:u},T=(t={})=>{const n=[];for(const i of Object.keys(o))t[i]&&n.push(o[i]());return n},v=()=>globalThis.location?.origin,I=(t,n={})=>{const{baseURL:i,extraPlugins:a=[],oneTapClientId:e,plugins:r,...s}=n,l=e===void 0||e===""?[]:[c({clientId:e})];return t({...s,baseURL:i??v(),plugins:[...T(r),...l,...a]})};export{I as createLunoraAuthClient,T as lunoraAuthPlugins};
@@ -0,0 +1 @@
1
+ import{defineErrorCodes as _}from"@better-auth/core/utils/error-codes";import{LunoraError as A}from"@lunora/errors";import{createAuthMiddleware as D,APIError as g}from"better-auth/api";const c="signUpInvitation",T=10080*60,v=365*24*60*60,y=500,w=_({SIGN_UP_INVITE_INVALID:"That sign-up invitation is not valid. Ask an administrator for a new invitation link.",SIGN_UP_INVITE_REQUIRED:"Sign-up is invite-only — ask an administrator for an invitation."}),O=32,N=/^[^\s@]+@[^\s@]+$/,x=e=>{if(!N.test(e))return!1;const t=e.slice(e.indexOf("@")+1);return t.includes(".")&&!t.startsWith(".")&&!t.endsWith(".")},m=e=>({acceptedAt:e.acceptedAt instanceof Date?e.acceptedAt:null,createdAt:e.createdAt,email:String(e.email),expiresAt:e.expiresAt,id:String(e.id),invitedBy:typeof e.invitedBy=="string"?e.invitedBy:null}),R=()=>btoa(String.fromCodePoint(...crypto.getRandomValues(new Uint8Array(O)))).replaceAll("+","-").replaceAll("/","_").replaceAll("=",""),S=async e=>{const t=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e));return[...new Uint8Array(t)].map(i=>i.toString(16).padStart(2,"0")).join("")},U=(e,t)=>{if(e.length!==t.length)return!1;let i=0;for(let o=0;o<e.length;o+=1)i|=(e.codePointAt(o)??0)^(t.codePointAt(o)??0);return i===0},p=e=>e.trim().toLowerCase(),I=e=>{if(typeof e.email!="string")return;const t=p(e.email);return t===""?void 0:t},k="anonymous",b=e=>(e.plugins??[]).some(t=>t.id===k),E=e=>e.isAnonymous===!0,P=async(e,t)=>{const i=await e.findOne({model:c,where:[{field:"email",value:t}]});return i===null||i.acceptedAt instanceof Date?!1:i.expiresAt instanceof Date&&i.expiresAt.getTime()>Date.now()},L=e=>{e.emailAndPassword?.enabled!==!0||e.emailAndPassword.requireEmailVerification===!0||console.warn("@lunora/auth: inviteOnly() is installed with password sign-up but without `emailAndPassword: { requireEmailVerification: true }`. An invitation is keyed by email address alone, so anyone who learns an invited address can sign up as it — and without verification they hold a session the moment they do.")},C=(e={})=>{let t=e.allowFirstUser??!1,i=!1;const o=async a=>{if(!t)return!1;const n=await a.count({model:"user"}),d=i&&n>0?await a.count({model:"user",where:[{field:"isAnonymous",value:!0}]}):0;return n-d<=0?!0:(t=!1,!1)};return{$ERROR_CODES:w,hooks:{before:[{handler:D(async a=>{const n=a.body??{},d=typeof n.email=="string"?p(n.email):"",u=typeof n.inviteToken=="string"?n.inviteToken:"",r=()=>{throw new g("BAD_REQUEST",w.SIGN_UP_INVITE_INVALID)};if(d===""&&r(),await o(a.context.adapter))return;u===""&&r();const s=await a.context.adapter.findOne({model:c,where:[{field:"email",value:d}]}),f=s===null?void 0:s.tokenHash,l=await S(u);(typeof f!="string"||!U(f,l))&&r()}),matcher:a=>a.path==="/sign-up/email"}]},id:"lunora-invite-only",init:a=>{L(a.options);const{adapter:n}=a;return i=b(a.options),{options:{databaseHooks:{user:{create:{after:async r=>{const s=I(r);s===void 0||i&&E(r)||await n.update({model:c,update:{acceptedAt:new Date},where:[{field:"email",value:s}]})},before:async r=>{if(i&&E(r))return;const s=I(r);if(!(s!==void 0&&await P(n,s))&&!await o(n))throw new g("BAD_REQUEST",w.SIGN_UP_INVITE_REQUIRED)}}}}}}},schema:{[c]:{fields:{acceptedAt:{required:!1,type:"date"},tokenHash:{required:!1,type:"string"},createdAt:{defaultValue:()=>new Date,required:!0,type:"date"},email:{required:!0,type:"string",unique:!0},expiresAt:{required:!0,type:"date"},invitedBy:{required:!1,type:"string"}}}}}},H=async(e,t)=>{const i=p(t.email);if(!x(i))throw new A("VALIDATION_ERROR",`not an email address to invite: ${JSON.stringify(t.email)}`);const{expiresInSeconds:o=T}=t;if(!Number.isInteger(o)||o<=0||o>v)throw new A("VALIDATION_ERROR",`expiresInSeconds must be a positive integer no greater than ${String(v)}`);const a=new Date(Date.now()+o*1e3),n=R(),d=await S(n),u=t.invitedBy??null,r=await e.$context,s=[{field:"email",value:i}],f=async()=>r.adapter.update({model:c,update:{acceptedAt:null,expiresAt:a,invitedBy:u,tokenHash:d},where:s});if(await r.adapter.findOne({model:c,where:s})){const l=await f();if(l)return{...m(l),token:n}}try{const l=await r.adapter.create({data:{createdAt:new Date,email:i,expiresAt:a,invitedBy:u,tokenHash:d},model:c});return{...m(l),token:n}}catch(l){const h=await f();if(h===null)throw l;return{...m(h),token:n}}},G=async(e,t={})=>{const a=(await(await e.$context).adapter.findMany({limit:y,model:c,sortBy:{direction:"desc",field:"createdAt"}})).map(n=>m(n));return t.pendingOnly===!0?a.filter(n=>n.acceptedAt===null&&n.expiresAt.getTime()>Date.now()):a},Q=async(e,t)=>{await(await e.$context).adapter.delete({model:c,where:[{field:"email",value:p(t.email)}]})},V=100,B=async(e,t,i,o)=>{const a=t.filter(n=>n.acceptedAt===null&&n.expiresAt.getTime()<=i).slice(0,o);for(const n of a)await e.delete({model:c,where:[{field:"id",value:n.id}]});return a.length},W=async(e,t={})=>{const i=t.limit??y;if(!Number.isInteger(i)||i<=0)throw new A("VALIDATION_ERROR","limit must be a positive integer");const o=Math.min(i,y),a=await e.$context,n=Date.now();let d=0,u=0;for(;d<o;){const r=await a.adapter.findMany({limit:V,model:c,offset:u,sortBy:{direction:"asc",field:"expiresAt"}});if(r.length===0)break;const s=r.map(l=>m(l)),f=await B(a.adapter,s,n,o-d);if(d+=f,s.some(l=>l.expiresAt.getTime()>n))break;u+=r.length-f}return d};export{H as createSignUpInvitation,C as inviteOnly,G as listSignUpInvitations,W as pruneSignUpInvitations,Q as revokeSignUpInvitation};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";import{APIError as u}from"better-auth/api";import{assertEmailAllowed as E}from"../email-guard.mjs";const m=e=>{switch(e){case 400:return"BAD_REQUEST";case 422:return"UNPROCESSABLE_ENTITY";case 429:return"TOO_MANY_REQUESTS";default:return"INTERNAL_SERVER_ERROR"}},i=e=>async(t,s)=>{const r=typeof t.email=="string"?t.email:void 0;if(r===void 0||r==="")return;let o;try{o=await E(r,e)}catch(a){throw a instanceof n?new u(m(a.status),{code:a.code,message:a.message}):a}e.onClassify?.(o,t,s)},b=(e={})=>({user:{create:{before:i(e)}}}),R=(e,t={})=>{const s=i(t),r=e.databaseHooks?.user?.create?.before,o=r?async(a,c)=>(await s(a,c),r(a,c)):s;return{...e,databaseHooks:{...e.databaseHooks,user:{...e.databaseHooks?.user,create:{...e.databaseHooks?.user?.create,before:o}}}}};export{b as emailGateDatabaseHooks,R as withEmailGate};
@@ -0,0 +1,126 @@
1
+ import { BetterAuthPlugin } from 'better-auth';
2
+ import { L as LunoraAuth } from "./create-auth.d-D1CKOFWc.js";
3
+ /** One pending or spent sign-up invitation. */
4
+ interface SignUpInvitation {
5
+ /** When an account was created for this address; `null` while the invitation is unspent. */
6
+ acceptedAt: Date | null;
7
+ createdAt: Date;
8
+ /** The invited address, lowercased — the only thing an invitation is matched on. */
9
+ email: string;
10
+ expiresAt: Date;
11
+ id: string;
12
+ /** Free-form attribution (a user id, an operator name); never read by the gate. */
13
+ invitedBy: null | string;
14
+ }
15
+ /**
16
+ * What {@link createSignUpInvitation} hands back: the stored row plus the one
17
+ * and only sight of the plaintext `token`. Nothing reads it back afterwards —
18
+ * the database holds a SHA-256 of it — so an invitation link that is lost is
19
+ * reissued, not recovered.
20
+ */
21
+ interface IssuedSignUpInvitation extends SignUpInvitation {
22
+ /** Put this in the sign-up link as `?invite=…`. Never stored, never logged, never listed. */
23
+ token: string;
24
+ }
25
+ /** Options for {@link inviteOnly}. */
26
+ interface InviteOnlyOptions {
27
+ /**
28
+ * Let the very first account through uninvited, so a fresh deployment can be
29
+ * bootstrapped without seeding a row.
30
+ *
31
+ * **Off by default.** The check is "the `user` table is empty", which two
32
+ * concurrent sign-ups can both observe, and the window it opens is the gap
33
+ * between deploying and the owner signing up — whoever finds the URL first
34
+ * gets an account on a deployment whose whole point is that nobody does. Seed
35
+ * the first invitation with {@link createSignUpInvitation} instead (a one-off
36
+ * call at worker init, or an internal mutation you run once).
37
+ * @default false
38
+ */
39
+ allowFirstUser?: boolean;
40
+ }
41
+ /**
42
+ * A better-auth server plugin that refuses to create an account for an address
43
+ * with no unspent invitation, and the `signUpInvitation` table those live in.
44
+ *
45
+ * Issue invitations with {@link createSignUpInvitation}; there is no HTTP
46
+ * endpoint for it on purpose. Who counts as an administrator is your
47
+ * application's question — call it from a mutation you already authorize, the
48
+ * same trust model `createAuthAdmin` documents.
49
+ *
50
+ * The return type is better-auth's own `BetterAuthPlugin` rather than the
51
+ * precise shape of the schema map, for the reason `./ui-config.ts` spells out:
52
+ * an anonymous inferred type is the difference between a build that emits
53
+ * declarations and one that fails in the bundler alone.
54
+ */
55
+ declare const inviteOnly: (options?: InviteOnlyOptions) => BetterAuthPlugin;
56
+ /**
57
+ * Invite `email` to sign up, or refresh an existing invitation for it.
58
+ *
59
+ * Re-inviting an address updates the row in place — a new expiry, and `acceptedAt`
60
+ * cleared — because `email` is unique. That is also how you re-open a seat after
61
+ * deleting the account that took it.
62
+ *
63
+ * This is a trusted server-side call with no authorization of its own; gate it
64
+ * the way you gate any other administrative action. Delivering the invitation is
65
+ * yours too: nothing here sends mail, so the returned row is the whole handoff.
66
+ *
67
+ * Nothing prunes the table — a spent or expired row stays until you delete it with
68
+ * {@link revokeSignUpInvitation}, which is also what keeps it a record of who was
69
+ * let in.
70
+ *
71
+ * Each call mints a **new token** and returns it in the clear, once. Only its
72
+ * SHA-256 is stored, so re-inviting an address invalidates the previous link, and
73
+ * a link that was never delivered is reissued rather than looked up.
74
+ */
75
+ declare const createSignUpInvitation: (auth: LunoraAuth, input: {
76
+ email: string;
77
+ expiresInSeconds?: number;
78
+ invitedBy?: string;
79
+ }) => Promise<IssuedSignUpInvitation>;
80
+ /**
81
+ * The most recent invitations, newest first, up to a fixed ceiling of
82
+ * {@link MAX_LISTED}. `pendingOnly` drops the spent and the expired, which is the
83
+ * list an operator usually wants; the unfiltered form doubles as the record of who
84
+ * was let in.
85
+ *
86
+ * Deliberately not paged. "Pending" is two conditions, one of them a comparison
87
+ * against `now`, and filtering those after a page would let page 1 come back empty
88
+ * while pending invitations sat on page 2. An operator list that outgrows the
89
+ * ceiling wants a query against the `signUpInvitation` table, not an offset.
90
+ */
91
+ declare const listSignUpInvitations: (auth: LunoraAuth, options?: {
92
+ pendingOnly?: boolean;
93
+ }) => Promise<SignUpInvitation[]>;
94
+ /**
95
+ * Withdraw the invitation for `email`. Deletes the row, so it also forgets a spent
96
+ * one — the account it created is untouched, and removing that is
97
+ * `AuthAdmin.removeUser`'s job.
98
+ *
99
+ * Not retroactive, and not atomic against a sign-up already in flight: better-auth
100
+ * creates the user without wrapping the `before` hook and the insert in one
101
+ * transaction, so a revoke landing between the two lets that one account through.
102
+ * There is no conditional consume in the adapter contract to close it with. Treat
103
+ * revocation as "no further sign-ups", and `AuthAdmin.removeUser` as the way to
104
+ * undo one that already happened.
105
+ */
106
+ declare const revokeSignUpInvitation: (auth: LunoraAuth, input: {
107
+ email: string;
108
+ }) => Promise<void>;
109
+ /**
110
+ * Delete invitations that expired without being used, and report how many went.
111
+ *
112
+ * Only the dead ones: a spent invitation is the record of who was let in, and an
113
+ * unexpired one is still live, so both stay. Nothing calls this for you — an app
114
+ * that invites at any volume should put it on a cron; one that doesn't can leave
115
+ * the rows.
116
+ *
117
+ * Bounded by `limit` and therefore incremental: a backlog larger than one pass
118
+ * takes several. It reads a page and deletes row by row rather than issuing one
119
+ * ranged `deleteMany`, because a `lt` comparison against a `date` column is the
120
+ * kind of thing that behaves differently on each of the three adapters this
121
+ * package ships, and a prune job is not where that should be discovered.
122
+ */
123
+ declare const pruneSignUpInvitations: (auth: LunoraAuth, options?: {
124
+ limit?: number;
125
+ }) => Promise<number>;
126
+ export { InviteOnlyOptions as I, SignUpInvitation as S, IssuedSignUpInvitation as a, createSignUpInvitation as c, inviteOnly as i, listSignUpInvitations as l, pruneSignUpInvitations as p, revokeSignUpInvitation as r };