@autumnsgrove/groveengine 0.6.3 → 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.
- package/dist/components/admin/FloatingToolbar.svelte +373 -0
- package/dist/components/admin/FloatingToolbar.svelte.d.ts +17 -0
- package/dist/components/admin/MarkdownEditor.svelte +26 -347
- package/dist/components/admin/MarkdownEditor.svelte.d.ts +1 -1
- package/dist/components/admin/composables/index.d.ts +0 -2
- package/dist/components/admin/composables/index.js +0 -2
- package/dist/components/custom/MobileTOC.svelte +20 -13
- package/dist/components/quota/UpgradePrompt.svelte +1 -1
- package/dist/server/services/database.d.ts +138 -0
- package/dist/server/services/database.js +234 -0
- package/dist/server/services/index.d.ts +5 -1
- package/dist/server/services/index.js +24 -2
- package/dist/server/services/turnstile.d.ts +66 -0
- package/dist/server/services/turnstile.js +131 -0
- package/dist/server/services/users.d.ts +104 -0
- package/dist/server/services/users.js +158 -0
- package/dist/styles/README.md +50 -0
- package/dist/styles/vine-pattern.css +24 -0
- package/dist/types/turnstile.d.ts +42 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte +111 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte.d.ts +14 -0
- package/dist/ui/components/primitives/dialog/dialog-overlay.svelte +1 -1
- package/dist/ui/components/primitives/sheet/sheet-overlay.svelte +1 -1
- package/dist/ui/components/ui/Logo.svelte +161 -23
- package/dist/ui/components/ui/Logo.svelte.d.ts +4 -10
- package/dist/ui/tokens/fonts.d.ts +69 -0
- package/dist/ui/tokens/fonts.js +341 -0
- package/dist/ui/tokens/index.d.ts +6 -5
- package/dist/ui/tokens/index.js +7 -6
- package/package.json +1 -1
- package/static/robots.txt +487 -0
- package/dist/components/admin/composables/useCommandPalette.svelte.d.ts +0 -87
- package/dist/components/admin/composables/useCommandPalette.svelte.js +0 -158
- package/dist/components/admin/composables/useSlashCommands.svelte.d.ts +0 -104
- 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
|
|
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
|
+
}
|