@autumnsgrove/groveengine 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/auth/session.d.ts +2 -2
  2. package/dist/components/admin/FloatingToolbar.svelte +373 -0
  3. package/dist/components/admin/FloatingToolbar.svelte.d.ts +17 -0
  4. package/dist/components/admin/MarkdownEditor.svelte +26 -347
  5. package/dist/components/admin/MarkdownEditor.svelte.d.ts +1 -1
  6. package/dist/components/admin/composables/index.d.ts +0 -2
  7. package/dist/components/admin/composables/index.js +0 -2
  8. package/dist/components/custom/MobileTOC.svelte +20 -13
  9. package/dist/components/quota/UpgradePrompt.svelte +1 -1
  10. package/dist/server/services/database.d.ts +138 -0
  11. package/dist/server/services/database.js +234 -0
  12. package/dist/server/services/index.d.ts +5 -1
  13. package/dist/server/services/index.js +24 -2
  14. package/dist/server/services/turnstile.d.ts +66 -0
  15. package/dist/server/services/turnstile.js +131 -0
  16. package/dist/server/services/users.d.ts +104 -0
  17. package/dist/server/services/users.js +158 -0
  18. package/dist/styles/README.md +50 -0
  19. package/dist/styles/vine-pattern.css +24 -0
  20. package/dist/types/turnstile.d.ts +42 -0
  21. package/dist/ui/components/forms/TurnstileWidget.svelte +111 -0
  22. package/dist/ui/components/forms/TurnstileWidget.svelte.d.ts +14 -0
  23. package/dist/ui/components/primitives/dialog/dialog-overlay.svelte +1 -1
  24. package/dist/ui/components/primitives/sheet/sheet-overlay.svelte +1 -1
  25. package/dist/ui/components/ui/Logo.svelte +161 -23
  26. package/dist/ui/components/ui/Logo.svelte.d.ts +4 -10
  27. package/dist/ui/tokens/fonts.d.ts +69 -0
  28. package/dist/ui/tokens/fonts.js +341 -0
  29. package/dist/ui/tokens/index.d.ts +6 -5
  30. package/dist/ui/tokens/index.js +7 -6
  31. package/package.json +22 -21
  32. package/static/fonts/alagard.ttf +0 -0
  33. package/static/robots.txt +487 -0
  34. package/LICENSE +0 -378
  35. package/dist/components/admin/composables/useCommandPalette.svelte.d.ts +0 -87
  36. package/dist/components/admin/composables/useCommandPalette.svelte.js +0 -158
  37. package/dist/components/admin/composables/useSlashCommands.svelte.d.ts +0 -104
  38. package/dist/components/admin/composables/useSlashCommands.svelte.js +0 -215
