@lunora/auth 0.0.0 → 1.0.0-alpha.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.
Files changed (41) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +125 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/adapter.d.mts +43 -0
  5. package/dist/adapter.d.ts +43 -0
  6. package/dist/adapter.mjs +47 -0
  7. package/dist/index.d.mts +386 -0
  8. package/dist/index.d.ts +386 -0
  9. package/dist/index.mjs +12 -0
  10. package/dist/middleware.d.mts +189 -0
  11. package/dist/middleware.d.ts +189 -0
  12. package/dist/middleware.mjs +53 -0
  13. package/dist/packem_shared/DEFAULT_AUTH_BASE_PATH-DjcUWEQl.mjs +11 -0
  14. package/dist/packem_shared/LunoraAuthAdminError-BxrfEeA_.mjs +249 -0
  15. package/dist/packem_shared/compileMigrationsSql-wZH3oXDu.mjs +28 -0
  16. package/dist/packem_shared/create-auth.d-M36jwG_Y.d.mts +58 -0
  17. package/dist/packem_shared/create-auth.d-M36jwG_Y.d.ts +58 -0
  18. package/dist/packem_shared/createAuth-B-tvsvQU.mjs +56 -0
  19. package/dist/packem_shared/sessionPresets-B95rXrd8.mjs +35 -0
  20. package/dist/plugins-client.d.mts +2 -0
  21. package/dist/plugins-client.d.ts +2 -0
  22. package/dist/plugins-client.mjs +2 -0
  23. package/dist/plugins.d.mts +22 -0
  24. package/dist/plugins.d.ts +22 -0
  25. package/dist/plugins.mjs +22 -0
  26. package/dist/schema.d.mts +44 -0
  27. package/dist/schema.d.ts +44 -0
  28. package/dist/schema.mjs +62 -0
  29. package/dist/sql-store.d.mts +51 -0
  30. package/dist/sql-store.d.ts +51 -0
  31. package/dist/sql-store.mjs +162 -0
  32. package/dist/store.d.mts +66 -0
  33. package/dist/store.d.ts +66 -0
  34. package/dist/store.mjs +170 -0
  35. package/dist/turnstile-middleware.d.mts +80 -0
  36. package/dist/turnstile-middleware.d.ts +80 -0
  37. package/dist/turnstile-middleware.mjs +45 -0
  38. package/dist/turnstile.d.mts +98 -0
  39. package/dist/turnstile.d.ts +98 -0
  40. package/dist/turnstile.mjs +61 -0
  41. package/package.json +80 -18
