@zeyolabs/kit 2026.11.9 → 2026.11.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cloudflare/d1/testing.ts +113 -0
- package/package.json +7 -1
- package/server/turnstile.ts +57 -6
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Miniflare as MiniflareClass } from 'miniflare';
|
|
3
|
+
|
|
4
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
5
|
+
import { afterAll, beforeAll, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// A real D1 for tests, the D1 counterpart of `storage/postgres/testing` (`createPgliteTestDb`):
|
|
8
|
+
// Miniflare runs workerd's own D1, so the limits production has (100 bound parameters per
|
|
9
|
+
// statement, FTS5, batch semantics) hold in tests too. A plain SQLite stand-in would pass queries
|
|
10
|
+
// that fail on D1. Needs `miniflare` (an optional peer dependency), imported lazily so apps that
|
|
11
|
+
// never call these don't load it.
|
|
12
|
+
//
|
|
13
|
+
// Like glasa: this file is the low-level piece. An app wraps it once in its own `tests/db.ts`
|
|
14
|
+
// (its schema, its migrations, its fixtures) and its tests call that wrapper's `mockDb()`.
|
|
15
|
+
|
|
16
|
+
export type D1TestDb<TSchema extends Record<string, unknown>> = {
|
|
17
|
+
/** Stops the Miniflare instance (and restores the clock if it was frozen). Call it in `afterAll`. */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
/** The raw D1 binding, for tests that need `prepare`, `batch` or `exec`. */
|
|
20
|
+
d1: D1Database;
|
|
21
|
+
/** The drizzle client over D1, with `schema` for relational queries. */
|
|
22
|
+
db: ReturnType<typeof drizzle<TSchema>>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fresh, fully-migrated in-memory D1 for one test file, wrapped in drizzle over `schema`. Each
|
|
27
|
+
* `migrations` entry is one SQL statement or a drizzle migration file's contents
|
|
28
|
+
* (`--> statement-breakpoint` separates its statements), applied in order. `frozenTime`, if given,
|
|
29
|
+
* freezes `Date` until `close()` so fixture data computed relative to "now" is stable. The clock
|
|
30
|
+
* stays frozen for the whole test file, so code that measures elapsed time with `Date.now()` (and
|
|
31
|
+
* `vi.waitFor`) sees it frozen too.
|
|
32
|
+
*/
|
|
33
|
+
export async function createD1TestDb<TSchema extends Record<string, unknown>>(
|
|
34
|
+
schema: TSchema,
|
|
35
|
+
migrations: readonly string[],
|
|
36
|
+
frozenTime?: Date
|
|
37
|
+
): Promise<D1TestDb<TSchema>> {
|
|
38
|
+
let Miniflare: typeof MiniflareClass;
|
|
39
|
+
try {
|
|
40
|
+
({ Miniflare } = await import('miniflare'));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'createD1TestDb needs the optional peer dependency "miniflare": add it to your devDependencies.',
|
|
44
|
+
{ cause: error }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (frozenTime) {
|
|
48
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
49
|
+
vi.setSystemTime(frozenTime);
|
|
50
|
+
}
|
|
51
|
+
const miniflare = new Miniflare({
|
|
52
|
+
d1Databases: { DB: crypto.randomUUID() },
|
|
53
|
+
modules: true,
|
|
54
|
+
script: 'export default { fetch() { return new Response(null); } }',
|
|
55
|
+
});
|
|
56
|
+
const restoreClock = () => {
|
|
57
|
+
if (frozenTime) vi.useRealTimers();
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
const d1 = (await miniflare.getD1Database('DB')) as unknown as D1Database;
|
|
61
|
+
const statements = migrations
|
|
62
|
+
.flatMap((migration) => migration.split('--> statement-breakpoint'))
|
|
63
|
+
.map((statement) => statement.trim())
|
|
64
|
+
.filter((statement) => statement.length > 0);
|
|
65
|
+
if (statements.length > 0) {
|
|
66
|
+
await d1.batch(statements.map((statement) => d1.prepare(statement)));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
close: async () => {
|
|
70
|
+
await miniflare.dispose();
|
|
71
|
+
restoreClock();
|
|
72
|
+
},
|
|
73
|
+
d1,
|
|
74
|
+
db: drizzle(d1, { schema }) as D1TestDb<TSchema>['db'],
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A bad migration must not leave workerd running, or the test run can hang.
|
|
78
|
+
await miniflare.dispose();
|
|
79
|
+
restoreClock();
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wires `createD1TestDb` into a `describe` block's `beforeAll`/`afterAll`: call once at the top of
|
|
86
|
+
* the block, then read `.db` / `.d1` inside any `it()`. Every `it()` in the block shares the one
|
|
87
|
+
* database; use a separate `describe` for anything that needs its own. `seed` runs once after the
|
|
88
|
+
* migrations (fixtures). An app wraps this in its own `mockDb()` with its schema and migrations.
|
|
89
|
+
*/
|
|
90
|
+
export function mockD1<TSchema extends Record<string, unknown>>(options: {
|
|
91
|
+
frozenTime?: Date;
|
|
92
|
+
migrations: readonly string[];
|
|
93
|
+
schema: TSchema;
|
|
94
|
+
seed?: (handle: D1TestDb<TSchema>) => Promise<void>;
|
|
95
|
+
}): { d1: D1Database; db: D1TestDb<TSchema>['db'] } {
|
|
96
|
+
// Populated by beforeAll before any it() in this describe block runs; the cast reflects that
|
|
97
|
+
// timing guarantee, not the object's real shape at declaration time.
|
|
98
|
+
const ctx = {} as { d1: D1Database; db: D1TestDb<TSchema>['db'] };
|
|
99
|
+
let handle: D1TestDb<TSchema>;
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
handle = await createD1TestDb(options.schema, options.migrations, options.frozenTime);
|
|
103
|
+
ctx.d1 = handle.d1;
|
|
104
|
+
ctx.db = handle.db;
|
|
105
|
+
await options.seed?.(handle);
|
|
106
|
+
}, 60_000);
|
|
107
|
+
|
|
108
|
+
afterAll(async () => {
|
|
109
|
+
await handle?.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return ctx;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeyolabs/kit",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.11",
|
|
4
4
|
"description": "Shared SvelteKit-on-Cloudflare server toolkit: remote-function wrappers, passwordless auth (magic link + passkeys), email over a send_email binding or SMTP, Cloudflare context, monitoring, storage clients, utils and the ESLint config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"./cloudflare/*": "./cloudflare/*.ts",
|
|
49
49
|
"./cloudflare/context": "./cloudflare/context.ts",
|
|
50
50
|
"./cloudflare/amqp": "./cloudflare/amqp/client.ts",
|
|
51
|
+
"./cloudflare/d1/testing": "./cloudflare/d1/testing.ts",
|
|
51
52
|
"./cloudflare/dispatch": "./cloudflare/dispatch/client.ts",
|
|
52
53
|
"./cloudflare/email": "./cloudflare/email/client.ts",
|
|
53
54
|
"./cloudflare/email/dev": "./cloudflare/email/dev.ts",
|
|
@@ -118,6 +119,7 @@
|
|
|
118
119
|
"@sveltejs/kit": "^3.1.1",
|
|
119
120
|
"drizzle-orm": "^0.45.3",
|
|
120
121
|
"eslint": "^9.39.4",
|
|
122
|
+
"miniflare": "^4.20260730.0",
|
|
121
123
|
"vitest": "^4.1.11",
|
|
122
124
|
"zod": "^4.6.5"
|
|
123
125
|
},
|
|
@@ -131,6 +133,9 @@
|
|
|
131
133
|
"eslint": {
|
|
132
134
|
"optional": true
|
|
133
135
|
},
|
|
136
|
+
"miniflare": {
|
|
137
|
+
"optional": true
|
|
138
|
+
},
|
|
134
139
|
"vitest": {
|
|
135
140
|
"optional": true
|
|
136
141
|
}
|
|
@@ -147,6 +152,7 @@
|
|
|
147
152
|
"@types/snappyjs": "0.7.1",
|
|
148
153
|
"drizzle-orm": "0.45.3",
|
|
149
154
|
"eslint": "9.39.4",
|
|
155
|
+
"miniflare": "4.20260730.0",
|
|
150
156
|
"prettier": "3.9.9",
|
|
151
157
|
"svelte": "5.57.3",
|
|
152
158
|
"typescript": "6.0.3",
|
package/server/turnstile.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { FormResult } from '../utils/form';
|
|
2
|
+
|
|
3
|
+
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Server-side verification of a Cloudflare Turnstile token against the siteverify API. Pure
|
|
3
|
-
* `fetch`, no framework or binding dependency.
|
|
4
|
-
* a rejection surfaces (see `apps/web/src/lib/server/turnstile.ts`).
|
|
7
|
+
* `fetch`, no framework or binding dependency. Prefer `Turnstile.verify` / `Turnstile.gate`.
|
|
5
8
|
*/
|
|
6
9
|
export async function verifyTurnstile(
|
|
7
10
|
token: string,
|
|
@@ -10,10 +13,58 @@ export async function verifyTurnstile(
|
|
|
10
13
|
): Promise<boolean> {
|
|
11
14
|
const params = new URLSearchParams({ response: token, secret: secretKey });
|
|
12
15
|
if (ip) params.set('remoteip', ip);
|
|
13
|
-
const response = await fetch('
|
|
14
|
-
body: params,
|
|
15
|
-
method: 'POST',
|
|
16
|
-
});
|
|
16
|
+
const response = await fetch(SITEVERIFY_URL, { body: params, method: 'POST' });
|
|
17
17
|
const result = (await response.json()) as { success?: boolean };
|
|
18
18
|
return result.success === true;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/** Where a gate gets the request's secret key and client address. The app supplies both. */
|
|
22
|
+
export type TurnstileGateOptions = {
|
|
23
|
+
/** Client address per call (SvelteKit's `getRequestEvent().getClientAddress()`); may be null. */
|
|
24
|
+
ip?: () => null | string | undefined;
|
|
25
|
+
/** The error shown when the check fails. */
|
|
26
|
+
message?: string;
|
|
27
|
+
/** The secret key: a string, or a function called per check (a Secrets Store binding, a loopback test key). */
|
|
28
|
+
secret: (() => Promise<string> | string) | string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Turnstile helpers: a library (an object of static functions, no state, no bindings), like
|
|
33
|
+
* `Schema` and `FormResult`.
|
|
34
|
+
*/
|
|
35
|
+
export const Turnstile = {
|
|
36
|
+
/**
|
|
37
|
+
* Gates a form handler behind Turnstile. The validated input carries `turnstileToken`; the gate
|
|
38
|
+
* verifies it and strips it before calling the handler. A missing or rejected token returns
|
|
39
|
+
* `FormResult.err(message)` and the handler never runs; a throwing `secret` or `fetch` also
|
|
40
|
+
* stops the handler (fails closed).
|
|
41
|
+
*/
|
|
42
|
+
gate({
|
|
43
|
+
ip = () => null,
|
|
44
|
+
message = 'Bot check failed. Please try again.',
|
|
45
|
+
secret,
|
|
46
|
+
}: TurnstileGateOptions) {
|
|
47
|
+
return function gated<TData extends { turnstileToken: string }, TResult>(
|
|
48
|
+
handler: (data: Omit<TData, 'turnstileToken'>) => Promise<FormResult<TResult | void>>
|
|
49
|
+
): (data: TData) => Promise<FormResult<TResult | void>> {
|
|
50
|
+
return async ({ turnstileToken, ...data }: TData) => {
|
|
51
|
+
if (!(await Turnstile.verify(turnstileToken, ip(), secret))) return FormResult.err(message);
|
|
52
|
+
return handler(data as Omit<TData, 'turnstileToken'>);
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True only when siteverify accepts the token. An empty token is false without a request, and
|
|
59
|
+
* `secret` is only resolved when there is a token to check.
|
|
60
|
+
*/
|
|
61
|
+
async verify(
|
|
62
|
+
token: string,
|
|
63
|
+
ip: null | string | undefined,
|
|
64
|
+
secret: (() => Promise<string> | string) | string
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (!token) return false;
|
|
67
|
+
const key = typeof secret === 'function' ? await secret() : secret;
|
|
68
|
+
return verifyTurnstile(token, ip, key);
|
|
69
|
+
},
|
|
70
|
+
};
|