flarecms 0.1.0

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 (110) hide show
  1. package/README.md +73 -0
  2. package/dist/auth/index.js +40 -0
  3. package/dist/cli/commands.js +389 -0
  4. package/dist/cli/index.js +403 -0
  5. package/dist/cli/mcp.js +209 -0
  6. package/dist/db/index.js +164 -0
  7. package/dist/index.js +17626 -0
  8. package/package.json +105 -0
  9. package/scripts/fix-api-paths.mjs +32 -0
  10. package/scripts/fix-imports.mjs +38 -0
  11. package/scripts/prefix-css.mjs +45 -0
  12. package/src/api/lib/cache.ts +45 -0
  13. package/src/api/lib/response.ts +40 -0
  14. package/src/api/middlewares/auth.ts +186 -0
  15. package/src/api/middlewares/cors.ts +10 -0
  16. package/src/api/middlewares/rbac.ts +85 -0
  17. package/src/api/routes/auth.ts +377 -0
  18. package/src/api/routes/collections.ts +205 -0
  19. package/src/api/routes/content.ts +175 -0
  20. package/src/api/routes/device.ts +160 -0
  21. package/src/api/routes/magic.ts +150 -0
  22. package/src/api/routes/mcp.ts +273 -0
  23. package/src/api/routes/oauth.ts +160 -0
  24. package/src/api/routes/settings.ts +43 -0
  25. package/src/api/routes/setup.ts +307 -0
  26. package/src/api/routes/tokens.ts +80 -0
  27. package/src/api/schemas/auth.ts +15 -0
  28. package/src/api/schemas/index.ts +51 -0
  29. package/src/api/schemas/tokens.ts +24 -0
  30. package/src/auth/index.ts +28 -0
  31. package/src/cli/commands.ts +217 -0
  32. package/src/cli/index.ts +21 -0
  33. package/src/cli/mcp.ts +210 -0
  34. package/src/cli/tests/cli.test.ts +40 -0
  35. package/src/cli/tests/create.test.ts +87 -0
  36. package/src/client/FlareAdminRouter.tsx +47 -0
  37. package/src/client/app.tsx +175 -0
  38. package/src/client/components/app-sidebar.tsx +227 -0
  39. package/src/client/components/collection-modal.tsx +215 -0
  40. package/src/client/components/content-list.tsx +247 -0
  41. package/src/client/components/dynamic-form.tsx +190 -0
  42. package/src/client/components/field-modal.tsx +221 -0
  43. package/src/client/components/settings/api-token-section.tsx +400 -0
  44. package/src/client/components/settings/general-section.tsx +224 -0
  45. package/src/client/components/settings/security-section.tsx +154 -0
  46. package/src/client/components/settings/seo-section.tsx +200 -0
  47. package/src/client/components/settings/signup-section.tsx +257 -0
  48. package/src/client/components/ui/accordion.tsx +78 -0
  49. package/src/client/components/ui/avatar.tsx +107 -0
  50. package/src/client/components/ui/badge.tsx +52 -0
  51. package/src/client/components/ui/button.tsx +60 -0
  52. package/src/client/components/ui/card.tsx +103 -0
  53. package/src/client/components/ui/checkbox.tsx +27 -0
  54. package/src/client/components/ui/collapsible.tsx +19 -0
  55. package/src/client/components/ui/dialog.tsx +162 -0
  56. package/src/client/components/ui/icon-picker.tsx +485 -0
  57. package/src/client/components/ui/icons-data.ts +8476 -0
  58. package/src/client/components/ui/input.tsx +20 -0
  59. package/src/client/components/ui/label.tsx +20 -0
  60. package/src/client/components/ui/popover.tsx +91 -0
  61. package/src/client/components/ui/select.tsx +204 -0
  62. package/src/client/components/ui/separator.tsx +23 -0
  63. package/src/client/components/ui/sheet.tsx +141 -0
  64. package/src/client/components/ui/sidebar.tsx +722 -0
  65. package/src/client/components/ui/skeleton.tsx +13 -0
  66. package/src/client/components/ui/sonner.tsx +47 -0
  67. package/src/client/components/ui/switch.tsx +30 -0
  68. package/src/client/components/ui/table.tsx +116 -0
  69. package/src/client/components/ui/tabs.tsx +80 -0
  70. package/src/client/components/ui/textarea.tsx +18 -0
  71. package/src/client/components/ui/tooltip.tsx +68 -0
  72. package/src/client/hooks/use-mobile.ts +19 -0
  73. package/src/client/index.css +149 -0
  74. package/src/client/index.ts +7 -0
  75. package/src/client/layouts/admin-layout.tsx +93 -0
  76. package/src/client/layouts/settings-layout.tsx +104 -0
  77. package/src/client/lib/api.ts +72 -0
  78. package/src/client/lib/utils.ts +6 -0
  79. package/src/client/main.tsx +10 -0
  80. package/src/client/pages/collection-detail.tsx +634 -0
  81. package/src/client/pages/collections.tsx +180 -0
  82. package/src/client/pages/dashboard.tsx +133 -0
  83. package/src/client/pages/device.tsx +66 -0
  84. package/src/client/pages/document-detail-page.tsx +139 -0
  85. package/src/client/pages/documents-page.tsx +103 -0
  86. package/src/client/pages/login.tsx +345 -0
  87. package/src/client/pages/settings.tsx +65 -0
  88. package/src/client/pages/setup.tsx +129 -0
  89. package/src/client/pages/signup.tsx +188 -0
  90. package/src/client/store/auth.ts +30 -0
  91. package/src/client/store/collections.ts +13 -0
  92. package/src/client/store/config.ts +12 -0
  93. package/src/client/store/fetcher.ts +30 -0
  94. package/src/client/store/router.ts +95 -0
  95. package/src/client/store/schema.ts +39 -0
  96. package/src/client/store/settings.ts +31 -0
  97. package/src/client/types.ts +34 -0
  98. package/src/db/dynamic.ts +70 -0
  99. package/src/db/index.ts +16 -0
  100. package/src/db/migrations/001_initial_schema.ts +57 -0
  101. package/src/db/migrations/002_auth_tables.ts +84 -0
  102. package/src/db/migrator.ts +61 -0
  103. package/src/db/schema.ts +142 -0
  104. package/src/index.ts +12 -0
  105. package/src/server/index.ts +66 -0
  106. package/src/types.ts +20 -0
  107. package/style.css.d.ts +8 -0
  108. package/tests/css.test.ts +21 -0
  109. package/tests/modular.test.ts +29 -0
  110. package/tsconfig.json +10 -0
@@ -0,0 +1,175 @@
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
+ });
@@ -0,0 +1,160 @@
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);
@@ -0,0 +1,150 @@
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
+ });