@hollware/server 0.1.0 → 0.3.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/dist/ceremonies-D7tsWU3S.mjs +157 -0
- package/dist/index.d.mts +175 -28
- package/dist/index.mjs +2314 -445
- package/dist/store-Sq0pwUQv.mjs +770 -0
- package/migrations/0003_events_and_schedules.sql +57 -0
- package/migrations/0004_identity.sql +90 -0
- package/migrations/0005_reviews.sql +27 -0
- package/migrations/meta/0003_snapshot.json +1406 -0
- package/migrations/meta/0004_snapshot.json +1977 -0
- package/migrations/meta/0005_snapshot.json +2188 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +6 -4
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { newId } from "@hollware/core";
|
|
2
|
+
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import { check, foreignKey, index, integer, sqliteTable, text, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
4
|
+
//#region src/auth/roles.ts
|
|
5
|
+
/**
|
|
6
|
+
* The roles of a user, as the `users_role` CHECK of the schema lists them (ADR-026): the owner,
|
|
7
|
+
* whom `hollware init` creates, and the editors and authors whom invitations bring in.
|
|
8
|
+
*/
|
|
9
|
+
const ROLES = [
|
|
10
|
+
"owner",
|
|
11
|
+
"editor",
|
|
12
|
+
"author"
|
|
13
|
+
];
|
|
14
|
+
/** Whether a string is one of the roles. */
|
|
15
|
+
function isRole(value) {
|
|
16
|
+
return ROLES.some((role) => role === value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The scopes each role allows. What a token may do is its own scopes intersected with these,
|
|
20
|
+
* read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
|
|
21
|
+
* scopes, never for a role.
|
|
22
|
+
*
|
|
23
|
+
* - `owner` may do everything: all four scopes.
|
|
24
|
+
* - `editor` reads, writes and publishes.
|
|
25
|
+
* - `author` reads and writes; what an author publishes goes through review (v0.3).
|
|
26
|
+
*/
|
|
27
|
+
const ROLE_SCOPES = {
|
|
28
|
+
owner: [
|
|
29
|
+
"content:read",
|
|
30
|
+
"content:write",
|
|
31
|
+
"content:publish",
|
|
32
|
+
"schema:admin"
|
|
33
|
+
],
|
|
34
|
+
editor: [
|
|
35
|
+
"content:read",
|
|
36
|
+
"content:write",
|
|
37
|
+
"content:publish"
|
|
38
|
+
],
|
|
39
|
+
author: ["content:read", "content:write"]
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The scopes a token really has: those it carries that the role of its user allows, in the order
|
|
43
|
+
* of the role. A role that is not one of the roles allows nothing, so a role added to the schema
|
|
44
|
+
* before this table knows it grants no access.
|
|
45
|
+
*/
|
|
46
|
+
function effectiveScopes(role, tokenScopes) {
|
|
47
|
+
if (!isRole(role)) return [];
|
|
48
|
+
return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The name a role had in v0.1 and v0.2, which the CHECK of `users.role` still allows: what a new
|
|
52
|
+
* user's `users.role` gets. Roles live in `members` since v0.3; nothing reads `users.role`.
|
|
53
|
+
*/
|
|
54
|
+
function legacyRoleOf(role) {
|
|
55
|
+
return role === "author" ? "reviewer" : role;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/db/columns.ts
|
|
59
|
+
/**
|
|
60
|
+
* The columns every table shares. Each helper returns a new builder, because a Drizzle column
|
|
61
|
+
* belongs to one table. Only the repository functions write rows, so these defaults are what
|
|
62
|
+
* every row gets.
|
|
63
|
+
*/
|
|
64
|
+
/** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
|
|
65
|
+
function nowIso() {
|
|
66
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
67
|
+
}
|
|
68
|
+
/** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
|
|
69
|
+
function id(prefix) {
|
|
70
|
+
return text("id").primaryKey().$defaultFn(() => newId(prefix));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
|
|
74
|
+
* row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
|
|
75
|
+
*/
|
|
76
|
+
function siteId() {
|
|
77
|
+
return text("site_id").notNull();
|
|
78
|
+
}
|
|
79
|
+
/** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
|
|
80
|
+
function timestamps() {
|
|
81
|
+
return {
|
|
82
|
+
createdAt: text("created_at").notNull().$defaultFn(nowIso),
|
|
83
|
+
updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
sqliteTable("_hollware_meta", {
|
|
87
|
+
key: text("key").primaryKey().notNull(),
|
|
88
|
+
value: text("value").notNull()
|
|
89
|
+
});
|
|
90
|
+
/** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
|
|
91
|
+
const sites = sqliteTable("sites", {
|
|
92
|
+
id: text("id").primaryKey(),
|
|
93
|
+
...timestamps()
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* People. A user belongs to the deployment; what they may do in a site is their row of `members`.
|
|
97
|
+
*
|
|
98
|
+
* `role` is the role of v0.1 and v0.2, kept with its CHECK of then: SQLite changes a CHECK only by
|
|
99
|
+
* rebuilding the table, and D1, which never turns foreign keys off, refuses to drop a table that
|
|
100
|
+
* other rows point at. Nothing reads it since v0.3. A new user gets the old name of their role
|
|
101
|
+
* (`legacyRoleOf`), so that the column stays true to the CHECK.
|
|
102
|
+
*/
|
|
103
|
+
const users = sqliteTable("users", {
|
|
104
|
+
id: id("usr"),
|
|
105
|
+
name: text("name"),
|
|
106
|
+
role: text("role").notNull(),
|
|
107
|
+
...timestamps()
|
|
108
|
+
}, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
|
|
109
|
+
/** What a person may do in a site (ADR-026): one role per person and site. */
|
|
110
|
+
const members = sqliteTable("members", {
|
|
111
|
+
id: id("mbr"),
|
|
112
|
+
siteId: siteId(),
|
|
113
|
+
userId: text("user_id").notNull(),
|
|
114
|
+
role: text("role").notNull(),
|
|
115
|
+
...timestamps()
|
|
116
|
+
}, (t) => [
|
|
117
|
+
unique("members_id_site").on(t.id, t.siteId),
|
|
118
|
+
unique("members_person").on(t.siteId, t.userId),
|
|
119
|
+
foreignKey({
|
|
120
|
+
name: "members_site",
|
|
121
|
+
columns: [t.siteId],
|
|
122
|
+
foreignColumns: [sites.id]
|
|
123
|
+
}),
|
|
124
|
+
foreignKey({
|
|
125
|
+
name: "members_user",
|
|
126
|
+
columns: [t.userId],
|
|
127
|
+
foreignColumns: [users.id]
|
|
128
|
+
}),
|
|
129
|
+
check("members_role", sql`${t.role} IN ('owner', 'editor', 'author')`)
|
|
130
|
+
]);
|
|
131
|
+
/** Personal tokens (ADR-019): only the hash is stored. */
|
|
132
|
+
const tokens = sqliteTable("tokens", {
|
|
133
|
+
id: id("tok"),
|
|
134
|
+
siteId: siteId(),
|
|
135
|
+
userId: text("user_id").notNull(),
|
|
136
|
+
label: text("label").notNull(),
|
|
137
|
+
hash: text("hash").notNull(),
|
|
138
|
+
/** A JSON array of scopes. */
|
|
139
|
+
scopes: text("scopes").notNull(),
|
|
140
|
+
expiresAt: text("expires_at"),
|
|
141
|
+
revokedAt: text("revoked_at"),
|
|
142
|
+
lastUsedAt: text("last_used_at"),
|
|
143
|
+
...timestamps()
|
|
144
|
+
}, (t) => [
|
|
145
|
+
unique("tokens_id_site").on(t.id, t.siteId),
|
|
146
|
+
unique("tokens_hash").on(t.hash),
|
|
147
|
+
foreignKey({
|
|
148
|
+
name: "tokens_site",
|
|
149
|
+
columns: [t.siteId],
|
|
150
|
+
foreignColumns: [sites.id]
|
|
151
|
+
}),
|
|
152
|
+
foreignKey({
|
|
153
|
+
name: "tokens_user",
|
|
154
|
+
columns: [t.userId],
|
|
155
|
+
foreignColumns: [users.id]
|
|
156
|
+
}),
|
|
157
|
+
check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
|
|
158
|
+
]);
|
|
159
|
+
/** One entry per site, collection, locale and slug; its content lives in `revisions`. */
|
|
160
|
+
const entries = sqliteTable("entries", {
|
|
161
|
+
id: id("ent"),
|
|
162
|
+
siteId: siteId(),
|
|
163
|
+
collection: text("collection").notNull(),
|
|
164
|
+
locale: text("locale").notNull(),
|
|
165
|
+
slug: text("slug").notNull(),
|
|
166
|
+
translationGroup: text("translation_group"),
|
|
167
|
+
status: text("status").notNull(),
|
|
168
|
+
/** Bumped by every change of the entry: the optimistic write compares it. */
|
|
169
|
+
version: integer("version").notNull(),
|
|
170
|
+
headRev: text("head_rev").notNull(),
|
|
171
|
+
publishedRev: text("published_rev"),
|
|
172
|
+
publishedAt: text("published_at"),
|
|
173
|
+
publishedSortKey: text("published_sort_key"),
|
|
174
|
+
scheduledAt: text("scheduled_at"),
|
|
175
|
+
...timestamps()
|
|
176
|
+
}, (t) => [
|
|
177
|
+
unique("entries_id_site").on(t.id, t.siteId),
|
|
178
|
+
unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
|
|
179
|
+
unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
|
|
180
|
+
index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
|
|
181
|
+
index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
|
|
182
|
+
index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
|
|
183
|
+
foreignKey({
|
|
184
|
+
name: "entries_site",
|
|
185
|
+
columns: [t.siteId],
|
|
186
|
+
foreignColumns: [sites.id]
|
|
187
|
+
}),
|
|
188
|
+
check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
|
|
189
|
+
check("entries_version", sql`${t.version} >= 1`),
|
|
190
|
+
check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
|
|
191
|
+
check("entries_published", sql`${t.status} <> 'published' OR (${t.publishedRev} IS NOT NULL AND ${t.publishedAt} IS NOT NULL AND ${t.publishedSortKey} IS NOT NULL)`),
|
|
192
|
+
check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
|
|
193
|
+
]);
|
|
194
|
+
/**
|
|
195
|
+
* Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
|
|
196
|
+
* frontmatter included; the derived columns can be rebuilt by `reparse`.
|
|
197
|
+
*/
|
|
198
|
+
const revisions = sqliteTable("revisions", {
|
|
199
|
+
id: id("rev"),
|
|
200
|
+
siteId: siteId(),
|
|
201
|
+
entryId: text("entry_id").notNull(),
|
|
202
|
+
markdown: text("markdown").notNull(),
|
|
203
|
+
/** Derived: the frontmatter as JSON. */
|
|
204
|
+
frontmatter: text("frontmatter").notNull(),
|
|
205
|
+
/** Derived: the Comark document as JSON. */
|
|
206
|
+
document: text("document").notNull(),
|
|
207
|
+
comarkVersion: text("comark_version").notNull(),
|
|
208
|
+
pipelineVersion: text("pipeline_version").notNull(),
|
|
209
|
+
wordCount: integer("word_count").notNull(),
|
|
210
|
+
/** The verdict of the contracts when the revision was written, as JSON. */
|
|
211
|
+
verdict: text("verdict").notNull(),
|
|
212
|
+
actorKind: text("actor_kind").notNull(),
|
|
213
|
+
actorUserId: text("actor_user_id").notNull(),
|
|
214
|
+
actorTokenId: text("actor_token_id"),
|
|
215
|
+
/** The client and, when it says, the model behind the write, as JSON. */
|
|
216
|
+
actorMeta: text("actor_meta"),
|
|
217
|
+
...timestamps()
|
|
218
|
+
}, (t) => [
|
|
219
|
+
unique("revisions_id_site").on(t.id, t.siteId),
|
|
220
|
+
index("revisions_entry").on(t.entryId, t.createdAt),
|
|
221
|
+
foreignKey({
|
|
222
|
+
name: "revisions_site",
|
|
223
|
+
columns: [t.siteId],
|
|
224
|
+
foreignColumns: [sites.id]
|
|
225
|
+
}),
|
|
226
|
+
foreignKey({
|
|
227
|
+
name: "revisions_entry",
|
|
228
|
+
columns: [t.entryId, t.siteId],
|
|
229
|
+
foreignColumns: [entries.id, entries.siteId]
|
|
230
|
+
}),
|
|
231
|
+
foreignKey({
|
|
232
|
+
name: "revisions_user",
|
|
233
|
+
columns: [t.actorUserId],
|
|
234
|
+
foreignColumns: [users.id]
|
|
235
|
+
}),
|
|
236
|
+
foreignKey({
|
|
237
|
+
name: "revisions_token",
|
|
238
|
+
columns: [t.actorTokenId, t.siteId],
|
|
239
|
+
foreignColumns: [tokens.id, tokens.siteId]
|
|
240
|
+
}),
|
|
241
|
+
check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
242
|
+
check("revisions_json", sql`json_valid(${t.frontmatter}) AND json_valid(${t.document}) AND json_valid(${t.verdict}) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`),
|
|
243
|
+
check("revisions_word_count", sql`${t.wordCount} >= 0`)
|
|
244
|
+
]);
|
|
245
|
+
/** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
|
|
246
|
+
const revisionTags = sqliteTable("revision_tags", {
|
|
247
|
+
id: id("tag"),
|
|
248
|
+
siteId: siteId(),
|
|
249
|
+
revisionId: text("revision_id").notNull(),
|
|
250
|
+
tag: text("tag").notNull(),
|
|
251
|
+
...timestamps()
|
|
252
|
+
}, (t) => [
|
|
253
|
+
unique("revision_tags_id_site").on(t.id, t.siteId),
|
|
254
|
+
unique("revision_tags_tag").on(t.revisionId, t.tag),
|
|
255
|
+
index("revision_tags_by_tag").on(t.siteId, t.tag),
|
|
256
|
+
foreignKey({
|
|
257
|
+
name: "revision_tags_site",
|
|
258
|
+
columns: [t.siteId],
|
|
259
|
+
foreignColumns: [sites.id]
|
|
260
|
+
}),
|
|
261
|
+
foreignKey({
|
|
262
|
+
name: "revision_tags_revision",
|
|
263
|
+
columns: [t.revisionId, t.siteId],
|
|
264
|
+
foreignColumns: [revisions.id, revisions.siteId]
|
|
265
|
+
})
|
|
266
|
+
]);
|
|
267
|
+
/**
|
|
268
|
+
* Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
|
|
269
|
+
* caller acknowledged (ADR-021).
|
|
270
|
+
*/
|
|
271
|
+
const publications = sqliteTable("publications", {
|
|
272
|
+
id: id("pub"),
|
|
273
|
+
siteId: siteId(),
|
|
274
|
+
entryId: text("entry_id").notNull(),
|
|
275
|
+
action: text("action").notNull(),
|
|
276
|
+
revisionId: text("revision_id"),
|
|
277
|
+
verdict: text("verdict"),
|
|
278
|
+
/** A JSON array: the warnings the caller acknowledged to publish. */
|
|
279
|
+
acknowledged: text("acknowledged").notNull(),
|
|
280
|
+
actorKind: text("actor_kind").notNull(),
|
|
281
|
+
actorUserId: text("actor_user_id").notNull(),
|
|
282
|
+
actorTokenId: text("actor_token_id"),
|
|
283
|
+
actorMeta: text("actor_meta"),
|
|
284
|
+
...timestamps()
|
|
285
|
+
}, (t) => [
|
|
286
|
+
unique("publications_id_site").on(t.id, t.siteId),
|
|
287
|
+
index("publications_entry").on(t.entryId, t.createdAt),
|
|
288
|
+
foreignKey({
|
|
289
|
+
name: "publications_site",
|
|
290
|
+
columns: [t.siteId],
|
|
291
|
+
foreignColumns: [sites.id]
|
|
292
|
+
}),
|
|
293
|
+
foreignKey({
|
|
294
|
+
name: "publications_entry",
|
|
295
|
+
columns: [t.entryId, t.siteId],
|
|
296
|
+
foreignColumns: [entries.id, entries.siteId]
|
|
297
|
+
}),
|
|
298
|
+
foreignKey({
|
|
299
|
+
name: "publications_revision",
|
|
300
|
+
columns: [t.revisionId, t.siteId],
|
|
301
|
+
foreignColumns: [revisions.id, revisions.siteId]
|
|
302
|
+
}),
|
|
303
|
+
foreignKey({
|
|
304
|
+
name: "publications_user",
|
|
305
|
+
columns: [t.actorUserId],
|
|
306
|
+
foreignColumns: [users.id]
|
|
307
|
+
}),
|
|
308
|
+
foreignKey({
|
|
309
|
+
name: "publications_token",
|
|
310
|
+
columns: [t.actorTokenId, t.siteId],
|
|
311
|
+
foreignColumns: [tokens.id, tokens.siteId]
|
|
312
|
+
}),
|
|
313
|
+
check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
|
|
314
|
+
check("publications_shape", sql`(${t.action} = 'publish' AND ${t.revisionId} IS NOT NULL AND ${t.verdict} IS NOT NULL) OR (${t.action} = 'unpublish' AND ${t.revisionId} IS NULL AND ${t.verdict} IS NULL)`),
|
|
315
|
+
check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
316
|
+
check("publications_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.verdict} IS NULL OR json_valid(${t.verdict})) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
|
|
317
|
+
]);
|
|
318
|
+
/**
|
|
319
|
+
* Scheduled publications (ADR-025): one per entry at most. The cron trigger publishes the entry when
|
|
320
|
+
* `scheduled_at` has passed, if it is still at `version`, with the warnings that were acknowledged
|
|
321
|
+
* when it was scheduled, and in the name of who scheduled it.
|
|
322
|
+
*/
|
|
323
|
+
const schedules = sqliteTable("schedules", {
|
|
324
|
+
id: id("sch"),
|
|
325
|
+
siteId: siteId(),
|
|
326
|
+
entryId: text("entry_id").notNull(),
|
|
327
|
+
scheduledAt: text("scheduled_at").notNull(),
|
|
328
|
+
/** The version of the entry once scheduled: a later change cancels the schedule. */
|
|
329
|
+
version: integer("version").notNull(),
|
|
330
|
+
/** A JSON array: the warnings acknowledged when the publication was scheduled. */
|
|
331
|
+
acknowledged: text("acknowledged").notNull(),
|
|
332
|
+
actorKind: text("actor_kind").notNull(),
|
|
333
|
+
actorUserId: text("actor_user_id").notNull(),
|
|
334
|
+
actorTokenId: text("actor_token_id"),
|
|
335
|
+
actorMeta: text("actor_meta"),
|
|
336
|
+
...timestamps()
|
|
337
|
+
}, (t) => [
|
|
338
|
+
unique("schedules_id_site").on(t.id, t.siteId),
|
|
339
|
+
unique("schedules_entry").on(t.siteId, t.entryId),
|
|
340
|
+
index("schedules_due").on(t.scheduledAt),
|
|
341
|
+
foreignKey({
|
|
342
|
+
name: "schedules_site",
|
|
343
|
+
columns: [t.siteId],
|
|
344
|
+
foreignColumns: [sites.id]
|
|
345
|
+
}),
|
|
346
|
+
foreignKey({
|
|
347
|
+
name: "schedules_entry_fk",
|
|
348
|
+
columns: [t.entryId, t.siteId],
|
|
349
|
+
foreignColumns: [entries.id, entries.siteId]
|
|
350
|
+
}),
|
|
351
|
+
foreignKey({
|
|
352
|
+
name: "schedules_user",
|
|
353
|
+
columns: [t.actorUserId],
|
|
354
|
+
foreignColumns: [users.id]
|
|
355
|
+
}),
|
|
356
|
+
foreignKey({
|
|
357
|
+
name: "schedules_token",
|
|
358
|
+
columns: [t.actorTokenId, t.siteId],
|
|
359
|
+
foreignColumns: [tokens.id, tokens.siteId]
|
|
360
|
+
}),
|
|
361
|
+
check("schedules_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
362
|
+
check("schedules_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
|
|
363
|
+
]);
|
|
364
|
+
/**
|
|
365
|
+
* The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
|
|
366
|
+
* row per event. The Queue consumer delivers each event to every subscriber, and records it in
|
|
367
|
+
* `deliveries`; `sent_at` says that the event was put on the Queue.
|
|
368
|
+
*/
|
|
369
|
+
const outbox = sqliteTable("outbox", {
|
|
370
|
+
id: id("evt"),
|
|
371
|
+
siteId: siteId(),
|
|
372
|
+
/** `entry.published` or `entry.unpublished`. */
|
|
373
|
+
kind: text("kind").notNull(),
|
|
374
|
+
entryId: text("entry_id").notNull(),
|
|
375
|
+
/** A JSON object: what subscribers get. */
|
|
376
|
+
payload: text("payload").notNull(),
|
|
377
|
+
sentAt: text("sent_at"),
|
|
378
|
+
...timestamps()
|
|
379
|
+
}, (t) => [
|
|
380
|
+
unique("outbox_id_site").on(t.id, t.siteId),
|
|
381
|
+
index("outbox_unsent").on(t.sentAt, t.createdAt),
|
|
382
|
+
foreignKey({
|
|
383
|
+
name: "outbox_site",
|
|
384
|
+
columns: [t.siteId],
|
|
385
|
+
foreignColumns: [sites.id]
|
|
386
|
+
}),
|
|
387
|
+
check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
|
|
388
|
+
check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
|
|
389
|
+
]);
|
|
390
|
+
/** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
|
|
391
|
+
const deliveries = sqliteTable("deliveries", {
|
|
392
|
+
id: id("dlv"),
|
|
393
|
+
siteId: siteId(),
|
|
394
|
+
eventId: text("event_id").notNull(),
|
|
395
|
+
/** The subscriber, by its name in the config, such as `webhook:newsletter` or `indexnow`. */
|
|
396
|
+
subscriber: text("subscriber").notNull(),
|
|
397
|
+
attempts: integer("attempts").notNull(),
|
|
398
|
+
deliveredAt: text("delivered_at"),
|
|
399
|
+
lastError: text("last_error"),
|
|
400
|
+
...timestamps()
|
|
401
|
+
}, (t) => [
|
|
402
|
+
unique("deliveries_id_site").on(t.id, t.siteId),
|
|
403
|
+
unique("deliveries_event_subscriber").on(t.eventId, t.subscriber),
|
|
404
|
+
foreignKey({
|
|
405
|
+
name: "deliveries_site",
|
|
406
|
+
columns: [t.siteId],
|
|
407
|
+
foreignColumns: [sites.id]
|
|
408
|
+
}),
|
|
409
|
+
foreignKey({
|
|
410
|
+
name: "deliveries_event",
|
|
411
|
+
columns: [t.eventId, t.siteId],
|
|
412
|
+
foreignColumns: [outbox.id, outbox.siteId]
|
|
413
|
+
}),
|
|
414
|
+
check("deliveries_attempts", sql`${t.attempts} >= 0`)
|
|
415
|
+
]);
|
|
416
|
+
/** The passkeys of a person (ADR-026): WebAuthn credentials, by their id, with their public key. */
|
|
417
|
+
const passkeys = sqliteTable("passkeys", {
|
|
418
|
+
id: id("pky"),
|
|
419
|
+
siteId: siteId(),
|
|
420
|
+
userId: text("user_id").notNull(),
|
|
421
|
+
/** The credential id, base64url, as the authenticator gives it. */
|
|
422
|
+
credentialId: text("credential_id").notNull(),
|
|
423
|
+
/** The COSE public key, base64url. */
|
|
424
|
+
publicKey: text("public_key").notNull(),
|
|
425
|
+
counter: integer("counter").notNull(),
|
|
426
|
+
/** A JSON array of the transports the authenticator reported. */
|
|
427
|
+
transports: text("transports").notNull(),
|
|
428
|
+
/** What the person calls it, such as "MacBook". */
|
|
429
|
+
label: text("label").notNull(),
|
|
430
|
+
lastUsedAt: text("last_used_at"),
|
|
431
|
+
...timestamps()
|
|
432
|
+
}, (t) => [
|
|
433
|
+
unique("passkeys_id_site").on(t.id, t.siteId),
|
|
434
|
+
unique("passkeys_credential").on(t.credentialId),
|
|
435
|
+
index("passkeys_user").on(t.userId),
|
|
436
|
+
foreignKey({
|
|
437
|
+
name: "passkeys_site",
|
|
438
|
+
columns: [t.siteId],
|
|
439
|
+
foreignColumns: [sites.id]
|
|
440
|
+
}),
|
|
441
|
+
foreignKey({
|
|
442
|
+
name: "passkeys_user_fk",
|
|
443
|
+
columns: [t.userId],
|
|
444
|
+
foreignColumns: [users.id]
|
|
445
|
+
}),
|
|
446
|
+
check("passkeys_counter", sql`${t.counter} >= 0`),
|
|
447
|
+
check("passkeys_json", sql`json_valid(${t.transports}) AND json_type(${t.transports}) = 'array'`)
|
|
448
|
+
]);
|
|
449
|
+
/**
|
|
450
|
+
* Links that let a person in, once: the owner's setup link that `hollware init` prints (it names
|
|
451
|
+
* the owner) and invitations (they carry a role, and the person they make is new). Only the hash
|
|
452
|
+
* of the link's secret is stored.
|
|
453
|
+
*/
|
|
454
|
+
const invitations = sqliteTable("invitations", {
|
|
455
|
+
id: id("inv"),
|
|
456
|
+
siteId: siteId(),
|
|
457
|
+
hash: text("hash").notNull(),
|
|
458
|
+
role: text("role").notNull(),
|
|
459
|
+
/** The existing person the link is for, or null for an invitation that makes a new one. */
|
|
460
|
+
userId: text("user_id"),
|
|
461
|
+
expiresAt: text("expires_at").notNull(),
|
|
462
|
+
usedAt: text("used_at"),
|
|
463
|
+
...timestamps()
|
|
464
|
+
}, (t) => [
|
|
465
|
+
unique("invitations_id_site").on(t.id, t.siteId),
|
|
466
|
+
unique("invitations_hash").on(t.hash),
|
|
467
|
+
foreignKey({
|
|
468
|
+
name: "invitations_site",
|
|
469
|
+
columns: [t.siteId],
|
|
470
|
+
foreignColumns: [sites.id]
|
|
471
|
+
}),
|
|
472
|
+
foreignKey({
|
|
473
|
+
name: "invitations_user",
|
|
474
|
+
columns: [t.userId],
|
|
475
|
+
foreignColumns: [users.id]
|
|
476
|
+
}),
|
|
477
|
+
check("invitations_role", sql`${t.role} IN ('owner', 'editor', 'author')`)
|
|
478
|
+
]);
|
|
479
|
+
/** WebAuthn challenges, each used once and briefly (ADR-026). */
|
|
480
|
+
const authChallenges = sqliteTable("auth_challenges", {
|
|
481
|
+
id: id("chl"),
|
|
482
|
+
siteId: siteId(),
|
|
483
|
+
challenge: text("challenge").notNull(),
|
|
484
|
+
/** `register` or `authenticate`. */
|
|
485
|
+
purpose: text("purpose").notNull(),
|
|
486
|
+
/** The invitation a registration redeems, or null for a sign-in. */
|
|
487
|
+
invitationId: text("invitation_id"),
|
|
488
|
+
/** The person a registration makes or adds a passkey to: the id the passkey is bound to. */
|
|
489
|
+
userId: text("user_id"),
|
|
490
|
+
expiresAt: text("expires_at").notNull(),
|
|
491
|
+
...timestamps()
|
|
492
|
+
}, (t) => [
|
|
493
|
+
unique("auth_challenges_id_site").on(t.id, t.siteId),
|
|
494
|
+
foreignKey({
|
|
495
|
+
name: "auth_challenges_site",
|
|
496
|
+
columns: [t.siteId],
|
|
497
|
+
foreignColumns: [sites.id]
|
|
498
|
+
}),
|
|
499
|
+
check("auth_challenges_purpose", sql`${t.purpose} IN ('register', 'authenticate')`)
|
|
500
|
+
]);
|
|
501
|
+
/** Signed-in people (ADR-026): the session cookie that `/authorize` reads, by its hash. */
|
|
502
|
+
const sessions = sqliteTable("sessions", {
|
|
503
|
+
id: id("ses"),
|
|
504
|
+
siteId: siteId(),
|
|
505
|
+
userId: text("user_id").notNull(),
|
|
506
|
+
hash: text("hash").notNull(),
|
|
507
|
+
expiresAt: text("expires_at").notNull(),
|
|
508
|
+
...timestamps()
|
|
509
|
+
}, (t) => [
|
|
510
|
+
unique("sessions_id_site").on(t.id, t.siteId),
|
|
511
|
+
unique("sessions_hash").on(t.hash),
|
|
512
|
+
foreignKey({
|
|
513
|
+
name: "sessions_site",
|
|
514
|
+
columns: [t.siteId],
|
|
515
|
+
foreignColumns: [sites.id]
|
|
516
|
+
}),
|
|
517
|
+
foreignKey({
|
|
518
|
+
name: "sessions_user",
|
|
519
|
+
columns: [t.userId],
|
|
520
|
+
foreignColumns: [users.id]
|
|
521
|
+
})
|
|
522
|
+
]);
|
|
523
|
+
/**
|
|
524
|
+
* Reviews (v0.3): a person who may write but not publish asks for a revision to be published, and
|
|
525
|
+
* a person who may publish approves it, which publishes it, or asks for changes with a note. An
|
|
526
|
+
* entry has at most one pending review; a new revision of the entry cancels it, and a publication
|
|
527
|
+
* of the entry approves it, in the batch of that write.
|
|
528
|
+
*/
|
|
529
|
+
const reviews = sqliteTable("reviews", {
|
|
530
|
+
id: id("rvw"),
|
|
531
|
+
siteId: siteId(),
|
|
532
|
+
entryId: text("entry_id").notNull(),
|
|
533
|
+
/** The revision asked for: the head of the entry when the review was asked. */
|
|
534
|
+
revisionId: text("revision_id").notNull(),
|
|
535
|
+
status: text("status").notNull(),
|
|
536
|
+
/** A JSON array: the warnings the person who asked acknowledged. */
|
|
537
|
+
acknowledged: text("acknowledged").notNull(),
|
|
538
|
+
requestedBy: text("requested_by").notNull(),
|
|
539
|
+
/** What the client that asked said about itself, as JSON, or `null`. */
|
|
540
|
+
requestedMeta: text("requested_meta"),
|
|
541
|
+
decidedBy: text("decided_by"),
|
|
542
|
+
decidedAt: text("decided_at"),
|
|
543
|
+
/** What the person who asked for changes wrote. */
|
|
544
|
+
note: text("note"),
|
|
545
|
+
...timestamps()
|
|
546
|
+
}, (t) => [
|
|
547
|
+
unique("reviews_id_site").on(t.id, t.siteId),
|
|
548
|
+
uniqueIndex("reviews_pending").on(t.siteId, t.entryId).where(sql`${t.status} = 'pending'`),
|
|
549
|
+
index("reviews_queue").on(t.siteId, t.status, t.createdAt),
|
|
550
|
+
foreignKey({
|
|
551
|
+
name: "reviews_site",
|
|
552
|
+
columns: [t.siteId],
|
|
553
|
+
foreignColumns: [sites.id]
|
|
554
|
+
}),
|
|
555
|
+
foreignKey({
|
|
556
|
+
name: "reviews_entry",
|
|
557
|
+
columns: [t.entryId, t.siteId],
|
|
558
|
+
foreignColumns: [entries.id, entries.siteId]
|
|
559
|
+
}),
|
|
560
|
+
foreignKey({
|
|
561
|
+
name: "reviews_revision",
|
|
562
|
+
columns: [t.revisionId, t.siteId],
|
|
563
|
+
foreignColumns: [revisions.id, revisions.siteId]
|
|
564
|
+
}),
|
|
565
|
+
foreignKey({
|
|
566
|
+
name: "reviews_requester",
|
|
567
|
+
columns: [t.requestedBy],
|
|
568
|
+
foreignColumns: [users.id]
|
|
569
|
+
}),
|
|
570
|
+
foreignKey({
|
|
571
|
+
name: "reviews_decider",
|
|
572
|
+
columns: [t.decidedBy],
|
|
573
|
+
foreignColumns: [users.id]
|
|
574
|
+
}),
|
|
575
|
+
check("reviews_status", sql`${t.status} IN ('pending', 'approved', 'changes_requested', 'cancelled')`),
|
|
576
|
+
check("reviews_decided", sql`(${t.status} IN ('pending', 'cancelled')) OR (${t.decidedBy} IS NOT NULL AND ${t.decidedAt} IS NOT NULL)`),
|
|
577
|
+
check("reviews_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.requestedMeta} IS NULL OR json_valid(${t.requestedMeta}))`)
|
|
578
|
+
]);
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/identity/secrets.ts
|
|
581
|
+
/**
|
|
582
|
+
* The secrets of identity (ADR-026): an invitation link and a session cookie are 32 random bytes,
|
|
583
|
+
* base64url, with a prefix that says what they are. Only the SHA-256 of a secret is stored.
|
|
584
|
+
*/
|
|
585
|
+
function base64url(bytes) {
|
|
586
|
+
let binary = "";
|
|
587
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
588
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
589
|
+
}
|
|
590
|
+
/** A new secret: `<prefix>_` and 43 base64url characters. */
|
|
591
|
+
function newSecret(prefix) {
|
|
592
|
+
return `${prefix}_${base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))}`;
|
|
593
|
+
}
|
|
594
|
+
/** The SHA-256 of a secret, in hex: what the database keeps. */
|
|
595
|
+
async function hashSecret(secret) {
|
|
596
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
597
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
598
|
+
}
|
|
599
|
+
/** The bytes of a user id, as WebAuthn binds a passkey to them. */
|
|
600
|
+
function userHandle(userId) {
|
|
601
|
+
const bytes = new TextEncoder().encode(userId);
|
|
602
|
+
const handle = new Uint8Array(bytes.length);
|
|
603
|
+
handle.set(bytes);
|
|
604
|
+
return handle;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/identity/store.ts
|
|
608
|
+
function roleOf(value) {
|
|
609
|
+
if (value === "owner" || value === "editor" || value === "author") return value;
|
|
610
|
+
throw new Error(`An invitation has the role ${value}, which the schema does not allow`);
|
|
611
|
+
}
|
|
612
|
+
/** Creates a link that lets one person in, and gives its secret, which is never stored. */
|
|
613
|
+
async function createInvitation(db, input) {
|
|
614
|
+
const secret = newSecret("hw_inv");
|
|
615
|
+
const id = newId("inv");
|
|
616
|
+
const now = input.now.toISOString();
|
|
617
|
+
const expiresAt = new Date(input.now.getTime() + input.days * 864e5).toISOString();
|
|
618
|
+
await db.insert(invitations).values({
|
|
619
|
+
id,
|
|
620
|
+
siteId: input.site,
|
|
621
|
+
hash: await hashSecret(secret),
|
|
622
|
+
role: input.role,
|
|
623
|
+
userId: input.userId,
|
|
624
|
+
expiresAt,
|
|
625
|
+
usedAt: null,
|
|
626
|
+
createdAt: now,
|
|
627
|
+
updatedAt: now
|
|
628
|
+
});
|
|
629
|
+
return {
|
|
630
|
+
id,
|
|
631
|
+
secret,
|
|
632
|
+
expiresAt
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/** The invitation of a secret, while it is unused and has not expired. */
|
|
636
|
+
async function findInvitation(db, site, secret, now) {
|
|
637
|
+
const [row] = await db.select({
|
|
638
|
+
id: invitations.id,
|
|
639
|
+
role: invitations.role,
|
|
640
|
+
userId: invitations.userId
|
|
641
|
+
}).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.hash, await hashSecret(secret)), isNull(invitations.usedAt), gt(invitations.expiresAt, now.toISOString()))).limit(1);
|
|
642
|
+
return row === void 0 ? null : {
|
|
643
|
+
...row,
|
|
644
|
+
role: roleOf(row.role)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/** Uses an invitation, once: `false` when it was used already or has expired. */
|
|
648
|
+
async function redeemInvitation(db, site, id, now) {
|
|
649
|
+
const at = now.toISOString();
|
|
650
|
+
return (await db.update(invitations).set({
|
|
651
|
+
usedAt: at,
|
|
652
|
+
updatedAt: at
|
|
653
|
+
}).where(and(eq(invitations.siteId, site), eq(invitations.id, id), isNull(invitations.usedAt), gt(invitations.expiresAt, at))).returning({ id: invitations.id })).length === 1;
|
|
654
|
+
}
|
|
655
|
+
/** Keeps a WebAuthn challenge for five minutes. */
|
|
656
|
+
async function saveChallenge(db, input) {
|
|
657
|
+
const id = newId("chl");
|
|
658
|
+
const now = input.now.toISOString();
|
|
659
|
+
await db.insert(authChallenges).values({
|
|
660
|
+
id,
|
|
661
|
+
siteId: input.site,
|
|
662
|
+
challenge: input.challenge,
|
|
663
|
+
purpose: input.purpose,
|
|
664
|
+
invitationId: input.invitationId,
|
|
665
|
+
userId: input.userId,
|
|
666
|
+
expiresAt: new Date(input.now.getTime() + 3e5).toISOString(),
|
|
667
|
+
createdAt: now,
|
|
668
|
+
updatedAt: now
|
|
669
|
+
});
|
|
670
|
+
return id;
|
|
671
|
+
}
|
|
672
|
+
/** Takes a challenge, once, if it has the purpose asked and has not expired. */
|
|
673
|
+
async function takeChallenge(db, input) {
|
|
674
|
+
const [row] = await db.delete(authChallenges).where(and(eq(authChallenges.siteId, input.site), eq(authChallenges.id, input.id))).returning();
|
|
675
|
+
if (row === void 0 || row.purpose !== input.purpose) return null;
|
|
676
|
+
if (row.expiresAt <= input.now.toISOString()) return null;
|
|
677
|
+
return row;
|
|
678
|
+
}
|
|
679
|
+
async function passkeysOf(db, site, userId) {
|
|
680
|
+
return db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.userId, userId)));
|
|
681
|
+
}
|
|
682
|
+
async function findPasskey(db, site, credentialId) {
|
|
683
|
+
const [row] = await db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.credentialId, credentialId))).limit(1);
|
|
684
|
+
return row ?? null;
|
|
685
|
+
}
|
|
686
|
+
/** Records a sign-in with a passkey: its new counter, and when. */
|
|
687
|
+
async function touchPasskey(db, id, counter, now) {
|
|
688
|
+
const at = now.toISOString();
|
|
689
|
+
await db.update(passkeys).set({
|
|
690
|
+
counter,
|
|
691
|
+
lastUsedAt: at,
|
|
692
|
+
updatedAt: at
|
|
693
|
+
}).where(eq(passkeys.id, id));
|
|
694
|
+
}
|
|
695
|
+
/** Makes the person of a new invitation, a member of the site with its role, in one batch. */
|
|
696
|
+
async function createUser(db, input) {
|
|
697
|
+
const at = input.now.toISOString();
|
|
698
|
+
await db.batch([db.insert(users).values({
|
|
699
|
+
id: input.id,
|
|
700
|
+
name: input.name,
|
|
701
|
+
role: legacyRoleOf(input.role),
|
|
702
|
+
createdAt: at,
|
|
703
|
+
updatedAt: at
|
|
704
|
+
}), db.insert(members).values({
|
|
705
|
+
id: newId("mbr"),
|
|
706
|
+
siteId: input.site,
|
|
707
|
+
userId: input.id,
|
|
708
|
+
role: input.role,
|
|
709
|
+
createdAt: at,
|
|
710
|
+
updatedAt: at
|
|
711
|
+
})]);
|
|
712
|
+
}
|
|
713
|
+
async function addPasskey(db, input) {
|
|
714
|
+
const at = input.now.toISOString();
|
|
715
|
+
await db.insert(passkeys).values({
|
|
716
|
+
id: newId("pky"),
|
|
717
|
+
siteId: input.site,
|
|
718
|
+
userId: input.userId,
|
|
719
|
+
credentialId: input.credentialId,
|
|
720
|
+
publicKey: input.publicKey,
|
|
721
|
+
counter: input.counter,
|
|
722
|
+
transports: JSON.stringify(input.transports),
|
|
723
|
+
label: input.label,
|
|
724
|
+
lastUsedAt: null,
|
|
725
|
+
createdAt: at,
|
|
726
|
+
updatedAt: at
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** Opens a session for a person, and gives the secret of its cookie, which is never stored. */
|
|
730
|
+
async function createSession(db, input) {
|
|
731
|
+
const secret = newSecret("hw_ses");
|
|
732
|
+
const at = input.now.toISOString();
|
|
733
|
+
const expiresAt = new Date(input.now.getTime() + input.hours * 36e5).toISOString();
|
|
734
|
+
await db.insert(sessions).values({
|
|
735
|
+
id: newId("ses"),
|
|
736
|
+
siteId: input.site,
|
|
737
|
+
userId: input.userId,
|
|
738
|
+
hash: await hashSecret(secret),
|
|
739
|
+
expiresAt,
|
|
740
|
+
createdAt: at,
|
|
741
|
+
updatedAt: at
|
|
742
|
+
});
|
|
743
|
+
return {
|
|
744
|
+
secret,
|
|
745
|
+
expiresAt
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** The person of a session cookie, while it lasts, with their role in its site now. */
|
|
749
|
+
async function findSession(db, site, secret, now) {
|
|
750
|
+
const [row] = await db.select({
|
|
751
|
+
userId: sessions.userId,
|
|
752
|
+
name: users.name,
|
|
753
|
+
role: members.role
|
|
754
|
+
}).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).innerJoin(members, and(eq(members.userId, sessions.userId), eq(members.siteId, sessions.siteId))).where(and(eq(sessions.siteId, site), eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
|
|
755
|
+
return row ?? null;
|
|
756
|
+
}
|
|
757
|
+
/** An invitation by its id, used or not: what a registration redeems. */
|
|
758
|
+
async function invitationById(db, site, id) {
|
|
759
|
+
const [row] = await db.select({
|
|
760
|
+
id: invitations.id,
|
|
761
|
+
role: invitations.role,
|
|
762
|
+
userId: invitations.userId
|
|
763
|
+
}).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.id, id))).limit(1);
|
|
764
|
+
return row === void 0 ? null : {
|
|
765
|
+
...row,
|
|
766
|
+
role: roleOf(row.role)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
//#endregion
|
|
770
|
+
export { isRole as A, revisions as C, users as D, tokens as E, ROLE_SCOPES as O, revisionTags as S, sites as T, invitations as _, findInvitation as a, publications as b, invitationById as c, saveChallenge as d, takeChallenge as f, entries as g, deliveries as h, createUser as i, effectiveScopes as k, passkeysOf as l, userHandle as m, createInvitation as n, findPasskey as o, touchPasskey as p, createSession as r, findSession as s, addPasskey as t, redeemInvitation as u, members as v, schedules as w, reviews as x, outbox as y };
|