@@ -0,0 +1,66 @@
1
+ import { CustomAdapter } from 'better-auth/adapters';
2
+ /** A stored auth row — an opaque bag of columns keyed by better-auth field name. */
3
+ type AuthRow = Record<string, unknown>;
4
+ /**
5
+ * One normalized better-auth where clause. Derived from {@link CustomAdapter}'s
6
+ * own method signature (rather than re-declared) so a better-auth change to the
7
+ * clause shape surfaces as a compile error here, not a silent mis-match.
8
+ */
9
+ type AuthWhereClause = NonNullable<Parameters<CustomAdapter["findOne"]>[0]["where"]>[number];
10
+ /** Read query handed to {@link AuthStore.read}: a where filter plus optional sort/window. */
11
+ interface AuthQuery {
12
+ limit?: number;
13
+ offset?: number;
14
+ sortBy?: {
15
+ direction: "asc" | "desc";
16
+ field: string;
17
+ };
18
+ where: ReadonlyArray<AuthWhereClause>;
19
+ }
20
+ /**
21
+ * The minimal table-addressed store the `lunoraAuthAdapter` drives. This is the
22
+ * seam a Lunora runtime binds to its ORM: back each method with `ctx.db` over
23
+ * the global (D1) auth tables that `authTables(...)` generates, and better-auth's
24
+ * reads/writes flow through Lunora's data layer (triggers, aggregates, OCC)
25
+ * instead of better-auth's own adapter. Field names and table (`model`) names are
26
+ * already the database names better-auth resolved from the schema, so a store
27
+ * passes them straight through.
28
+ *
29
+ * {@link createMemoryAuthStore} is a reference in-memory implementation (also
30
+ * what the tests run better-auth against); `createSqlAuthStore` is the SQL one.
31
+ */
32
+ interface AuthStore {
33
+ /**
34
+ * Atomically delete **at most one** row in `model` matching `where` and
35
+ * return it (or `undefined` if none matched). Backs better-auth's
36
+ * single-use-token consume (OTP / magic-link / email-verification /
37
+ * password-reset): implementing it natively — one round trip that finds and
38
+ * deletes in a single statement — closes the read-then-delete race the
39
+ * factory's `findMany` + `deleteMany` fallback would otherwise leave open.
40
+ */
41
+ consumeOne: (model: string, where: ReadonlyArray<AuthWhereClause>) => Promise<AuthRow | undefined>;
42
+ /** Count rows in `model` matching `where` (empty `where` = all rows). */
43
+ count: (model: string, where: ReadonlyArray<AuthWhereClause>) => Promise<number>;
44
+ /** Insert `data` into `model`; return the stored row (the adapter pre-fills `id`). */
45
+ create: (model: string, data: AuthRow) => Promise<AuthRow>;
46
+ /** Read rows from `model` honouring the filter/sort/window in `query`. */
47
+ read: (model: string, query: AuthQuery) => Promise<AuthRow[]>;
48
+ /** Delete rows in `model` matching `where`; return how many were removed. */
49
+ remove: (model: string, where: ReadonlyArray<AuthWhereClause>) => Promise<number>;
50
+ /** Patch rows in `model` matching `where` with `values`; return the updated rows. */
51
+ update: (model: string, where: ReadonlyArray<AuthWhereClause>, values: AuthRow) => Promise<AuthRow[]>;
52
+ }
53
+ /**
54
+ * Evaluate a better-auth where clause list against a row. Clauses fold
55
+ * left-to-right by their `connector` (`AND` by default, `OR` when set) — the
56
+ * same precedence better-auth's own adapters use. An empty list matches every
57
+ * row. Exported so any in-memory-style {@link AuthStore} can reuse it.
58
+ */
59
+ declare const matchesWhere: (row: AuthRow, where: ReadonlyArray<AuthWhereClause>) => boolean;
60
+ /**
61
+ * Reference in-memory {@link AuthStore} — used to run better-auth end to end in
62
+ * tests, and a worked example of the contract a Lunora-`ctx.db`-backed store
63
+ * fulfils. Not for production (state is per-instance and non-durable).
64
+ */
65
+ declare const createMemoryAuthStore: () => AuthStore;
66
+ export { AuthQuery, type AuthRow, AuthStore, type AuthWhereClause, createMemoryAuthStore, matchesWhere };
package/dist/store.mjs ADDED
@@ -0,0 +1,170 @@
1
+ const asString = (value) => typeof value === "string" ? value : void 0;
2
+ const isNullish = (value) => value === void 0 || value === null;
3
+ const looseEquals = (left, right, insensitive) => {
4
+ if (insensitive) {
5
+ const a = asString(left);
6
+ const b = asString(right);
7
+ if (a !== void 0 && b !== void 0) {
8
+ return a.toLowerCase() === b.toLowerCase();
9
+ }
10
+ }
11
+ return left === right;
12
+ };
13
+ const matchesPattern = (cell, value, operator, insensitive) => {
14
+ const haystack = asString(cell);
15
+ const needle = asString(value);
16
+ if (haystack === void 0 || needle === void 0) {
17
+ return false;
18
+ }
19
+ const [a, b] = insensitive ? [haystack.toLowerCase(), needle.toLowerCase()] : [haystack, needle];
20
+ if (operator === "contains") {
21
+ return a.includes(b);
22
+ }
23
+ return operator === "starts_with" ? a.startsWith(b) : a.endsWith(b);
24
+ };
25
+ const evaluateClause = (row, clause) => {
26
+ const { field, mode, operator, value } = clause;
27
+ const cell = row[field];
28
+ const insensitive = mode === "insensitive";
29
+ switch (operator) {
30
+ case "contains":
31
+ case "ends_with":
32
+ case "starts_with": {
33
+ return matchesPattern(cell, value, operator, insensitive);
34
+ }
35
+ case "gt": {
36
+ return !isNullish(value) && cell > value;
37
+ }
38
+ case "gte": {
39
+ return !isNullish(value) && cell >= value;
40
+ }
41
+ case "in": {
42
+ return Array.isArray(value) && value.some((entry) => looseEquals(cell, entry, insensitive));
43
+ }
44
+ case "lt": {
45
+ return !isNullish(value) && cell < value;
46
+ }
47
+ case "lte": {
48
+ return !isNullish(value) && cell <= value;
49
+ }
50
+ case "ne": {
51
+ return !looseEquals(cell, value, insensitive);
52
+ }
53
+ case "not_in": {
54
+ return Array.isArray(value) && !value.some((entry) => looseEquals(cell, entry, insensitive));
55
+ }
56
+ // "eq" and the default: null-aware equality.
57
+ default: {
58
+ return isNullish(value) ? isNullish(cell) : looseEquals(cell, value, insensitive);
59
+ }
60
+ }
61
+ };
62
+ const compareCells = (a, b) => {
63
+ if (typeof a === "string" && typeof b === "string") {
64
+ return a.localeCompare(b);
65
+ }
66
+ const left = Number(a);
67
+ const right = Number(b);
68
+ if (left < right) {
69
+ return -1;
70
+ }
71
+ return left > right ? 1 : 0;
72
+ };
73
+ const sortRows = (rows, sortBy) => {
74
+ const direction = sortBy.direction === "asc" ? 1 : -1;
75
+ return rows.toSorted((left, right) => {
76
+ const a = left[sortBy.field];
77
+ const b = right[sortBy.field];
78
+ if (isNullish(a) && isNullish(b)) {
79
+ return 0;
80
+ }
81
+ if (isNullish(a)) {
82
+ return -direction;
83
+ }
84
+ if (isNullish(b)) {
85
+ return direction;
86
+ }
87
+ return compareCells(a, b) * direction;
88
+ });
89
+ };
90
+ const matchesWhere = (row, where) => {
91
+ let result = true;
92
+ for (const [index, clause] of where.entries()) {
93
+ const clauseResult = evaluateClause(row, clause);
94
+ if (index === 0) {
95
+ result = clauseResult;
96
+ } else if (clause.connector === "OR") {
97
+ result = result || clauseResult;
98
+ } else {
99
+ result = result && clauseResult;
100
+ }
101
+ }
102
+ return result;
103
+ };
104
+ const createMemoryAuthStore = () => {
105
+ const tables = /* @__PURE__ */ new Map();
106
+ const tableOf = (model) => {
107
+ const existing = tables.get(model);
108
+ if (existing) {
109
+ return existing;
110
+ }
111
+ const created = [];
112
+ tables.set(model, created);
113
+ return created;
114
+ };
115
+ return {
116
+ consumeOne: (model, where) => {
117
+ const table = tableOf(model);
118
+ const index = table.findIndex((row2) => matchesWhere(row2, where));
119
+ if (index === -1) {
120
+ return Promise.resolve(void 0);
121
+ }
122
+ const [row] = table.splice(index, 1);
123
+ return Promise.resolve(row ? { ...row } : void 0);
124
+ },
125
+ count: (model, where) => Promise.resolve(tableOf(model).filter((row) => matchesWhere(row, where)).length),
126
+ create: (model, data) => {
127
+ const row = { ...data };
128
+ tableOf(model).push(row);
129
+ return Promise.resolve({ ...row });
130
+ },
131
+ read: (model, query) => {
132
+ let rows = tableOf(model).filter((row) => matchesWhere(row, query.where));
133
+ if (query.sortBy) {
134
+ rows = sortRows(rows, query.sortBy);
135
+ }
136
+ if (query.offset) {
137
+ rows = rows.slice(query.offset);
138
+ }
139
+ if (query.limit !== void 0) {
140
+ rows = rows.slice(0, query.limit);
141
+ }
142
+ return Promise.resolve(
143
+ rows.map((row) => {
144
+ return { ...row };
145
+ })
146
+ );
147
+ },
148
+ remove: (model, where) => {
149
+ const table = tableOf(model);
150
+ const kept = table.filter((row) => !matchesWhere(row, where));
151
+ const removed = table.length - kept.length;
152
+ table.length = 0;
153
+ table.push(...kept);
154
+ return Promise.resolve(removed);
155
+ },
156
+ update: (model, where, values) => {
157
+ const matched = tableOf(model).filter((row) => matchesWhere(row, where));
158
+ for (const row of matched) {
159
+ Object.assign(row, values);
160
+ }
161
+ return Promise.resolve(
162
+ matched.map((row) => {
163
+ return { ...row };
164
+ })
165
+ );
166
+ }
167
+ };
168
+ };
169
+
170
+ export { createMemoryAuthStore, matchesWhere };
@@ -0,0 +1,80 @@
1
+ import { Middleware } from '@lunora/server';
2
+ import { FetchLike, TurnstileVerifyResult } from "./turnstile.mjs";
3
+ interface VerifyTurnstileMiddlewareOptions<Context> {
4
+ /**
5
+ * Assert the widget `action` the token was solved for (forwarded to
6
+ * {@link verifyTurnstile}). When set and the siteverify response's `action`
7
+ * does not match, the verdict is treated as a failure (403). Optional.
8
+ */
9
+ expectedAction?: string;
10
+ /**
11
+ * Assert the `hostname` the challenge was solved on (forwarded to
12
+ * {@link verifyTurnstile}). When set and the siteverify response's `hostname`
13
+ * does not match, the verdict is treated as a failure (403). Set this when a
14
+ * single secret/sitekey is shared across multiple domains to stop a token
15
+ * harvested on one origin being replayed against this procedure. Optional.
16
+ */
17
+ expectedHostname?: string;
18
+ /**
19
+ * Behavior when the siteverify call itself throws (network error, non-2xx).
20
+ * Defaults to `false` (**fail closed**: reject with 403). Set `true` only
21
+ * when degraded availability is preferable to denying traffic — a failing
22
+ * siteverify then admits every request. Mirrors `@lunora/ratelimit`'s
23
+ * `rateLimit` failure policy.
24
+ */
25
+ failOpen?: boolean;
26
+ /**
27
+ * Inject a `fetch` implementation (forwarded to {@link verifyTurnstile}).
28
+ * Primarily for tests.
29
+ */
30
+ fetch?: FetchLike;
31
+ /** Override the error message thrown on a failed verdict. */
32
+ message?: string;
33
+ /**
34
+ * Selector that pulls the visitor IP from `ctx` (the procedure context has
35
+ * no raw `Headers`, so this must come from `args`/ctx). Optional.
36
+ */
37
+ remoteip?: (context: Context) => string | undefined;
38
+ /** Your Turnstile secret key (the `TURNSTILE_SECRET_KEY` env var). */
39
+ secret: string;
40
+ /**
41
+ * Selector that pulls the `cf-turnstile-response` token from `ctx`. The
42
+ * procedure context carries only the resolved identity, **not** the raw
43
+ * inbound `Headers` (see `withAuthPlugins` in `./middleware`), so the token
44
+ * must travel in the function `args` and be read out here.
45
+ */
46
+ token: (context: Context) => string | undefined;
47
+ /**
48
+ * Extra predicate run on a `success: true` verdict (after the built-in
49
+ * `expectedHostname`/`expectedAction` checks). Return `false` to reject the
50
+ * request with 403 — use it for any custom replay/abuse guard that needs the
51
+ * full verdict (e.g. matching `cdata`, or one hostname out of an allow-list).
52
+ */
53
+ validate?: (result: TurnstileVerifyResult) => boolean;
54
+ }
55
+ /**
56
+ * Procedure middleware that enforces a Turnstile (CAPTCHA) check before the
57
+ * handler runs. Attach it with `.use()`. It reads the token (and optional IP)
58
+ * from `ctx` via the provided selectors — because the procedure context does
59
+ * not carry raw request headers, the token must be passed through the function
60
+ * `args`. To gate the better-auth sign-in/sign-up flow itself, prefer
61
+ * better-auth's native `captcha` plugin (re-exported from `@lunora/auth/plugins`)
62
+ * — this middleware is for non-auth Lunora procedures.
63
+ *
64
+ * On a `success: false` verdict (or a missing token) it throws a structural
65
+ * `LunoraError` (`{ name: "LunoraError", code: "FORBIDDEN", status: 403 }`) —
66
+ * the runtime maps it to the matching RPC/HTTP status without any runtime
67
+ * import of `@lunora/server` (the `Middleware` import is type-only).
68
+ *
69
+ * **Failure policy:** if the siteverify call itself throws, the middleware
70
+ * **fails closed by default** (logs and rejects with 403). Pass
71
+ * `failOpen: true` to admit the request instead — mirrors `@lunora/ratelimit`.
72
+ *
73
+ * **Cross-origin replay:** a token solved on one origin can be replayed against
74
+ * a different endpoint when a single secret/sitekey is shared across multiple
75
+ * domains. Pass `expectedHostname` (and optionally `expectedAction`, or a custom
76
+ * `validate(result)` predicate) to assert the siteverify response's `hostname`
77
+ * /`action` and reject mismatches with 403.
78
+ */
79
+ declare const verifyTurnstileMiddleware: <Context>(options: VerifyTurnstileMiddlewareOptions<Context>) => Middleware<Context, Context>;
80
+ export { type VerifyTurnstileMiddlewareOptions, verifyTurnstileMiddleware };
@@ -0,0 +1,80 @@
1
+ import { Middleware } from '@lunora/server';
2
+ import { FetchLike, TurnstileVerifyResult } from "./turnstile.js";
3
+ interface VerifyTurnstileMiddlewareOptions<Context> {
4
+ /**
5
+ * Assert the widget `action` the token was solved for (forwarded to
6
+ * {@link verifyTurnstile}). When set and the siteverify response's `action`
7
+ * does not match, the verdict is treated as a failure (403). Optional.
8
+ */
9
+ expectedAction?: string;
10
+ /**
11
+ * Assert the `hostname` the challenge was solved on (forwarded to
12
+ * {@link verifyTurnstile}). When set and the siteverify response's `hostname`
13
+ * does not match, the verdict is treated as a failure (403). Set this when a
14
+ * single secret/sitekey is shared across multiple domains to stop a token
15
+ * harvested on one origin being replayed against this procedure. Optional.
16
+ */
17
+ expectedHostname?: string;
18
+ /**
19
+ * Behavior when the siteverify call itself throws (network error, non-2xx).
20
+ * Defaults to `false` (**fail closed**: reject with 403). Set `true` only
21
+ * when degraded availability is preferable to denying traffic — a failing
22
+ * siteverify then admits every request. Mirrors `@lunora/ratelimit`'s
23
+ * `rateLimit` failure policy.
24
+ */
25
+ failOpen?: boolean;
26
+ /**
27
+ * Inject a `fetch` implementation (forwarded to {@link verifyTurnstile}).
28
+ * Primarily for tests.
29
+ */
30
+ fetch?: FetchLike;
31
+ /** Override the error message thrown on a failed verdict. */
32
+ message?: string;
33
+ /**
34
+ * Selector that pulls the visitor IP from `ctx` (the procedure context has
35
+ * no raw `Headers`, so this must come from `args`/ctx). Optional.
36
+ */
37
+ remoteip?: (context: Context) => string | undefined;
38
+ /** Your Turnstile secret key (the `TURNSTILE_SECRET_KEY` env var). */
39
+ secret: string;
40
+ /**
41
+ * Selector that pulls the `cf-turnstile-response` token from `ctx`. The
42
+ * procedure context carries only the resolved identity, **not** the raw
43
+ * inbound `Headers` (see `withAuthPlugins` in `./middleware`), so the token
44
+ * must travel in the function `args` and be read out here.
45
+ */
46
+ token: (context: Context) => string | undefined;
47
+ /**
48
+ * Extra predicate run on a `success: true` verdict (after the built-in
49
+ * `expectedHostname`/`expectedAction` checks). Return `false` to reject the
50
+ * request with 403 — use it for any custom replay/abuse guard that needs the
51
+ * full verdict (e.g. matching `cdata`, or one hostname out of an allow-list).
52
+ */
53
+ validate?: (result: TurnstileVerifyResult) => boolean;
54
+ }
55
+ /**
56
+ * Procedure middleware that enforces a Turnstile (CAPTCHA) check before the
57
+ * handler runs. Attach it with `.use()`. It reads the token (and optional IP)
58
+ * from `ctx` via the provided selectors — because the procedure context does
59
+ * not carry raw request headers, the token must be passed through the function
60
+ * `args`. To gate the better-auth sign-in/sign-up flow itself, prefer
61
+ * better-auth's native `captcha` plugin (re-exported from `@lunora/auth/plugins`)
62
+ * — this middleware is for non-auth Lunora procedures.
63
+ *
64
+ * On a `success: false` verdict (or a missing token) it throws a structural
65
+ * `LunoraError` (`{ name: "LunoraError", code: "FORBIDDEN", status: 403 }`) —
66
+ * the runtime maps it to the matching RPC/HTTP status without any runtime
67
+ * import of `@lunora/server` (the `Middleware` import is type-only).
68
+ *
69
+ * **Failure policy:** if the siteverify call itself throws, the middleware
70
+ * **fails closed by default** (logs and rejects with 403). Pass
71
+ * `failOpen: true` to admit the request instead — mirrors `@lunora/ratelimit`.
72
+ *
73
+ * **Cross-origin replay:** a token solved on one origin can be replayed against
74
+ * a different endpoint when a single secret/sitekey is shared across multiple
75
+ * domains. Pass `expectedHostname` (and optionally `expectedAction`, or a custom
76
+ * `validate(result)` predicate) to assert the siteverify response's `hostname`
77
+ * /`action` and reject mismatches with 403.
78
+ */
79
+ declare const verifyTurnstileMiddleware: <Context>(options: VerifyTurnstileMiddlewareOptions<Context>) => Middleware<Context, Context>;
80
+ export { type VerifyTurnstileMiddlewareOptions, verifyTurnstileMiddleware };
@@ -0,0 +1,45 @@
1
+ import { verifyTurnstile } from './turnstile.mjs';
2
+
3
+ const verifyTurnstileMiddleware = (options) => async ({ ctx, next }) => {
4
+ const token = options.token(ctx);
5
+ if (token === void 0 || token === "") {
6
+ throw Object.assign(new Error(options.message ?? "turnstile token missing"), {
7
+ code: "FORBIDDEN",
8
+ name: "LunoraError",
9
+ status: 403
10
+ });
11
+ }
12
+ let result;
13
+ try {
14
+ result = await verifyTurnstile({
15
+ expectedAction: options.expectedAction,
16
+ expectedHostname: options.expectedHostname,
17
+ fetch: options.fetch,
18
+ remoteip: options.remoteip?.(ctx),
19
+ secret: options.secret,
20
+ token
21
+ });
22
+ } catch (error) {
23
+ console.error(`@lunora/auth: verifyTurnstileMiddleware siteverify threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
24
+ if (options.failOpen) {
25
+ return next();
26
+ }
27
+ throw Object.assign(new Error(options.message ?? "turnstile verification unavailable"), {
28
+ cause: error,
29
+ code: "FORBIDDEN",
30
+ name: "LunoraError",
31
+ status: 403
32
+ });
33
+ }
34
+ if (!result.success || options.validate !== void 0 && !options.validate(result)) {
35
+ throw Object.assign(new Error(options.message ?? "turnstile verification failed"), {
36
+ code: "FORBIDDEN",
37
+ errorCodes: result.errorCodes,
38
+ name: "LunoraError",
39
+ status: 403
40
+ });
41
+ }
42
+ return next();
43
+ };
44
+
45
+ export { verifyTurnstileMiddleware };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Cloudflare Turnstile server-side verification.
3
+ *
4
+ * Turnstile has **no Cloudflare binding** — verification is a single HTTPS POST
5
+ * to the public `siteverify` endpoint with your secret key. The secret lives in
6
+ * a plain env var / `.dev.vars` (conventionally `TURNSTILE_SECRET_KEY`), not in
7
+ * `wrangler.jsonc`. This module is therefore pure, transport-agnostic, and
8
+ * usable from any mutation/action — not just the auth flow.
9
+ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
10
+ */
11
+ /** Cloudflare's public Turnstile `siteverify` endpoint. */
12
+ declare const TURNSTILE_VERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
13
+ /** Minimal `fetch` shape we depend on, so callers can inject a stub in tests. */
14
+ type FetchLike = (input: string, init?: {
15
+ body?: BodyInit;
16
+ headers?: Record<string, string>;
17
+ method?: string;
18
+ }) => Promise<Response>;
19
+ interface VerifyTurnstileOptions {
20
+ /**
21
+ * Assert the widget `action` the token was solved for. Cloudflare echoes the
22
+ * customer-supplied `action` back in the siteverify response; when this is
23
+ * set and the returned `action` does not match, the verdict is downgraded to
24
+ * `success: false` (with error code `action-mismatch`). Leave unset to skip
25
+ * the check.
26
+ */
27
+ expectedAction?: string;
28
+ /**
29
+ * Assert the `hostname` the challenge was solved on. Cloudflare returns the
30
+ * solving hostname in the siteverify response; when this is set and the
31
+ * returned `hostname` does not match, the verdict is downgraded to
32
+ * `success: false` (with error code `hostname-mismatch`). Set this when a
33
+ * single secret/sitekey is shared across multiple domains to stop a token
34
+ * harvested on one origin from being replayed against another. Leave unset
35
+ * to skip the check.
36
+ */
37
+ expectedHostname?: string;
38
+ /**
39
+ * Inject a `fetch` implementation. Defaults to `globalThis.fetch`. Primarily
40
+ * for unit tests — production callers can omit it.
41
+ */
42
+ fetch?: FetchLike;
43
+ /**
44
+ * The visitor's IP address, if known. Optional; Cloudflare uses it as an
45
+ * extra signal but verification works without it.
46
+ */
47
+ remoteip?: string;
48
+ /** Your Turnstile secret key (the `TURNSTILE_SECRET_KEY` env var). */
49
+ secret: string;
50
+ /** The `cf-turnstile-response` token produced by the widget on the client. */
51
+ token: string;
52
+ }
53
+ /**
54
+ * The normalized result of a Turnstile verification. Snake-cased fields from
55
+ * Cloudflare (`error-codes`, `challenge_ts`) are mapped to camelCase.
56
+ *
57
+ * A `success: false` verdict is a **bot/invalid-token** outcome, not an error —
58
+ * it is returned, never thrown. `verifyTurnstile` only throws on transport
59
+ * failure (network error, non-2xx response).
60
+ */
61
+ interface TurnstileVerifyResult {
62
+ /** The customer-supplied `action` the widget was rendered with, if any. */
63
+ action?: string;
64
+ /** Customer data passed through the widget (`cData`), if any. */
65
+ cdata?: string;
66
+ /** ISO timestamp of the challenge, if Cloudflare returned one. */
67
+ challengeTs?: string;
68
+ /**
69
+ * Cloudflare error codes for a failed verification (e.g.
70
+ * `"invalid-input-response"`, `"timeout-or-duplicate"`). Empty on success.
71
+ */
72
+ errorCodes: string[];
73
+ /** Hostname the challenge was solved on, if Cloudflare returned one. */
74
+ hostname?: string;
75
+ /** Whether Cloudflare considers the token valid. */
76
+ success: boolean;
77
+ }
78
+ /**
79
+ * Verify a Turnstile token against Cloudflare's `siteverify` endpoint.
80
+ *
81
+ * POSTs `application/x-www-form-urlencoded` (`secret`, `response`=token, and an
82
+ * optional `remoteip`) and returns the parsed verdict. A `success: false`
83
+ * outcome (bot / invalid / expired token) is **returned**, not thrown — callers
84
+ * decide how to react. The function throws a structural `LunoraError`-shaped
85
+ * error (`{ name: "LunoraError", code: "SERVICE_UNAVAILABLE", status: 503 }`)
86
+ * only when the siteverify call itself fails (network error or non-2xx), so a
87
+ * "siteverify is down" failure is distinguishable from a "this is a bot"
88
+ * verdict.
89
+ */
90
+ declare const verifyTurnstile: ({
91
+ expectedAction,
92
+ expectedHostname,
93
+ fetch,
94
+ remoteip,
95
+ secret,
96
+ token
97
+ }: VerifyTurnstileOptions) => Promise<TurnstileVerifyResult>;
98
+ export { type FetchLike, TURNSTILE_VERIFY_ENDPOINT, type TurnstileVerifyResult, type VerifyTurnstileOptions, verifyTurnstile };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Cloudflare Turnstile server-side verification.
3
+ *
4
+ * Turnstile has **no Cloudflare binding** — verification is a single HTTPS POST
5
+ * to the public `siteverify` endpoint with your secret key. The secret lives in
6
+ * a plain env var / `.dev.vars` (conventionally `TURNSTILE_SECRET_KEY`), not in
7
+ * `wrangler.jsonc`. This module is therefore pure, transport-agnostic, and
8
+ * usable from any mutation/action — not just the auth flow.
9
+ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
10
+ */
11
+ /** Cloudflare's public Turnstile `siteverify` endpoint. */
12
+ declare const TURNSTILE_VERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
13
+ /** Minimal `fetch` shape we depend on, so callers can inject a stub in tests. */
14
+ type FetchLike = (input: string, init?: {
15
+ body?: BodyInit;
16
+ headers?: Record<string, string>;
17
+ method?: string;
18
+ }) => Promise<Response>;
19
+ interface VerifyTurnstileOptions {
20
+ /**
21
+ * Assert the widget `action` the token was solved for. Cloudflare echoes the
22
+ * customer-supplied `action` back in the siteverify response; when this is
23
+ * set and the returned `action` does not match, the verdict is downgraded to
24
+ * `success: false` (with error code `action-mismatch`). Leave unset to skip
25
+ * the check.
26
+ */
27
+ expectedAction?: string;
28
+ /**
29
+ * Assert the `hostname` the challenge was solved on. Cloudflare returns the
30
+ * solving hostname in the siteverify response; when this is set and the
31
+ * returned `hostname` does not match, the verdict is downgraded to
32
+ * `success: false` (with error code `hostname-mismatch`). Set this when a
33
+ * single secret/sitekey is shared across multiple domains to stop a token
34
+ * harvested on one origin from being replayed against another. Leave unset
35
+ * to skip the check.
36
+ */
37
+ expectedHostname?: string;
38
+ /**
39
+ * Inject a `fetch` implementation. Defaults to `globalThis.fetch`. Primarily
40
+ * for unit tests — production callers can omit it.
41
+ */
42
+ fetch?: FetchLike;
43
+ /**
44
+ * The visitor's IP address, if known. Optional; Cloudflare uses it as an
45
+ * extra signal but verification works without it.
46
+ */
47
+ remoteip?: string;
48
+ /** Your Turnstile secret key (the `TURNSTILE_SECRET_KEY` env var). */
49
+ secret: string;
50
+ /** The `cf-turnstile-response` token produced by the widget on the client. */
51
+ token: string;
52
+ }
53
+ /**
54
+ * The normalized result of a Turnstile verification. Snake-cased fields from
55
+ * Cloudflare (`error-codes`, `challenge_ts`) are mapped to camelCase.
56
+ *
57
+ * A `success: false` verdict is a **bot/invalid-token** outcome, not an error —
58
+ * it is returned, never thrown. `verifyTurnstile` only throws on transport
59
+ * failure (network error, non-2xx response).
60
+ */
61
+ interface TurnstileVerifyResult {
62
+ /** The customer-supplied `action` the widget was rendered with, if any. */
63
+ action?: string;
64
+ /** Customer data passed through the widget (`cData`), if any. */
65
+ cdata?: string;
66
+ /** ISO timestamp of the challenge, if Cloudflare returned one. */
67
+ challengeTs?: string;
68
+ /**
69
+ * Cloudflare error codes for a failed verification (e.g.
70
+ * `"invalid-input-response"`, `"timeout-or-duplicate"`). Empty on success.
71
+ */
72
+ errorCodes: string[];
73
+ /** Hostname the challenge was solved on, if Cloudflare returned one. */
74
+ hostname?: string;
75
+ /** Whether Cloudflare considers the token valid. */
76
+ success: boolean;
77
+ }
78
+ /**
79
+ * Verify a Turnstile token against Cloudflare's `siteverify` endpoint.
80
+ *
81
+ * POSTs `application/x-www-form-urlencoded` (`secret`, `response`=token, and an
82
+ * optional `remoteip`) and returns the parsed verdict. A `success: false`
83
+ * outcome (bot / invalid / expired token) is **returned**, not thrown — callers
84
+ * decide how to react. The function throws a structural `LunoraError`-shaped
85
+ * error (`{ name: "LunoraError", code: "SERVICE_UNAVAILABLE", status: 503 }`)
86
+ * only when the siteverify call itself fails (network error or non-2xx), so a
87
+ * "siteverify is down" failure is distinguishable from a "this is a bot"
88
+ * verdict.
89
+ */
90
+ declare const verifyTurnstile: ({
91
+ expectedAction,
92
+ expectedHostname,
93
+ fetch,
94
+ remoteip,
95
+ secret,
96
+ token
97
+ }: VerifyTurnstileOptions) => Promise<TurnstileVerifyResult>;
98
+ export { type FetchLike, TURNSTILE_VERIFY_ENDPOINT, type TurnstileVerifyResult, type VerifyTurnstileOptions, verifyTurnstile };