flarecms 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.js +201 -1
  3. package/dist/cli/commands.js +5554 -55
  4. package/dist/cli/index.js +5554 -55
  5. package/dist/cli/mcp.js +30 -0
  6. package/dist/client/index.js +23576 -0
  7. package/dist/db/index.js +10392 -25
  8. package/dist/index.js +56776 -7582
  9. package/dist/server/index.js +43280 -0
  10. package/dist/style.css +5536 -0
  11. package/package.json +33 -30
  12. package/scripts/fix-api-paths.mjs +0 -32
  13. package/scripts/fix-imports.mjs +0 -38
  14. package/scripts/prefix-css.mjs +0 -45
  15. package/src/api/lib/cache.ts +0 -45
  16. package/src/api/lib/response.ts +0 -40
  17. package/src/api/middlewares/auth.ts +0 -186
  18. package/src/api/middlewares/cors.ts +0 -10
  19. package/src/api/middlewares/rbac.ts +0 -85
  20. package/src/api/routes/auth.ts +0 -377
  21. package/src/api/routes/collections.ts +0 -205
  22. package/src/api/routes/content.ts +0 -175
  23. package/src/api/routes/device.ts +0 -160
  24. package/src/api/routes/magic.ts +0 -150
  25. package/src/api/routes/mcp.ts +0 -273
  26. package/src/api/routes/oauth.ts +0 -160
  27. package/src/api/routes/settings.ts +0 -43
  28. package/src/api/routes/setup.ts +0 -307
  29. package/src/api/routes/tokens.ts +0 -80
  30. package/src/api/schemas/auth.ts +0 -15
  31. package/src/api/schemas/index.ts +0 -51
  32. package/src/api/schemas/tokens.ts +0 -24
  33. package/src/auth/index.ts +0 -28
  34. package/src/cli/commands.ts +0 -217
  35. package/src/cli/index.ts +0 -21
  36. package/src/cli/mcp.ts +0 -210
  37. package/src/cli/tests/cli.test.ts +0 -40
  38. package/src/cli/tests/create.test.ts +0 -87
  39. package/src/client/FlareAdminRouter.tsx +0 -47
  40. package/src/client/app.tsx +0 -175
  41. package/src/client/components/app-sidebar.tsx +0 -227
  42. package/src/client/components/collection-modal.tsx +0 -215
  43. package/src/client/components/content-list.tsx +0 -247
  44. package/src/client/components/dynamic-form.tsx +0 -190
  45. package/src/client/components/field-modal.tsx +0 -221
  46. package/src/client/components/settings/api-token-section.tsx +0 -400
  47. package/src/client/components/settings/general-section.tsx +0 -224
  48. package/src/client/components/settings/security-section.tsx +0 -154
  49. package/src/client/components/settings/seo-section.tsx +0 -200
  50. package/src/client/components/settings/signup-section.tsx +0 -257
  51. package/src/client/components/ui/accordion.tsx +0 -78
  52. package/src/client/components/ui/avatar.tsx +0 -107
  53. package/src/client/components/ui/badge.tsx +0 -52
  54. package/src/client/components/ui/button.tsx +0 -60
  55. package/src/client/components/ui/card.tsx +0 -103
  56. package/src/client/components/ui/checkbox.tsx +0 -27
  57. package/src/client/components/ui/collapsible.tsx +0 -19
  58. package/src/client/components/ui/dialog.tsx +0 -162
  59. package/src/client/components/ui/icon-picker.tsx +0 -485
  60. package/src/client/components/ui/icons-data.ts +0 -8476
  61. package/src/client/components/ui/input.tsx +0 -20
  62. package/src/client/components/ui/label.tsx +0 -20
  63. package/src/client/components/ui/popover.tsx +0 -91
  64. package/src/client/components/ui/select.tsx +0 -204
  65. package/src/client/components/ui/separator.tsx +0 -23
  66. package/src/client/components/ui/sheet.tsx +0 -141
  67. package/src/client/components/ui/sidebar.tsx +0 -722
  68. package/src/client/components/ui/skeleton.tsx +0 -13
  69. package/src/client/components/ui/sonner.tsx +0 -47
  70. package/src/client/components/ui/switch.tsx +0 -30
  71. package/src/client/components/ui/table.tsx +0 -116
  72. package/src/client/components/ui/tabs.tsx +0 -80
  73. package/src/client/components/ui/textarea.tsx +0 -18
  74. package/src/client/components/ui/tooltip.tsx +0 -68
  75. package/src/client/hooks/use-mobile.ts +0 -19
  76. package/src/client/index.css +0 -149
  77. package/src/client/index.ts +0 -7
  78. package/src/client/layouts/admin-layout.tsx +0 -93
  79. package/src/client/layouts/settings-layout.tsx +0 -104
  80. package/src/client/lib/api.ts +0 -72
  81. package/src/client/lib/utils.ts +0 -6
  82. package/src/client/main.tsx +0 -10
  83. package/src/client/pages/collection-detail.tsx +0 -634
  84. package/src/client/pages/collections.tsx +0 -180
  85. package/src/client/pages/dashboard.tsx +0 -133
  86. package/src/client/pages/device.tsx +0 -66
  87. package/src/client/pages/document-detail-page.tsx +0 -139
  88. package/src/client/pages/documents-page.tsx +0 -103
  89. package/src/client/pages/login.tsx +0 -345
  90. package/src/client/pages/settings.tsx +0 -65
  91. package/src/client/pages/setup.tsx +0 -129
  92. package/src/client/pages/signup.tsx +0 -188
  93. package/src/client/store/auth.ts +0 -30
  94. package/src/client/store/collections.ts +0 -13
  95. package/src/client/store/config.ts +0 -12
  96. package/src/client/store/fetcher.ts +0 -30
  97. package/src/client/store/router.ts +0 -95
  98. package/src/client/store/schema.ts +0 -39
  99. package/src/client/store/settings.ts +0 -31
  100. package/src/client/types.ts +0 -34
  101. package/src/db/dynamic.ts +0 -70
  102. package/src/db/index.ts +0 -16
  103. package/src/db/migrations/001_initial_schema.ts +0 -57
  104. package/src/db/migrations/002_auth_tables.ts +0 -84
  105. package/src/db/migrator.ts +0 -61
  106. package/src/db/schema.ts +0 -142
  107. package/src/index.ts +0 -12
  108. package/src/server/index.ts +0 -66
  109. package/src/types.ts +0 -20
  110. package/tests/css.test.ts +0 -21
  111. package/tests/modular.test.ts +0 -29
  112. package/tsconfig.json +0 -10
  113. /package/{style.css.d.ts → dist/style.css.d.ts} +0 -0
