@edge-base/shared 0.1.1

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/src/config.ts ADDED
@@ -0,0 +1,2188 @@
1
+ // ─── Schema Field Types ───
2
+
3
+ export type FieldType = 'string' | 'text' | 'number' | 'boolean' | 'datetime' | 'json';
4
+
5
+ export interface SchemaField {
6
+ type: FieldType;
7
+ required?: boolean;
8
+ default?: unknown;
9
+ unique?: boolean;
10
+ min?: number;
11
+ max?: number;
12
+ pattern?: string;
13
+ enum?: string[];
14
+ primaryKey?: boolean;
15
+ onUpdate?: 'now';
16
+ /**
17
+ * SQLite REFERENCES (FK) for this column. (#133 §35)
18
+ * Object form: { table: 'users', onDelete: 'CASCADE' }
19
+ * String short form: 'users' or 'users(id)'
20
+ * Note: PRAGMA foreign_keys = ON is set at DB init in database-do.ts.
21
+ * Auth-user references (`users`, `_users`, `_users_public`) are logical-only
22
+ * because auth data lives in AUTH_DB, so no physical FK is emitted for them.
23
+ */
24
+ references?: string | FkReference;
25
+ /** SQLite CHECK expression. e.g. check: 'score >= 0 AND score <= 100' (#133 §35) */
26
+ check?: string;
27
+ }
28
+
29
+ export interface IndexConfig {
30
+ fields: string[];
31
+ unique?: boolean;
32
+ }
33
+
34
+ // ─── Foreign Key Reference (§35) ───
35
+
36
+ /**
37
+ * Foreign key reference config for SchemaField.references. (#133 §35)
38
+ * database-do.ts sets PRAGMA foreign_keys = ON at DO init.
39
+ * Cross-DB-block FKs are DDL-excluded (different SQLite files).
40
+ * Auth-user references are also DDL-excluded because they live in AUTH_DB.
41
+ */
42
+ export interface FkReference {
43
+ table: string;
44
+ column?: string;
45
+ onDelete?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';
46
+ onUpdate?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';
47
+ }
48
+
49
+ export interface MigrationConfig {
50
+ version: number;
51
+ description: string;
52
+ /** SQLite migration SQL. Used for provider='do' or when upPg is not provided. */
53
+ up: string;
54
+ /** PostgreSQL-specific migration SQL. When present, used instead of `up` for provider='neon'|'postgres'. */
55
+ upPg?: string;
56
+ }
57
+
58
+ // ─── Auth Context ───
59
+
60
+ export interface AuthContext {
61
+ id: string;
62
+ role?: string;
63
+ isAnonymous?: boolean;
64
+ email?: string;
65
+ custom?: Record<string, unknown>;
66
+ memberships?: Array<{ id: string; role?: string }>;
67
+ /**
68
+ * Open-ended extension map injected by `auth.handlers.hooks.enrich` (#133 §38).
69
+ * Allows passing arbitrary request-scoped data into rules without JWT re-issuance.
70
+ * e.g. { workspaceRole: 'admin', orgIds: ['o1', 'o2'] }
71
+ */
72
+ meta?: Record<string, unknown>;
73
+ }
74
+
75
+ // ─── Hook Context (passed to table hooks) ───
76
+
77
+ export interface HookCtx {
78
+ db: {
79
+ get(table: string, id: string): Promise<Record<string, unknown> | null>;
80
+ list(table: string, filter?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
81
+ exists(table: string, filter: Record<string, unknown>): Promise<boolean>;
82
+ };
83
+ databaseLive: {
84
+ broadcast(channel: string, event: string, data: unknown): Promise<void>;
85
+ };
86
+ push: {
87
+ send(userId: string, payload: { title?: string; body: string }): Promise<void>;
88
+ };
89
+ waitUntil(promise: Promise<unknown>): void;
90
+ }
91
+
92
+ // ─── DB Rule Context (passed to DB-level access rule) ───
93
+
94
+ export interface DbRuleCtx {
95
+ db: {
96
+ get(table: string, id: string): Promise<Record<string, unknown> | null>;
97
+ exists(table: string, filter: Record<string, unknown>): Promise<boolean>;
98
+ };
99
+ }
100
+
101
+ // ─── Table-level Rules (§3) ───
102
+ // Rules return only true | false — pure access gate, no data transformation.
103
+
104
+ export interface TableRules {
105
+ /** Who can read (list/get/search) rows. Boolean or (auth, row) => boolean. */
106
+ read?:
107
+ | boolean
108
+ | ((auth: AuthContext | null, row: Record<string, unknown>) => boolean | Promise<boolean>);
109
+ /** Who can insert rows. Boolean or (auth) => boolean. */
110
+ insert?: boolean | ((auth: AuthContext | null) => boolean | Promise<boolean>);
111
+ /** Who can update rows. Boolean or (auth, row) => boolean. */
112
+ update?:
113
+ | boolean
114
+ | ((auth: AuthContext | null, row: Record<string, unknown>) => boolean | Promise<boolean>);
115
+ /** Who can delete rows. Boolean or (auth, row) => boolean. */
116
+ delete?:
117
+ | boolean
118
+ | ((auth: AuthContext | null, row: Record<string, unknown>) => boolean | Promise<boolean>);
119
+ }
120
+
121
+ // ─── Table-level Hooks (§6) ───
122
+ // before*: blocking — return object to transform data, throw to reject.
123
+ // after*: non-blocking (waitUntil) — side effects, return value ignored.
124
+
125
+ export interface TableHooks {
126
+ /** Runs before insert. Return transformed data or throw to reject. */
127
+ beforeInsert?: (
128
+ auth: AuthContext | null,
129
+ data: Record<string, unknown>,
130
+ ctx: HookCtx,
131
+ ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
132
+ /** Runs after insert (fire-and-forget via waitUntil). */
133
+ afterInsert?: (data: Record<string, unknown>, ctx: HookCtx) => Promise<void> | void;
134
+ /** Runs before update. Return transformed data or throw to reject. */
135
+ beforeUpdate?: (
136
+ auth: AuthContext | null,
137
+ before: Record<string, unknown>,
138
+ data: Record<string, unknown>,
139
+ ctx: HookCtx,
140
+ ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
141
+ /** Runs after update (fire-and-forget via waitUntil). */
142
+ afterUpdate?: (
143
+ before: Record<string, unknown>,
144
+ after: Record<string, unknown>,
145
+ ctx: HookCtx,
146
+ ) => Promise<void> | void;
147
+ /** Runs before delete. Throw to reject. */
148
+ beforeDelete?: (
149
+ auth: AuthContext | null,
150
+ data: Record<string, unknown>,
151
+ ctx: HookCtx,
152
+ ) => Promise<void> | void;
153
+ /** Runs after delete (fire-and-forget via waitUntil). */
154
+ afterDelete?: (data: Record<string, unknown>, ctx: HookCtx) => Promise<void> | void;
155
+ /**
156
+ * Runs after read (GET/LIST/SEARCH), before response. Applied per-row. Blocking.
157
+ * Return modified record to add computed fields or strip fields. Return void for no change.
158
+ */
159
+ onEnrich?: (
160
+ auth: AuthContext | null,
161
+ record: Record<string, unknown>,
162
+ ctx: HookCtx,
163
+ ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
164
+ }
165
+
166
+ export type TableAccess = TableRules;
167
+
168
+ export interface TableHandlers {
169
+ hooks?: TableHooks;
170
+ }
171
+
172
+ // ─── Table Config ───
173
+
174
+ export interface TableConfig {
175
+ /** Schema definition. Optional — omit for schemaless CRUD (no type validation/indexes/FTS). */
176
+ schema?: Record<string, SchemaField | false>;
177
+ access?: TableAccess;
178
+ handlers?: TableHandlers;
179
+ indexes?: IndexConfig[];
180
+ fts?: string[];
181
+ migrations?: MigrationConfig[];
182
+ }
183
+
184
+ // ─── DB-level Rules (§4) ───
185
+ // canCreate: deny-by-default when omitted (§12 ③).
186
+ // access: async supported — may do DB lookup for membership.
187
+
188
+ export interface DbLevelRules {
189
+ /**
190
+ * Allow creating a new DO instance (new namespace:id).
191
+ * Default: false — deny-by-default. Must explicitly set to allow creation.
192
+ */
193
+ canCreate?: (auth: AuthContext | null, id: string) => boolean | Promise<boolean>;
194
+ /**
195
+ * Allow accessing an existing DO instance.
196
+ * async supported — can perform DB lookups for membership checks.
197
+ */
198
+ access?: (auth: AuthContext | null, id: string, ctx: DbRuleCtx) => boolean | Promise<boolean>;
199
+ /** Allow deleting a DO instance. */
200
+ delete?: (auth: AuthContext | null, id: string) => boolean;
201
+ }
202
+
203
+ export type DbAccess = DbLevelRules;
204
+
205
+ export interface AdminInstanceDiscoveryOption {
206
+ id: string;
207
+ label?: string;
208
+ description?: string;
209
+ }
210
+
211
+ export interface AdminInstanceDiscoveryContext {
212
+ namespace: string;
213
+ query: string;
214
+ limit: number;
215
+ admin: {
216
+ sql(namespace: string, sql: string, options?: { id?: string; params?: unknown[] }): Promise<Record<string, unknown>[]>;
217
+ };
218
+ }
219
+
220
+ export interface ManualAdminInstanceDiscovery {
221
+ source: 'manual';
222
+ targetLabel?: string;
223
+ placeholder?: string;
224
+ helperText?: string;
225
+ }
226
+
227
+ export interface TableAdminInstanceDiscovery {
228
+ source: 'table';
229
+ targetLabel?: string;
230
+ namespace: string;
231
+ table: string;
232
+ idField?: string;
233
+ labelField?: string;
234
+ descriptionField?: string;
235
+ searchFields?: string[];
236
+ orderBy?: string;
237
+ limit?: number;
238
+ placeholder?: string;
239
+ helperText?: string;
240
+ }
241
+
242
+ export interface FunctionAdminInstanceDiscovery {
243
+ source: 'function';
244
+ targetLabel?: string;
245
+ resolve: (
246
+ ctx: AdminInstanceDiscoveryContext,
247
+ ) => Promise<AdminInstanceDiscoveryOption[]> | AdminInstanceDiscoveryOption[];
248
+ placeholder?: string;
249
+ helperText?: string;
250
+ }
251
+
252
+ export type AdminInstanceDiscovery =
253
+ | ManualAdminInstanceDiscovery
254
+ | TableAdminInstanceDiscovery
255
+ | FunctionAdminInstanceDiscovery;
256
+
257
+ export interface DbAdminConfig {
258
+ /**
259
+ * Admin dashboard instance discovery for dynamic namespaces.
260
+ * Lets the dashboard suggest instance IDs instead of relying on manual entry.
261
+ */
262
+ instances?: AdminInstanceDiscovery;
263
+ }
264
+
265
+ // ─── DB Block (§1) ───
266
+ // Static DB (no id): key = 'shared'
267
+ // Dynamic DB (with id): key = 'workspace' | 'user' | any namespace name
268
+ // Clients explicitly send namespace + id: edgebase.db('workspace', 'ws-456')
269
+
270
+ /** Database backend provider type. */
271
+ export type DbProvider = 'do' | 'd1' | 'neon' | 'postgres';
272
+
273
+ /** Auth database backend provider type. */
274
+ export type AuthDbProvider = 'd1' | 'neon' | 'postgres';
275
+
276
+ export interface DbBlock {
277
+ /**
278
+ * Database backend provider.
279
+ * - `'do'`: Durable Object + SQLite. Edge-native, physical isolation per instance.
280
+ * - `'d1'`: Cloudflare D1. Exportable via `wrangler d1 export`, enables migration to PostgreSQL.
281
+ * - `'neon'`: Neon PostgreSQL. Use `npx edgebase neon setup` or provide a connectionString env key; deploy provisions Hyperdrive from it.
282
+ * - `'postgres'`: Custom PostgreSQL. User provides connectionString manually.
283
+ *
284
+ * Default: Single-instance namespaces (no `instance` flag) default to D1.
285
+ * Multi-tenant namespaces (`instance: true`) default to DO.
286
+ *
287
+ * SDK code is identical regardless of provider — the server routes internally.
288
+ */
289
+ provider?: DbProvider;
290
+ /**
291
+ * Multi-tenant instance mode.
292
+ * When true, each instanceId gets its own Durable Object (physical isolation).
293
+ * When false/omitted, the namespace is a single-instance database routed to D1.
294
+ *
295
+ * Example:
296
+ * `instance: true` → `edgebase.db('workspace', 'ws-456')` creates DO per workspace
297
+ * (no instance) → `edgebase.db('shared')` routes to a single D1 database
298
+ */
299
+ instance?: boolean;
300
+ /**
301
+ * PostgreSQL connection string (or env variable reference).
302
+ * Only used when provider is `'neon'` or `'postgres'`.
303
+ * For deploy: reads from `.env.release` using key `DB_POSTGRES_{NAMESPACE_UPPER}_URL`
304
+ * (or a custom env key if connectionString is set).
305
+ * For dev: reads from `.env.development` using the same key.
306
+ */
307
+ connectionString?: string;
308
+ access?: DbAccess;
309
+ admin?: DbAdminConfig;
310
+ /** Tables within this DB namespace. */
311
+ tables?: Record<string, TableConfig>;
312
+ }
313
+
314
+ // ─── Storage Config (§5) ───
315
+ // maxFileSize / allowedMimeTypes removed — use write rule function instead.
316
+
317
+ export interface WriteFileMeta {
318
+ /** File size in bytes from form data */
319
+ size: number;
320
+ /** MIME type from form data */
321
+ contentType: string;
322
+ /** Requested file path/key */
323
+ key: string;
324
+ }
325
+
326
+ export interface R2FileMeta {
327
+ size: number;
328
+ contentType: string;
329
+ key: string;
330
+ etag?: string;
331
+ uploadedAt?: string;
332
+ uploadedBy?: string;
333
+ customMetadata?: Record<string, string>;
334
+ }
335
+
336
+ export interface StorageBucketRules {
337
+ read?: (auth: AuthContext | null, file: R2FileMeta) => boolean;
338
+ /** write: file = form data meta (§19) — size/contentType available before upload */
339
+ write?: (auth: AuthContext | null, file: WriteFileMeta) => boolean;
340
+ delete?: (auth: AuthContext | null, file: R2FileMeta) => boolean;
341
+ }
342
+
343
+ // ─── Storage Hook Context ───
344
+ // Storage runs in Worker (not DO), so no db access. Only waitUntil + push.
345
+
346
+ export interface StorageHookCtx {
347
+ waitUntil(promise: Promise<unknown>): void;
348
+ push: {
349
+ send(userId: string, payload: { title?: string; body: string }): Promise<void>;
350
+ };
351
+ }
352
+
353
+ // ─── Storage Hooks ───
354
+ // All hooks receive metadata only — NO file binary (128MB Worker memory limit).
355
+
356
+ export interface StorageHooks {
357
+ /** Before upload. Return custom metadata to merge, or throw to reject. NO file body. */
358
+ beforeUpload?: (
359
+ auth: AuthContext | null,
360
+ file: WriteFileMeta,
361
+ ctx: StorageHookCtx,
362
+ ) => Promise<Record<string, string> | void> | Record<string, string> | void;
363
+ /** After upload (fire-and-forget via waitUntil). Receives final R2 metadata. */
364
+ afterUpload?: (
365
+ auth: AuthContext | null,
366
+ file: R2FileMeta,
367
+ ctx: StorageHookCtx,
368
+ ) => Promise<void> | void;
369
+ /** Before delete. Throw to reject. */
370
+ beforeDelete?: (
371
+ auth: AuthContext | null,
372
+ file: R2FileMeta,
373
+ ctx: StorageHookCtx,
374
+ ) => Promise<void> | void;
375
+ /** After delete (fire-and-forget via waitUntil). */
376
+ afterDelete?: (
377
+ auth: AuthContext | null,
378
+ file: R2FileMeta,
379
+ ctx: StorageHookCtx,
380
+ ) => Promise<void> | void;
381
+ /** Before download. Throw to reject. */
382
+ beforeDownload?: (
383
+ auth: AuthContext | null,
384
+ file: R2FileMeta,
385
+ ctx: StorageHookCtx,
386
+ ) => Promise<void> | void;
387
+ }
388
+
389
+ export type StorageBucketAccess = StorageBucketRules;
390
+
391
+ export interface StorageHandlers {
392
+ hooks?: StorageHooks;
393
+ }
394
+
395
+ export interface StorageBucketConfig {
396
+ access?: StorageBucketAccess;
397
+ handlers?: StorageHandlers;
398
+ binding?: string;
399
+ }
400
+
401
+ export interface StorageConfig {
402
+ buckets?: Record<string, StorageBucketConfig>;
403
+ }
404
+
405
+ // ─── Auth Config ───
406
+
407
+ export interface MagicLinkConfig {
408
+ /** Enable magic link (passwordless email) authentication. Default: false */
409
+ enabled?: boolean;
410
+ /** Auto-create account if email is not registered. Default: true */
411
+ autoCreate?: boolean;
412
+ /** Token time-to-live. Default: '15m' */
413
+ tokenTTL?: string;
414
+ }
415
+
416
+ export interface MfaConfig {
417
+ /** Enable TOTP-based multi-factor authentication. Default: false */
418
+ totp?: boolean;
419
+ }
420
+
421
+ export interface EmailOtpConfig {
422
+ /** Enable email OTP (passwordless email code) authentication. Default: false */
423
+ enabled?: boolean;
424
+ /** Auto-create new user on first OTP request if email is not registered. Default: true */
425
+ autoCreate?: boolean;
426
+ }
427
+
428
+ export interface PasswordPolicyConfig {
429
+ /** Minimum password length. Default: 8 */
430
+ minLength?: number;
431
+ /** Require at least one uppercase letter. Default: false */
432
+ requireUppercase?: boolean;
433
+ /** Require at least one lowercase letter. Default: false */
434
+ requireLowercase?: boolean;
435
+ /** Require at least one digit. Default: false */
436
+ requireNumber?: boolean;
437
+ /** Require at least one special character. Default: false */
438
+ requireSpecial?: boolean;
439
+ /** Check password against HIBP (Have I Been Pwned) database via k-anonymity. Fail-open if API unavailable. Default: false */
440
+ checkLeaked?: boolean;
441
+ }
442
+
443
+ export interface OAuthProviderCredentialsConfig {
444
+ clientId: string;
445
+ clientSecret: string;
446
+ }
447
+
448
+ export interface OidcProviderCredentialsConfig extends OAuthProviderCredentialsConfig {
449
+ issuer: string;
450
+ scopes?: string[];
451
+ }
452
+
453
+ export interface OAuthProvidersConfig {
454
+ /** OIDC federation providers keyed by provider slug. */
455
+ oidc?: Record<string, OidcProviderCredentialsConfig>;
456
+ /** Built-in provider name → credentials. */
457
+ [provider: string]:
458
+ | OAuthProviderCredentialsConfig
459
+ | Record<string, OidcProviderCredentialsConfig>
460
+ | undefined;
461
+ }
462
+
463
+ export interface AuthConfig {
464
+ /**
465
+ * Auth database backend provider.
466
+ * - `'d1'` (default): Cloudflare D1 (AUTH_DB binding). Zero-cost, global.
467
+ * - `'neon'`: Neon PostgreSQL via Hyperdrive. Zero-downtime upgrade from D1.
468
+ * - `'postgres'`: Custom PostgreSQL. User provides connectionString.
469
+ *
470
+ * SDK code is identical regardless of provider — the server routes internally.
471
+ * Migration: `npx edgebase migrate auth --from=d1 --to=neon`
472
+ */
473
+ provider?: AuthDbProvider;
474
+ /**
475
+ * PostgreSQL connection string environment variable name.
476
+ * Required when `provider` is `'neon'` or `'postgres'`.
477
+ * `npx edgebase neon setup --auth` writes the corresponding value to local env files.
478
+ * CLI stores the actual URL in secrets; this is the env variable key.
479
+ * Example: `'AUTH_POSTGRES_URL'`
480
+ */
481
+ connectionString?: string;
482
+ emailAuth?: boolean;
483
+ anonymousAuth?: boolean;
484
+ /** Enable phone/SMS OTP authentication. Default: false */
485
+ phoneAuth?: boolean;
486
+ allowedOAuthProviders?: string[];
487
+ /**
488
+ * OAuth provider credentials.
489
+ * - Built-in: auth.oauth.{provider}.clientId / clientSecret
490
+ * - OIDC: auth.oauth.oidc.{name}.clientId / clientSecret / issuer
491
+ */
492
+ oauth?: OAuthProvidersConfig;
493
+ /**
494
+ * Optional client redirect URL allowlist for OAuth and email-based auth actions.
495
+ * When unset, redirect URLs are accepted as-is for backward compatibility.
496
+ *
497
+ * Supported forms:
498
+ * - exact URL: 'https://app.example.com/auth/callback'
499
+ * - origin-wide: 'https://app.example.com'
500
+ * - prefix wildcard: 'https://app.example.com/auth/*'
501
+ */
502
+ allowedRedirectUrls?: string[];
503
+ session?: {
504
+ accessTokenTTL?: string;
505
+ refreshTokenTTL?: string;
506
+ /** Maximum number of active sessions per user. 0 or undefined = unlimited. Oldest sessions are evicted when limit is exceeded. */
507
+ maxActiveSessions?: number;
508
+ };
509
+ anonymousRetentionDays?: number;
510
+ /** If true, deletes user DB (user:{id}) when a user is deleted. */
511
+ cleanupOrphanData?: boolean;
512
+ /** Magic link (passwordless email login) configuration. */
513
+ magicLink?: MagicLinkConfig;
514
+ /** MFA/TOTP configuration. */
515
+ mfa?: MfaConfig;
516
+ /** Email OTP (passwordless email code) configuration. */
517
+ emailOtp?: EmailOtpConfig;
518
+ /** Password strength policy configuration. */
519
+ passwordPolicy?: PasswordPolicyConfig;
520
+ /** Passkeys / WebAuthn configuration. */
521
+ passkeys?: PasskeysConfig;
522
+ /** Preferred auth action access config. */
523
+ access?: AuthAccess;
524
+ /** Preferred auth handler groups. */
525
+ handlers?: AuthHandlers;
526
+ }
527
+
528
+ export interface PasskeysConfig {
529
+ /** Enable WebAuthn/Passkeys. Default: false */
530
+ enabled?: boolean;
531
+ /** Relying Party name (displayed in authenticator UI). */
532
+ rpName: string;
533
+ /** Relying Party ID (usually your domain, e.g. 'example.com'). */
534
+ rpID: string;
535
+ /** Expected origin(s) for WebAuthn requests (e.g. 'https://example.com'). */
536
+ origin: string | string[];
537
+ }
538
+
539
+ // ─── Email Config ───
540
+
541
+ /**
542
+ * A string value that can be either a single string (applies to all locales)
543
+ * or a per-locale map (e.g. { en: '...', ko: '...', ja: '...' }).
544
+ * When a per-locale map is used, locale resolution falls back: exact → base language → 'en'.
545
+ */
546
+ export type LocalizedString = string | Record<string, string>;
547
+
548
+ /**
549
+ * Custom HTML template overrides for auth emails.
550
+ * Use {{variable}} placeholders for dynamic values.
551
+ * When provided, replaces the default built-in template entirely.
552
+ * Can be a single string (all locales) or a per-locale map for i18n.
553
+ */
554
+ export interface EmailTemplateOverrides {
555
+ /** Custom HTML for email verification. Variables: {{appName}}, {{verifyUrl}}, {{token}}, {{expiresInHours}} */
556
+ verification?: LocalizedString;
557
+ /** Custom HTML for password reset. Variables: {{appName}}, {{resetUrl}}, {{token}}, {{expiresInMinutes}} */
558
+ passwordReset?: LocalizedString;
559
+ /** Custom HTML for magic link login. Variables: {{appName}}, {{magicLinkUrl}}, {{expiresInMinutes}} */
560
+ magicLink?: LocalizedString;
561
+ /** Custom HTML for email OTP. Variables: {{appName}}, {{code}}, {{expiresInMinutes}} */
562
+ emailOtp?: LocalizedString;
563
+ /** Custom HTML for email change verification. Variables: {{appName}}, {{verifyUrl}}, {{token}}, {{newEmail}}, {{expiresInHours}} */
564
+ emailChange?: LocalizedString;
565
+ }
566
+
567
+ /**
568
+ * Custom email subject overrides. Use {{appName}} placeholder for the app name.
569
+ * Defaults: "[{{appName}}] Verify your email", "[{{appName}}] Reset your password", etc.
570
+ * Can be a single string (all locales) or a per-locale map for i18n.
571
+ */
572
+ export interface EmailSubjectOverrides {
573
+ verification?: LocalizedString;
574
+ passwordReset?: LocalizedString;
575
+ magicLink?: LocalizedString;
576
+ emailOtp?: LocalizedString;
577
+ emailChange?: LocalizedString;
578
+ }
579
+
580
+ export interface EmailConfig {
581
+ provider: 'resend' | 'sendgrid' | 'mailgun' | 'ses';
582
+ apiKey: string;
583
+ from: string;
584
+ domain?: string;
585
+ region?: string;
586
+ appName?: string;
587
+ /** Default locale for auth emails when user has no preference. Default: 'en' */
588
+ defaultLocale?: string;
589
+ verifyUrl?: string;
590
+ resetUrl?: string;
591
+ /** Magic link URL template. Use {token} placeholder. e.g. 'https://app.com/auth/magic-link?token={token}' */
592
+ magicLinkUrl?: string;
593
+ /** Email change verification URL template. Use {token} placeholder. e.g. 'https://app.com/auth/verify-email-change?token={token}' */
594
+ emailChangeUrl?: string;
595
+ /** Custom HTML template overrides for auth emails. */
596
+ templates?: EmailTemplateOverrides;
597
+ /** Custom email subject line overrides. */
598
+ subjects?: EmailSubjectOverrides;
599
+ }
600
+
601
+ // ─── Mail Hooks ───
602
+
603
+ export type MailType = 'verification' | 'passwordReset' | 'magicLink' | 'emailOtp' | 'emailChange';
604
+
605
+ export interface MailHookCtx {
606
+ waitUntil(promise: Promise<unknown>): void;
607
+ }
608
+
609
+ export interface MailHooks {
610
+ /**
611
+ * Intercept outgoing emails. Can modify subject/html or reject (throw). Blocking, 5s timeout.
612
+ * The optional `locale` parameter contains the resolved locale used for the email.
613
+ */
614
+ onSend?: (
615
+ type: MailType,
616
+ to: string,
617
+ subject: string,
618
+ html: string,
619
+ ctx: MailHookCtx,
620
+ locale?: string,
621
+ ) =>
622
+ | Promise<{ subject?: string; html?: string } | void>
623
+ | { subject?: string; html?: string }
624
+ | void;
625
+ }
626
+
627
+ export type SmsType = 'phoneOtp' | 'phoneLink';
628
+
629
+ export interface SmsHookCtx {
630
+ waitUntil(promise: Promise<unknown>): void;
631
+ }
632
+
633
+ export interface SmsHooks {
634
+ /**
635
+ * Intercept outgoing SMS. Can modify body or reject (throw). Blocking, 5s timeout.
636
+ */
637
+ onSend?: (
638
+ type: SmsType,
639
+ to: string,
640
+ body: string,
641
+ ctx: SmsHookCtx,
642
+ ) => Promise<{ body?: string } | void> | { body?: string } | void;
643
+ }
644
+
645
+ export interface AuthAccessCtx {
646
+ request?: unknown;
647
+ auth?: AuthContext | null;
648
+ ip?: string;
649
+ }
650
+
651
+ export type AuthAccessRule = (
652
+ input: Record<string, unknown> | null,
653
+ ctx: AuthAccessCtx,
654
+ ) => boolean | Promise<boolean>;
655
+
656
+ export interface AuthAccess {
657
+ signUp?: AuthAccessRule;
658
+ signIn?: AuthAccessRule;
659
+ signInAnonymous?: AuthAccessRule;
660
+ signInMagicLink?: AuthAccessRule;
661
+ verifyMagicLink?: AuthAccessRule;
662
+ signInPhone?: AuthAccessRule;
663
+ verifyPhoneOtp?: AuthAccessRule;
664
+ linkPhone?: AuthAccessRule;
665
+ verifyLinkPhone?: AuthAccessRule;
666
+ signInEmailOtp?: AuthAccessRule;
667
+ verifyEmailOtp?: AuthAccessRule;
668
+ mfaTotpEnroll?: AuthAccessRule;
669
+ mfaTotpVerify?: AuthAccessRule;
670
+ mfaVerify?: AuthAccessRule;
671
+ mfaRecovery?: AuthAccessRule;
672
+ mfaTotpDelete?: AuthAccessRule;
673
+ mfaFactors?: AuthAccessRule;
674
+ requestPasswordReset?: AuthAccessRule;
675
+ resetPassword?: AuthAccessRule;
676
+ verifyEmail?: AuthAccessRule;
677
+ changePassword?: AuthAccessRule;
678
+ changeEmail?: AuthAccessRule;
679
+ verifyEmailChange?: AuthAccessRule;
680
+ passkeysRegisterOptions?: AuthAccessRule;
681
+ passkeysRegister?: AuthAccessRule;
682
+ passkeysAuthOptions?: AuthAccessRule;
683
+ passkeysAuthenticate?: AuthAccessRule;
684
+ passkeysList?: AuthAccessRule;
685
+ passkeysDelete?: AuthAccessRule;
686
+ getMe?: AuthAccessRule;
687
+ updateProfile?: AuthAccessRule;
688
+ getSessions?: AuthAccessRule;
689
+ deleteSession?: AuthAccessRule;
690
+ getIdentities?: AuthAccessRule;
691
+ deleteIdentity?: AuthAccessRule;
692
+ linkEmail?: AuthAccessRule;
693
+ oauthRedirect?: AuthAccessRule;
694
+ oauthCallback?: AuthAccessRule;
695
+ oauthLinkStart?: AuthAccessRule;
696
+ oauthLinkCallback?: AuthAccessRule;
697
+ refresh?: AuthAccessRule;
698
+ signOut?: AuthAccessRule;
699
+ }
700
+
701
+ export interface AuthHandlerHooks {
702
+ enrich?: (
703
+ auth: AuthContext,
704
+ request: unknown,
705
+ ) => Promise<Record<string, unknown>> | Record<string, unknown>;
706
+ }
707
+
708
+ export interface AuthHandlers {
709
+ hooks?: AuthHandlerHooks;
710
+ email?: MailHooks;
711
+ sms?: SmsHooks;
712
+ }
713
+
714
+ // ─── SMS Config ───
715
+
716
+ export interface SmsConfig {
717
+ provider: 'twilio' | 'messagebird' | 'vonage';
718
+ /** Twilio Account SID */
719
+ accountSid?: string;
720
+ /** Twilio Auth Token */
721
+ authToken?: string;
722
+ /** MessageBird / Vonage API Key */
723
+ apiKey?: string;
724
+ /** Vonage API Secret */
725
+ apiSecret?: string;
726
+ /** Sender phone number in E.164 format (e.g. '+15551234567') */
727
+ from: string;
728
+ }
729
+
730
+ // ─── CORS Config ───
731
+
732
+ export interface CorsConfig {
733
+ origin?: string | string[];
734
+ methods?: string[];
735
+ credentials?: boolean;
736
+ maxAge?: number;
737
+ }
738
+
739
+ // ─── Rate Limiting Config ───
740
+
741
+ export interface RateLimitGroupConfig {
742
+ requests: number;
743
+ window: string | number;
744
+ /**
745
+ * Optional Cloudflare Rate Limiting Binding override.
746
+ * Applied by the CLI when synthesizing a temporary wrangler.toml for dev/deploy.
747
+ */
748
+ binding?: RateLimitBindingConfig;
749
+ }
750
+
751
+ export interface RateLimitBindingConfig {
752
+ /** Disable the Cloudflare binding for this built-in group. */
753
+ enabled?: boolean;
754
+ /** Binding ceiling. Defaults to the framework safety-net value when omitted. */
755
+ limit?: number;
756
+ /** Cloudflare currently supports only 10s or 60s periods. */
757
+ period?: 10 | 60;
758
+ /** Optional custom namespace_id for the binding. */
759
+ namespaceId?: string;
760
+ }
761
+
762
+ export interface RateLimitingConfig {
763
+ [key: string]: RateLimitGroupConfig | undefined;
764
+ global?: RateLimitGroupConfig;
765
+ auth?: RateLimitGroupConfig;
766
+ authSignin?: RateLimitGroupConfig;
767
+ authSignup?: RateLimitGroupConfig;
768
+ db?: RateLimitGroupConfig;
769
+ storage?: RateLimitGroupConfig;
770
+ functions?: RateLimitGroupConfig;
771
+ events?: RateLimitGroupConfig;
772
+ }
773
+
774
+ // ─── Functions Config ───
775
+
776
+ export interface FunctionsConfig {
777
+ scheduleFunctionTimeout?: string;
778
+ }
779
+
780
+ // ─── Cloudflare Config ───
781
+
782
+ export interface CloudflareConfig {
783
+ /**
784
+ * Additional raw Wrangler cron triggers to include at deploy time.
785
+ * These wake the Worker's scheduled() handler even when not tied to a
786
+ * specific schedule function.
787
+ */
788
+ extraCrons?: string[];
789
+ }
790
+
791
+ // ─── API Config ───
792
+
793
+ export interface ApiConfig {
794
+ schemaEndpoint?: boolean | 'authenticated';
795
+ }
796
+
797
+ // ─── Service Key Config ───
798
+
799
+ export type ScopeString = string;
800
+
801
+ export interface ServiceKeyConstraints {
802
+ expiresAt?: string;
803
+ env?: string[];
804
+ ipCidr?: string[];
805
+ tenant?: string;
806
+ }
807
+
808
+ export interface ServiceKeyEntry {
809
+ kid: string;
810
+ tier: 'root' | 'scoped';
811
+ scopes: ScopeString[];
812
+ constraints?: ServiceKeyConstraints;
813
+ secretSource: 'dashboard' | 'inline';
814
+ secretRef?: string;
815
+ inlineSecret?: string;
816
+ enabled?: boolean;
817
+ }
818
+
819
+ export interface ServiceKeysConfig {
820
+ policyVersion?: number;
821
+ keys: ServiceKeyEntry[];
822
+ }
823
+
824
+ // ─── Captcha Config ───
825
+
826
+ export interface CaptchaConfig {
827
+ siteKey: string;
828
+ secretKey: string;
829
+ failMode?: 'open' | 'closed';
830
+ siteverifyTimeout?: number;
831
+ }
832
+
833
+ // ─── KV/D1/Vectorize Config ───
834
+
835
+ export interface KvNamespaceRules {
836
+ read?: (auth: AuthContext | null) => boolean;
837
+ write?: (auth: AuthContext | null) => boolean;
838
+ }
839
+
840
+ export interface KvNamespaceConfig {
841
+ binding: string;
842
+ rules?: KvNamespaceRules;
843
+ }
844
+
845
+ export interface D1DatabaseConfig {
846
+ binding: string;
847
+ }
848
+
849
+ export interface VectorizeConfig {
850
+ binding?: string;
851
+ dimensions: number;
852
+ metric: 'cosine' | 'euclidean' | 'dot-product';
853
+ }
854
+
855
+ // ─── Push Config ───
856
+
857
+ /**
858
+ * Optional endpoint overrides for FCM-related APIs.
859
+ * Defaults to Google production URLs when omitted.
860
+ * Used for testing with a mock FCM server.
861
+ */
862
+ export interface PushFcmEndpoints {
863
+ /** Google OAuth2 token endpoint. Default: 'https://oauth2.googleapis.com/token' */
864
+ oauth2TokenUrl?: string;
865
+ /** FCM HTTP v1 send endpoint. Default: 'https://fcm.googleapis.com/v1/projects/{projectId}/messages:send' */
866
+ fcmSendUrl?: string;
867
+ /** IID (Instance ID) API base URL. Default: 'https://iid.googleapis.com' */
868
+ iidBaseUrl?: string;
869
+ }
870
+
871
+ export interface PushFcmConfig {
872
+ projectId: string;
873
+ /** Override FCM/OAuth2/IID endpoints for testing. Omit for production. */
874
+ endpoints?: PushFcmEndpoints;
875
+ /**
876
+ * FCM Service Account JSON string. Fallback for environments where
877
+ * the PUSH_FCM_SERVICE_ACCOUNT env var is not directly accessible.
878
+ * Prefer the env var in production; this is primarily for test setups.
879
+ */
880
+ serviceAccount?: string;
881
+ }
882
+
883
+ export interface PushRules {
884
+ /** Who can send push notifications. */
885
+ send?: (auth: AuthContext | null, target: { userId: string }) => boolean;
886
+ }
887
+
888
+ export type PushAccess = PushRules;
889
+
890
+ export interface PushHookCtx {
891
+ request?: unknown;
892
+ waitUntil(promise: Promise<unknown>): void;
893
+ }
894
+
895
+ export interface PushSendInput {
896
+ kind: 'user' | 'users' | 'token' | 'topic' | 'broadcast';
897
+ payload: Record<string, unknown>;
898
+ userId?: string;
899
+ userIds?: string[];
900
+ token?: string;
901
+ topic?: string;
902
+ platform?: string;
903
+ }
904
+
905
+ export interface PushSendOutput {
906
+ sent?: number;
907
+ failed?: number;
908
+ removed?: number;
909
+ error?: string;
910
+ raw?: unknown;
911
+ }
912
+
913
+ export interface PushHandlers {
914
+ hooks?: {
915
+ beforeSend?: (
916
+ auth: AuthContext | null,
917
+ input: PushSendInput,
918
+ ctx: PushHookCtx,
919
+ ) => Promise<PushSendInput | void> | PushSendInput | void;
920
+ afterSend?: (
921
+ auth: AuthContext | null,
922
+ input: PushSendInput,
923
+ output: PushSendOutput,
924
+ ctx: PushHookCtx,
925
+ ) => Promise<void> | void;
926
+ };
927
+ }
928
+
929
+ export interface PushConfig {
930
+ fcm?: PushFcmConfig;
931
+ access?: PushAccess;
932
+ handlers?: PushHandlers;
933
+ }
934
+
935
+ export interface DatabaseLiveConfig {
936
+ authTimeoutMs?: number;
937
+ batchThreshold?: number;
938
+ }
939
+
940
+ // ─── Room Config v2 ───
941
+
942
+ /** Info about the player who triggered the action / lifecycle event. */
943
+ export interface RoomSender {
944
+ userId: string;
945
+ connectionId: string;
946
+ role?: string;
947
+ }
948
+
949
+ /** DB table proxy available inside Room handlers via ctx.admin.db(). */
950
+ export interface RoomTableProxy {
951
+ get(id: string): Promise<Record<string, unknown> | null>;
952
+ list(filter?: Record<string, unknown>): Promise<Record<string, unknown>[]>;
953
+ insert(data: Record<string, unknown>): Promise<Record<string, unknown>>;
954
+ update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
955
+ delete(id: string): Promise<void>;
956
+ }
957
+
958
+ /** DB namespace proxy: ctx.admin.db('shared').table('posts') */
959
+ export interface RoomDbProxy {
960
+ table(name: string): RoomTableProxy;
961
+ }
962
+
963
+ /** Admin context injected into Room handlers (ctx parameter). */
964
+ export interface RoomHandlerContext {
965
+ admin: {
966
+ db(namespace: string, id?: string): RoomDbProxy;
967
+ push: {
968
+ send(userId: string, payload: { title?: string; body: string }): Promise<void>;
969
+ sendMany(userIds: string[], payload: { title?: string; body: string }): Promise<void>;
970
+ };
971
+ broadcast(channel: string, event: string, data?: unknown): Promise<void>;
972
+ };
973
+ }
974
+
975
+ /** Public member descriptor used by canonical room hooks. */
976
+ export interface RoomMemberInfo {
977
+ memberId: string;
978
+ userId: string;
979
+ connectionId?: string;
980
+ connectionCount?: number;
981
+ role?: string;
982
+ }
983
+
984
+ export type RoomRuntimeTarget = 'rooms';
985
+
986
+ export interface RoomRuntimeConfig {
987
+ /** Target runtime. */
988
+ target?: RoomRuntimeTarget;
989
+ }
990
+
991
+ /**
992
+ * Server-side Room API available inside handlers (room parameter).
993
+ * All state mutations are server-only — clients can only read + subscribe + send().
994
+ */
995
+ export interface RoomServerAPI {
996
+ /** Current shared state (visible to all clients). */
997
+ getSharedState(): Record<string, unknown>;
998
+ /** Mutate shared state via updater function. Delta auto-broadcast to all clients. */
999
+ setSharedState(updater: (state: Record<string, unknown>) => Record<string, unknown>): void;
1000
+
1001
+ /** Get a specific player's state by userId. */
1002
+ player(userId: string): Record<string, unknown>;
1003
+ /** Get all players: [userId, state][] */
1004
+ players(): Array<[string, Record<string, unknown>]>;
1005
+ /** Mutate a player's state. Delta unicast to that player only. */
1006
+ setPlayerState(
1007
+ userId: string,
1008
+ updater: (state: Record<string, unknown>) => Record<string, unknown>,
1009
+ ): void;
1010
+
1011
+ /** Current server-only state (never sent to clients). */
1012
+ getServerState(): Record<string, unknown>;
1013
+ /** Mutate server-only state. No broadcast. */
1014
+ setServerState(updater: (state: Record<string, unknown>) => Record<string, unknown>): void;
1015
+
1016
+ /** Broadcast a one-off message to all connected clients. options.exclude: userIds to skip. */
1017
+ sendMessage(type: string, data?: unknown, options?: { exclude?: string[] }): void;
1018
+ /** Send a one-off message to a specific user only (all their connections). */
1019
+ sendMessageTo(userId: string, type: string, data?: unknown): void;
1020
+ /** Forcefully disconnect a player. Triggers onLeave with reason='kicked'. */
1021
+ kick(userId: string): void;
1022
+ /** Immediately persist all 3 state areas to DO Storage. Use after critical state changes. */
1023
+ saveState(): Promise<void>;
1024
+
1025
+ /** Schedule a named timer. Calls onTimer[name] after ms milliseconds. */
1026
+ setTimer(name: string, ms: number, data?: unknown): void;
1027
+ /** Cancel a named timer. No-op if timer doesn't exist. */
1028
+ clearTimer(name: string): void;
1029
+
1030
+ /** Set developer-defined metadata (queryable via HTTP without joining). */
1031
+ setMetadata(data: Record<string, unknown>): void;
1032
+ /** Get current room metadata. */
1033
+ getMetadata(): Record<string, unknown>;
1034
+ }
1035
+
1036
+ /**
1037
+ * Room namespace config. Each key in `rooms` is a namespace (e.g. 'game', 'lobby').
1038
+ * Client connects via: client.room("namespace", "roomId")
1039
+ */
1040
+ export interface RoomNamespaceConfig {
1041
+ /** Reconnect grace period in ms. 0 = immediate onLeave on disconnect. Default: 30000 */
1042
+ reconnectTimeout?: number;
1043
+ /** Rate limit for send() calls. Default: { actions: 10 } (per second, token bucket) */
1044
+ rateLimit?: { actions: number };
1045
+ /** Maximum concurrent players. Default: 100 */
1046
+ maxPlayers?: number;
1047
+ /** Maximum state size in bytes (shared + all player states combined). Default: 1MB */
1048
+ maxStateSize?: number;
1049
+ /** How often to persist all 3 state areas to DO Storage (ms). Default: 60000 (1 minute). */
1050
+ stateSaveInterval?: number;
1051
+ /** Time after last save before persisted state is auto-deleted (ms). Default: 86400000 (24 hours). Acts as safety net for orphaned storage. */
1052
+ stateTTL?: number;
1053
+ /** Public room operation escape hatch for release mode. Default: false. */
1054
+ public?:
1055
+ | boolean
1056
+ | {
1057
+ metadata?: boolean;
1058
+ join?: boolean;
1059
+ action?: boolean;
1060
+ };
1061
+ /** Preferred access config for room operations. */
1062
+ access?: RoomAccess;
1063
+ /** Parallel runtime selection policy for room-sticky rollout. */
1064
+ runtime?: RoomRuntimeConfig;
1065
+ /** Canonical authoritative state config used by the unified Room runtime. */
1066
+ state?: RoomStateConfig;
1067
+ /** Canonical extension hooks used by the unified Room runtime. */
1068
+ hooks?: RoomHooks;
1069
+ /** Preferred handler groups. */
1070
+ handlers?: RoomHandlers;
1071
+ }
1072
+
1073
+ export interface RoomAccess {
1074
+ metadata?: (auth: AuthContext | null, roomId: string) => boolean | Promise<boolean>;
1075
+ join?: (auth: AuthContext | null, roomId: string) => boolean | Promise<boolean>;
1076
+ action?: (
1077
+ auth: AuthContext | null,
1078
+ roomId: string,
1079
+ actionType: string,
1080
+ payload: unknown,
1081
+ ) => boolean | Promise<boolean>;
1082
+ signal?: (
1083
+ auth: AuthContext | null,
1084
+ roomId: string,
1085
+ event: string,
1086
+ payload: unknown,
1087
+ ) => boolean | Promise<boolean>;
1088
+ media?: RoomMediaAccess;
1089
+ admin?: (
1090
+ auth: AuthContext | null,
1091
+ roomId: string,
1092
+ operation: string,
1093
+ payload: unknown,
1094
+ ) => boolean | Promise<boolean>;
1095
+ }
1096
+
1097
+ export type RoomCreateHandler = (
1098
+ room: RoomServerAPI,
1099
+ ctx: RoomHandlerContext,
1100
+ ) => Promise<void> | void;
1101
+
1102
+ export type RoomJoinHandler = (
1103
+ sender: RoomSender,
1104
+ room: RoomServerAPI,
1105
+ ctx: RoomHandlerContext,
1106
+ ) => Promise<void> | void;
1107
+
1108
+ export type RoomLeaveHandler = (
1109
+ sender: RoomSender,
1110
+ room: RoomServerAPI,
1111
+ ctx: RoomHandlerContext,
1112
+ reason: 'leave' | 'disconnect' | 'kicked',
1113
+ ) => Promise<void> | void;
1114
+
1115
+ export type RoomDestroyHandler = (
1116
+ room: RoomServerAPI,
1117
+ ctx: RoomHandlerContext,
1118
+ ) => Promise<void> | void;
1119
+
1120
+ export type RoomActionHandlers = Record<
1121
+ string,
1122
+ (
1123
+ action: unknown,
1124
+ room: RoomServerAPI,
1125
+ sender: RoomSender,
1126
+ ctx: RoomHandlerContext,
1127
+ ) => Promise<unknown> | unknown
1128
+ >;
1129
+
1130
+ export type RoomTimerHandlers = Record<
1131
+ string,
1132
+ (room: RoomServerAPI, ctx: RoomHandlerContext, data?: unknown) => Promise<void> | void
1133
+ >;
1134
+
1135
+ export interface RoomLifecycleHandlers {
1136
+ onCreate?: RoomCreateHandler;
1137
+ onJoin?: RoomJoinHandler;
1138
+ onLeave?: RoomLeaveHandler;
1139
+ onDestroy?: RoomDestroyHandler;
1140
+ }
1141
+
1142
+ export interface RoomMediaAccess {
1143
+ subscribe?: (
1144
+ auth: AuthContext | null,
1145
+ roomId: string,
1146
+ payload: unknown,
1147
+ ) => boolean | Promise<boolean>;
1148
+ publish?: (
1149
+ auth: AuthContext | null,
1150
+ roomId: string,
1151
+ kind: string,
1152
+ payload: unknown,
1153
+ ) => boolean | Promise<boolean>;
1154
+ control?: (
1155
+ auth: AuthContext | null,
1156
+ roomId: string,
1157
+ operation: string,
1158
+ payload: unknown,
1159
+ ) => boolean | Promise<boolean>;
1160
+ }
1161
+
1162
+ export interface RoomStateConfig {
1163
+ actions?: RoomActionHandlers;
1164
+ timers?: RoomTimerHandlers;
1165
+ }
1166
+
1167
+ export interface RoomMemberHooks {
1168
+ onJoin?: (
1169
+ member: RoomMemberInfo,
1170
+ room: RoomServerAPI,
1171
+ ctx: RoomHandlerContext,
1172
+ ) => Promise<void> | void;
1173
+ onLeave?: (
1174
+ member: RoomMemberInfo,
1175
+ room: RoomServerAPI,
1176
+ ctx: RoomHandlerContext,
1177
+ reason: string,
1178
+ ) => Promise<void> | void;
1179
+ onStateChange?: (
1180
+ member: RoomMemberInfo,
1181
+ state: Record<string, unknown>,
1182
+ room: RoomServerAPI,
1183
+ ctx: RoomHandlerContext,
1184
+ ) => Promise<void> | void;
1185
+ }
1186
+
1187
+ export interface RoomStateHooks {
1188
+ onStateChange?: (
1189
+ delta: Record<string, unknown>,
1190
+ room: RoomServerAPI,
1191
+ ctx: RoomHandlerContext,
1192
+ ) => Promise<void> | void;
1193
+ }
1194
+
1195
+ export interface RoomSignalHooks {
1196
+ beforeSend?: (
1197
+ event: string,
1198
+ payload: unknown,
1199
+ sender: RoomSender,
1200
+ room: RoomServerAPI,
1201
+ ctx: RoomHandlerContext,
1202
+ ) => Promise<unknown | false | void> | unknown | false | void;
1203
+ onSend?: (
1204
+ event: string,
1205
+ payload: unknown,
1206
+ sender: RoomSender,
1207
+ room: RoomServerAPI,
1208
+ ctx: RoomHandlerContext,
1209
+ ) => Promise<void> | void;
1210
+ }
1211
+
1212
+ export interface RoomMediaHooks {
1213
+ beforePublish?: (
1214
+ kind: string,
1215
+ sender: RoomSender,
1216
+ room: RoomServerAPI,
1217
+ ctx: RoomHandlerContext,
1218
+ ) => Promise<unknown | false | void> | unknown | false | void;
1219
+ onPublished?: (
1220
+ kind: string,
1221
+ sender: RoomSender,
1222
+ room: RoomServerAPI,
1223
+ ctx: RoomHandlerContext,
1224
+ ) => Promise<void> | void;
1225
+ onUnpublished?: (
1226
+ kind: string,
1227
+ sender: RoomSender,
1228
+ room: RoomServerAPI,
1229
+ ctx: RoomHandlerContext,
1230
+ ) => Promise<void> | void;
1231
+ onMuteChange?: (
1232
+ kind: string,
1233
+ sender: RoomSender,
1234
+ muted: boolean,
1235
+ room: RoomServerAPI,
1236
+ ctx: RoomHandlerContext,
1237
+ ) => Promise<void> | void;
1238
+ }
1239
+
1240
+ export interface RoomSessionHooks {
1241
+ onReconnect?: (
1242
+ sender: RoomSender,
1243
+ room: RoomServerAPI,
1244
+ ctx: RoomHandlerContext,
1245
+ ) => Promise<void> | void;
1246
+ onDisconnectTimeout?: (
1247
+ sender: RoomSender,
1248
+ room: RoomServerAPI,
1249
+ ctx: RoomHandlerContext,
1250
+ ) => Promise<void> | void;
1251
+ }
1252
+
1253
+ export interface RoomHooks {
1254
+ lifecycle?: RoomLifecycleHandlers;
1255
+ members?: RoomMemberHooks;
1256
+ state?: RoomStateHooks;
1257
+ signals?: RoomSignalHooks;
1258
+ media?: RoomMediaHooks;
1259
+ session?: RoomSessionHooks;
1260
+ }
1261
+
1262
+ export interface RoomHandlers {
1263
+ lifecycle?: RoomLifecycleHandlers;
1264
+ actions?: RoomActionHandlers;
1265
+ timers?: RoomTimerHandlers;
1266
+ }
1267
+
1268
+ // ─── Top-level Config (§9) ───
1269
+ // DB blocks are keyed by namespace ('shared', 'workspace', 'user', etc.)
1270
+ // Other config is nested under named keys (auth, storage, databaseLive, rooms, ...)
1271
+
1272
+ export interface EdgeBaseConfig {
1273
+ /** Optional canonical base URL for auth/OAuth redirects and runtime metadata. */
1274
+ baseUrl?: string;
1275
+ /**
1276
+ * Trust reverse-proxy forwarded client IP headers in self-hosted environments.
1277
+ * Default: false — only Cloudflare's CF-Connecting-IP is trusted.
1278
+ */
1279
+ trustSelfHostedProxy?: boolean;
1280
+ /**
1281
+ * Database blocks. Each key is a namespace:
1282
+ * - 'shared': single static DB (no id)
1283
+ * - 'workspace', 'user', etc.: dynamic per-id DB
1284
+ * Clients send: edgebase.db('workspace', 'ws-456')
1285
+ */
1286
+ databases?: Record<string, DbBlock>;
1287
+
1288
+ /** Release mode. false = rules bypassed (dev), true = enforce (prod). Default: false. */
1289
+ release?: boolean;
1290
+
1291
+ auth?: AuthConfig;
1292
+ email?: EmailConfig;
1293
+ /** SMS provider configuration for phone authentication. */
1294
+ sms?: SmsConfig;
1295
+ storage?: StorageConfig;
1296
+ cors?: CorsConfig;
1297
+ databaseLive?: DatabaseLiveConfig;
1298
+ rateLimiting?: RateLimitingConfig;
1299
+ functions?: FunctionsConfig;
1300
+ cloudflare?: CloudflareConfig;
1301
+ api?: ApiConfig;
1302
+ serviceKeys?: ServiceKeysConfig;
1303
+ plugins?: PluginInstance[];
1304
+ kv?: Record<string, KvNamespaceConfig>;
1305
+ d1?: Record<string, D1DatabaseConfig>;
1306
+ vectorize?: Record<string, VectorizeConfig>;
1307
+ captcha?: boolean | CaptchaConfig;
1308
+ push?: PushConfig;
1309
+ /** Room namespaces. Key = namespace name (e.g. 'game', 'lobby'). */
1310
+ rooms?: Record<string, RoomNamespaceConfig>;
1311
+ }
1312
+
1313
+ export function getDbAccess(dbBlock?: DbBlock): DbAccess | undefined {
1314
+ return dbBlock?.access;
1315
+ }
1316
+
1317
+ export function getTableAccess(tableConfig?: TableConfig): TableAccess | undefined {
1318
+ return tableConfig?.access;
1319
+ }
1320
+
1321
+ export function getTableHooks(tableConfig?: TableConfig): TableHooks | undefined {
1322
+ return tableConfig?.handlers?.hooks;
1323
+ }
1324
+
1325
+ export function getStorageBucketAccess(
1326
+ bucketConfig?: StorageBucketConfig,
1327
+ ): StorageBucketAccess | undefined {
1328
+ return bucketConfig?.access;
1329
+ }
1330
+
1331
+ export function getStorageHooks(bucketConfig?: StorageBucketConfig): StorageHooks | undefined {
1332
+ return bucketConfig?.handlers?.hooks;
1333
+ }
1334
+
1335
+ export function getPushAccess(config?: PushConfig): PushAccess | undefined {
1336
+ return config?.access;
1337
+ }
1338
+
1339
+ export function getPushHandlers(config?: PushConfig): PushHandlers | undefined {
1340
+ return config?.handlers;
1341
+ }
1342
+
1343
+ export function getAuthAccess(config?: AuthConfig): AuthAccess | undefined {
1344
+ return config?.access;
1345
+ }
1346
+
1347
+ export function getAuthHandlers(config?: EdgeBaseConfig): AuthHandlers | undefined {
1348
+ return config?.auth?.handlers;
1349
+ }
1350
+
1351
+ export function getAuthEnrichHandler(
1352
+ config?: EdgeBaseConfig,
1353
+ ): AuthHandlerHooks['enrich'] | undefined {
1354
+ return getAuthHandlers(config)?.hooks?.enrich;
1355
+ }
1356
+
1357
+ export function getMailHooks(config?: EdgeBaseConfig): MailHooks | undefined {
1358
+ return getAuthHandlers(config)?.email;
1359
+ }
1360
+
1361
+ export function getRoomAccess(namespaceConfig?: RoomNamespaceConfig): RoomAccess | undefined {
1362
+ return namespaceConfig?.access;
1363
+ }
1364
+
1365
+ export function getRoomStateConfig(
1366
+ namespaceConfig?: RoomNamespaceConfig,
1367
+ ): RoomStateConfig | undefined {
1368
+ if (namespaceConfig?.state) return namespaceConfig.state;
1369
+ const handlers = namespaceConfig?.handlers;
1370
+ if (!handlers?.actions && !handlers?.timers) return undefined;
1371
+ return {
1372
+ actions: handlers.actions,
1373
+ timers: handlers.timers,
1374
+ };
1375
+ }
1376
+
1377
+ export function getRoomHooks(namespaceConfig?: RoomNamespaceConfig): RoomHooks | undefined {
1378
+ if (namespaceConfig?.hooks) return namespaceConfig.hooks;
1379
+ const handlers = namespaceConfig?.handlers;
1380
+ if (!handlers?.lifecycle) return undefined;
1381
+ return {
1382
+ lifecycle: handlers.lifecycle,
1383
+ };
1384
+ }
1385
+
1386
+ export function getRoomHandlers(namespaceConfig?: RoomNamespaceConfig): RoomHandlers | undefined {
1387
+ const state = getRoomStateConfig(namespaceConfig);
1388
+ const hooks = getRoomHooks(namespaceConfig);
1389
+ if (!state?.actions && !state?.timers && !hooks?.lifecycle) {
1390
+ return undefined;
1391
+ }
1392
+ return {
1393
+ actions: state?.actions,
1394
+ timers: state?.timers,
1395
+ lifecycle: hooks?.lifecycle,
1396
+ };
1397
+ }
1398
+
1399
+ export function getRoomLifecycleHandlers(
1400
+ namespaceConfig?: RoomNamespaceConfig,
1401
+ ): RoomLifecycleHandlers | undefined {
1402
+ return getRoomHooks(namespaceConfig)?.lifecycle;
1403
+ }
1404
+
1405
+ export function getRoomActionHandlers(
1406
+ namespaceConfig?: RoomNamespaceConfig,
1407
+ ): RoomActionHandlers | undefined {
1408
+ return getRoomStateConfig(namespaceConfig)?.actions;
1409
+ }
1410
+
1411
+ export function getRoomTimerHandlers(
1412
+ namespaceConfig?: RoomNamespaceConfig,
1413
+ ): RoomTimerHandlers | undefined {
1414
+ return getRoomStateConfig(namespaceConfig)?.timers;
1415
+ }
1416
+
1417
+ export function materializeConfig(config: EdgeBaseConfig): EdgeBaseConfig {
1418
+ if (!config || typeof config !== 'object') {
1419
+ return {};
1420
+ }
1421
+
1422
+ if ((config as EdgeBaseConfig & { [MATERIALIZED_CONFIG]?: true })[MATERIALIZED_CONFIG]) {
1423
+ return config;
1424
+ }
1425
+
1426
+ if (config.plugins?.length) {
1427
+ config.databases ??= {};
1428
+ for (const plugin of config.plugins) {
1429
+ if (!plugin.tables) continue;
1430
+ const dbKey = plugin.dbBlock ?? 'shared';
1431
+ config.databases[dbKey] ??= { tables: {} };
1432
+ config.databases[dbKey].tables ??= {};
1433
+ for (const [tableName, tableConfig] of Object.entries(plugin.tables)) {
1434
+ const namespacedTable = `${plugin.name}/${tableName}`;
1435
+ const existing = config.databases[dbKey].tables?.[namespacedTable];
1436
+ if (existing && existing !== tableConfig) {
1437
+ throw new Error(
1438
+ `Plugin table collision: '${namespacedTable}' already exists in databases.${dbKey}.tables.`,
1439
+ );
1440
+ }
1441
+ config.databases[dbKey].tables![namespacedTable] = tableConfig;
1442
+ }
1443
+ }
1444
+ }
1445
+
1446
+ assertNoLegacyConfigAliases(config);
1447
+ normalizeRoomConfig(config);
1448
+ normalizeServiceKeysShorthand(config);
1449
+
1450
+ return config;
1451
+ }
1452
+
1453
+ /**
1454
+ * Normalize `secret` shorthand on service key entries to the canonical
1455
+ * `secretSource: 'inline'` + `inlineSecret` form.
1456
+ *
1457
+ * This allows users to write:
1458
+ * { kid: 'dev', tier: 'root', scopes: ['*'], secret: 'sk-xxx' }
1459
+ * instead of:
1460
+ * { kid: 'dev', tier: 'root', scopes: ['*'], secretSource: 'inline', inlineSecret: 'sk-xxx' }
1461
+ */
1462
+ function normalizeServiceKeysShorthand(config: EdgeBaseConfig): void {
1463
+ if (!config.serviceKeys?.keys?.length) return;
1464
+ for (const entry of config.serviceKeys.keys) {
1465
+ const raw = entry as ServiceKeyEntry & { secret?: string };
1466
+ if (raw.secret && !raw.secretSource) {
1467
+ raw.secretSource = 'inline';
1468
+ raw.inlineSecret = raw.secret;
1469
+ delete raw.secret;
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
1475
+ return Object.prototype.hasOwnProperty.call(target, key);
1476
+ }
1477
+
1478
+ function legacyConfigError(path: string, guidance: string): Error {
1479
+ return new Error(`Legacy config syntax is no longer supported at ${path}. ${guidance}`);
1480
+ }
1481
+
1482
+ const MATERIALIZED_CONFIG = Symbol.for('edgebase.config.materialized');
1483
+
1484
+ function hasEquivalentRecordValues(
1485
+ left: object | undefined,
1486
+ right: object | undefined,
1487
+ ): boolean {
1488
+ if (!left || !right) return false;
1489
+ const leftRecord = left as Record<string, unknown>;
1490
+ const rightRecord = right as Record<string, unknown>;
1491
+ const leftKeys = Object.keys(leftRecord);
1492
+ const rightKeys = Object.keys(rightRecord);
1493
+ if (leftKeys.length !== rightKeys.length) return false;
1494
+ return leftKeys.every((key) => rightRecord[key] === leftRecord[key]);
1495
+ }
1496
+
1497
+ function normalizeRoomConfig(config: EdgeBaseConfig): void {
1498
+ for (const [namespace, roomConfig] of Object.entries(config.rooms ?? {})) {
1499
+ const handlers = roomConfig.handlers;
1500
+ if (!handlers) continue;
1501
+
1502
+ if (
1503
+ handlers.actions &&
1504
+ roomConfig.state?.actions &&
1505
+ !hasEquivalentRecordValues(roomConfig.state.actions, handlers.actions)
1506
+ ) {
1507
+ throw new Error(
1508
+ `rooms.${namespace} cannot define both handlers.actions and state.actions. Use the canonical state.actions shape only once.`,
1509
+ );
1510
+ }
1511
+ if (
1512
+ handlers.timers &&
1513
+ roomConfig.state?.timers &&
1514
+ !hasEquivalentRecordValues(roomConfig.state.timers, handlers.timers)
1515
+ ) {
1516
+ throw new Error(
1517
+ `rooms.${namespace} cannot define both handlers.timers and state.timers. Use the canonical state.timers shape only once.`,
1518
+ );
1519
+ }
1520
+ if (
1521
+ handlers.lifecycle &&
1522
+ roomConfig.hooks?.lifecycle &&
1523
+ !hasEquivalentRecordValues(roomConfig.hooks.lifecycle, handlers.lifecycle)
1524
+ ) {
1525
+ throw new Error(
1526
+ `rooms.${namespace} cannot define both handlers.lifecycle and hooks.lifecycle. Use the canonical hooks.lifecycle shape only once.`,
1527
+ );
1528
+ }
1529
+
1530
+ if (handlers.actions || handlers.timers) {
1531
+ roomConfig.state ??= {};
1532
+ if (!roomConfig.state.actions && handlers.actions) {
1533
+ roomConfig.state.actions = handlers.actions;
1534
+ }
1535
+ if (!roomConfig.state.timers && handlers.timers) {
1536
+ roomConfig.state.timers = handlers.timers;
1537
+ }
1538
+ }
1539
+
1540
+ if (handlers.lifecycle) {
1541
+ roomConfig.hooks ??= {};
1542
+ if (!roomConfig.hooks.lifecycle) {
1543
+ roomConfig.hooks.lifecycle = handlers.lifecycle;
1544
+ }
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ function assertNoLegacyConfigAliases(config: EdgeBaseConfig): void {
1550
+ const authConfig = config.auth as Record<string, unknown> | undefined;
1551
+ if (authConfig && hasOwnKey(authConfig, 'shardCount')) {
1552
+ throw legacyConfigError(
1553
+ 'auth.shardCount',
1554
+ 'Auth shards are fixed internally now, so remove shardCount from the config.',
1555
+ );
1556
+ }
1557
+
1558
+ const functionsConfig = config.functions as Record<string, unknown> | undefined;
1559
+ if (functionsConfig && hasOwnKey(functionsConfig, 'hookTimeout')) {
1560
+ throw legacyConfigError(
1561
+ 'functions.hookTimeout',
1562
+ 'Blocking auth/storage hook timeouts are fixed internally. Remove hookTimeout and use functions.scheduleFunctionTimeout only for scheduled functions.',
1563
+ );
1564
+ }
1565
+
1566
+ for (const [dbKey, dbBlock] of Object.entries(config.databases ?? {})) {
1567
+ const rawDbBlock = dbBlock as DbBlock & Record<string, unknown>;
1568
+ if (hasOwnKey(rawDbBlock, 'rules')) {
1569
+ throw legacyConfigError(
1570
+ `databases.${dbKey}.rules`,
1571
+ `Use databases.${dbKey}.access instead.`,
1572
+ );
1573
+ }
1574
+
1575
+ for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
1576
+ const rawTableConfig = tableConfig as TableConfig & Record<string, unknown>;
1577
+ if (hasOwnKey(rawTableConfig, 'rules')) {
1578
+ throw legacyConfigError(
1579
+ `databases.${dbKey}.tables.${tableName}.rules`,
1580
+ `Use databases.${dbKey}.tables.${tableName}.access instead.`,
1581
+ );
1582
+ }
1583
+
1584
+ for (const [fieldName, fieldConfig] of Object.entries(tableConfig.schema ?? {})) {
1585
+ if (fieldConfig === false) continue;
1586
+ const rawFieldConfig = fieldConfig as SchemaField & Record<string, unknown>;
1587
+ if (hasOwnKey(rawFieldConfig, 'ref')) {
1588
+ throw legacyConfigError(
1589
+ `databases.${dbKey}.tables.${tableName}.schema.${fieldName}.ref`,
1590
+ 'Use references instead.',
1591
+ );
1592
+ }
1593
+ }
1594
+
1595
+ for (const [index, migration] of (tableConfig.migrations ?? []).entries()) {
1596
+ if (typeof migration.description !== 'string' || migration.description.trim().length === 0) {
1597
+ throw new Error(
1598
+ `databases.${dbKey}.tables.${tableName}.migrations[${index}].description is required. ` +
1599
+ 'Add a short summary such as "Add slug column".',
1600
+ );
1601
+ }
1602
+ }
1603
+ }
1604
+ }
1605
+
1606
+ for (const [bucketName, bucketConfig] of Object.entries(config.storage?.buckets ?? {})) {
1607
+ const rawBucketConfig = bucketConfig as StorageBucketConfig & Record<string, unknown>;
1608
+ if (hasOwnKey(rawBucketConfig, 'rules')) {
1609
+ throw legacyConfigError(
1610
+ `storage.buckets.${bucketName}.rules`,
1611
+ `Use storage.buckets.${bucketName}.access instead.`,
1612
+ );
1613
+ }
1614
+ if (hasOwnKey(rawBucketConfig, 'maxFileSize')) {
1615
+ throw legacyConfigError(
1616
+ `storage.buckets.${bucketName}.maxFileSize`,
1617
+ `Validate file.size inside storage.buckets.${bucketName}.access.write instead.`,
1618
+ );
1619
+ }
1620
+ if (hasOwnKey(rawBucketConfig, 'allowedMimeTypes')) {
1621
+ throw legacyConfigError(
1622
+ `storage.buckets.${bucketName}.allowedMimeTypes`,
1623
+ `Validate file.contentType inside storage.buckets.${bucketName}.access.write instead.`,
1624
+ );
1625
+ }
1626
+ }
1627
+
1628
+ for (const [namespace, roomConfig] of Object.entries(config.rooms ?? {})) {
1629
+ const rawRoomConfig = roomConfig as RoomNamespaceConfig & Record<string, unknown>;
1630
+ if (hasOwnKey(rawRoomConfig, 'mode')) {
1631
+ throw legacyConfigError(
1632
+ `rooms.${namespace}.mode`,
1633
+ 'Room mode no longer exists. Remove the field and use handlers/access only.',
1634
+ );
1635
+ }
1636
+ if (hasOwnKey(rawRoomConfig, 'onCreate')) {
1637
+ throw legacyConfigError(
1638
+ `rooms.${namespace}.onCreate`,
1639
+ `Move it to rooms.${namespace}.handlers.lifecycle.onCreate.`,
1640
+ );
1641
+ }
1642
+ if (hasOwnKey(rawRoomConfig, 'onJoin')) {
1643
+ throw legacyConfigError(
1644
+ `rooms.${namespace}.onJoin`,
1645
+ `Move it to rooms.${namespace}.handlers.lifecycle.onJoin.`,
1646
+ );
1647
+ }
1648
+ if (hasOwnKey(rawRoomConfig, 'onLeave')) {
1649
+ throw legacyConfigError(
1650
+ `rooms.${namespace}.onLeave`,
1651
+ `Move it to rooms.${namespace}.handlers.lifecycle.onLeave.`,
1652
+ );
1653
+ }
1654
+ if (hasOwnKey(rawRoomConfig, 'onDestroy')) {
1655
+ throw legacyConfigError(
1656
+ `rooms.${namespace}.onDestroy`,
1657
+ `Move it to rooms.${namespace}.handlers.lifecycle.onDestroy.`,
1658
+ );
1659
+ }
1660
+ if (hasOwnKey(rawRoomConfig, 'onAction')) {
1661
+ throw legacyConfigError(
1662
+ `rooms.${namespace}.onAction`,
1663
+ `Move it to rooms.${namespace}.handlers.actions.`,
1664
+ );
1665
+ }
1666
+ if (hasOwnKey(rawRoomConfig, 'onTimer')) {
1667
+ throw legacyConfigError(
1668
+ `rooms.${namespace}.onTimer`,
1669
+ `Move it to rooms.${namespace}.handlers.timers.`,
1670
+ );
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ // ─── Function Definition ───
1676
+
1677
+ export type FunctionTriggerType = 'db' | 'http' | 'schedule' | 'auth' | 'storage';
1678
+
1679
+ export interface DbTrigger {
1680
+ type: 'db';
1681
+ /** Table name within the DB block. */
1682
+ table: string;
1683
+ event: 'insert' | 'update' | 'delete';
1684
+ }
1685
+
1686
+ export interface HttpTrigger {
1687
+ type: 'http';
1688
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
1689
+ path?: string;
1690
+ }
1691
+
1692
+ export interface ScheduleTrigger {
1693
+ type: 'schedule';
1694
+ cron: string;
1695
+ }
1696
+
1697
+ export interface AuthTrigger {
1698
+ type: 'auth';
1699
+ event:
1700
+ | 'beforeSignUp'
1701
+ | 'afterSignUp'
1702
+ | 'beforeSignIn'
1703
+ | 'afterSignIn'
1704
+ | 'onTokenRefresh'
1705
+ | 'beforePasswordReset'
1706
+ | 'afterPasswordReset'
1707
+ | 'beforeSignOut'
1708
+ | 'afterSignOut'
1709
+ | 'onDeleteAccount'
1710
+ | 'onEmailVerified';
1711
+ }
1712
+
1713
+ export interface StorageTrigger {
1714
+ type: 'storage';
1715
+ event:
1716
+ | 'beforeUpload'
1717
+ | 'afterUpload'
1718
+ | 'beforeDelete'
1719
+ | 'afterDelete'
1720
+ | 'beforeDownload'
1721
+ | 'onMetadataUpdate';
1722
+ }
1723
+
1724
+ export type FunctionTrigger =
1725
+ | DbTrigger
1726
+ | HttpTrigger
1727
+ | ScheduleTrigger
1728
+ | AuthTrigger
1729
+ | StorageTrigger;
1730
+
1731
+ export interface FunctionDefinition {
1732
+ trigger: FunctionTrigger;
1733
+ captcha?: boolean;
1734
+ handler: (context: unknown) => Promise<unknown>;
1735
+ }
1736
+
1737
+ /** Increment when the public plugin contract changes incompatibly. */
1738
+ export const CURRENT_PLUGIN_API_VERSION = 1;
1739
+
1740
+ export interface PluginManifest {
1741
+ /** Human-readable summary shown in CLI and docs. */
1742
+ description?: string;
1743
+ /** Canonical docs page for this plugin. */
1744
+ docsUrl?: string;
1745
+ /** Suggested config object for installation/setup tooling. */
1746
+ configTemplate?: Record<string, unknown>;
1747
+ }
1748
+
1749
+ // ─── Plugin Instance (Explicit Import Pattern) ───
1750
+
1751
+ /**
1752
+ * A plugin instance returned by a plugin factory function.
1753
+ *
1754
+ * @example
1755
+ * ```typescript
1756
+ * // In edgebase.config.ts:
1757
+ * import { stripePlugin } from '@edge-base/plugin-stripe';
1758
+ * export default defineConfig({
1759
+ * plugins: [ stripePlugin({ secretKey: process.env.STRIPE_SECRET_KEY! }) ],
1760
+ * });
1761
+ * ```
1762
+ */
1763
+ export interface PluginInstance {
1764
+ /** Plugin unique name (e.g. '@edge-base/plugin-stripe'). Used for namespacing. */
1765
+ name: string;
1766
+ /** Public plugin contract version used for compatibility checks. */
1767
+ pluginApiVersion: number;
1768
+ /** Semantic version string (e.g. '1.0.0'). Required for migration support. */
1769
+ version?: string;
1770
+ /** Manifest metadata used by CLI/docs tooling. */
1771
+ manifest?: PluginManifest;
1772
+ /** Developer-supplied plugin config (captured by factory closure). */
1773
+ config: Record<string, unknown>;
1774
+ /** Plugin tables. Keys = table names (plugin.name/ prefix added automatically by CLI). */
1775
+ tables?: Record<string, TableConfig>;
1776
+ /** DB block for plugin tables. Default: 'shared'. */
1777
+ dbBlock?: string;
1778
+ /**
1779
+ * Database provider required by this plugin.
1780
+ * Plugin developers set this based on their data characteristics.
1781
+ * - `'do'` (default): Durable Object + SQLite
1782
+ * - `'neon'`: Requires Neon PostgreSQL and a configured connection string
1783
+ * - `'postgres'`: Requires custom PostgreSQL
1784
+ */
1785
+ provider?: DbProvider;
1786
+ /** Plugin functions. Keys = function names (plugin.name/ prefix added automatically by CLI). */
1787
+ functions?: Record<string, FunctionDefinition>;
1788
+ /** Auth + storage hooks. Event name → handler. */
1789
+ hooks?: Partial<
1790
+ Record<AuthTrigger['event'] | StorageTrigger['event'], (context: unknown) => Promise<unknown>>
1791
+ >;
1792
+ /** Runs once on first deploy with this plugin (version null → version). */
1793
+ onInstall?: (context: unknown) => Promise<void>;
1794
+ /** Version-keyed migration functions. Run in semver order on deploy when version changes. */
1795
+ migrations?: Record<string, (context: unknown) => Promise<void>>;
1796
+ }
1797
+
1798
+ // ─── Helper Functions ───
1799
+
1800
+ const VALID_FIELD_TYPES: readonly string[] = [
1801
+ 'string',
1802
+ 'text',
1803
+ 'number',
1804
+ 'boolean',
1805
+ 'datetime',
1806
+ 'json',
1807
+ ];
1808
+
1809
+ /** Auto-field names that cannot be type-overridden in user schema. */
1810
+ const AUTO_FIELD_NAMES: readonly string[] = ['id', 'createdAt', 'updatedAt'];
1811
+
1812
+ /**
1813
+ * Validate table name uniqueness across all DB blocks. (§18)
1814
+ * Throws if the same table name appears in multiple DB blocks.
1815
+ */
1816
+ function validateTableUniqueness(databases: Record<string, DbBlock>): void {
1817
+ const seen = new Map<string, string>(); // tableName → dbKey
1818
+ for (const [dbKey, dbBlock] of Object.entries(databases)) {
1819
+ for (const tableName of Object.keys(dbBlock.tables ?? {})) {
1820
+ if (seen.has(tableName)) {
1821
+ throw new Error(
1822
+ `Table name '${tableName}' is duplicated in '${seen.get(tableName)}' and '${dbKey}'. ` +
1823
+ `Table names must be unique across all DB blocks.`,
1824
+ );
1825
+ }
1826
+ seen.set(tableName, dbKey);
1827
+ }
1828
+ }
1829
+ }
1830
+
1831
+ /**
1832
+ * Define an EdgeBase configuration. (§22)
1833
+ * Validates the config and throws on invalid values.
1834
+ * TypeScript functions are supported — config is bundled via esbuild (§13).
1835
+ */
1836
+ const VALID_DB_PROVIDERS: readonly DbProvider[] = ['do', 'd1', 'neon', 'postgres'];
1837
+ const VALID_AUTH_PROVIDERS: readonly AuthDbProvider[] = ['d1', 'neon', 'postgres'];
1838
+ const SERVICE_KEY_KID_PATTERN = /^[A-Za-z0-9-]+$/;
1839
+
1840
+ function validateServiceKeysConfig(serviceKeys: ServiceKeysConfig): void {
1841
+ const seenKids = new Set<string>();
1842
+
1843
+ for (const [index, entry] of serviceKeys.keys.entries()) {
1844
+ if (!entry.kid || typeof entry.kid !== 'string') {
1845
+ throw new Error(`serviceKeys.keys[${index}].kid is required and must be a string.`);
1846
+ }
1847
+
1848
+ if (!SERVICE_KEY_KID_PATTERN.test(entry.kid)) {
1849
+ throw new Error(
1850
+ `serviceKeys.keys[${index}].kid '${entry.kid}' is invalid. ` +
1851
+ `Use letters, numbers, and hyphens only. ` +
1852
+ `Underscore is reserved by the structured key format 'jb_{kid}_{secret}'.`,
1853
+ );
1854
+ }
1855
+
1856
+ if (seenKids.has(entry.kid)) {
1857
+ throw new Error(`Duplicate Service Key kid '${entry.kid}'. Each serviceKeys.keys entry must be unique.`);
1858
+ }
1859
+ seenKids.add(entry.kid);
1860
+
1861
+ if (entry.secretSource === 'dashboard' && (!entry.secretRef || typeof entry.secretRef !== 'string')) {
1862
+ throw new Error(
1863
+ `serviceKeys.keys[${index}] (${entry.kid}): secretSource 'dashboard' requires a non-empty secretRef.`,
1864
+ );
1865
+ }
1866
+
1867
+ if (entry.secretSource === 'inline' && (!entry.inlineSecret || typeof entry.inlineSecret !== 'string')) {
1868
+ throw new Error(
1869
+ `serviceKeys.keys[${index}] (${entry.kid}): secretSource 'inline' requires a non-empty inlineSecret.`,
1870
+ );
1871
+ }
1872
+ }
1873
+ }
1874
+
1875
+ function validateCloudflareConfig(cloudflare: CloudflareConfig): void {
1876
+ if (cloudflare.extraCrons === undefined) return;
1877
+ if (!Array.isArray(cloudflare.extraCrons)) {
1878
+ throw new Error('cloudflare.extraCrons must be an array of cron strings.');
1879
+ }
1880
+
1881
+ for (const [index, cron] of cloudflare.extraCrons.entries()) {
1882
+ if (typeof cron !== 'string' || cron.trim().length === 0) {
1883
+ throw new Error(`cloudflare.extraCrons[${index}] must be a non-empty cron string.`);
1884
+ }
1885
+ }
1886
+ }
1887
+
1888
+ export function defineConfig(config: EdgeBaseConfig): EdgeBaseConfig {
1889
+ config = materializeConfig(config);
1890
+
1891
+ if (config.trustSelfHostedProxy !== undefined && typeof config.trustSelfHostedProxy !== 'boolean') {
1892
+ throw new Error('trustSelfHostedProxy must be a boolean.');
1893
+ }
1894
+
1895
+ if (config.serviceKeys?.keys?.length) {
1896
+ validateServiceKeysConfig(config.serviceKeys);
1897
+ }
1898
+ if (config.cloudflare) {
1899
+ validateCloudflareConfig(config.cloudflare);
1900
+ }
1901
+
1902
+ // ─── Auth Provider Validation ───
1903
+ if (config.auth?.provider) {
1904
+ if (!VALID_AUTH_PROVIDERS.includes(config.auth.provider)) {
1905
+ throw new Error(
1906
+ `auth.provider: invalid value '${config.auth.provider}'. Must be one of: ${VALID_AUTH_PROVIDERS.join(', ')}.`,
1907
+ );
1908
+ }
1909
+ }
1910
+
1911
+ if (config.auth?.provider === 'neon' || config.auth?.provider === 'postgres') {
1912
+ if (!config.auth.connectionString) {
1913
+ throw new Error(
1914
+ `auth.provider '${config.auth.provider}' requires a connectionString (env variable name). ` +
1915
+ `Example: connectionString: 'AUTH_POSTGRES_URL'`,
1916
+ );
1917
+ }
1918
+ }
1919
+
1920
+ if ((config.auth?.provider === 'd1' || !config.auth?.provider) && config.auth?.connectionString) {
1921
+ throw new Error(
1922
+ `auth.connectionString is not used with provider '${config.auth?.provider ?? 'd1'}'. ` +
1923
+ `Remove connectionString or change auth.provider to 'neon' or 'postgres'.`,
1924
+ );
1925
+ }
1926
+
1927
+ // ─── DB Block Validation ───
1928
+ if (config.databases) {
1929
+ // Validate provider settings for each DB block
1930
+ for (const [dbKey, dbBlock] of Object.entries(config.databases)) {
1931
+ const provider = dbBlock.provider ?? 'do';
1932
+
1933
+ // Provider value validation
1934
+ if (!VALID_DB_PROVIDERS.includes(provider)) {
1935
+ throw new Error(
1936
+ `DB '${dbKey}': invalid provider '${provider}'. Must be one of: ${VALID_DB_PROVIDERS.join(', ')}.`,
1937
+ );
1938
+ }
1939
+
1940
+ // Multi-tenant blocks (canCreate/access rules or instance: true) cannot use non-DO providers
1941
+ const isDynamic = !!(dbBlock.access?.canCreate || dbBlock.access?.access || dbBlock.instance);
1942
+ if (isDynamic && provider !== 'do') {
1943
+ throw new Error(
1944
+ `DB '${dbKey}': provider '${provider}' is not supported on multi-tenant blocks ` +
1945
+ `(blocks with canCreate/access rules or instance: true). Multi-tenant blocks require ` +
1946
+ `physical isolation via Durable Objects. Remove the provider field or use provider: 'do'.`,
1947
+ );
1948
+ }
1949
+
1950
+ // connectionString only valid for PostgreSQL providers
1951
+ if ((provider === 'do' || provider === 'd1') && dbBlock.connectionString) {
1952
+ throw new Error(
1953
+ `DB '${dbKey}': connectionString is not used with provider '${provider}'. ` +
1954
+ `Remove connectionString or change provider to 'neon' or 'postgres'.`,
1955
+ );
1956
+ }
1957
+
1958
+ const instanceDiscovery = dbBlock.admin?.instances;
1959
+ if (instanceDiscovery) {
1960
+ if (!isDynamic) {
1961
+ throw new Error(
1962
+ `DB '${dbKey}': admin.instances is only supported on dynamic namespaces ` +
1963
+ `(blocks with canCreate/access rules or instance: true).`,
1964
+ );
1965
+ }
1966
+
1967
+ if (instanceDiscovery.placeholder !== undefined && typeof instanceDiscovery.placeholder !== 'string') {
1968
+ throw new Error(`DB '${dbKey}': admin.instances.placeholder must be a string.`);
1969
+ }
1970
+ if (instanceDiscovery.helperText !== undefined && typeof instanceDiscovery.helperText !== 'string') {
1971
+ throw new Error(`DB '${dbKey}': admin.instances.helperText must be a string.`);
1972
+ }
1973
+ if (instanceDiscovery.targetLabel !== undefined && typeof instanceDiscovery.targetLabel !== 'string') {
1974
+ throw new Error(`DB '${dbKey}': admin.instances.targetLabel must be a string.`);
1975
+ }
1976
+
1977
+ if (instanceDiscovery.source === 'manual') {
1978
+ // No additional validation.
1979
+ } else if (instanceDiscovery.source === 'table') {
1980
+ if (!instanceDiscovery.namespace || typeof instanceDiscovery.namespace !== 'string') {
1981
+ throw new Error(`DB '${dbKey}': admin.instances.namespace is required when source is 'table'.`);
1982
+ }
1983
+ if (!instanceDiscovery.table || typeof instanceDiscovery.table !== 'string') {
1984
+ throw new Error(`DB '${dbKey}': admin.instances.table is required when source is 'table'.`);
1985
+ }
1986
+ if (instanceDiscovery.idField !== undefined && typeof instanceDiscovery.idField !== 'string') {
1987
+ throw new Error(`DB '${dbKey}': admin.instances.idField must be a string.`);
1988
+ }
1989
+ if (instanceDiscovery.labelField !== undefined && typeof instanceDiscovery.labelField !== 'string') {
1990
+ throw new Error(`DB '${dbKey}': admin.instances.labelField must be a string.`);
1991
+ }
1992
+ if (instanceDiscovery.descriptionField !== undefined && typeof instanceDiscovery.descriptionField !== 'string') {
1993
+ throw new Error(`DB '${dbKey}': admin.instances.descriptionField must be a string.`);
1994
+ }
1995
+ if (instanceDiscovery.orderBy !== undefined && typeof instanceDiscovery.orderBy !== 'string') {
1996
+ throw new Error(`DB '${dbKey}': admin.instances.orderBy must be a string.`);
1997
+ }
1998
+ if (instanceDiscovery.limit !== undefined) {
1999
+ if (!Number.isInteger(instanceDiscovery.limit) || instanceDiscovery.limit < 1 || instanceDiscovery.limit > 100) {
2000
+ throw new Error(`DB '${dbKey}': admin.instances.limit must be an integer between 1 and 100.`);
2001
+ }
2002
+ }
2003
+ if (instanceDiscovery.searchFields !== undefined) {
2004
+ if (
2005
+ !Array.isArray(instanceDiscovery.searchFields) ||
2006
+ instanceDiscovery.searchFields.length === 0 ||
2007
+ instanceDiscovery.searchFields.some((field) => typeof field !== 'string' || field.length === 0)
2008
+ ) {
2009
+ throw new Error(`DB '${dbKey}': admin.instances.searchFields must be a non-empty string array.`);
2010
+ }
2011
+ }
2012
+
2013
+ const sourceDbBlock = config.databases?.[instanceDiscovery.namespace];
2014
+ if (!sourceDbBlock) {
2015
+ throw new Error(
2016
+ `DB '${dbKey}': admin.instances.namespace '${instanceDiscovery.namespace}' was not found in databases.`,
2017
+ );
2018
+ }
2019
+ const sourceIsDynamic = !!(sourceDbBlock.access?.canCreate || sourceDbBlock.access?.access || sourceDbBlock.instance);
2020
+ if (sourceIsDynamic) {
2021
+ throw new Error(
2022
+ `DB '${dbKey}': admin.instances.namespace '${instanceDiscovery.namespace}' must be a single-instance namespace when source is 'table'.`,
2023
+ );
2024
+ }
2025
+ if (!sourceDbBlock.tables?.[instanceDiscovery.table]) {
2026
+ throw new Error(
2027
+ `DB '${dbKey}': admin.instances.table '${instanceDiscovery.table}' was not found in namespace '${instanceDiscovery.namespace}'.`,
2028
+ );
2029
+ }
2030
+ } else if (instanceDiscovery.source === 'function') {
2031
+ if (typeof instanceDiscovery.resolve !== 'function') {
2032
+ throw new Error(`DB '${dbKey}': admin.instances.resolve must be a function when source is 'function'.`);
2033
+ }
2034
+ } else {
2035
+ const unexpectedSource = (instanceDiscovery as { source?: unknown }).source;
2036
+ throw new Error(
2037
+ `DB '${dbKey}': admin.instances.source '${String(unexpectedSource)}' is invalid. ` +
2038
+ `Must be one of: manual, table, function.`,
2039
+ );
2040
+ }
2041
+ }
2042
+ }
2043
+
2044
+ // Validate each DB block's table schemas
2045
+ for (const [dbKey, dbBlock] of Object.entries(config.databases)) {
2046
+ for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
2047
+ if (!tableConfig.schema) continue;
2048
+ for (const [field, def] of Object.entries(tableConfig.schema)) {
2049
+ // Auto-fields: only `false` (disable) is allowed, type override is blocked
2050
+ if (AUTO_FIELD_NAMES.includes(field)) {
2051
+ if (def !== false) {
2052
+ throw new Error(
2053
+ `DB '${dbKey}' table '${tableName}.${field}': auto-field '${field}' cannot be type-overridden. ` +
2054
+ `Use '${field}: false' to disable it, or omit it to use the default.`,
2055
+ );
2056
+ }
2057
+ continue;
2058
+ }
2059
+ if (def === false) continue;
2060
+ if (!def.type) {
2061
+ throw new Error(`DB '${dbKey}' table '${tableName}.${field}': 'type' is required.`);
2062
+ }
2063
+ if (!VALID_FIELD_TYPES.includes(def.type)) {
2064
+ throw new Error(
2065
+ `DB '${dbKey}' table '${tableName}.${field}': invalid type '${def.type}'. ` +
2066
+ `Must be one of: ${VALID_FIELD_TYPES.join(', ')}.`,
2067
+ );
2068
+ }
2069
+ }
2070
+ }
2071
+ }
2072
+
2073
+ // Validate table name uniqueness (§18)
2074
+ validateTableUniqueness(config.databases);
2075
+ }
2076
+
2077
+ // ─── Plugin Validation ───
2078
+ if (config.plugins) {
2079
+ if (!Array.isArray(config.plugins)) {
2080
+ throw new Error('plugins must be an array. Example: plugins: [stripePlugin({...})]');
2081
+ }
2082
+ const seen = new Set<string>();
2083
+ for (const p of config.plugins) {
2084
+ if (!p.name) throw new Error('Each plugin must have a "name" property.');
2085
+ if (p.pluginApiVersion !== CURRENT_PLUGIN_API_VERSION) {
2086
+ throw new Error(
2087
+ `Plugin '${p.name}' targets pluginApiVersion '${String(p.pluginApiVersion)}', ` +
2088
+ `but this EdgeBase build requires '${CURRENT_PLUGIN_API_VERSION}'. ` +
2089
+ `Rebuild the plugin against the current @edge-base/plugin-core version.`,
2090
+ );
2091
+ }
2092
+ if (seen.has(p.name)) throw new Error(`Duplicate plugin: '${p.name}'.`);
2093
+ seen.add(p.name);
2094
+ }
2095
+
2096
+ // Validate provider consistency: plugin provider must match its target dbBlock provider
2097
+ for (const p of config.plugins) {
2098
+ if (!p.provider || !p.dbBlock) continue;
2099
+ const targetBlock = config.databases?.[p.dbBlock];
2100
+ if (!targetBlock) continue; // dbBlock may not exist yet (created by merge)
2101
+ const blockProvider = targetBlock.provider ?? 'do';
2102
+ const pluginProvider = p.provider;
2103
+ if (blockProvider !== pluginProvider) {
2104
+ throw new Error(
2105
+ `Plugin '${p.name}' requires provider '${pluginProvider}' but DB block '${p.dbBlock}' ` +
2106
+ `uses provider '${blockProvider}'. All plugins targeting the same DB block must use the same provider.`,
2107
+ );
2108
+ }
2109
+ }
2110
+ }
2111
+
2112
+ // ─── Room Config Validation ───
2113
+ if (config.rooms) {
2114
+ // Detect v1 config structure and provide migration hint
2115
+ if (
2116
+ (config.rooms as Record<string, unknown>).rooms &&
2117
+ typeof (config.rooms as Record<string, unknown>).rooms === 'object'
2118
+ ) {
2119
+ throw new Error(
2120
+ 'Room config has changed in v2. Remove the nested "rooms" key — namespaces are now top-level:\n' +
2121
+ ' Before: rooms: { rooms: { "game:*": { mode: "direct" } } }\n' +
2122
+ ' After: rooms: { "game": { handlers: { actions: { ... } } } }',
2123
+ );
2124
+ }
2125
+ for (const [ns, def] of Object.entries(config.rooms)) {
2126
+ if (def.maxPlayers !== undefined && (def.maxPlayers < 1 || def.maxPlayers > 32768)) {
2127
+ throw new Error(`Room namespace '${ns}': maxPlayers must be between 1 and 32768.`);
2128
+ }
2129
+ if (def.maxStateSize !== undefined && def.maxStateSize < 1024) {
2130
+ throw new Error(`Room namespace '${ns}': maxStateSize must be at least 1024 bytes (1KB).`);
2131
+ }
2132
+ if (def.reconnectTimeout !== undefined && def.reconnectTimeout < 0) {
2133
+ throw new Error(`Room namespace '${ns}': reconnectTimeout must be non-negative.`);
2134
+ }
2135
+ if (def.rateLimit?.actions !== undefined && def.rateLimit.actions < 1) {
2136
+ throw new Error(`Room namespace '${ns}': rateLimit.actions must be at least 1.`);
2137
+ }
2138
+ if (def.handlers?.timers !== undefined) {
2139
+ if (typeof def.handlers.timers !== 'object' || def.handlers.timers === null) {
2140
+ throw new Error(
2141
+ `Room namespace '${ns}': handlers.timers must be an object of named handlers.`,
2142
+ );
2143
+ }
2144
+ for (const timerName of Object.keys(def.handlers.timers)) {
2145
+ if (typeof def.handlers.timers[timerName] !== 'function') {
2146
+ throw new Error(
2147
+ `Room namespace '${ns}': handlers.timers['${timerName}'] must be a function.`,
2148
+ );
2149
+ }
2150
+ }
2151
+ }
2152
+ }
2153
+ }
2154
+
2155
+ Object.defineProperty(config, MATERIALIZED_CONFIG, {
2156
+ value: true,
2157
+ enumerable: false,
2158
+ configurable: false,
2159
+ });
2160
+
2161
+ return config;
2162
+ }
2163
+
2164
+ /**
2165
+ * Define an App Function.
2166
+ *
2167
+ * @example
2168
+ * // Full definition (DB trigger, schedule, auth hook, or HTTP with explicit trigger)
2169
+ * export default defineFunction({
2170
+ * trigger: { type: 'db', table: 'posts', event: 'insert' },
2171
+ * handler: async ({ data, admin }) => { ... },
2172
+ * });
2173
+ *
2174
+ * // Method-export style (HTTP functions — trigger auto-inferred from export name)
2175
+ * export const GET = defineFunction(async ({ params, admin }) => { ... });
2176
+ * export const POST = defineFunction(async ({ params, auth, admin }) => { ... });
2177
+ */
2178
+ export function defineFunction(definition: FunctionDefinition): FunctionDefinition;
2179
+ export function defineFunction(handler: (context: unknown) => Promise<unknown>): FunctionDefinition;
2180
+ export function defineFunction(
2181
+ defOrHandler: FunctionDefinition | ((context: unknown) => Promise<unknown>),
2182
+ ): FunctionDefinition {
2183
+ if (typeof defOrHandler === 'function') {
2184
+ // Method-export form: trigger filled by CLI registry generator (wrapMethodExport)
2185
+ return { trigger: { type: 'http' } as HttpTrigger, handler: defOrHandler };
2186
+ }
2187
+ return defOrHandler;
2188
+ }