@velastack/cms 0.1.0 → 0.2.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 +1 -1
- package/dist/backend/auth/editors.d.ts +51 -0
- package/dist/backend/auth/editors.js +157 -0
- package/dist/backend/auth/local-editors.d.ts +22 -0
- package/dist/backend/auth/local-editors.js +44 -0
- package/dist/backend/auth/scrypt.d.ts +20 -0
- package/dist/backend/auth/scrypt.js +80 -0
- package/dist/backend/factory.d.ts +25 -0
- package/dist/backend/factory.js +202 -0
- package/dist/backend/http.d.ts +34 -0
- package/dist/backend/http.js +79 -0
- package/dist/backend/index.d.ts +45 -0
- package/dist/backend/index.js +39 -0
- package/dist/backend/router.d.ts +52 -0
- package/dist/backend/router.js +57 -0
- package/dist/backend/routes/context.d.ts +46 -0
- package/dist/backend/routes/context.js +30 -0
- package/dist/backend/routes/docs.d.ts +2 -0
- package/dist/backend/routes/docs.js +150 -0
- package/dist/backend/routes/login.d.ts +19 -0
- package/dist/backend/routes/login.js +155 -0
- package/dist/backend/routes/media.d.ts +22 -0
- package/dist/backend/routes/media.js +119 -0
- package/dist/backend/routes/pages.d.ts +19 -0
- package/dist/backend/routes/pages.js +221 -0
- package/dist/backend/routes/release.d.ts +10 -0
- package/dist/backend/routes/release.js +147 -0
- package/dist/backend/routes/site.d.ts +2 -0
- package/dist/backend/routes/site.js +40 -0
- package/dist/backend/routes/table.d.ts +11 -0
- package/dist/backend/routes/table.js +72 -0
- package/dist/backend/routes/test-reset.d.ts +15 -0
- package/dist/backend/routes/test-reset.js +112 -0
- package/dist/backend/routes/user.d.ts +14 -0
- package/dist/backend/routes/user.js +21 -0
- package/dist/backend/storage.d.ts +18 -0
- package/dist/backend/storage.js +45 -0
- package/dist/backend/store/cache.d.ts +34 -0
- package/dist/backend/store/cache.js +38 -0
- package/dist/backend/store/db.d.ts +10 -0
- package/dist/backend/store/db.js +29 -0
- package/dist/backend/store/migrate.d.ts +10 -0
- package/dist/backend/store/migrate.js +30 -0
- package/dist/backend/store/migrations/0001-init.d.ts +1 -0
- package/dist/backend/store/migrations/0001-init.js +129 -0
- package/dist/backend/store/migrations/0002-site.d.ts +1 -0
- package/dist/backend/store/migrations/0002-site.js +86 -0
- package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
- package/dist/backend/store/migrations/0003-editors.js +44 -0
- package/dist/backend/store/migrations/index.d.ts +15 -0
- package/dist/backend/store/migrations/index.js +18 -0
- package/dist/backend/store/queries.d.ts +67 -0
- package/dist/backend/store/queries.js +1207 -0
- package/dist/backend/store/sqlite.d.ts +22 -0
- package/dist/backend/store/sqlite.js +29 -0
- package/dist/backend/testing/fixtures.d.ts +39 -0
- package/dist/backend/testing/fixtures.js +61 -0
- package/dist/backend/testing/harness.d.ts +35 -0
- package/dist/backend/testing/harness.js +149 -0
- package/dist/backend/types.d.ts +102 -0
- package/dist/cms.d.ts +3 -3
- package/dist/cms.js +1 -1
- package/dist/components/admin-bar/publish-dialog.svelte +1 -1
- package/dist/components/cms/cms-image-editable.svelte +1 -1
- package/dist/components/cms/cms-store.svelte.d.ts +3 -68
- package/dist/components/cms/cms-store.svelte.js +25 -10
- package/dist/components/cms/locale-merge.d.ts +1 -1
- package/dist/components/cms/locale-merge.js +1 -1
- package/dist/components/cms/scope.d.ts +1 -1
- package/dist/core/page-entry.d.ts +43 -0
- package/dist/core/page-entry.js +21 -0
- package/dist/{components/cms → core}/path.d.ts +13 -0
- package/dist/{components/cms → core}/path.js +36 -0
- package/dist/core/wire.d.ts +182 -0
- package/dist/core/wire.js +1 -0
- package/dist/{srv → server}/api-adapter.js +2 -2
- package/dist/{srv → server}/load-cms.js +1 -1
- package/dist/{srv → server}/mock-adapter.d.ts +3 -24
- package/dist/{srv → server}/mock-adapter.js +5 -22
- package/dist/{srv → server}/types.d.ts +2 -6
- package/dist/server/types.js +1 -0
- package/package.json +13 -4
- package/plugin/dist/index.js +7 -0
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/media.d.ts +16 -7
- package/plugin/dist/media.js +23 -10
- package/plugin/dist/media.js.map +1 -1
- /package/dist/{srv → backend}/types.js +0 -0
- /package/dist/{srv → server}/README.md +0 -0
- /package/dist/{srv → server}/api-adapter.d.ts +0 -0
- /package/dist/{srv → server}/cms.d.ts +0 -0
- /package/dist/{srv → server}/cms.js +0 -0
- /package/dist/{srv → server}/index.d.ts +0 -0
- /package/dist/{srv → server}/index.js +0 -0
- /package/dist/{srv → server}/load-cms.d.ts +0 -0
package/README.md
CHANGED
|
@@ -635,7 +635,7 @@ plugin/src/ Vite plugin (route discovery, AST parse, manifest builder, tran
|
|
|
635
635
|
src/lib/ Public library
|
|
636
636
|
components/cms/ Display + editable components, scope helpers, store
|
|
637
637
|
components/admin-bar/ Admin bar wrapper + async internal + SEO panel
|
|
638
|
-
|
|
638
|
+
server/ loadCms, mockAdapter, types
|
|
639
639
|
src/routes/ Test harness / showcase
|
|
640
640
|
```
|
|
641
641
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SqliteDb } from '../store/sqlite.js';
|
|
2
|
+
import type { CmsEditor } from '../types.js';
|
|
3
|
+
import { type ScryptParams } from './scrypt.js';
|
|
4
|
+
export type EditorRow = {
|
|
5
|
+
id: string;
|
|
6
|
+
email: string;
|
|
7
|
+
name: string;
|
|
8
|
+
password_hash: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
disabled_at: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type CreateEditorInput = {
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
/** Projects to grant immediately. Equivalent to `grant()` per id. */
|
|
17
|
+
projects?: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare const createEditorStore: (db: SqliteDb, scryptParams?: ScryptParams) => {
|
|
20
|
+
/** Create an account. Throws on a duplicate email (the unique index on
|
|
21
|
+
* `email_lower` is the arbiter, so case doesn't let one through). */
|
|
22
|
+
create(input: CreateEditorInput): Promise<CmsEditor>;
|
|
23
|
+
setPassword(idOrEmail: string, password: string): Promise<void>;
|
|
24
|
+
/** Verify credentials. `null` for an unknown email, a wrong password, or
|
|
25
|
+
* a disabled account — callers must not distinguish between them. */
|
|
26
|
+
verify(email: string, password: string): Promise<CmsEditor | null>;
|
|
27
|
+
find: (idOrEmail: string) => CmsEditor | null;
|
|
28
|
+
list: () => CmsEditor[];
|
|
29
|
+
listForProject: (projectId: string) => CmsEditor[];
|
|
30
|
+
delete: (idOrEmail: string) => boolean;
|
|
31
|
+
setDisabled: (idOrEmail: string, disabled: boolean) => void;
|
|
32
|
+
grant: (idOrEmail: string, projectId: string) => void;
|
|
33
|
+
revoke: (idOrEmail: string, projectId: string) => boolean;
|
|
34
|
+
can: (editorId: string, projectId: string) => boolean;
|
|
35
|
+
projectsFor: (idOrEmail: string) => string[];
|
|
36
|
+
/** Mint a session and return the raw token for the cookie. Only the
|
|
37
|
+
* digest is stored. */
|
|
38
|
+
createSession: (editorId: string, opts?: {
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
}) => CmsSessionToken;
|
|
41
|
+
/** Resolve a raw token to its editor, or `null` when unknown, expired, or
|
|
42
|
+
* belonging to a disabled account. */
|
|
43
|
+
resolveSession: (token: string) => CmsEditor | null;
|
|
44
|
+
destroySession: (token: string) => void;
|
|
45
|
+
purgeExpiredSessions: () => number;
|
|
46
|
+
};
|
|
47
|
+
export type CmsSessionToken = {
|
|
48
|
+
token: string;
|
|
49
|
+
expiresAt: Date;
|
|
50
|
+
};
|
|
51
|
+
export type CmsEditorStore = ReturnType<typeof createEditorStore>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS's own editor directory: accounts, per-project grants, and sessions.
|
|
3
|
+
*
|
|
4
|
+
* Bound to one database, like the content store. Everything here is
|
|
5
|
+
* synchronous except the two password paths, which go through scrypt.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
8
|
+
import { hashPassword, verifyPassword } from './scrypt.js';
|
|
9
|
+
const nowIso = () => new Date().toISOString();
|
|
10
|
+
/** Sessions are keyed by digest, never by the token itself, so the table is
|
|
11
|
+
* useless to anyone who reads it. */
|
|
12
|
+
const tokenHash = (token) => createHash('sha256').update(token).digest('hex');
|
|
13
|
+
const toEditor = (row) => ({
|
|
14
|
+
id: row.id,
|
|
15
|
+
email: row.email,
|
|
16
|
+
name: row.name
|
|
17
|
+
});
|
|
18
|
+
const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
19
|
+
export const createEditorStore = (db, scryptParams) => {
|
|
20
|
+
const insertEditor = db.prepare(`INSERT INTO cms_editors (id, email, email_lower, name, password_hash, created_at)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
22
|
+
const byEmail = db.prepare('SELECT id, email, name, password_hash, created_at, disabled_at FROM cms_editors WHERE email_lower = ?');
|
|
23
|
+
const byId = db.prepare('SELECT id, email, name, password_hash, created_at, disabled_at FROM cms_editors WHERE id = ?');
|
|
24
|
+
const listAll = db.prepare('SELECT id, email, name, password_hash, created_at, disabled_at FROM cms_editors ORDER BY created_at, id');
|
|
25
|
+
const updateHash = db.prepare('UPDATE cms_editors SET password_hash = ? WHERE id = ?');
|
|
26
|
+
const removeEditor = db.prepare('DELETE FROM cms_editors WHERE id = ?');
|
|
27
|
+
const setDisabled = db.prepare('UPDATE cms_editors SET disabled_at = ? WHERE id = ?');
|
|
28
|
+
const insertGrant = db.prepare(`INSERT INTO cms_project_editors (project_id, editor_id, role, granted_at)
|
|
29
|
+
VALUES (?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT (project_id, editor_id) DO NOTHING`);
|
|
31
|
+
const removeGrant = db.prepare('DELETE FROM cms_project_editors WHERE project_id = ? AND editor_id = ?');
|
|
32
|
+
const hasGrant = db.prepare('SELECT 1 AS one FROM cms_project_editors WHERE project_id = ? AND editor_id = ?');
|
|
33
|
+
const grantsFor = db.prepare('SELECT project_id FROM cms_project_editors WHERE editor_id = ? ORDER BY project_id');
|
|
34
|
+
const editorsFor = db.prepare(`SELECT e.id, e.email, e.name, e.password_hash, e.created_at, e.disabled_at
|
|
35
|
+
FROM cms_editors e
|
|
36
|
+
JOIN cms_project_editors pe ON pe.editor_id = e.id
|
|
37
|
+
WHERE pe.project_id = ?
|
|
38
|
+
ORDER BY e.created_at, e.id`);
|
|
39
|
+
const insertSession = db.prepare('INSERT INTO cms_sessions (token_hash, editor_id, created_at, expires_at) VALUES (?, ?, ?, ?)');
|
|
40
|
+
const sessionEditor = db.prepare(`SELECT e.id, e.email, e.name, e.password_hash, e.created_at, e.disabled_at
|
|
41
|
+
FROM cms_sessions s
|
|
42
|
+
JOIN cms_editors e ON e.id = s.editor_id
|
|
43
|
+
WHERE s.token_hash = ? AND s.expires_at > ?`);
|
|
44
|
+
const removeSession = db.prepare('DELETE FROM cms_sessions WHERE token_hash = ?');
|
|
45
|
+
const removeEditorSessions = db.prepare('DELETE FROM cms_sessions WHERE editor_id = ?');
|
|
46
|
+
const removeExpired = db.prepare('DELETE FROM cms_sessions WHERE expires_at <= ?');
|
|
47
|
+
const find = (idOrEmail) => byId.get(idOrEmail) ?? byEmail.get(idOrEmail.toLowerCase());
|
|
48
|
+
return {
|
|
49
|
+
/** Create an account. Throws on a duplicate email (the unique index on
|
|
50
|
+
* `email_lower` is the arbiter, so case doesn't let one through). */
|
|
51
|
+
async create(input) {
|
|
52
|
+
const email = input.email.trim();
|
|
53
|
+
if (!email.includes('@'))
|
|
54
|
+
throw new Error(`invalid email: ${input.email}`);
|
|
55
|
+
if (input.password.length === 0)
|
|
56
|
+
throw new Error('password required');
|
|
57
|
+
if (byEmail.get(email.toLowerCase())) {
|
|
58
|
+
throw new Error(`an editor with the email ${email} already exists`);
|
|
59
|
+
}
|
|
60
|
+
const id = randomUUID();
|
|
61
|
+
const hash = await hashPassword(input.password, scryptParams);
|
|
62
|
+
insertEditor.run(id, email, email.toLowerCase(), input.name ?? email, hash, nowIso());
|
|
63
|
+
for (const projectId of input.projects ?? []) {
|
|
64
|
+
insertGrant.run(projectId, id, 'editor', nowIso());
|
|
65
|
+
}
|
|
66
|
+
return { id, email, name: input.name ?? email };
|
|
67
|
+
},
|
|
68
|
+
async setPassword(idOrEmail, password) {
|
|
69
|
+
const row = find(idOrEmail);
|
|
70
|
+
if (!row)
|
|
71
|
+
throw new Error(`no such editor: ${idOrEmail}`);
|
|
72
|
+
if (password.length === 0)
|
|
73
|
+
throw new Error('password required');
|
|
74
|
+
updateHash.run(await hashPassword(password, scryptParams), row.id);
|
|
75
|
+
// A password change ends every existing session for that editor.
|
|
76
|
+
removeEditorSessions.run(row.id);
|
|
77
|
+
},
|
|
78
|
+
/** Verify credentials. `null` for an unknown email, a wrong password, or
|
|
79
|
+
* a disabled account — callers must not distinguish between them. */
|
|
80
|
+
async verify(email, password) {
|
|
81
|
+
const row = byEmail.get(email.trim().toLowerCase());
|
|
82
|
+
if (!row) {
|
|
83
|
+
// Hash anyway so a missing account isn't measurably faster than a
|
|
84
|
+
// wrong password.
|
|
85
|
+
await verifyPassword(password, 'scrypt$16384$8$1$AAAA$AAAA');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (row.disabled_at)
|
|
89
|
+
return null;
|
|
90
|
+
if (!(await verifyPassword(password, row.password_hash)))
|
|
91
|
+
return null;
|
|
92
|
+
return toEditor(row);
|
|
93
|
+
},
|
|
94
|
+
find: (idOrEmail) => {
|
|
95
|
+
const row = find(idOrEmail);
|
|
96
|
+
return row ? toEditor(row) : null;
|
|
97
|
+
},
|
|
98
|
+
list: () => listAll.all().map(toEditor),
|
|
99
|
+
listForProject: (projectId) => editorsFor.all(projectId).map(toEditor),
|
|
100
|
+
delete: (idOrEmail) => {
|
|
101
|
+
const row = find(idOrEmail);
|
|
102
|
+
if (!row)
|
|
103
|
+
return false;
|
|
104
|
+
return removeEditor.run(row.id).changes > 0;
|
|
105
|
+
},
|
|
106
|
+
setDisabled: (idOrEmail, disabled) => {
|
|
107
|
+
const row = find(idOrEmail);
|
|
108
|
+
if (!row)
|
|
109
|
+
throw new Error(`no such editor: ${idOrEmail}`);
|
|
110
|
+
setDisabled.run(disabled ? nowIso() : null, row.id);
|
|
111
|
+
if (disabled)
|
|
112
|
+
removeEditorSessions.run(row.id);
|
|
113
|
+
},
|
|
114
|
+
grant: (idOrEmail, projectId) => {
|
|
115
|
+
const row = find(idOrEmail);
|
|
116
|
+
if (!row)
|
|
117
|
+
throw new Error(`no such editor: ${idOrEmail}`);
|
|
118
|
+
insertGrant.run(projectId, row.id, 'editor', nowIso());
|
|
119
|
+
},
|
|
120
|
+
revoke: (idOrEmail, projectId) => {
|
|
121
|
+
const row = find(idOrEmail);
|
|
122
|
+
if (!row)
|
|
123
|
+
return false;
|
|
124
|
+
return removeGrant.run(projectId, row.id).changes > 0;
|
|
125
|
+
},
|
|
126
|
+
can: (editorId, projectId) => hasGrant.get(projectId, editorId) !== undefined,
|
|
127
|
+
projectsFor: (idOrEmail) => {
|
|
128
|
+
const row = find(idOrEmail);
|
|
129
|
+
if (!row)
|
|
130
|
+
return [];
|
|
131
|
+
return grantsFor.all(row.id).map((r) => r.project_id);
|
|
132
|
+
},
|
|
133
|
+
/** Mint a session and return the raw token for the cookie. Only the
|
|
134
|
+
* digest is stored. */
|
|
135
|
+
createSession: (editorId, opts = {}) => {
|
|
136
|
+
const token = randomBytes(32).toString('base64url');
|
|
137
|
+
const expiresAt = new Date(Date.now() + (opts.ttlMs ?? DEFAULT_SESSION_TTL_MS));
|
|
138
|
+
insertSession.run(tokenHash(token), editorId, nowIso(), expiresAt.toISOString());
|
|
139
|
+
return { token, expiresAt };
|
|
140
|
+
},
|
|
141
|
+
/** Resolve a raw token to its editor, or `null` when unknown, expired, or
|
|
142
|
+
* belonging to a disabled account. */
|
|
143
|
+
resolveSession: (token) => {
|
|
144
|
+
if (!token)
|
|
145
|
+
return null;
|
|
146
|
+
const row = sessionEditor.get(tokenHash(token), nowIso());
|
|
147
|
+
if (!row || row.disabled_at)
|
|
148
|
+
return null;
|
|
149
|
+
return toEditor(row);
|
|
150
|
+
},
|
|
151
|
+
destroySession: (token) => {
|
|
152
|
+
if (token)
|
|
153
|
+
removeSession.run(tokenHash(token));
|
|
154
|
+
},
|
|
155
|
+
purgeExpiredSessions: () => removeExpired.run(nowIso()).changes
|
|
156
|
+
};
|
|
157
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in {@link CmsAuthAdapter}: the CMS owns its editors.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained — no external identity provider, no second service — which is
|
|
5
|
+
* what makes a single-tenant mount work out of the box. A host that already has
|
|
6
|
+
* accounts writes its own adapter instead; the router only ever sees this
|
|
7
|
+
* interface.
|
|
8
|
+
*/
|
|
9
|
+
import type { CmsAuthAdapter } from '../types.js';
|
|
10
|
+
import type { ScryptParams } from './scrypt.js';
|
|
11
|
+
export type LocalEditorsOptions = {
|
|
12
|
+
/** Session lifetime. Defaults to 30 days, matching the cookie's `maxAge`. */
|
|
13
|
+
sessionTtlMs?: number;
|
|
14
|
+
/** Password work factor. Lower it only in tests. */
|
|
15
|
+
scrypt?: ScryptParams;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The adapter caches one editor store per database. `resolve` runs on every
|
|
19
|
+
* request, and re-preparing a dozen statements each time would be wasteful; the
|
|
20
|
+
* store's own statements are prepared once and reused.
|
|
21
|
+
*/
|
|
22
|
+
export declare const localEditors: (options?: LocalEditorsOptions) => CmsAuthAdapter;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createEditorStore } from './editors.js';
|
|
2
|
+
/**
|
|
3
|
+
* The adapter caches one editor store per database. `resolve` runs on every
|
|
4
|
+
* request, and re-preparing a dozen statements each time would be wasteful; the
|
|
5
|
+
* store's own statements are prepared once and reused.
|
|
6
|
+
*/
|
|
7
|
+
export const localEditors = (options = {}) => {
|
|
8
|
+
const stores = new WeakMap();
|
|
9
|
+
const storeFor = (ctx) => {
|
|
10
|
+
const key = ctx.db;
|
|
11
|
+
let store = stores.get(key);
|
|
12
|
+
if (!store) {
|
|
13
|
+
store = createEditorStore(ctx.db, options.scrypt);
|
|
14
|
+
stores.set(key, store);
|
|
15
|
+
}
|
|
16
|
+
return store;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
async resolve(event, ctx) {
|
|
20
|
+
const token = event.cookies.get(ctx.cookieName);
|
|
21
|
+
if (!token)
|
|
22
|
+
return null;
|
|
23
|
+
return storeFor(ctx).resolveSession(token);
|
|
24
|
+
},
|
|
25
|
+
async authorize(user, projectId, ctx) {
|
|
26
|
+
return storeFor(ctx).can(user.id, projectId);
|
|
27
|
+
},
|
|
28
|
+
async login(email, password, ctx) {
|
|
29
|
+
const store = storeFor(ctx);
|
|
30
|
+
const user = await store.verify(email, password);
|
|
31
|
+
if (!user)
|
|
32
|
+
return null;
|
|
33
|
+
const { token, expiresAt } = store.createSession(user.id, {
|
|
34
|
+
ttlMs: options.sessionTtlMs
|
|
35
|
+
});
|
|
36
|
+
return { user, token, expiresAt };
|
|
37
|
+
},
|
|
38
|
+
async logout(event, ctx) {
|
|
39
|
+
const token = event.cookies.get(ctx.cookieName);
|
|
40
|
+
if (token)
|
|
41
|
+
storeFor(ctx).destroySession(token);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ScryptParams = {
|
|
2
|
+
/** CPU/memory cost. Must be a power of two. */
|
|
3
|
+
N: number;
|
|
4
|
+
/** Block size. */
|
|
5
|
+
r: number;
|
|
6
|
+
/** Parallelisation. */
|
|
7
|
+
p: number;
|
|
8
|
+
};
|
|
9
|
+
/** OWASP's floor for scrypt at the time of writing. */
|
|
10
|
+
export declare const DEFAULT_PARAMS: ScryptParams;
|
|
11
|
+
/**
|
|
12
|
+
* `scrypt$N$r$p$salt$hash`, salt and hash base64url. Self-describing, so
|
|
13
|
+
* {@link verifyPassword} needs no configuration to read one back.
|
|
14
|
+
*/
|
|
15
|
+
export declare const hashPassword: (password: string, params?: ScryptParams) => Promise<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Constant-time verify. Returns `false` rather than throwing for a malformed or
|
|
18
|
+
* unrecognised hash, so a corrupt row denies access instead of 500ing.
|
|
19
|
+
*/
|
|
20
|
+
export declare const verifyPassword: (password: string, stored: string) => Promise<boolean>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing on `node:crypto` alone — no dependency, native or otherwise.
|
|
3
|
+
*
|
|
4
|
+
* scrypt is memory-hard, which is what makes a stolen `cms_editors` table
|
|
5
|
+
* expensive to attack offline. The parameters are stored alongside each hash so
|
|
6
|
+
* they can be raised later without invalidating existing passwords: a verify
|
|
7
|
+
* reads the cost the hash was written with, and a re-hash on next login is a
|
|
8
|
+
* separate, additive change.
|
|
9
|
+
*
|
|
10
|
+
* The async form is used on the login path deliberately. `scryptSync` at these
|
|
11
|
+
* parameters blocks the event loop for ~100ms, which on a shared server is a
|
|
12
|
+
* denial-of-service vector against every other in-flight request.
|
|
13
|
+
*/
|
|
14
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
/** OWASP's floor for scrypt at the time of writing. */
|
|
16
|
+
export const DEFAULT_PARAMS = { N: 16384, r: 8, p: 1 };
|
|
17
|
+
const KEY_LENGTH = 64;
|
|
18
|
+
const derive = (password, salt, params) => new Promise((resolve, reject) => {
|
|
19
|
+
scrypt(password, salt, KEY_LENGTH, {
|
|
20
|
+
N: params.N,
|
|
21
|
+
r: params.r,
|
|
22
|
+
p: params.p,
|
|
23
|
+
// Node's default cap is 32 MB, which N=16384/r=8 exceeds.
|
|
24
|
+
maxmem: 256 * 1024 * 1024
|
|
25
|
+
}, (err, key) => (err ? reject(err) : resolve(key)));
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* `scrypt$N$r$p$salt$hash`, salt and hash base64url. Self-describing, so
|
|
29
|
+
* {@link verifyPassword} needs no configuration to read one back.
|
|
30
|
+
*/
|
|
31
|
+
export const hashPassword = async (password, params = DEFAULT_PARAMS) => {
|
|
32
|
+
const salt = randomBytes(16);
|
|
33
|
+
const key = await derive(password, salt, params);
|
|
34
|
+
return [
|
|
35
|
+
'scrypt',
|
|
36
|
+
params.N,
|
|
37
|
+
params.r,
|
|
38
|
+
params.p,
|
|
39
|
+
salt.toString('base64url'),
|
|
40
|
+
key.toString('base64url')
|
|
41
|
+
].join('$');
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Constant-time verify. Returns `false` rather than throwing for a malformed or
|
|
45
|
+
* unrecognised hash, so a corrupt row denies access instead of 500ing.
|
|
46
|
+
*/
|
|
47
|
+
export const verifyPassword = async (password, stored) => {
|
|
48
|
+
const parts = stored.split('$');
|
|
49
|
+
if (parts.length !== 6 || parts[0] !== 'scrypt')
|
|
50
|
+
return false;
|
|
51
|
+
const N = Number(parts[1]);
|
|
52
|
+
const r = Number(parts[2]);
|
|
53
|
+
const p = Number(parts[3]);
|
|
54
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p))
|
|
55
|
+
return false;
|
|
56
|
+
let salt;
|
|
57
|
+
let expected;
|
|
58
|
+
try {
|
|
59
|
+
salt = Buffer.from(parts[4], 'base64url');
|
|
60
|
+
expected = Buffer.from(parts[5], 'base64url');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (salt.length === 0 || expected.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
let actual;
|
|
68
|
+
try {
|
|
69
|
+
actual = await derive(password, salt, { N, r, p });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// timingSafeEqual throws on a length mismatch, which is itself public
|
|
75
|
+
// information (it's in the stored hash), so comparing lengths first leaks
|
|
76
|
+
// nothing.
|
|
77
|
+
if (actual.length !== expected.length)
|
|
78
|
+
return false;
|
|
79
|
+
return timingSafeEqual(actual, expected);
|
|
80
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RequestEvent, RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import { type CmsStore } from './store/queries.js';
|
|
3
|
+
import { type ReadCache } from './store/cache.js';
|
|
4
|
+
import type { SqliteDb } from './store/sqlite.js';
|
|
5
|
+
import { type CmsEditorStore } from './auth/editors.js';
|
|
6
|
+
import { type SeedInput } from './routes/test-reset.js';
|
|
7
|
+
import type { CmsBackendOptions } from './types.js';
|
|
8
|
+
export type CmsBackend = {
|
|
9
|
+
/** Drop into a catch-all `+server.ts` as `export const fallback`. */
|
|
10
|
+
handler: RequestHandler;
|
|
11
|
+
/** For a host-level `/uploads/[filename]` route. Reads `params.filename`. */
|
|
12
|
+
serveUpload: RequestHandler;
|
|
13
|
+
/** Same as `handler`, for callers holding a `RequestEvent` directly. */
|
|
14
|
+
handle: (event: RequestEvent) => Promise<Response>;
|
|
15
|
+
editors: CmsEditorStore;
|
|
16
|
+
store: CmsStore;
|
|
17
|
+
db: SqliteDb;
|
|
18
|
+
cache: ReadCache;
|
|
19
|
+
/** Test-only. Throws unless `testReset` is enabled. */
|
|
20
|
+
testing: {
|
|
21
|
+
reset: (projectId: string, seed?: SeedInput) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
close: () => void;
|
|
24
|
+
};
|
|
25
|
+
export declare const createCmsBackend: (options?: CmsBackendOptions) => CmsBackend;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createCmsBackend` — everything the HTTP layer needs, bound to one database.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this shape is that the application's mount file is three
|
|
5
|
+
* lines and never changes: the route table, the auth pipeline, CORS, and the
|
|
6
|
+
* cache contract all live in the package, so a fix ships as an npm update
|
|
7
|
+
* rather than a re-vendoring.
|
|
8
|
+
*/
|
|
9
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
10
|
+
import { createStore } from './store/queries.js';
|
|
11
|
+
import { createReadCache } from './store/cache.js';
|
|
12
|
+
import { openDatabase } from './store/db.js';
|
|
13
|
+
import { runMigrations } from './store/migrate.js';
|
|
14
|
+
import { MIGRATIONS } from './store/migrations/index.js';
|
|
15
|
+
import { createResponders } from './http.js';
|
|
16
|
+
import { createStorage } from './storage.js';
|
|
17
|
+
import { serveUploadBytes } from './routes/media.js';
|
|
18
|
+
import { createEditorStore } from './auth/editors.js';
|
|
19
|
+
import { localEditors } from './auth/local-editors.js';
|
|
20
|
+
import { lookupRoute, methodNotAllowed, normalizeMethod, notFound, splitPath } from './router.js';
|
|
21
|
+
import { ROUTES } from './routes/table.js';
|
|
22
|
+
import { clearUploads, normalizeSeed } from './routes/test-reset.js';
|
|
23
|
+
/** `vela` sets `VELA_DATA_DIR` wherever it runs an app; the fallback covers a
|
|
24
|
+
* bare `vitest` or a script run by hand, where the project root is right. */
|
|
25
|
+
const defaultDataDir = () => process.env.VELA_DATA_DIR ?? resolvePath(process.cwd(), 'data');
|
|
26
|
+
export const createCmsBackend = (options = {}) => {
|
|
27
|
+
const dbPath = options.dbPath ?? join(defaultDataDir(), 'cms.sqlite');
|
|
28
|
+
const db = options.db ?? openDatabase(dbPath);
|
|
29
|
+
// An injected connection may not have been migrated yet.
|
|
30
|
+
if (options.db)
|
|
31
|
+
runMigrations(db, MIGRATIONS);
|
|
32
|
+
const uploadDir = options.uploadDir ??
|
|
33
|
+
(dbPath === ':memory:' ? join(defaultDataDir(), 'uploads') : join(dirname(dbPath), 'uploads'));
|
|
34
|
+
const store = createStore(db);
|
|
35
|
+
const cache = createReadCache();
|
|
36
|
+
const respond = createResponders(store, cache);
|
|
37
|
+
const storage = createStorage(uploadDir);
|
|
38
|
+
const editors = createEditorStore(db);
|
|
39
|
+
const auth = options.auth ?? localEditors();
|
|
40
|
+
const restParam = options.restParam ?? 'path';
|
|
41
|
+
const cookieName = options.cookie?.name ?? 'cms_session';
|
|
42
|
+
const resolveProject = options.resolveProject ?? (() => 'default');
|
|
43
|
+
const frameAncestors = options.frameAncestors ?? "'self'";
|
|
44
|
+
const cors = options.cors ?? false;
|
|
45
|
+
const testReset = options.testReset ?? false;
|
|
46
|
+
/**
|
|
47
|
+
* The path the backend is mounted at: the request path with the matched rest
|
|
48
|
+
* segments trimmed off the end. Derived per request so the same code works
|
|
49
|
+
* at `/cms` and `/v1/projects/x/cms` with no configuration.
|
|
50
|
+
*/
|
|
51
|
+
const mountPathFor = (event, restPath) => {
|
|
52
|
+
const pathname = event.url.pathname.replace(/\/$/, '');
|
|
53
|
+
if (!restPath)
|
|
54
|
+
return pathname;
|
|
55
|
+
const suffix = `/${restPath}`;
|
|
56
|
+
return pathname.endsWith(suffix) ? pathname.slice(0, -suffix.length) : pathname;
|
|
57
|
+
};
|
|
58
|
+
const mediaUrlFor = (event, mountPath) => {
|
|
59
|
+
const base = options.mediaBaseUrl;
|
|
60
|
+
const resolved = typeof base === 'function'
|
|
61
|
+
? base(event)
|
|
62
|
+
: (base ?? `${new URL(mountPath, event.url).origin}/uploads`);
|
|
63
|
+
const trimmed = resolved.replace(/\/$/, '');
|
|
64
|
+
return (filename) => `${trimmed}/${filename}`;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Cookie attributes. `sameSite: 'none'` is required for the cross-origin
|
|
68
|
+
* iframe login and wrong for a same-origin mount on plain http, so the
|
|
69
|
+
* default follows whether CORS is configured rather than being hardcoded
|
|
70
|
+
* one way.
|
|
71
|
+
*/
|
|
72
|
+
const cookieOptionsFor = (event) => {
|
|
73
|
+
const crossOrigin = cors !== false;
|
|
74
|
+
const isHttps = event.url.protocol === 'https:';
|
|
75
|
+
return {
|
|
76
|
+
path: options.cookie?.path ?? '/',
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
sameSite: options.cookie?.sameSite ?? (crossOrigin ? 'none' : 'lax'),
|
|
79
|
+
secure: options.cookie?.secure ?? (crossOrigin ? true : isHttps),
|
|
80
|
+
...(options.cookie?.partitioned ? { partitioned: true } : {}),
|
|
81
|
+
maxAge: options.cookie?.maxAge ?? 60 * 60 * 24 * 30
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const applyCors = (event, response) => {
|
|
85
|
+
if (cors === false)
|
|
86
|
+
return response;
|
|
87
|
+
// Appended, not set, and always — a cached response must not be reused
|
|
88
|
+
// for a different origin.
|
|
89
|
+
response.headers.append('vary', 'Origin');
|
|
90
|
+
const origin = event.request.headers.get('origin');
|
|
91
|
+
if (origin && cors.origin(origin, event)) {
|
|
92
|
+
response.headers.set('access-control-allow-origin', origin);
|
|
93
|
+
if (cors.credentials !== false) {
|
|
94
|
+
response.headers.set('access-control-allow-credentials', 'true');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return response;
|
|
98
|
+
};
|
|
99
|
+
const preflight = (event) => {
|
|
100
|
+
if (cors === false)
|
|
101
|
+
return new Response(null, { status: 204 });
|
|
102
|
+
const headers = new Headers({
|
|
103
|
+
'access-control-allow-methods': (cors.methods ?? ['GET', 'POST', 'DELETE', 'OPTIONS']).join(','),
|
|
104
|
+
'access-control-allow-headers': (cors.headers ?? ['content-type', 'authorization']).join(', ')
|
|
105
|
+
});
|
|
106
|
+
const response = new Response(null, { status: 204, headers });
|
|
107
|
+
return applyCors(event, response);
|
|
108
|
+
};
|
|
109
|
+
const handle = async (event) => {
|
|
110
|
+
const restPath = event.params[restParam] ?? '';
|
|
111
|
+
const segments = splitPath(restPath);
|
|
112
|
+
const method = normalizeMethod(event);
|
|
113
|
+
// Preflight is answered before auth and before any database access.
|
|
114
|
+
if (method === 'OPTIONS')
|
|
115
|
+
return preflight(event);
|
|
116
|
+
const lookup = lookupRoute(ROUTES, method, segments);
|
|
117
|
+
if (lookup.kind === 'not-found')
|
|
118
|
+
return applyCors(event, notFound());
|
|
119
|
+
if (lookup.kind === 'method-not-allowed') {
|
|
120
|
+
return applyCors(event, methodNotAllowed(lookup.allowed));
|
|
121
|
+
}
|
|
122
|
+
const { route, params } = lookup.match;
|
|
123
|
+
if (route.handler === undefined)
|
|
124
|
+
return applyCors(event, notFound());
|
|
125
|
+
if (route.segments[0] === '__test_reset__' && !testReset) {
|
|
126
|
+
return applyCors(event, notFound());
|
|
127
|
+
}
|
|
128
|
+
const projectId = resolveProject(event);
|
|
129
|
+
if (projectId === null)
|
|
130
|
+
return applyCors(event, notFound());
|
|
131
|
+
const authCtx = { db, projectId, cookieName };
|
|
132
|
+
// Resolve, then authorize, then dispatch. Both checks happen before any
|
|
133
|
+
// handler body runs, which is what keeps a cross-project write from
|
|
134
|
+
// touching the database at all.
|
|
135
|
+
let user = null;
|
|
136
|
+
try {
|
|
137
|
+
user = await auth.resolve(event, authCtx);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
user = null;
|
|
141
|
+
}
|
|
142
|
+
if (user && route.auth !== 'exempt' && !(await auth.authorize(user, projectId, authCtx))) {
|
|
143
|
+
return applyCors(event, new Response(null, { status: 403 }));
|
|
144
|
+
}
|
|
145
|
+
if (route.auth === 'required' && !user) {
|
|
146
|
+
return applyCors(event, new Response(null, { status: 403 }));
|
|
147
|
+
}
|
|
148
|
+
const mountPath = mountPathFor(event, restPath);
|
|
149
|
+
const ctx = {
|
|
150
|
+
event,
|
|
151
|
+
projectId,
|
|
152
|
+
user,
|
|
153
|
+
params,
|
|
154
|
+
store,
|
|
155
|
+
cache,
|
|
156
|
+
respond,
|
|
157
|
+
storage,
|
|
158
|
+
auth,
|
|
159
|
+
authCtx,
|
|
160
|
+
mediaUrl: mediaUrlFor(event, mountPath),
|
|
161
|
+
mountPath,
|
|
162
|
+
frameAncestors,
|
|
163
|
+
setSessionCookie: (token, expiresAt) => {
|
|
164
|
+
const opts = cookieOptionsFor(event);
|
|
165
|
+
event.cookies.set(cookieName, token, {
|
|
166
|
+
...opts,
|
|
167
|
+
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
clearSessionCookie: () => {
|
|
171
|
+
event.cookies.set(cookieName, '', { ...cookieOptionsFor(event), maxAge: 0 });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
return applyCors(event, await route.handler(ctx));
|
|
175
|
+
};
|
|
176
|
+
const handler = (event) => handle(event);
|
|
177
|
+
const serveUpload = (event) => serveUploadBytes(storage, event.params.filename);
|
|
178
|
+
return {
|
|
179
|
+
handler,
|
|
180
|
+
serveUpload,
|
|
181
|
+
handle,
|
|
182
|
+
editors,
|
|
183
|
+
store,
|
|
184
|
+
db,
|
|
185
|
+
cache,
|
|
186
|
+
testing: {
|
|
187
|
+
reset: async (projectId, seed = {}) => {
|
|
188
|
+
if (!testReset) {
|
|
189
|
+
throw new Error('createCmsBackend({ testReset: true }) is required to call reset()');
|
|
190
|
+
}
|
|
191
|
+
store.__resetProjectForTests(projectId);
|
|
192
|
+
cache.clear();
|
|
193
|
+
await clearUploads(uploadDir);
|
|
194
|
+
const { layouts, pages } = normalizeSeed(seed);
|
|
195
|
+
if (Object.keys(layouts).length > 0 || Object.keys(pages).length > 0) {
|
|
196
|
+
store.seedPublishedDocs(projectId, layouts, pages);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
close: () => db.close()
|
|
201
|
+
};
|
|
202
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReadCache } from './store/cache.js';
|
|
2
|
+
import type { CmsStore } from './store/queries.js';
|
|
3
|
+
type CacheEvent = {
|
|
4
|
+
request: Request;
|
|
5
|
+
setHeaders: (headers: Record<string, string>) => void;
|
|
6
|
+
};
|
|
7
|
+
export type PublicCachePass = {
|
|
8
|
+
hit: false;
|
|
9
|
+
version: number;
|
|
10
|
+
etag: string;
|
|
11
|
+
};
|
|
12
|
+
type PublicCacheHit = {
|
|
13
|
+
hit: true;
|
|
14
|
+
response: Response;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Bind the cache-aware responders to one store and one read cache.
|
|
18
|
+
*
|
|
19
|
+
* The bodies are a verbatim move: the header values, the ETag basis, the
|
|
20
|
+
* positive-only caching, and the branch order are the contract the shipped
|
|
21
|
+
* client already depends on, so none of it is re-derived here.
|
|
22
|
+
*
|
|
23
|
+
* Note these take a duck-typed `{ request, setHeaders }` rather than a full
|
|
24
|
+
* `RequestEvent`, which is what lets the tests drive them directly.
|
|
25
|
+
*/
|
|
26
|
+
export declare const createResponders: (store: CmsStore, cache: ReadCache) => {
|
|
27
|
+
tryPublicCache: (event: CacheEvent, projectId: string, scope: string) => PublicCacheHit | PublicCachePass;
|
|
28
|
+
servePublicJson: (event: CacheEvent, projectId: string, scope: string, pass: PublicCachePass, payload: unknown) => Response;
|
|
29
|
+
servePublicNotFound: (event: CacheEvent, pass: PublicCachePass) => Response;
|
|
30
|
+
serveImmutableJson: (event: CacheEvent, payload: unknown) => Response;
|
|
31
|
+
serveImmutableNotFound: (event: CacheEvent) => Response;
|
|
32
|
+
};
|
|
33
|
+
export type CmsResponders = ReturnType<typeof createResponders>;
|
|
34
|
+
export {};
|