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.
- package/LICENSE +21 -0
- package/dist/auth/index.js +201 -1
- package/dist/cli/commands.js +5554 -55
- package/dist/cli/index.js +5554 -55
- package/dist/cli/mcp.js +30 -0
- package/dist/client/index.js +23576 -0
- package/dist/db/index.js +10392 -25
- package/dist/index.js +56776 -7582
- package/dist/server/index.js +43280 -0
- package/dist/style.css +5536 -0
- package/package.json +33 -30
- package/scripts/fix-api-paths.mjs +0 -32
- package/scripts/fix-imports.mjs +0 -38
- package/scripts/prefix-css.mjs +0 -45
- package/src/api/lib/cache.ts +0 -45
- package/src/api/lib/response.ts +0 -40
- package/src/api/middlewares/auth.ts +0 -186
- package/src/api/middlewares/cors.ts +0 -10
- package/src/api/middlewares/rbac.ts +0 -85
- package/src/api/routes/auth.ts +0 -377
- package/src/api/routes/collections.ts +0 -205
- package/src/api/routes/content.ts +0 -175
- package/src/api/routes/device.ts +0 -160
- package/src/api/routes/magic.ts +0 -150
- package/src/api/routes/mcp.ts +0 -273
- package/src/api/routes/oauth.ts +0 -160
- package/src/api/routes/settings.ts +0 -43
- package/src/api/routes/setup.ts +0 -307
- package/src/api/routes/tokens.ts +0 -80
- package/src/api/schemas/auth.ts +0 -15
- package/src/api/schemas/index.ts +0 -51
- package/src/api/schemas/tokens.ts +0 -24
- package/src/auth/index.ts +0 -28
- package/src/cli/commands.ts +0 -217
- package/src/cli/index.ts +0 -21
- package/src/cli/mcp.ts +0 -210
- package/src/cli/tests/cli.test.ts +0 -40
- package/src/cli/tests/create.test.ts +0 -87
- package/src/client/FlareAdminRouter.tsx +0 -47
- package/src/client/app.tsx +0 -175
- package/src/client/components/app-sidebar.tsx +0 -227
- package/src/client/components/collection-modal.tsx +0 -215
- package/src/client/components/content-list.tsx +0 -247
- package/src/client/components/dynamic-form.tsx +0 -190
- package/src/client/components/field-modal.tsx +0 -221
- package/src/client/components/settings/api-token-section.tsx +0 -400
- package/src/client/components/settings/general-section.tsx +0 -224
- package/src/client/components/settings/security-section.tsx +0 -154
- package/src/client/components/settings/seo-section.tsx +0 -200
- package/src/client/components/settings/signup-section.tsx +0 -257
- package/src/client/components/ui/accordion.tsx +0 -78
- package/src/client/components/ui/avatar.tsx +0 -107
- package/src/client/components/ui/badge.tsx +0 -52
- package/src/client/components/ui/button.tsx +0 -60
- package/src/client/components/ui/card.tsx +0 -103
- package/src/client/components/ui/checkbox.tsx +0 -27
- package/src/client/components/ui/collapsible.tsx +0 -19
- package/src/client/components/ui/dialog.tsx +0 -162
- package/src/client/components/ui/icon-picker.tsx +0 -485
- package/src/client/components/ui/icons-data.ts +0 -8476
- package/src/client/components/ui/input.tsx +0 -20
- package/src/client/components/ui/label.tsx +0 -20
- package/src/client/components/ui/popover.tsx +0 -91
- package/src/client/components/ui/select.tsx +0 -204
- package/src/client/components/ui/separator.tsx +0 -23
- package/src/client/components/ui/sheet.tsx +0 -141
- package/src/client/components/ui/sidebar.tsx +0 -722
- package/src/client/components/ui/skeleton.tsx +0 -13
- package/src/client/components/ui/sonner.tsx +0 -47
- package/src/client/components/ui/switch.tsx +0 -30
- package/src/client/components/ui/table.tsx +0 -116
- package/src/client/components/ui/tabs.tsx +0 -80
- package/src/client/components/ui/textarea.tsx +0 -18
- package/src/client/components/ui/tooltip.tsx +0 -68
- package/src/client/hooks/use-mobile.ts +0 -19
- package/src/client/index.css +0 -149
- package/src/client/index.ts +0 -7
- package/src/client/layouts/admin-layout.tsx +0 -93
- package/src/client/layouts/settings-layout.tsx +0 -104
- package/src/client/lib/api.ts +0 -72
- package/src/client/lib/utils.ts +0 -6
- package/src/client/main.tsx +0 -10
- package/src/client/pages/collection-detail.tsx +0 -634
- package/src/client/pages/collections.tsx +0 -180
- package/src/client/pages/dashboard.tsx +0 -133
- package/src/client/pages/device.tsx +0 -66
- package/src/client/pages/document-detail-page.tsx +0 -139
- package/src/client/pages/documents-page.tsx +0 -103
- package/src/client/pages/login.tsx +0 -345
- package/src/client/pages/settings.tsx +0 -65
- package/src/client/pages/setup.tsx +0 -129
- package/src/client/pages/signup.tsx +0 -188
- package/src/client/store/auth.ts +0 -30
- package/src/client/store/collections.ts +0 -13
- package/src/client/store/config.ts +0 -12
- package/src/client/store/fetcher.ts +0 -30
- package/src/client/store/router.ts +0 -95
- package/src/client/store/schema.ts +0 -39
- package/src/client/store/settings.ts +0 -31
- package/src/client/types.ts +0 -34
- package/src/db/dynamic.ts +0 -70
- package/src/db/index.ts +0 -16
- package/src/db/migrations/001_initial_schema.ts +0 -57
- package/src/db/migrations/002_auth_tables.ts +0 -84
- package/src/db/migrator.ts +0 -61
- package/src/db/schema.ts +0 -142
- package/src/index.ts +0 -12
- package/src/server/index.ts +0 -66
- package/src/types.ts +0 -20
- package/tests/css.test.ts +0 -21
- package/tests/modular.test.ts +0 -29
- package/tsconfig.json +0 -10
- /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
|
-
});
|
package/src/api/routes/device.ts
DELETED
|
@@ -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);
|
package/src/api/routes/magic.ts
DELETED
|
@@ -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
|
-
});
|