@@ -234,3 +234,141 @@ export declare function exists(db: D1DatabaseOrSession, table: string, where: st
234
234
  * ```
235
235
  */
236
236
  export declare function count(db: D1DatabaseOrSession, table: string, where?: string, whereParams?: unknown[]): Promise<number>;
237
+ /**
238
+ * Error thrown when tenant context is missing or invalid
239
+ */
240
+ export declare class TenantContextError extends Error {
241
+ constructor(message: string);
242
+ }
243
+ /**
244
+ * Tenant-scoped database context
245
+ * Ensures all queries are automatically scoped to a specific tenant
246
+ */
247
+ export interface TenantContext {
248
+ /** The tenant ID for this context */
249
+ tenantId: string;
250
+ /** Optional user ID for audit logging */
251
+ userId?: string;
252
+ }
253
+ /**
254
+ * Tenant-aware database wrapper
255
+ *
256
+ * This wrapper enforces tenant isolation by:
257
+ * 1. Automatically adding tenant_id to all INSERT operations
258
+ * 2. Requiring tenant_id in all SELECT/UPDATE/DELETE WHERE clauses
259
+ * 3. Validating tenant_id in query results
260
+ *
261
+ * SECURITY: This is a critical security boundary. All multi-tenant data access
262
+ * MUST go through this wrapper to prevent cross-tenant data leaks.
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * // In your API route or server load function:
267
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
268
+ *
269
+ * // All queries are now automatically scoped to this tenant
270
+ * const posts = await tenantDb.queryMany<Post>('posts', 'status = ?', ['published']);
271
+ * // Equivalent to: SELECT * FROM posts WHERE tenant_id = ? AND status = ?
272
+ * ```
273
+ */
274
+ export declare class TenantDb {
275
+ private db;
276
+ private context;
277
+ constructor(db: D1DatabaseOrSession, context: TenantContext);
278
+ /**
279
+ * Get the tenant ID for this context
280
+ */
281
+ get tenantId(): string;
282
+ /**
283
+ * Query a single row with automatic tenant scoping
284
+ */
285
+ queryOne<T>(table: string, where?: string, whereParams?: unknown[]): Promise<T | null>;
286
+ /**
287
+ * Query a single row, throw if not found
288
+ */
289
+ queryOneOrThrow<T>(table: string, where?: string, whereParams?: unknown[], errorMessage?: string): Promise<T>;
290
+ /**
291
+ * Query multiple rows with automatic tenant scoping
292
+ */
293
+ queryMany<T>(table: string, where?: string, whereParams?: unknown[], options?: {
294
+ orderBy?: string;
295
+ limit?: number;
296
+ offset?: number;
297
+ }): Promise<T[]>;
298
+ /**
299
+ * Insert a row with automatic tenant_id injection
300
+ */
301
+ insert(table: string, data: Record<string, unknown>, options?: {
302
+ id?: string;
303
+ }): Promise<string>;
304
+ /**
305
+ * Update rows with automatic tenant scoping
306
+ * The WHERE clause is automatically combined with tenant_id check
307
+ */
308
+ update(table: string, data: Record<string, unknown>, where: string, whereParams?: unknown[]): Promise<number>;
309
+ /**
310
+ * Update a row by ID with tenant scoping
311
+ */
312
+ updateById(table: string, id: string, data: Record<string, unknown>): Promise<boolean>;
313
+ /**
314
+ * Delete rows with automatic tenant scoping
315
+ */
316
+ delete(table: string, where: string, whereParams?: unknown[]): Promise<number>;
317
+ /**
318
+ * Delete a row by ID with tenant scoping
319
+ */
320
+ deleteById(table: string, id: string): Promise<boolean>;
321
+ /**
322
+ * Check if a row exists with tenant scoping
323
+ */
324
+ exists(table: string, where: string, whereParams?: unknown[]): Promise<boolean>;
325
+ /**
326
+ * Count rows with tenant scoping
327
+ */
328
+ count(table: string, where?: string, whereParams?: unknown[]): Promise<number>;
329
+ /**
330
+ * Execute a raw query with tenant scoping
331
+ *
332
+ * WARNING: You must ensure the query includes tenant_id filtering.
333
+ * This method validates that 'tenant_id' appears in the SQL.
334
+ */
335
+ rawQuery<T>(sql: string, params?: unknown[]): Promise<T[]>;
336
+ /**
337
+ * Execute a raw statement with tenant scoping validation
338
+ *
339
+ * WARNING: You must ensure the statement includes tenant_id filtering.
340
+ */
341
+ rawExecute(sql: string, params?: unknown[]): Promise<ExecuteResult>;
342
+ }
343
+ /**
344
+ * Create a tenant-scoped database wrapper
345
+ *
346
+ * This is the primary entry point for multi-tenant database access.
347
+ * All data operations for tenant-specific tables MUST use this wrapper.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * // In a SvelteKit load function:
352
+ * export async function load({ platform, locals }) {
353
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
354
+ *
355
+ * const posts = await tenantDb.queryMany<Post>('posts', 'status = ?', ['published']);
356
+ * return { posts };
357
+ * }
358
+ *
359
+ * // In an API route:
360
+ * export async function POST({ request, platform, locals }) {
361
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
362
+ *
363
+ * const data = await request.json();
364
+ * const postId = await tenantDb.insert('posts', {
365
+ * title: data.title,
366
+ * content: data.content,
367
+ * status: 'draft'
368
+ * });
369
+ *
370
+ * return json({ id: postId });
371
+ * }
372
+ * ```
373
+ */
374
+ export declare function getTenantDb(db: D1DatabaseOrSession, context: TenantContext): TenantDb;
@@ -448,3 +448,237 @@ export async function count(db, table, where, whereParams = []) {
448
448
  throw new DatabaseError(`Count on ${table} failed`, 'QUERY_FAILED', err);
449
449
  }
450
450
  }