@@ -1,175 +0,0 @@
1
- import { Hono } from 'hono';
2
- import { createDb, ensureUniqueSlug } from '../../db';
3
- import { sql } from 'kysely';
4
- import { ulid } from 'ulidx';
5
- import { dynamicContentSchema } from '../schemas';
6
- import type { Bindings } from '../index';
7
- import { apiResponse } from '../lib/response';
8
-
9
- import { requireRole, requireScope } from '../middlewares/rbac';
10
-
11
- export const contentRoutes = new Hono<{ Bindings: Bindings }>();
12
-
13
- // Write operations (POST, PUT, DELETE) restricted to admin or editor roles.
14
- contentRoutes.post('/:collection', requireScope('write', 'collection_slug'), requireRole(['admin', 'editor']));
15
- contentRoutes.put('/:collection/*', requireScope('update', 'collection_slug'), requireRole(['admin', 'editor']));
16
- contentRoutes.delete('/:collection/*', requireScope('delete', 'collection_slug'), requireRole(['admin', 'editor']));
17
-
18
- contentRoutes.get('/:collection', requireScope('read', 'collection_slug'), async (c) => {
19
- const collection = c.req.param('collection');
20
- const db = createDb(c.env.DB);
21
-
22
- const page = Number(c.req.query('page')) || 1;
23
- const limit = Math.min(Number(c.req.query('limit')) || 20, 100);
24
- const offset = (page - 1) * limit;
25
-
26
- try {
27
- // 1. Get total count
28
- const countRes = await db.selectFrom(`ec_${collection}` as any)
29
- .select(db.fn.count('id').as('count'))
30
- .where('status', '!=', 'deleted')
31
- .executeTakeFirst();
32
-
33
- const total = Number(countRes?.count || 0);
34
- const totalPages = Math.ceil(total / limit);
35
-
36
- // 2. Get data slice
37
- const result = await db.selectFrom(`ec_${collection}` as any)
38
- .selectAll()
39
- .where('status', '!=', 'deleted')
40
- .orderBy('created_at', 'desc')
41
- .limit(limit)
42
- .offset(offset)
43
- .execute();
44
-
45
- return apiResponse.paginated(c, result, {
46
- page,
47
- limit,
48
- total,
49
- totalPages,
50
- hasNextPage: page < totalPages,
51
- hasPrevPage: page > 1,
52
- });
53
- } catch (e: any) {
54
- return apiResponse.error(c, e.message);
55
- }
56
- });
57
-
58
- contentRoutes.get('/:collection/:id', async (c) => {
59
- const collection = c.req.param('collection');
60
- const id = c.req.param('id');
61
- const db = createDb(c.env.DB);
62
- try {
63
- const result = await db.selectFrom(`ec_${collection}` as any)
64
- .selectAll()
65
- .where('id', '=', id)
66
- .executeTakeFirst();
67
-
68
- if (!result) return apiResponse.error(c, 'Document not found', 404);
69
- return apiResponse.ok(c, result);
70
- } catch (e) {
71
- return apiResponse.error(c, 'Access error', 404);
72
- }
73
- });
74
-
75
-
76
- contentRoutes.post('/:collection', async (c) => {
77
- const collectionName = c.req.param('collection');
78
- const db = createDb(c.env.DB);
79
-
80
- // 1. Get collection metadata
81
- const collection = await db.selectFrom('fc_collections')
82
- .select('id')
83
- .where('slug', '=', collectionName)
84
- .executeTakeFirst();
85
-
86
- if (!collection) return apiResponse.error(c, 'Collection not found', 404);
87
-
88
- // 2. Check for fields
89
- const fieldCount = await db.selectFrom('fc_fields')
90
- .select(db.fn.count('id').as('total'))
91
- .where('collection_id', '=', collection.id)
92
- .executeTakeFirst();
93
-
94
- if (!fieldCount || Number(fieldCount.total) === 0) {
95
- return apiResponse.error(c, 'Cannot create documents in a collection without fields. Please define your schema first.');
96
- }
97
-
98
- const body = await c.req.json();
99
- const parsed = dynamicContentSchema.safeParse(body);
100
- if (!parsed.success) {
101
- return apiResponse.error(c, parsed.error.format());
102
- }
103
-
104
- const id = ulid();
105
- const data = parsed.data;
106
-
107
- // Handle Required Columns
108
- const baseSlug = data.slug || data.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-') || id;
109
- const slug = await ensureUniqueSlug(db, collectionName, baseSlug);
110
- const status = data.status || 'draft';
111
-
112
- const doc = {
113
- ...data,
114
- id,
115
- slug,
116
- status,
117
- };
118
-
119
- try {
120
- await db.insertInto(`ec_${collectionName}` as any)
121
- .values(doc)
122
- .execute();
123
- return apiResponse.created(c, { id, slug });
124
- } catch (e: any) {
125
- return apiResponse.error(c, `Failed query: ${e.message}`);
126
- }
127
- });
128
-
129
- contentRoutes.put('/:collection/:id', async (c) => {
130
- const collectionName = c.req.param('collection');
131
- const id = c.req.param('id');
132
- const body = await c.req.json();
133
- const parsed = dynamicContentSchema.safeParse(body);
134
- if (!parsed.success) {
135
- return apiResponse.error(c, parsed.error.format());
136
- }
137
-
138
- const db = createDb(c.env.DB);
139
- const data = parsed.data;
140
-
141
- // Handle slug change uniqueness
142
- let finalData = { ...data };
143
- if (data.slug) {
144
- const uniqueSlug = await ensureUniqueSlug(db, collectionName, data.slug, id);
145
- finalData.slug = uniqueSlug;
146
- }
147
-
148
- try {
149
- await db.updateTable(`ec_${collectionName}` as any)
150
- .set({
151
- ...finalData,
152
- updated_at: sql`CURRENT_TIMESTAMP`
153
- })
154
- .where('id', '=', id)
155
- .execute();
156
- return apiResponse.ok(c, { id, success: true, slug: finalData.slug });
157
- } catch (e: any) {
158
- return apiResponse.error(c, e.message);
159
- }
160
- });
161
-
162
- contentRoutes.delete('/:collection/:id', async (c) => {
163
- const collectionName = c.req.param('collection');
164
- const id = c.req.param('id');
165
- const db = createDb(c.env.DB);
166
-
167
- try {
168
- await db.deleteFrom(`ec_${collectionName}` as any)
169
- .where('id', '=', id)
170
- .execute();
171
- return apiResponse.ok(c, { success: true });
172
- } catch (e: any) {
173
- return apiResponse.error(c, e.message);
174
- }
175
- });
@@ -1,160 +0,0 @@
1
- import { Hono } from 'hono';
2
- import { createDb } from '../../db';
3
- import { ulid } from 'ulidx';
4
- import { deviceCodeRequestSchema, deviceTokenRequestSchema, deviceApproveSchema } from '../schemas/tokens';
5
- import { encodeHexLowerCase } from '@oslojs/encoding';
6
- import type { Bindings, Variables } from '../index';
7
- import { requireRole } from '../middlewares/rbac';
8
- import { authMiddleware } from '../middlewares/auth';
9
- import { apiResponse } from '../lib/response';
10
-
11
- export const deviceRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>();
12
-
13
-
14
- /**
15
- * 1. CLI requests a device code
16
- * Public unauthenticated endpoint
17
- */
18
- deviceRoutes.post('/code', async (c) => {
19
- const body = await c.req.json();
20
- const parsed = deviceCodeRequestSchema.safeParse(body);
21
- if (!parsed.success) return apiResponse.error(c, parsed.error.format());
22
-
23
- const db = createDb(c.env.DB);
24
- const clientId = parsed.data.client_id;
25
- const requestedScopes = parsed.data.scope ? parsed.data.scope.split(' ') : ['content:read']; // Default Scope
26
-
27
- // Generate Device Code
28
- const bytes = new Uint8Array(16);
29
- crypto.getRandomValues(bytes);
30
- const deviceCode = encodeHexLowerCase(bytes);
31
-
32
- // Generate short user code (ex: ABCD-1234)
33
- const userCode = Math.random().toString(36).substring(2, 6).toUpperCase() + '-' + Math.random().toString(36).substring(2, 6).toUpperCase();
34
-
35
- const expiresAt = new Date();
36
- expiresAt.setMinutes(expiresAt.getMinutes() + 15); // 15 mins to authorize
37
-
38
- await db.insertInto('fc_device_codes')
39
- .values({
40
- device_code: deviceCode,
41
- user_code: userCode,
42
- client_id: clientId,
43
- user_id: null,
44
- scopes: JSON.stringify(requestedScopes),
45
- expires_at: expiresAt.toISOString(),
46
- })
47
- .execute();
48
-
49
- return apiResponse.ok(c, {
50
- device_code: deviceCode,
51
- user_code: userCode,
52
- verification_uri: new URL(c.req.url).origin + '/device', // Assuming UI is there
53
- expires_in: 900,
54
- interval: 5
55
- });
56
- });
57
-
58
- /**
59
- * 2. CLI Polls for token using device code
60
- * Public unauthenticated endpoint
61
- */
62
- deviceRoutes.post('/token', async (c) => {
63
- const body = await c.req.json();
64
- const parsed = deviceTokenRequestSchema.safeParse(body);
65
- if (!parsed.success) return apiResponse.error(c, parsed.error.format());
66
-
67
- const db = createDb(c.env.DB);
68
- const { device_code, client_id } = parsed.data;
69
-
70
- const codeRecord = await db.selectFrom('fc_device_codes')
71
- .selectAll()
72
- .where('device_code', '=', device_code)
73
- .where('client_id', '=', client_id)
74
- .executeTakeFirst();
75
-
76
- if (!codeRecord) return apiResponse.error(c, 'invalid_grant');
77
-
78
- if (new Date(codeRecord.expires_at) < new Date()) {
79
- return apiResponse.error(c, 'expired_token');
80
- }
81
-
82
- if (!codeRecord.user_id) {
83
- return apiResponse.error(c, 'authorization_pending');
84
- }
85
-
86
- // Approved! Create a real PAT
87
- const randomBytes = new Uint8Array(24);
88
- crypto.getRandomValues(randomBytes);
89
- const suffix = encodeHexLowerCase(randomBytes);
90
- const tokenId = `ec_pat_${ulid()}`;
91
- const fullToken = `${tokenId}_${suffix}`;
92
-
93
- const encoder = new TextEncoder();
94
- const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(suffix));
95
- const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
96
-
97
- await db.insertInto('fc_api_tokens')
98
- .values({
99
- id: tokenId,
100
- user_id: codeRecord.user_id,
101
- name: `Device Authorization (${client_id})`,
102
- hash: hashHex,
103
- scopes: codeRecord.scopes, // Inherit requested scopes
104
- expires_at: null,
105
- last_used_at: null,
106
- })
107
- .execute();
108
-
109
- // Delete consumed device code
110
- await db.deleteFrom('fc_device_codes').where('device_code', '=', device_code).execute();
111
-
112
- return apiResponse.ok(c, {
113
- access_token: fullToken,
114
- token_type: 'bearer',
115
- scope: JSON.parse(codeRecord.scopes).join(' ')
116
- });
117
- });
118
-
119
- /**
120
- * 3. UI Approves code
121
- * Authenticated Endpoint (User must be logged in to approve)
122
- */
123
- // Temporary middleware stack application for this route specifically
124
- const approvalApp = new Hono<{ Bindings: Bindings; Variables: Variables }>();
125
-
126
- approvalApp.use('*', authMiddleware);
127
- approvalApp.use('*', requireRole(['admin', 'editor']));
128
-
129
- approvalApp.post('/verify', async (c) => {
130
- const body = await c.req.json();
131
- const parsed = deviceApproveSchema.safeParse(body);
132
- if (!parsed.success) return apiResponse.error(c, parsed.error.format());
133
-
134
- const user = c.get('user');
135
- const db = createDb(c.env.DB);
136
- const userCode = parsed.data.user_code.toUpperCase(); // normalize
137
-
138
- const codeRecord = await db.selectFrom('fc_device_codes')
139
- .selectAll()
140
- .where('user_code', '=', userCode)
141
- .where('user_id', 'is', null) // Only unapproved codes
142
- .executeTakeFirst();
143
-
144
- if (!codeRecord) return apiResponse.error(c, 'Invalid or expired user code', 404);
145
-
146
- if (new Date(codeRecord.expires_at) < new Date()) {
147
- await db.deleteFrom('fc_device_codes').where('user_code', '=', userCode).execute();
148
- return apiResponse.error(c, 'Code expired');
149
- }
150
-
151
- // Approve it! Attach the user ID
152
- await db.updateTable('fc_device_codes')
153
- .set({ user_id: user.id })
154
- .where('user_code', '=', userCode)
155
- .execute();
156
-
157
- return apiResponse.ok(c, { success: true, scopes: JSON.parse(codeRecord.scopes) });
158
- });
159
-
160
- deviceRoutes.route('/', approvalApp);
@@ -1,150 +0,0 @@
1
- import { Hono } from 'hono';
2
- import { createDb } from '../../db';
3
- import { generateSessionToken } from '../../auth';
4
- import { setCookie } from 'hono/cookie';
5
- import { magicLinkRequestSchema, magicLinkVerifySchema } from '../schemas/auth';
6
- import { encodeHexLowerCase } from '@oslojs/encoding';
7
- import { ulid } from 'ulidx';
8
- import type { Bindings, Variables } from '../index';
9
- import { apiResponse } from '../lib/response';
10
-
11
- export const magicRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>();
12
-
13
-
14
- // Generate Magic Link
15
- magicRoutes.post('/request', async (c) => {
16
- const body = await c.req.json();
17
- const parsed = magicLinkRequestSchema.safeParse(body);
18
- if (!parsed.success) return apiResponse.error(c, parsed.error.format());
19
-
20
- const db = createDb(c.env.DB);
21
- const { email } = parsed.data;
22
-
23
- // 1. Check Registration Policy
24
- const signupEnabled = await db.selectFrom('options').select('value').where('name', '=', 'flare:signup_enabled').executeTakeFirst();
25
- const defaultRole = await db.selectFrom('options').select('value').where('name', '=', 'flare:signup_default_role').executeTakeFirst();
26
- const domainRulesRaw = await db.selectFrom('options').select('value').where('name', '=', 'flare:signup_domain_rules').executeTakeFirst();
27
-
28
- const isEnabled = signupEnabled?.value === 'true';
29
- const roleDefault = defaultRole?.value || 'editor';
30
- const domainRules = JSON.parse(domainRulesRaw?.value || '{}') as Record<string, string>;
31
-
32
- // 2. Link or Provision User
33
- let user = await db.selectFrom('fc_users').selectAll().where('email', '=', email).executeTakeFirst();
34
-
35
- if (!user) {
36
- if (!isEnabled) {
37
- return apiResponse.error(c, 'Signups are currently disabled', 403);
38
- }
39
-
40
- // Determine role based on domain
41
- const domain = email.split('@')[1] || '';
42
- const assignedRole = domainRules[domain] || roleDefault;
43
-
44
- // Provision new user
45
- const newUser = {
46
- id: ulid(),
47
- email,
48
- password: null,
49
- role: assignedRole,
50
- disabled: 0,
51
- };
52
- await db.insertInto('fc_users').values(newUser as any).execute();
53
- user = await db.selectFrom('fc_users').selectAll().where('id', '=', newUser.id).executeTakeFirst();
54
- }
55
-
56
- if (!user || user.disabled) {
57
- return apiResponse.error(c, 'Account disabled or not found', 403);
58
- }
59
-
60
- // Generate secure token
61
- const randomBytes = new Uint8Array(32);
62
- crypto.getRandomValues(randomBytes);
63
- const rawToken = encodeHexLowerCase(randomBytes);
64
-
65
- // Hash for storage
66
- const encoder = new TextEncoder();
67
- const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawToken));
68
- const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
69
-
70
- const expiresAt = new Date();
71
- expiresAt.setMinutes(expiresAt.getMinutes() + 15); // 15 mins expiry
72
-
73
- // Upsert pattern: delete old tokens for this email
74
- await db.deleteFrom('fc_verification_tokens').where('identifier', '=', email).execute();
75
-
76
- await db.insertInto('fc_verification_tokens')
77
- .values({
78
- identifier: email,
79
- token: hashHex,
80
- expires_at: expiresAt.toISOString(),
81
- })
82
- .execute();
83
-
84
- // In a real app we'd send an email here using SendGrid/Resend
85
- // For now, we simulate logging it
86
- console.log(`[MAGIC LINK] -> https://${new URL(c.req.url).hostname}/verify?email=${encodeURIComponent(email)}&token=${rawToken}`);
87
-
88
- return apiResponse.ok(c, {
89
- success: true,
90
- message: 'Magic link sent',
91
- dev_link: `https://${new URL(c.req.url).hostname}/verify?email=${encodeURIComponent(email)}&token=${rawToken}`
92
- });
93
- });
94
-
95
- // Verify Magic Link
96
- magicRoutes.post('/verify', async (c) => {
97
- const body = await c.req.json();
98
- const parsed = magicLinkVerifySchema.safeParse(body);
99
- if (!parsed.success) return apiResponse.error(c, parsed.error.format());
100
-
101
- const db = createDb(c.env.DB);
102
- const { email, token } = parsed.data;
103
-
104
- const user = await db.selectFrom('fc_users').selectAll().where('email', '=', email).executeTakeFirst();
105
- if (!user || user.disabled) return apiResponse.error(c, 'Invalid or expired link', 401);
106
-
107
- // Hash provided token
108
- const encoder = new TextEncoder();
109
- const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(token));
110
- const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
111
-
112
- const record = await db.selectFrom('fc_verification_tokens')
113
- .selectAll()
114
- .where('identifier', '=', email)
115
- .where('token', '=', hashHex)
116
- .executeTakeFirst();
117
-
118
- if (!record) return apiResponse.error(c, 'Invalid or expired link', 401);
119
-
120
- if (new Date(record.expires_at) < new Date()) {
121
- await db.deleteFrom('fc_verification_tokens').where('identifier', '=', email).execute();
122
- return apiResponse.error(c, 'Link expired', 401);
123
- }
124
-
125
- // Success! Delete token and create session
126
- await db.deleteFrom('fc_verification_tokens').where('identifier', '=', email).execute();
127
-
128
- const sessionId = generateSessionToken();
129
- const expiresAt = new Date();
130
- expiresAt.setDate(expiresAt.getDate() + 30);
131
-
132
- await db.insertInto('fc_sessions')
133
- .values({
134
- id: sessionId,
135
- user_id: user.id,
136
- expires_at: expiresAt.toISOString(),
137
- })
138
- .execute();
139
-
140
- setCookie(c, 'session', sessionId, {
141
- httpOnly: true,
142
- secure: true,
143
- sameSite: 'Strict',
144
- expires: expiresAt,
145
- path: '/'
146
- });
147
-
148
-
149
- return apiResponse.ok(c, { success: true, message: 'Logged in via Magic Link' });
150
- });