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,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
+ }