451
+ // ============================================================================
452
+ // Multi-Tenant Database Wrapper
453
+ // ============================================================================
454
+ /**
455
+ * Error thrown when tenant context is missing or invalid
456
+ */
457
+ export class TenantContextError extends Error {
458
+ constructor(message) {
459
+ super(message);
460
+ this.name = 'TenantContextError';
461
+ }
462
+ }
463
+ /**
464
+ * Tenant-aware database wrapper
465
+ *
466
+ * This wrapper enforces tenant isolation by:
467
+ * 1. Automatically adding tenant_id to all INSERT operations
468
+ * 2. Requiring tenant_id in all SELECT/UPDATE/DELETE WHERE clauses
469
+ * 3. Validating tenant_id in query results
470
+ *
471
+ * SECURITY: This is a critical security boundary. All multi-tenant data access
472
+ * MUST go through this wrapper to prevent cross-tenant data leaks.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * // In your API route or server load function:
477
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
478
+ *
479
+ * // All queries are now automatically scoped to this tenant
480
+ * const posts = await tenantDb.queryMany<Post>('posts', 'status = ?', ['published']);
481
+ * // Equivalent to: SELECT * FROM posts WHERE tenant_id = ? AND status = ?
482
+ * ```
483
+ */
484
+ export class TenantDb {
485
+ db;
486
+ context;
487
+ constructor(db, context) {
488
+ if (!context.tenantId) {
489
+ throw new TenantContextError('Tenant ID is required for database operations');
490
+ }
491
+ this.db = db;
492
+ this.context = context;
493
+ }
494
+ /**
495
+ * Get the tenant ID for this context
496
+ */
497
+ get tenantId() {
498
+ return this.context.tenantId;
499
+ }
500
+ /**
501
+ * Query a single row with automatic tenant scoping
502
+ */
503
+ async queryOne(table, where, whereParams = []) {
504
+ validateTableName(table);
505
+ const tenantWhere = where
506
+ ? `tenant_id = ? AND (${where})`
507
+ : 'tenant_id = ?';
508
+ const params = [this.context.tenantId, ...whereParams];
509
+ const sql = `SELECT * FROM ${table} WHERE ${tenantWhere} LIMIT 1`;
510
+ return queryOne(this.db, sql, params);
511
+ }
512
+ /**
513
+ * Query a single row, throw if not found
514
+ */
515
+ async queryOneOrThrow(table, where, whereParams = [], errorMessage = 'Record not found') {
516
+ const result = await this.queryOne(table, where, whereParams);
517
+ if (result === null) {
518
+ throw new DatabaseError(errorMessage, 'NOT_FOUND');
519
+ }
520
+ return result;
521
+ }
522
+ /**
523
+ * Query multiple rows with automatic tenant scoping
524
+ */
525
+ async queryMany(table, where, whereParams = [], options) {
526
+ validateTableName(table);
527
+ const tenantWhere = where
528
+ ? `tenant_id = ? AND (${where})`
529
+ : 'tenant_id = ?';
530
+ const params = [this.context.tenantId, ...whereParams];
531
+ let sql = `SELECT * FROM ${table} WHERE ${tenantWhere}`;
532
+ if (options?.orderBy) {
533
+ // Strict validation for ORDER BY - only allow column names and ASC/DESC
534
+ const orderParts = options.orderBy.split(/\s+/);
535
+ if (orderParts.length > 2) {
536
+ throw new DatabaseError(`Invalid ORDER BY clause: too many parts`, 'VALIDATION_ERROR');
537
+ }
538
+ validateColumnName(orderParts[0]);
539
+ const direction = orderParts[1]?.toUpperCase();
540
+ if (direction && direction !== 'ASC' && direction !== 'DESC') {
541
+ throw new DatabaseError(`Invalid ORDER BY direction: ${direction}`, 'VALIDATION_ERROR');
542
+ }
543
+ sql += ` ORDER BY ${orderParts[0]}${direction ? ` ${direction}` : ''}`;
544
+ }
545
+ if (options?.limit !== undefined) {
546
+ // Clamp to reasonable bounds to prevent Infinity or excessive values
547
+ const limit = Math.max(0, Math.min(Math.floor(options.limit), 1000));
548
+ sql += ` LIMIT ${limit}`;
549
+ }
550
+ if (options?.offset !== undefined) {
551
+ // Clamp to reasonable bounds to prevent Infinity or excessive values
552
+ const offset = Math.max(0, Math.min(Math.floor(options.offset), 100000));
553
+ sql += ` OFFSET ${offset}`;
554
+ }
555
+ return queryMany(this.db, sql, params);
556
+ }
557
+ /**
558
+ * Insert a row with automatic tenant_id injection
559
+ */
560
+ async insert(table, data, options) {
561
+ const dataWithTenant = {
562
+ ...data,
563
+ tenant_id: this.context.tenantId
564
+ };
565
+ return insert(this.db, table, dataWithTenant, options);
566
+ }
567
+ /**
568
+ * Update rows with automatic tenant scoping
569
+ * The WHERE clause is automatically combined with tenant_id check
570
+ */
571
+ async update(table, data, where, whereParams = []) {
572
+ validateTableName(table);
573
+ const tenantWhere = `tenant_id = ? AND (${where})`;
574
+ const params = [this.context.tenantId, ...whereParams];
575
+ return update(this.db, table, data, tenantWhere, params);
576
+ }
577
+ /**
578
+ * Update a row by ID with tenant scoping
579
+ */
580
+ async updateById(table, id, data) {
581
+ const changes = await this.update(table, data, 'id = ?', [id]);
582
+ return changes > 0;
583
+ }
584
+ /**
585
+ * Delete rows with automatic tenant scoping
586
+ */
587
+ async delete(table, where, whereParams = []) {
588
+ validateTableName(table);
589
+ const tenantWhere = `tenant_id = ? AND (${where})`;
590
+ const params = [this.context.tenantId, ...whereParams];
591
+ return deleteWhere(this.db, table, tenantWhere, params);
592
+ }
593
+ /**
594
+ * Delete a row by ID with tenant scoping
595
+ */
596
+ async deleteById(table, id) {
597
+ const changes = await this.delete(table, 'id = ?', [id]);
598
+ return changes > 0;
599
+ }
600
+ /**
601
+ * Check if a row exists with tenant scoping
602
+ */
603
+ async exists(table, where, whereParams = []) {
604
+ validateTableName(table);
605
+ const tenantWhere = `tenant_id = ? AND (${where})`;
606
+ const params = [this.context.tenantId, ...whereParams];
607
+ return exists(this.db, table, tenantWhere, params);
608
+ }
609
+ /**
610
+ * Count rows with tenant scoping
611
+ */
612
+ async count(table, where, whereParams = []) {
613
+ validateTableName(table);
614
+ const tenantWhere = where
615
+ ? `tenant_id = ? AND (${where})`
616
+ : 'tenant_id = ?';
617
+ const params = [this.context.tenantId, ...whereParams];
618
+ return count(this.db, table, tenantWhere, params);
619
+ }
620
+ /**
621
+ * Execute a raw query with tenant scoping
622
+ *
623
+ * WARNING: You must ensure the query includes tenant_id filtering.
624
+ * This method validates that 'tenant_id' appears in the SQL.
625
+ */
626
+ async rawQuery(sql, params = []) {
627
+ if (!sql.toLowerCase().includes('tenant_id')) {
628
+ throw new TenantContextError('Raw queries must include tenant_id filtering. Use the scoped methods or add tenant_id to your WHERE clause.');
629
+ }
630
+ return queryMany(this.db, sql, params);
631
+ }
632
+ /**
633
+ * Execute a raw statement with tenant scoping validation
634
+ *
635
+ * WARNING: You must ensure the statement includes tenant_id filtering.
636
+ */
637
+ async rawExecute(sql, params = []) {
638
+ const sqlLower = sql.toLowerCase();
639
+ // INSERT statements should have tenant_id in the columns
640
+ // UPDATE/DELETE statements should have tenant_id in WHERE
641
+ if (sqlLower.startsWith('insert') && !sqlLower.includes('tenant_id')) {
642
+ throw new TenantContextError('INSERT statements must include tenant_id. Use the insert() method instead.');
643
+ }
644
+ if ((sqlLower.startsWith('update') || sqlLower.startsWith('delete')) &&
645
+ !sqlLower.includes('tenant_id')) {
646
+ throw new TenantContextError('UPDATE/DELETE statements must include tenant_id in WHERE clause. Use the scoped methods instead.');
647
+ }
648
+ return execute(this.db, sql, params);
649
+ }
650
+ }
651
+ /**
652
+ * Create a tenant-scoped database wrapper
653
+ *
654
+ * This is the primary entry point for multi-tenant database access.
655
+ * All data operations for tenant-specific tables MUST use this wrapper.
656
+ *
657
+ * @example
658
+ * ```ts
659
+ * // In a SvelteKit load function:
660
+ * export async function load({ platform, locals }) {
661
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
662
+ *
663
+ * const posts = await tenantDb.queryMany<Post>('posts', 'status = ?', ['published']);
664
+ * return { posts };
665
+ * }
666
+ *
667
+ * // In an API route:
668
+ * export async function POST({ request, platform, locals }) {
669
+ * const tenantDb = getTenantDb(platform.env.DB, { tenantId: locals.tenant.id });
670
+ *
671
+ * const data = await request.json();
672
+ * const postId = await tenantDb.insert('posts', {
673
+ * title: data.title,
674
+ * content: data.content,
675
+ * status: 'draft'
676
+ * });
677
+ *
678
+ * return json({ id: postId });
679
+ * }
680
+ * ```
681
+ */
682
+ export function getTenantDb(db, context) {
683
+ return new TenantDb(db, context);
684
+ }
@@ -29,6 +29,10 @@
29
29
  export * as storage from './storage.js';
