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.
- package/README.md +73 -0
- package/dist/auth/index.js +40 -0
- package/dist/cli/commands.js +389 -0
- package/dist/cli/index.js +403 -0
- package/dist/cli/mcp.js +209 -0
- package/dist/db/index.js +164 -0
- package/dist/index.js +17626 -0
- package/package.json +105 -0
- package/scripts/fix-api-paths.mjs +32 -0
- package/scripts/fix-imports.mjs +38 -0
- package/scripts/prefix-css.mjs +45 -0
- package/src/api/lib/cache.ts +45 -0
- package/src/api/lib/response.ts +40 -0
- package/src/api/middlewares/auth.ts +186 -0
- package/src/api/middlewares/cors.ts +10 -0
- package/src/api/middlewares/rbac.ts +85 -0
- package/src/api/routes/auth.ts +377 -0
- package/src/api/routes/collections.ts +205 -0
- package/src/api/routes/content.ts +175 -0
- package/src/api/routes/device.ts +160 -0
- package/src/api/routes/magic.ts +150 -0
- package/src/api/routes/mcp.ts +273 -0
- package/src/api/routes/oauth.ts +160 -0
- package/src/api/routes/settings.ts +43 -0
- package/src/api/routes/setup.ts +307 -0
- package/src/api/routes/tokens.ts +80 -0
- package/src/api/schemas/auth.ts +15 -0
- package/src/api/schemas/index.ts +51 -0
- package/src/api/schemas/tokens.ts +24 -0
- package/src/auth/index.ts +28 -0
- package/src/cli/commands.ts +217 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/mcp.ts +210 -0
- package/src/cli/tests/cli.test.ts +40 -0
- package/src/cli/tests/create.test.ts +87 -0
- package/src/client/FlareAdminRouter.tsx +47 -0
- package/src/client/app.tsx +175 -0
- package/src/client/components/app-sidebar.tsx +227 -0
- package/src/client/components/collection-modal.tsx +215 -0
- package/src/client/components/content-list.tsx +247 -0
- package/src/client/components/dynamic-form.tsx +190 -0
- package/src/client/components/field-modal.tsx +221 -0
- package/src/client/components/settings/api-token-section.tsx +400 -0
- package/src/client/components/settings/general-section.tsx +224 -0
- package/src/client/components/settings/security-section.tsx +154 -0
- package/src/client/components/settings/seo-section.tsx +200 -0
- package/src/client/components/settings/signup-section.tsx +257 -0
- package/src/client/components/ui/accordion.tsx +78 -0
- package/src/client/components/ui/avatar.tsx +107 -0
- package/src/client/components/ui/badge.tsx +52 -0
- package/src/client/components/ui/button.tsx +60 -0
- package/src/client/components/ui/card.tsx +103 -0
- package/src/client/components/ui/checkbox.tsx +27 -0
- package/src/client/components/ui/collapsible.tsx +19 -0
- package/src/client/components/ui/dialog.tsx +162 -0
- package/src/client/components/ui/icon-picker.tsx +485 -0
- package/src/client/components/ui/icons-data.ts +8476 -0
- package/src/client/components/ui/input.tsx +20 -0
- package/src/client/components/ui/label.tsx +20 -0
- package/src/client/components/ui/popover.tsx +91 -0
- package/src/client/components/ui/select.tsx +204 -0
- package/src/client/components/ui/separator.tsx +23 -0
- package/src/client/components/ui/sheet.tsx +141 -0
- package/src/client/components/ui/sidebar.tsx +722 -0
- package/src/client/components/ui/skeleton.tsx +13 -0
- package/src/client/components/ui/sonner.tsx +47 -0
- package/src/client/components/ui/switch.tsx +30 -0
- package/src/client/components/ui/table.tsx +116 -0
- package/src/client/components/ui/tabs.tsx +80 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/ui/tooltip.tsx +68 -0
- package/src/client/hooks/use-mobile.ts +19 -0
- package/src/client/index.css +149 -0
- package/src/client/index.ts +7 -0
- package/src/client/layouts/admin-layout.tsx +93 -0
- package/src/client/layouts/settings-layout.tsx +104 -0
- package/src/client/lib/api.ts +72 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +10 -0
- package/src/client/pages/collection-detail.tsx +634 -0
- package/src/client/pages/collections.tsx +180 -0
- package/src/client/pages/dashboard.tsx +133 -0
- package/src/client/pages/device.tsx +66 -0
- package/src/client/pages/document-detail-page.tsx +139 -0
- package/src/client/pages/documents-page.tsx +103 -0
- package/src/client/pages/login.tsx +345 -0
- package/src/client/pages/settings.tsx +65 -0
- package/src/client/pages/setup.tsx +129 -0
- package/src/client/pages/signup.tsx +188 -0
- package/src/client/store/auth.ts +30 -0
- package/src/client/store/collections.ts +13 -0
- package/src/client/store/config.ts +12 -0
- package/src/client/store/fetcher.ts +30 -0
- package/src/client/store/router.ts +95 -0
- package/src/client/store/schema.ts +39 -0
- package/src/client/store/settings.ts +31 -0
- package/src/client/types.ts +34 -0
- package/src/db/dynamic.ts +70 -0
- package/src/db/index.ts +16 -0
- package/src/db/migrations/001_initial_schema.ts +57 -0
- package/src/db/migrations/002_auth_tables.ts +84 -0
- package/src/db/migrator.ts +61 -0
- package/src/db/schema.ts +142 -0
- package/src/index.ts +12 -0
- package/src/server/index.ts +66 -0
- package/src/types.ts +20 -0
- package/style.css.d.ts +8 -0
- package/tests/css.test.ts +21 -0
- package/tests/modular.test.ts +29 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { createDb } from '../../db';
|
|
3
|
+
import { hashPassword, generateSessionToken } from '../../auth';
|
|
4
|
+
import {
|
|
5
|
+
generateRegistrationOptions,
|
|
6
|
+
verifyRegistrationResponse,
|
|
7
|
+
} from '@simplewebauthn/server';
|
|
8
|
+
import { setCookie } from 'hono/cookie';
|
|
9
|
+
import { ulid } from 'ulidx';
|
|
10
|
+
import { encodeBase64url } from '@oslojs/encoding';
|
|
11
|
+
import { setupSchema, webauthnOptionsSchema, webauthnVerifySchema } from '../schemas';
|
|
12
|
+
import type { Bindings } from '../index';
|
|
13
|
+
import { apiResponse } from '../lib/response';
|
|
14
|
+
import { runMigrations } from '../../db';
|
|
15
|
+
|
|
16
|
+
export const setupRoutes = new Hono<{ Bindings: Bindings }>();
|
|
17
|
+
|
|
18
|
+
// GET /api/setup/status
|
|
19
|
+
// Checks if the system is already configured
|
|
20
|
+
setupRoutes.get('/status', async (c) => {
|
|
21
|
+
const db = createDb(c.env.DB);
|
|
22
|
+
try {
|
|
23
|
+
const admin = await db
|
|
24
|
+
.selectFrom('fc_users')
|
|
25
|
+
.select('id')
|
|
26
|
+
.where('role', '=', 'admin')
|
|
27
|
+
.executeTakeFirst();
|
|
28
|
+
|
|
29
|
+
return apiResponse.ok(c, {
|
|
30
|
+
isConfigured: !!admin,
|
|
31
|
+
version: '0.1.0',
|
|
32
|
+
});
|
|
33
|
+
} catch (e: any) {
|
|
34
|
+
// If table doesn't exist, it's definitely not configured
|
|
35
|
+
return apiResponse.ok(c, {
|
|
36
|
+
isConfigured: false,
|
|
37
|
+
needsMigration: true,
|
|
38
|
+
error: e.message,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// POST /api/setup
|
|
44
|
+
// Initial admin creation and system configuration
|
|
45
|
+
setupRoutes.post('/', async (c) => {
|
|
46
|
+
const body = await c.req.json();
|
|
47
|
+
const parsed = setupSchema.safeParse(body);
|
|
48
|
+
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
return apiResponse.error(c, parsed.error.format());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const db = createDb(c.env.DB);
|
|
54
|
+
|
|
55
|
+
// 0. Ensure database tables exist
|
|
56
|
+
try {
|
|
57
|
+
await runMigrations(db);
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
return apiResponse.error(c, `Migration failed: ${e.message}`, 500);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 1. Check if an admin already exists (prevent re-setup)
|
|
63
|
+
const existingAdmin = await db
|
|
64
|
+
.selectFrom('fc_users')
|
|
65
|
+
.select('id')
|
|
66
|
+
.where('role', '=', 'admin')
|
|
67
|
+
.executeTakeFirst();
|
|
68
|
+
|
|
69
|
+
if (existingAdmin) {
|
|
70
|
+
return apiResponse.error(c, 'System is already configured', 403);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { email, password, title } = parsed.data;
|
|
74
|
+
const userId = ulid();
|
|
75
|
+
const hashedPassword = await hashPassword(password);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 2. Create the admin user
|
|
79
|
+
await db
|
|
80
|
+
.insertInto('fc_users')
|
|
81
|
+
.values({
|
|
82
|
+
id: userId,
|
|
83
|
+
email,
|
|
84
|
+
password: hashedPassword,
|
|
85
|
+
role: 'admin',
|
|
86
|
+
disabled: 0,
|
|
87
|
+
})
|
|
88
|
+
.execute();
|
|
89
|
+
|
|
90
|
+
// 3. Save initial settings
|
|
91
|
+
const settings = [
|
|
92
|
+
{ name: 'flare:site_name', value: title },
|
|
93
|
+
{ name: 'flare:signup_enabled', value: 'false' },
|
|
94
|
+
{ name: 'flare:signup_default_role', value: 'viewer' },
|
|
95
|
+
{ name: 'flare:setup_complete', value: 'true' },
|
|
96
|
+
{ name: 'flare:setup_completed_at', value: new Date().toISOString() },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const setting of settings) {
|
|
100
|
+
await db
|
|
101
|
+
.insertInto('options')
|
|
102
|
+
.values(setting)
|
|
103
|
+
.onConflict((oc) =>
|
|
104
|
+
oc.column('name').doUpdateSet({ value: setting.value }),
|
|
105
|
+
)
|
|
106
|
+
.execute();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Auto-login the new admin
|
|
110
|
+
const sessionId = generateSessionToken();
|
|
111
|
+
const expiresAt = new Date();
|
|
112
|
+
expiresAt.setDate(expiresAt.getDate() + 30);
|
|
113
|
+
|
|
114
|
+
await db
|
|
115
|
+
.insertInto('fc_sessions')
|
|
116
|
+
.values({
|
|
117
|
+
id: sessionId,
|
|
118
|
+
user_id: userId,
|
|
119
|
+
expires_at: expiresAt.toISOString(),
|
|
120
|
+
})
|
|
121
|
+
.execute();
|
|
122
|
+
|
|
123
|
+
setCookie(c, 'session', sessionId, {
|
|
124
|
+
httpOnly: true,
|
|
125
|
+
secure: true,
|
|
126
|
+
sameSite: 'Strict',
|
|
127
|
+
expires: expiresAt,
|
|
128
|
+
path: '/',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return apiResponse.ok(c, {
|
|
132
|
+
success: true,
|
|
133
|
+
user: { email, role: 'admin' },
|
|
134
|
+
});
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
return apiResponse.error(c, `Setup failed: ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Passkey Registration Options (Initial Setup)
|
|
141
|
+
setupRoutes.post('/passkey/options', async (c) => {
|
|
142
|
+
const body = await c.req.json();
|
|
143
|
+
const parsed = webauthnOptionsSchema.safeParse(body);
|
|
144
|
+
if (!parsed.success) return apiResponse.error(c, parsed.error.format());
|
|
145
|
+
|
|
146
|
+
const db = createDb(c.env.DB);
|
|
147
|
+
|
|
148
|
+
// 0. Ensure database tables exist
|
|
149
|
+
try {
|
|
150
|
+
await runMigrations(db);
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
return apiResponse.error(c, `Migration check failed: ${e.message}`, 500);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const userCount = await db
|
|
156
|
+
.selectFrom('fc_users')
|
|
157
|
+
.select((eb) => eb.fn.countAll<number>().as('count'))
|
|
158
|
+
.executeTakeFirst();
|
|
159
|
+
|
|
160
|
+
if (userCount && userCount.count > 0) {
|
|
161
|
+
return apiResponse.error(c, 'Setup already complete');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const tempUserId = ulid();
|
|
165
|
+
|
|
166
|
+
const options = await generateRegistrationOptions({
|
|
167
|
+
rpName: 'FlareCMS',
|
|
168
|
+
rpID: new URL(c.req.url).hostname,
|
|
169
|
+
userID: new TextEncoder().encode(tempUserId) as Uint8Array<ArrayBuffer>,
|
|
170
|
+
userName: parsed.data.email,
|
|
171
|
+
attestationType: 'none',
|
|
172
|
+
authenticatorSelection: {
|
|
173
|
+
residentKey: 'required',
|
|
174
|
+
userVerification: 'preferred',
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Save challenge to KV temporarily (300s TTL)
|
|
179
|
+
await c.env.KV.put(
|
|
180
|
+
`webauthn_reg_${parsed.data.email}`,
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
challenge: options.challenge,
|
|
183
|
+
userId: tempUserId,
|
|
184
|
+
}),
|
|
185
|
+
{ expirationTtl: 300 },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return apiResponse.ok(c, options);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Passkey Verification (Initial Setup)
|
|
192
|
+
setupRoutes.post('/passkey/verify', async (c) => {
|
|
193
|
+
const body = await c.req.json();
|
|
194
|
+
const parsed = webauthnVerifySchema.safeParse(body);
|
|
195
|
+
if (!parsed.success) return apiResponse.error(c, parsed.error.format());
|
|
196
|
+
|
|
197
|
+
const db = createDb(c.env.DB);
|
|
198
|
+
|
|
199
|
+
// 0. Ensure database tables exist
|
|
200
|
+
try {
|
|
201
|
+
await runMigrations(db);
|
|
202
|
+
} catch (e: any) {
|
|
203
|
+
return apiResponse.error(c, `Migration check failed: ${e.message}`, 500);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const userCount = await db
|
|
207
|
+
.selectFrom('fc_users')
|
|
208
|
+
.select((eb) => eb.fn.countAll<number>().as('count'))
|
|
209
|
+
.executeTakeFirst();
|
|
210
|
+
if (userCount && userCount.count > 0) {
|
|
211
|
+
return apiResponse.error(c, 'Setup already complete');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const cachedDataStr = await c.env.KV.get(`webauthn_reg_${parsed.data.email}`);
|
|
215
|
+
if (!cachedDataStr) {
|
|
216
|
+
return apiResponse.error(c, 'Registration session expired');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const cachedData = JSON.parse(cachedDataStr);
|
|
220
|
+
|
|
221
|
+
let verification;
|
|
222
|
+
try {
|
|
223
|
+
verification = await verifyRegistrationResponse({
|
|
224
|
+
response: parsed.data.response,
|
|
225
|
+
expectedChallenge: cachedData.challenge,
|
|
226
|
+
expectedOrigin: new URL(c.req.url).origin,
|
|
227
|
+
expectedRPID: new URL(c.req.url).hostname,
|
|
228
|
+
});
|
|
229
|
+
} catch (error: any) {
|
|
230
|
+
return apiResponse.error(c, error.message);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (verification.verified && verification.registrationInfo) {
|
|
234
|
+
const { credential } = verification.registrationInfo;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// Create the Admin User
|
|
238
|
+
await db
|
|
239
|
+
.insertInto('fc_users')
|
|
240
|
+
.values({
|
|
241
|
+
id: cachedData.userId,
|
|
242
|
+
email: parsed.data.email,
|
|
243
|
+
password: null, // Passkey only
|
|
244
|
+
role: 'admin',
|
|
245
|
+
disabled: 0,
|
|
246
|
+
})
|
|
247
|
+
.execute();
|
|
248
|
+
|
|
249
|
+
// Create Passkey
|
|
250
|
+
await db
|
|
251
|
+
.insertInto('fc_passkeys')
|
|
252
|
+
.values({
|
|
253
|
+
id: credential.id,
|
|
254
|
+
user_id: cachedData.userId,
|
|
255
|
+
public_key: encodeBase64url(credential.publicKey),
|
|
256
|
+
counter: credential.counter,
|
|
257
|
+
device_type: verification.registrationInfo.credentialDeviceType,
|
|
258
|
+
backed_up: verification.registrationInfo.credentialBackedUp ? 1 : 0,
|
|
259
|
+
transports: JSON.stringify(
|
|
260
|
+
parsed.data.response.response.transports || [],
|
|
261
|
+
),
|
|
262
|
+
})
|
|
263
|
+
.execute();
|
|
264
|
+
|
|
265
|
+
await db
|
|
266
|
+
.insertInto('options')
|
|
267
|
+
.values([
|
|
268
|
+
{ name: 'flare:setup_complete', value: 'true' },
|
|
269
|
+
{ name: 'flare:site_title', value: 'FlareCMS (Passkey Setup)' },
|
|
270
|
+
])
|
|
271
|
+
.execute();
|
|
272
|
+
|
|
273
|
+
// Generate session directly for setup flow convenience
|
|
274
|
+
const sessionId = generateSessionToken();
|
|
275
|
+
const expiresAt = new Date();
|
|
276
|
+
expiresAt.setDate(expiresAt.getDate() + 30);
|
|
277
|
+
|
|
278
|
+
await db
|
|
279
|
+
.insertInto('fc_sessions')
|
|
280
|
+
.values({
|
|
281
|
+
id: sessionId,
|
|
282
|
+
user_id: cachedData.userId,
|
|
283
|
+
expires_at: expiresAt.toISOString(),
|
|
284
|
+
})
|
|
285
|
+
.execute();
|
|
286
|
+
|
|
287
|
+
setCookie(c, 'session', sessionId, {
|
|
288
|
+
httpOnly: true,
|
|
289
|
+
secure: true,
|
|
290
|
+
sameSite: 'Strict',
|
|
291
|
+
expires: expiresAt,
|
|
292
|
+
path: '/',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await c.env.KV.delete(`webauthn_reg_${parsed.data.email}`);
|
|
296
|
+
|
|
297
|
+
return apiResponse.ok(c, {
|
|
298
|
+
success: true,
|
|
299
|
+
message: 'Setup completed with Passkey',
|
|
300
|
+
});
|
|
301
|
+
} catch (e: any) {
|
|
302
|
+
return apiResponse.error(c, `Setup failed during storage: ${e.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return apiResponse.error(c, 'Passkey verification failed');
|
|
307
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { createDb } from '../../db';
|
|
3
|
+
import { ulid } from 'ulidx';
|
|
4
|
+
import { tokenCreateSchema } from '../schemas/tokens';
|
|
5
|
+
import { encodeHexLowerCase } from '@oslojs/encoding';
|
|
6
|
+
import type { Bindings, Variables } from '../index';
|
|
7
|
+
import { requireRole } from '../middlewares/rbac';
|
|
8
|
+
import { apiResponse } from '../lib/response';
|
|
9
|
+
|
|
10
|
+
export const tokenRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>();
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// Tokens can only be managed by admins for now
|
|
14
|
+
tokenRoutes.use('*', requireRole(['admin']));
|
|
15
|
+
|
|
16
|
+
// Generate Personal Access Token
|
|
17
|
+
tokenRoutes.post('/', async (c) => {
|
|
18
|
+
const body = await c.req.json();
|
|
19
|
+
const parsed = tokenCreateSchema.safeParse(body);
|
|
20
|
+
if (!parsed.success) return apiResponse.error(c, parsed.error.format());
|
|
21
|
+
|
|
22
|
+
const db = createDb(c.env.DB);
|
|
23
|
+
const user = c.get('user');
|
|
24
|
+
|
|
25
|
+
const randomBytes = new Uint8Array(24);
|
|
26
|
+
crypto.getRandomValues(randomBytes);
|
|
27
|
+
const suffix = encodeHexLowerCase(randomBytes);
|
|
28
|
+
|
|
29
|
+
// Example token output: ec_pat_01H..._a1b2c3d4
|
|
30
|
+
const tokenId = `ec_pat_${ulid()}`;
|
|
31
|
+
const fullToken = `${tokenId}_${suffix}`;
|
|
32
|
+
|
|
33
|
+
// Here we only hash the suffix for storage for security
|
|
34
|
+
// but save tokenId as Primary Key for quick lookups
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(suffix));
|
|
37
|
+
const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
38
|
+
|
|
39
|
+
await db.insertInto('fc_api_tokens')
|
|
40
|
+
.values({
|
|
41
|
+
id: tokenId,
|
|
42
|
+
user_id: user.id,
|
|
43
|
+
name: parsed.data.name,
|
|
44
|
+
hash: hashHex,
|
|
45
|
+
scopes: JSON.stringify(parsed.data.scopes),
|
|
46
|
+
expires_at: null,
|
|
47
|
+
last_used_at: null,
|
|
48
|
+
})
|
|
49
|
+
.execute();
|
|
50
|
+
|
|
51
|
+
// Return the full unhashed token ONLY ONCE
|
|
52
|
+
return apiResponse.ok(c, { token: fullToken, id: tokenId, name: parsed.data.name });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// List User Tokens
|
|
56
|
+
tokenRoutes.get('/', async (c) => {
|
|
57
|
+
const db = createDb(c.env.DB);
|
|
58
|
+
const user = c.get('user');
|
|
59
|
+
|
|
60
|
+
const tokens = await db.selectFrom('fc_api_tokens')
|
|
61
|
+
.select(['id', 'name', 'scopes', 'created_at', 'last_used_at'])
|
|
62
|
+
.where('user_id', '=', user.id)
|
|
63
|
+
.execute();
|
|
64
|
+
|
|
65
|
+
return apiResponse.ok(c, tokens.map(t => ({ ...t, scopes: JSON.parse(t.scopes) })));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Revoke Token
|
|
69
|
+
tokenRoutes.delete('/:id', async (c) => {
|
|
70
|
+
const id = c.req.param('id');
|
|
71
|
+
const db = createDb(c.env.DB);
|
|
72
|
+
const user = c.get('user');
|
|
73
|
+
|
|
74
|
+
await db.deleteFrom('fc_api_tokens')
|
|
75
|
+
.where('id', '=', id)
|
|
76
|
+
.where('user_id', '=', user.id) // Only allow revoking own tokens
|
|
77
|
+
.execute();
|
|
78
|
+
|
|
79
|
+
return apiResponse.ok(c, { success: true });
|
|
80
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const magicLinkRequestSchema = z.object({
|
|
4
|
+
email: z.string().email('Valid email required'),
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const magicLinkVerifySchema = z.object({
|
|
8
|
+
email: z.string().email(),
|
|
9
|
+
token: z.string().min(1, 'Token is required'),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const oauthCallbackSchema = z.object({
|
|
13
|
+
code: z.string().min(1, 'Authorization code is required'),
|
|
14
|
+
state: z.string().optional()
|
|
15
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const loginSchema = z.object({
|
|
4
|
+
email: z.email({ message: 'Invalid email address' }),
|
|
5
|
+
password: z.string().min(1, { message: 'Password is required' }),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const signupSchema = z.object({
|
|
9
|
+
email: z.email({ message: 'Invalid email address' }),
|
|
10
|
+
password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const collectionSchema = z.object({
|
|
14
|
+
slug: z.string().min(1, { message: 'Slug is required' }),
|
|
15
|
+
label: z.string().min(1, { message: 'Label is required' }),
|
|
16
|
+
labelSingular: z.string().optional(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
icon: z.string().optional(),
|
|
19
|
+
isPublic: z.boolean().optional(),
|
|
20
|
+
features: z.array(z.string()).optional(),
|
|
21
|
+
urlPattern: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const fieldSchema = z.object({
|
|
25
|
+
slug: z.string().min(1, { message: 'Slug is required' }),
|
|
26
|
+
label: z.string().min(1, { message: 'Label is required' }),
|
|
27
|
+
type: z.string().min(1, { message: 'Type is required' }),
|
|
28
|
+
required: z.boolean().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const setupSchema = z.object({
|
|
32
|
+
title: z.string().min(1, { message: 'Site title is required' }),
|
|
33
|
+
email: z.email({ message: 'Invalid email address' }),
|
|
34
|
+
password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
|
|
35
|
+
name: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const dynamicContentSchema = z.object({
|
|
39
|
+
slug: z.string().optional(),
|
|
40
|
+
status: z.string().optional(),
|
|
41
|
+
title: z.string().optional(),
|
|
42
|
+
}).loose();
|
|
43
|
+
|
|
44
|
+
export const webauthnOptionsSchema = z.object({
|
|
45
|
+
email: z.string().email()
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const webauthnVerifySchema = z.object({
|
|
49
|
+
email: z.string().email(),
|
|
50
|
+
response: z.any()
|
|
51
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const tokenCreateSchema = z.object({
|
|
4
|
+
name: z.string().min(1, 'Name is required'),
|
|
5
|
+
scopes: z.array(z.object({
|
|
6
|
+
resource: z.string(),
|
|
7
|
+
actions: z.array(z.string()),
|
|
8
|
+
})).min(1, 'At least one scope is required'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const deviceCodeRequestSchema = z.object({
|
|
12
|
+
client_id: z.string(),
|
|
13
|
+
scope: z.string().optional() // Space separated scopes
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const deviceTokenRequestSchema = z.object({
|
|
17
|
+
client_id: z.string(),
|
|
18
|
+
device_code: z.string(),
|
|
19
|
+
grant_type: z.literal('urn:ietf:params:oauth:grant-type:device_code')
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const deviceApproveSchema = z.object({
|
|
23
|
+
user_code: z.string()
|
|
24
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { decodeHex, encodeHexLowerCase } from "@oslojs/encoding";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a random token for sessions
|
|
5
|
+
*/
|
|
6
|
+
export function generateSessionToken(): string {
|
|
7
|
+
const bytes = new Uint8Array(20);
|
|
8
|
+
crypto.getRandomValues(bytes);
|
|
9
|
+
return encodeHexLowerCase(bytes);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hash a password using SHA-256 (for ultra-lightweight worker usage)
|
|
14
|
+
* Note: In a real production app, you might want Scrypt or Argon2id,
|
|
15
|
+
* but those can be slow on the Edge. SHA-256 + Salt is a light starting point.
|
|
16
|
+
*/
|
|
17
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
const data = encoder.encode(password);
|
|
20
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
21
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
22
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
26
|
+
const newHash = await hashPassword(password);
|
|
27
|
+
return newHash === hash;
|
|
28
|
+
}
|