@materializecms/db 0.0.0-stage → 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/src/auth.js ADDED
@@ -0,0 +1,337 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { and, asc, count, eq, gt, lt, ne, sql } from 'drizzle-orm';
3
+ import { apiTokens, invites, loginAttempts, sessions, users } from './tables.js';
4
+ const toUser = (row) => ({
5
+ id: row.id,
6
+ email: row.email,
7
+ name: row.name,
8
+ role: row.role,
9
+ twoFactor: !!row.totpSecret,
10
+ });
11
+ /** Prefix of every personal access token, so they are easy to recognise (and to scan for if leaked). */
12
+ export const TOKEN_PREFIX = 'mzp_';
13
+ /** Raised when an email already belongs to someone. */
14
+ export class EmailTakenError extends Error {
15
+ constructor(email) {
16
+ super(`${email} already has an account`);
17
+ this.name = 'EmailTakenError';
18
+ }
19
+ }
20
+ const hashToken = (token) => createHash('sha256').update(token).digest('base64url');
21
+ /** Recovery codes are compared without dashes, spaces or case. */
22
+ const hashCode = (code) => hashToken(code.replace(/[\s-]/g, '').toLowerCase());
23
+ export function createAuthStore(db) {
24
+ return {
25
+ async countUsers() {
26
+ const [row] = await db.select({ n: count() }).from(users);
27
+ return row?.n ?? 0;
28
+ },
29
+ async createUser(input) {
30
+ const user = {
31
+ id: randomUUID(),
32
+ email: input.email.trim().toLowerCase(),
33
+ name: input.name.trim(),
34
+ role: input.role,
35
+ };
36
+ await db.insert(users).values({ ...user, passwordHash: input.passwordHash, createdAt: new Date() });
37
+ return { ...user, twoFactor: false };
38
+ },
39
+ async findForLogin(email) {
40
+ const row = await db.query.users.findFirst({ where: eq(users.email, email.trim().toLowerCase()) });
41
+ return row ? { ...toUser(row), passwordHash: row.passwordHash } : null;
42
+ },
43
+ /** Returns the raw token for the cookie; only its hash is stored. */
44
+ async createSession(userId, ttlMs, device = {}) {
45
+ const token = randomBytes(32).toString('base64url');
46
+ const expiresAt = new Date(Date.now() + ttlMs);
47
+ const now = new Date();
48
+ await db.insert(sessions).values({
49
+ id: hashToken(token),
50
+ userId,
51
+ expiresAt,
52
+ createdAt: now,
53
+ lastSeenAt: now,
54
+ userAgent: device.userAgent?.slice(0, 300) ?? null,
55
+ ip: device.ip ?? null,
56
+ });
57
+ await db.update(users).set({ lastSeenAt: now }).where(eq(users.id, userId));
58
+ return { token, expiresAt };
59
+ },
60
+ /**
61
+ * Who a session belongs to. It ends when it expires or when it has not been used for
62
+ * `idleMs`; use is noted at most every few minutes.
63
+ */
64
+ async userForSession(token, idleMs = Number.POSITIVE_INFINITY) {
65
+ const session = await db.query.sessions.findFirst({ where: eq(sessions.id, hashToken(token)) });
66
+ if (!session)
67
+ return null;
68
+ const now = Date.now();
69
+ const seen = (session.lastSeenAt ?? session.createdAt).getTime();
70
+ if (session.expiresAt.getTime() < now || now - seen > idleMs) {
71
+ await db.delete(sessions).where(eq(sessions.id, session.id));
72
+ return null;
73
+ }
74
+ if (now - seen > 5 * 60_000)
75
+ await db
76
+ .update(sessions)
77
+ .set({ lastSeenAt: new Date(now) })
78
+ .where(eq(sessions.id, session.id));
79
+ const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) });
80
+ return user ? toUser(user) : null;
81
+ },
82
+ /** The id under which a session is listed. */
83
+ sessionId: (token) => hashToken(token),
84
+ async listSessions(userId) {
85
+ const rows = await db.query.sessions.findMany({
86
+ where: and(eq(sessions.userId, userId), gt(sessions.expiresAt, new Date())),
87
+ });
88
+ return rows
89
+ .map((r) => ({
90
+ id: r.id,
91
+ userAgent: r.userAgent ?? null,
92
+ ip: r.ip ?? null,
93
+ createdAt: r.createdAt,
94
+ lastSeenAt: r.lastSeenAt ?? r.createdAt,
95
+ }))
96
+ .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime());
97
+ },
98
+ /** Signs one device out; only its owner can. */
99
+ async endSession(userId, id) {
100
+ const res = await db.delete(sessions).where(and(eq(sessions.id, id), eq(sessions.userId, userId)));
101
+ return res.rowsAffected > 0;
102
+ },
103
+ /** Signs every device out, except the session given. */
104
+ async endOtherSessions(userId, keepToken) {
105
+ const res = await db
106
+ .delete(sessions)
107
+ .where(and(eq(sessions.userId, userId), keepToken ? ne(sessions.id, hashToken(keepToken)) : undefined));
108
+ return res.rowsAffected;
109
+ },
110
+ // ---------- two-step verification ----------
111
+ async twoFactorOf(userId) {
112
+ const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
113
+ return row
114
+ ? {
115
+ secret: row.totpSecret ?? null,
116
+ pending: row.totpPending ?? null,
117
+ lastStep: row.totpLastStep ?? null,
118
+ recoveryLeft: row.recoveryCodes?.length ?? 0,
119
+ }
120
+ : null;
121
+ },
122
+ /** A new secret during setup, not in use until a first code confirms it. */
123
+ async setPendingSecret(userId, secret) {
124
+ await db.update(users).set({ totpPending: secret }).where(eq(users.id, userId));
125
+ },
126
+ /** The pending secret becomes the one in use, with fresh recovery codes (stored as hashes). */
127
+ async enableTwoFactor(userId, step, recoveryCodes) {
128
+ const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
129
+ if (!row?.totpPending)
130
+ return;
131
+ await db
132
+ .update(users)
133
+ .set({
134
+ totpSecret: row.totpPending,
135
+ totpPending: null,
136
+ totpLastStep: step,
137
+ recoveryCodes: recoveryCodes.map(hashCode),
138
+ })
139
+ .where(eq(users.id, userId));
140
+ },
141
+ async disableTwoFactor(userId) {
142
+ await db
143
+ .update(users)
144
+ .set({ totpSecret: null, totpPending: null, totpLastStep: null, recoveryCodes: null })
145
+ .where(eq(users.id, userId));
146
+ },
147
+ /** Notes the time step of an accepted code, so the same code cannot be used again. */
148
+ async useStep(userId, step) {
149
+ await db.update(users).set({ totpLastStep: step }).where(eq(users.id, userId));
150
+ },
151
+ async setRecoveryCodes(userId, codes) {
152
+ await db
153
+ .update(users)
154
+ .set({ recoveryCodes: codes.map(hashCode) })
155
+ .where(eq(users.id, userId));
156
+ },
157
+ /** A recovery code works once: true when it was valid (and is now spent). */
158
+ async useRecoveryCode(userId, code) {
159
+ const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
160
+ const hash = hashCode(code);
161
+ if (!row?.recoveryCodes?.includes(hash))
162
+ return false;
163
+ await db
164
+ .update(users)
165
+ .set({ recoveryCodes: row.recoveryCodes.filter((h) => h !== hash) })
166
+ .where(eq(users.id, userId));
167
+ return true;
168
+ },
169
+ // ---------- failed sign-ins ----------
170
+ /** Until when a key (an email, an address) is locked, if it is. */
171
+ async lockedUntil(key) {
172
+ const row = await db.query.loginAttempts.findFirst({ where: eq(loginAttempts.key, key) });
173
+ return row?.lockedUntil && row.lockedUntil.getTime() > Date.now() ? row.lockedUntil : null;
174
+ },
175
+ /**
176
+ * Counts a failure. From `threshold` failures on, the key is locked for a time that doubles
177
+ * with each further failure (1, 2, 4… minutes, at most an hour). Failures are forgotten
178
+ * after a day without any.
179
+ */
180
+ async recordFailure(key, threshold) {
181
+ const now = Date.now();
182
+ const row = await db.query.loginAttempts.findFirst({ where: eq(loginAttempts.key, key) });
183
+ const fresh = !row || now - row.updatedAt.getTime() > 24 * 60 * 60_000;
184
+ const failures = fresh ? 1 : row.failures + 1;
185
+ const lockedUntil = failures >= threshold ? new Date(now + Math.min(60, 2 ** (failures - threshold)) * 60_000) : null;
186
+ await db
187
+ .insert(loginAttempts)
188
+ .values({ key, failures, lockedUntil, updatedAt: new Date(now) })
189
+ .onConflictDoUpdate({ target: loginAttempts.key, set: { failures, lockedUntil, updatedAt: new Date(now) } });
190
+ return { failures, lockedUntil };
191
+ },
192
+ async clearFailures(key) {
193
+ await db.delete(loginAttempts).where(eq(loginAttempts.key, key));
194
+ },
195
+ /** A new personal access token for someone: returns the secret, which is never stored. */
196
+ async createToken(userId, name) {
197
+ const token = `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
198
+ const record = {
199
+ id: randomUUID(),
200
+ name: name.trim(),
201
+ hint: token.slice(-4),
202
+ createdAt: new Date(),
203
+ lastUsedAt: null,
204
+ };
205
+ await db.insert(apiTokens).values({ ...record, userId, tokenHash: hashToken(token) });
206
+ return { token, record };
207
+ },
208
+ async listTokens(userId) {
209
+ const rows = await db.query.apiTokens.findMany({
210
+ where: eq(apiTokens.userId, userId),
211
+ orderBy: asc(apiTokens.createdAt),
212
+ });
213
+ return rows.map(({ id, name, hint, createdAt, lastUsedAt }) => ({ id, name, hint, createdAt, lastUsedAt }));
214
+ },
215
+ /** Only the owner can revoke a token. */
216
+ async revokeToken(id, userId) {
217
+ const res = await db.delete(apiTokens).where(and(eq(apiTokens.id, id), eq(apiTokens.userId, userId)));
218
+ return res.rowsAffected > 0;
219
+ },
220
+ /** Who a token belongs to; notes when it was last used (at most once a minute). */
221
+ async userForToken(token) {
222
+ if (!token.startsWith(TOKEN_PREFIX))
223
+ return null;
224
+ const row = await db.query.apiTokens.findFirst({ where: eq(apiTokens.tokenHash, hashToken(token)) });
225
+ if (!row)
226
+ return null;
227
+ const user = await db.query.users.findFirst({ where: eq(users.id, row.userId) });
228
+ if (!user)
229
+ return null;
230
+ const now = Date.now();
231
+ if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000)
232
+ await db
233
+ .update(apiTokens)
234
+ .set({ lastUsedAt: new Date(now) })
235
+ .where(eq(apiTokens.id, row.id));
236
+ return toUser(user);
237
+ },
238
+ async deleteSession(token) {
239
+ await db.delete(sessions).where(eq(sessions.id, hashToken(token)));
240
+ },
241
+ async purgeExpiredSessions() {
242
+ await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
243
+ },
244
+ // ---------- people ----------
245
+ async listUsers() {
246
+ const rows = await db.select().from(users).orderBy(asc(users.createdAt), asc(sql `rowid`));
247
+ return rows.map((r) => ({ ...toUser(r), createdAt: r.createdAt, lastSeenAt: r.lastSeenAt ?? null }));
248
+ },
249
+ async getUser(id) {
250
+ const row = await db.query.users.findFirst({ where: eq(users.id, id) });
251
+ return row ? toUser(row) : null;
252
+ },
253
+ async getPasswordHash(id) {
254
+ return (await db.query.users.findFirst({ where: eq(users.id, id) }))?.passwordHash ?? null;
255
+ },
256
+ async countAdmins() {
257
+ const [row] = await db.select({ n: count() }).from(users).where(eq(users.role, 'admin'));
258
+ return row?.n ?? 0;
259
+ },
260
+ /** Changes name, role or email; an email someone else already uses raises EmailTakenError. */
261
+ async updateUser(id, input) {
262
+ const change = {};
263
+ if (input.name !== undefined)
264
+ change.name = input.name.trim();
265
+ if (input.role !== undefined)
266
+ change.role = input.role;
267
+ if (input.email !== undefined) {
268
+ const email = input.email.trim().toLowerCase();
269
+ const owner = await db.query.users.findFirst({ where: eq(users.email, email) });
270
+ if (owner && owner.id !== id)
271
+ throw new EmailTakenError(email);
272
+ change.email = email;
273
+ }
274
+ if (Object.keys(change).length)
275
+ await db.update(users).set(change).where(eq(users.id, id));
276
+ return this.getUser(id);
277
+ },
278
+ async deleteUser(id) {
279
+ return (await db.delete(users).where(eq(users.id, id))).rowsAffected > 0;
280
+ },
281
+ /** Sets a new password and signs the user out everywhere, except the session given. */
282
+ async setPassword(id, passwordHash, keepSession) {
283
+ await db.update(users).set({ passwordHash }).where(eq(users.id, id));
284
+ await db
285
+ .delete(sessions)
286
+ .where(and(eq(sessions.userId, id), keepSession ? ne(sessions.id, hashToken(keepSession)) : undefined));
287
+ },
288
+ async emailTaken(email) {
289
+ return !!(await db.query.users.findFirst({ where: eq(users.email, email.trim().toLowerCase()) }));
290
+ },
291
+ // ---------- invitations and resets ----------
292
+ /** Returns the raw token for the link; only its hash is stored. Replaces earlier links of the same kind. */
293
+ async createInvite(input) {
294
+ const email = input.email.trim().toLowerCase();
295
+ if (input.kind === 'invite' && (await this.emailTaken(email)))
296
+ throw new EmailTakenError(email);
297
+ await db.delete(invites).where(and(eq(invites.email, email), eq(invites.kind, input.kind)));
298
+ const token = randomBytes(32).toString('base64url');
299
+ const invite = {
300
+ id: hashToken(token),
301
+ kind: input.kind,
302
+ email,
303
+ name: input.name?.trim() ?? '',
304
+ role: input.role,
305
+ userId: input.userId ?? null,
306
+ createdBy: input.createdBy ?? null,
307
+ createdAt: new Date(),
308
+ expiresAt: new Date(Date.now() + input.ttlMs),
309
+ };
310
+ await db.insert(invites).values(invite);
311
+ const { createdBy: _, ...publicInvite } = invite;
312
+ return { token, invite: publicInvite };
313
+ },
314
+ async pendingInvites() {
315
+ const rows = await db
316
+ .select()
317
+ .from(invites)
318
+ .where(and(eq(invites.kind, 'invite'), gt(invites.expiresAt, new Date())))
319
+ .orderBy(asc(invites.createdAt));
320
+ return rows.map(({ createdBy: _, ...r }) => r);
321
+ },
322
+ async findInvite(token) {
323
+ const row = await db.query.invites.findFirst({ where: eq(invites.id, hashToken(token)) });
324
+ if (!row || row.expiresAt.getTime() < Date.now())
325
+ return null;
326
+ const { createdBy: _, ...invite } = row;
327
+ return invite;
328
+ },
329
+ async revokeInvite(id) {
330
+ return (await db.delete(invites).where(eq(invites.id, id))).rowsAffected > 0;
331
+ },
332
+ /** A link works once. */
333
+ async consumeInvite(token) {
334
+ await db.delete(invites).where(eq(invites.id, hashToken(token)));
335
+ },
336
+ };
337
+ }
@@ -0,0 +1,26 @@
1
+ import type { Db } from './database.ts';
2
+ export interface Comment {
3
+ id: string;
4
+ documentId: string;
5
+ nodeId: string | null;
6
+ authorId: string | null;
7
+ authorName: string;
8
+ body: string;
9
+ resolved: boolean;
10
+ createdAt: Date;
11
+ }
12
+ /** Comments on a page, in the order they were written. */
13
+ export declare function createCommentStore(db: Db): {
14
+ list(documentId: string): Promise<Comment[]>;
15
+ get(id: string): Promise<Comment | null>;
16
+ add(input: {
17
+ documentId: string;
18
+ nodeId: string | null;
19
+ authorId: string;
20
+ authorName: string;
21
+ body: string;
22
+ }): Promise<Comment>;
23
+ resolve(id: string, resolved: boolean): Promise<void>;
24
+ remove(id: string): Promise<void>;
25
+ };
26
+ export type CommentStore = ReturnType<typeof createCommentStore>;
@@ -0,0 +1,25 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { asc, eq } from 'drizzle-orm';
3
+ import { comments } from './tables.js';
4
+ /** Comments on a page, in the order they were written. */
5
+ export function createCommentStore(db) {
6
+ return {
7
+ async list(documentId) {
8
+ return db.select().from(comments).where(eq(comments.documentId, documentId)).orderBy(asc(comments.createdAt));
9
+ },
10
+ async get(id) {
11
+ return (await db.select().from(comments).where(eq(comments.id, id)))[0] ?? null;
12
+ },
13
+ async add(input) {
14
+ const row = { id: randomUUID(), ...input, resolved: false, createdAt: new Date() };
15
+ await db.insert(comments).values(row);
16
+ return row;
17
+ },
18
+ async resolve(id, resolved) {
19
+ await db.update(comments).set({ resolved }).where(eq(comments.id, id));
20
+ },
21
+ async remove(id) {
22
+ await db.delete(comments).where(eq(comments.id, id));
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,115 @@
1
+ import type { Db } from './database.ts';
2
+ export type DocumentStatus = 'draft' | 'published' | 'changed';
3
+ export interface StoredDocument {
4
+ id: string;
5
+ collection: string;
6
+ locale: string;
7
+ slug: string | null;
8
+ title: string;
9
+ status: DocumentStatus;
10
+ data: Record<string, unknown>;
11
+ publishedData: Record<string, unknown> | null;
12
+ createdAt: Date;
13
+ updatedAt: Date;
14
+ publishedAt: Date | null;
15
+ /** The original this document translates; null for originals. */
16
+ sourceId: string | null;
17
+ }
18
+ /** One language version of a document. */
19
+ export interface Translation {
20
+ id: string;
21
+ locale: string;
22
+ title: string;
23
+ status: DocumentStatus;
24
+ sourceId: string | null;
25
+ }
26
+ export interface DocumentSummary {
27
+ id: string;
28
+ title: string;
29
+ slug: string | null;
30
+ status: DocumentStatus;
31
+ updatedAt: Date;
32
+ publishedAt: Date | null;
33
+ /** The first image in the document, for thumbnails in lists. */
34
+ image: string | null;
35
+ /** The other languages it exists in. */
36
+ locales: string[];
37
+ }
38
+ export interface Revision {
39
+ id: string;
40
+ kind: 'save' | 'publish' | 'restore';
41
+ data: Record<string, unknown>;
42
+ createdAt: Date;
43
+ authorId: string | null;
44
+ }
45
+ /** Raised when a slug is already used in the same collection and locale. */
46
+ export declare class SlugTakenError extends Error {
47
+ constructor(slug: string);
48
+ }
49
+ /** The first image found in a document's data (fields first, then blocks, in order). */
50
+ export declare function firstImage(value: unknown, depth?: number): string | null;
51
+ export interface ListOptions {
52
+ locale: string;
53
+ limit?: number;
54
+ offset?: number;
55
+ search?: string;
56
+ /** Only documents that have a published version. */
57
+ publishedOnly?: boolean;
58
+ }
59
+ export declare function createContentStore(db: Db): {
60
+ list(collection: string, options: ListOptions): Promise<{
61
+ items: DocumentSummary[];
62
+ total: number;
63
+ }>;
64
+ /** Every language version of a document, itself included: the original and its translations. */
65
+ translations(id: string): Promise<Translation[]>;
66
+ /** Counts per collection and the latest changes across all of them: the panel's home. */
67
+ overview(locale: string, recent?: number): Promise<{
68
+ counts: {
69
+ collection: string;
70
+ total: number;
71
+ drafts: number;
72
+ changed: number;
73
+ }[];
74
+ recent: {
75
+ id: string;
76
+ collection: string;
77
+ title: string;
78
+ slug: string | null;
79
+ status: DocumentStatus;
80
+ updatedAt: Date;
81
+ publishedAt: Date | null;
82
+ image: string | null;
83
+ updatedBy: string | null;
84
+ }[];
85
+ }>;
86
+ get(id: string): Promise<StoredDocument | null>;
87
+ getMany(ids: string[]): Promise<StoredDocument[]>;
88
+ /** Every published document of a collection (all locales unless one is given), oldest first. */
89
+ published(collection: string, locale?: string): Promise<StoredDocument[]>;
90
+ getBySlug(collection: string, slug: string, locale: string): Promise<StoredDocument | null>;
91
+ create(input: {
92
+ collection: string;
93
+ locale: string;
94
+ title: string;
95
+ slug: string | null;
96
+ data: Record<string, unknown>;
97
+ userId?: string;
98
+ sourceId?: string | null;
99
+ }): Promise<StoredDocument>;
100
+ update(id: string, input: {
101
+ title: string;
102
+ slug: string | null;
103
+ data: Record<string, unknown>;
104
+ userId?: string;
105
+ }): Promise<StoredDocument | null>;
106
+ /** Makes the current draft live; `data` is the validated form of the draft, when the caller has one. */
107
+ publish(id: string, userId?: string, data?: Record<string, unknown>): Promise<StoredDocument | null>;
108
+ /** Takes the document off the live site; the draft stays. */
109
+ unpublish(id: string, userId?: string): Promise<StoredDocument | null>;
110
+ remove(id: string): Promise<boolean>;
111
+ revisions(documentId: string): Promise<Revision[]>;
112
+ /** Copies an old version back into the draft, recorded as a new revision. */
113
+ restore(documentId: string, revisionId: string, userId?: string): Promise<StoredDocument | null>;
114
+ };
115
+ export type ContentStore = ReturnType<typeof createContentStore>;