30
30
  export { type StorageFile, type UploadOptions, type GetFileResult, type FileMetadata, StorageError, type StorageErrorCode, uploadFile, getFile, getFileMetadata, fileExists, deleteFile, deleteFileByKey, getFileRecord, getFileRecordByKey, listFiles, listAllFiles, listFolders, updateAltText, validateFile, isAllowedContentType, shouldReturn304, buildFileHeaders } from './storage.js';
31
31
  export * as db from './database.js';
32
- export { type D1DatabaseOrSession, type QueryMeta, type ExecuteResult, DatabaseError, type DatabaseErrorCode, generateId, now, futureTimestamp, isExpired, queryOne, queryOneOrThrow, queryMany, execute, executeOrThrow, batch, withSession, insert, update, deleteWhere, deleteById, exists, count } from './database.js';
32
+ export { type D1DatabaseOrSession, type QueryMeta, type ExecuteResult, type TenantContext, DatabaseError, type DatabaseErrorCode, TenantContextError, generateId, now, futureTimestamp, isExpired, queryOne, queryOneOrThrow, queryMany, execute, executeOrThrow, batch, withSession, insert, update, deleteWhere, deleteById, exists, count, TenantDb, getTenantDb } from './database.js';
33
33
  export * as cache from './cache.js';
34
34
  export { type CacheOptions, type GetOrSetOptions, CacheError, type CacheErrorCode, get as cacheGet, set as cacheSet, del as cacheDel, getOrSet, getOrSetSync, delMany, delByPrefix, has as cacheHas, touch, rateLimit, CACHE_DEFAULTS } from './cache.js';
35
+ export * as users from './users.js';
36
+ export { type User, getUserByGroveAuthId, getUserById, getUserByEmail, getUserByTenantId, getUserFromSession, getUserFromValidatedSession, linkUserToTenant, updateUserDisplayName, deactivateUser, reactivateUser } from './users.js';
37
+ export * as turnstile from './turnstile.js';
38
+ export { type TurnstileVerifyResult, type TurnstileVerifyOptions, verifyTurnstileToken, TURNSTILE_COOKIE_NAME, TURNSTILE_COOKIE_MAX_AGE, createVerificationCookie, validateVerificationCookie, getVerificationCookieOptions } from './turnstile.js';
@@ -47,7 +47,7 @@ shouldReturn304, buildFileHeaders } from './storage.js';
47
47
  export * as db from './database.js';
48
48
  export {
49
49
  // Errors
50
- DatabaseError,
50
+ DatabaseError, TenantContextError,
51
51
  // Utilities
52
52
  generateId, now, futureTimestamp, isExpired,
53
53
  // Query Helpers
@@ -57,7 +57,9 @@ batch, withSession,
57
57
  // CRUD Helpers
58
58
  insert, update, deleteWhere, deleteById,
59
59
  // Existence Checks
60
- exists, count } from './database.js';
60
+ exists, count,
61
+ // Multi-Tenant
62
+ TenantDb, getTenantDb } from './database.js';
61
63
  // ============================================================================
62
64
  // Cache Service (KV)
63
65
  // ============================================================================
@@ -75,3 +77,23 @@ has as cacheHas, touch,
75
77
  rateLimit,
76
78
  // Constants
77
79
  CACHE_DEFAULTS } from './cache.js';
80
+ // ============================================================================
81
+ // Users Service (D1)
82
+ // ============================================================================
83
+ export * as users from './users.js';
84
+ export {
85
+ // Query Functions
86
+ getUserByGroveAuthId, getUserById, getUserByEmail, getUserByTenantId,
87
+ // Session Functions
88
+ getUserFromSession, getUserFromValidatedSession,
89
+ // Update Functions
90
+ linkUserToTenant, updateUserDisplayName, deactivateUser, reactivateUser } from './users.js';
91
+ // ============================================================================
92
+ // Turnstile Service (Shade - Human Verification)
93
+ // ============================================================================
94
+ export * as turnstile from './turnstile.js';
95
+ export {
96
+ // Verification
97
+ verifyTurnstileToken,
98
+ // Cookie Management
99
+ TURNSTILE_COOKIE_NAME, TURNSTILE_COOKIE_MAX_AGE, createVerificationCookie, validateVerificationCookie, getVerificationCookieOptions } from './turnstile.js';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Turnstile Server-Side Verification (Shade)
3
+ *
4
+ * Validates Turnstile tokens with Cloudflare's siteverify endpoint.
5
+ * Part of Grove's Shade AI protection system.
6
+ */
7
+ export interface TurnstileVerifyResult {
8
+ success: boolean;
9
+ challenge_ts?: string;
10
+ hostname?: string;
11
+ 'error-codes'?: string[];
12
+ action?: string;
13
+ cdata?: string;
14
+ }
15
+ export interface TurnstileVerifyOptions {
16
+ /** The Turnstile token from the client */
17
+ token: string;
18
+ /** The secret key from Cloudflare Dashboard */
19
+ secretKey: string;
20
+ /** Optional: The user's IP address for additional validation */
21
+ remoteip?: string;
22
+ }
23
+ /**
24
+ * Verify a Turnstile token server-side
25
+ *
26
+ * @example
27
+ * const result = await verifyTurnstileToken({
28
+ * token: formData.get('cf-turnstile-response'),
29
+ * secretKey: platform.env.TURNSTILE_SECRET_KEY,
30
+ * remoteip: request.headers.get('CF-Connecting-IP')
31
+ * });
32
+ *
33
+ * if (!result.success) {
34
+ * throw error(403, 'Human verification failed');
35
+ * }
36
+ */
37
+ export declare function verifyTurnstileToken(options: TurnstileVerifyOptions): Promise<TurnstileVerifyResult>;
38
+ /**
39
+ * Cookie name for tracking Turnstile verification status
40
+ */
41
+ export declare const TURNSTILE_COOKIE_NAME = "grove_verified";
42
+ /**
43
+ * Cookie max age (7 days in seconds)
44
+ */
45
+ export declare const TURNSTILE_COOKIE_MAX_AGE: number;
46
+ /**
47
+ * Create the verification cookie value (signed timestamp)
48
+ * Simple format: timestamp:hash
49
+ */
50
+ export declare function createVerificationCookie(secretKey: string): string;
51
+ /**
52
+ * Validate a verification cookie
53
+ * Returns true if valid and not expired
54
+ */
55
+ export declare function validateVerificationCookie(cookie: string | undefined, secretKey: string, maxAgeMs?: number): boolean;
56
+ /**
57
+ * Get cookie options for the verification cookie
58
+ */
59
+ export declare function getVerificationCookieOptions(domain?: string): {
60
+ domain?: string | undefined;
61
+ path: string;
62
+ httpOnly: boolean;
63
+ secure: boolean;
64
+ sameSite: "lax";
65
+ maxAge: number;
66
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Turnstile Server-Side Verification (Shade)
3
+ *
4
+ * Validates Turnstile tokens with Cloudflare's siteverify endpoint.
5
+ * Part of Grove's Shade AI protection system.
6
+ */
7
+ const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
8
+ /**
9
+ * Verify a Turnstile token server-side
10
+ *
11
+ * @example
12
+ * const result = await verifyTurnstileToken({
13
+ * token: formData.get('cf-turnstile-response'),
14
+ * secretKey: platform.env.TURNSTILE_SECRET_KEY,
15
+ * remoteip: request.headers.get('CF-Connecting-IP')
16
+ * });
17
+ *
18
+ * if (!result.success) {
19
+ * throw error(403, 'Human verification failed');
20
+ * }
21
+ */
22
+ export async function verifyTurnstileToken(options) {
23
+ const { token, secretKey, remoteip } = options;
24
+ if (!token) {
25
+ return {
26
+ success: false,
27
+ 'error-codes': ['missing-input-response']
28
+ };
29
+ }
30
+ if (!secretKey) {
31
+ console.error('Turnstile: Missing secret key');
32
+ return {
33
+ success: false,
34
+ 'error-codes': ['missing-input-secret']
35
+ };
36
+ }
37
+ const formData = new FormData();
38
+ formData.append('secret', secretKey);
39
+ formData.append('response', token);
40
+ if (remoteip) {
41
+ formData.append('remoteip', remoteip);
42
+ }
43
+ try {
44
+ const response = await fetch(TURNSTILE_VERIFY_URL, {
45
+ method: 'POST',
46
+ body: formData
47
+ });
48
+ if (!response.ok) {
49
+ console.error('Turnstile: Verification request failed', response.status);
50
+ return {
51
+ success: false,
52
+ 'error-codes': ['request-failed']
53
+ };
54
+ }
55
+ const result = await response.json();
56
+ return result;
57
+ }
58
+ catch (err) {
59
+ console.error('Turnstile: Verification error', err);
60
+ return {
61
+ success: false,
62
+ 'error-codes': ['network-error']
63
+ };
64
+ }
65
+ }
66
+ /**
67
+ * Cookie name for tracking Turnstile verification status
68
+ */
69
+ export const TURNSTILE_COOKIE_NAME = 'grove_verified';
70
+ /**
71
+ * Cookie max age (7 days in seconds)
72
+ */
73
+ export const TURNSTILE_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
74
+ /**
75
+ * Create the verification cookie value (signed timestamp)
76
+ * Simple format: timestamp:hash
77
+ */
78
+ export function createVerificationCookie(secretKey) {
79
+ const timestamp = Date.now().toString();
80
+ // Simple hash using the timestamp and a portion of the secret
81
+ const hash = simpleHash(timestamp + secretKey.slice(0, 16));
82
+ return `${timestamp}:${hash}`;
83
+ }
84
+ /**
85
+ * Validate a verification cookie
86
+ * Returns true if valid and not expired
87
+ */
88
+ export function validateVerificationCookie(cookie, secretKey, maxAgeMs = TURNSTILE_COOKIE_MAX_AGE * 1000) {
89
+ if (!cookie)
90
+ return false;
91
+ const parts = cookie.split(':');
92
+ if (parts.length !== 2)
93
+ return false;
94
+ const [timestamp, hash] = parts;
95
+ const timestampNum = parseInt(timestamp, 10);
96
+ if (isNaN(timestampNum))
97
+ return false;
98
+ // Check expiration
99
+ if (Date.now() - timestampNum > maxAgeMs)
100
+ return false;
101
+ // Verify hash
102
+ const expectedHash = simpleHash(timestamp + secretKey.slice(0, 16));
103
+ return hash === expectedHash;
104
+ }
105
+ /**
106
+ * Simple hash function for cookie signing
107
+ * Not cryptographically secure, but sufficient for cookie validation
108
+ */
109
+ function simpleHash(str) {
110
+ let hash = 0;
111
+ for (let i = 0; i < str.length; i++) {
112
+ const char = str.charCodeAt(i);
113
+ hash = (hash << 5) - hash + char;
114
+ hash = hash & hash; // Convert to 32bit integer
115
+ }
116
+ return Math.abs(hash).toString(36);
117
+ }
118
+ /**
119
+ * Get cookie options for the verification cookie
120
+ */
121
+ export function getVerificationCookieOptions(domain) {
122
+ return {
123
+ path: '/',
124
+ httpOnly: true,
125
+ secure: true,
126
+ sameSite: 'lax',
127
+ maxAge: TURNSTILE_COOKIE_MAX_AGE,
128
+ // Set domain to .grove.place to work across subdomains
129
+ ...(domain ? { domain } : {})
130
+ };
131
+ }