@kernhq/module-quire 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/contract/models.d.ts +46 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +17 -1
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +35 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/router.d.ts +756 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +346 -2
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +826 -0
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +336 -2
  16. package/dist/server/_impl.js.map +1 -1
  17. package/dist/server/schema.d.ts +318 -1
  18. package/dist/server/schema.d.ts.map +1 -1
  19. package/dist/server/schema.js +86 -0
  20. package/dist/server/schema.js.map +1 -1
  21. package/dist/server/services/access.d.ts +1 -0
  22. package/dist/server/services/access.d.ts.map +1 -1
  23. package/dist/server/services/index.d.ts +3 -0
  24. package/dist/server/services/index.d.ts.map +1 -1
  25. package/dist/server/services/index.js +5 -1
  26. package/dist/server/services/index.js.map +1 -1
  27. package/dist/server/services/pages.d.ts.map +1 -1
  28. package/dist/server/services/pages.js +6 -0
  29. package/dist/server/services/pages.js.map +1 -1
  30. package/dist/server/services/publications.d.ts +191 -0
  31. package/dist/server/services/publications.d.ts.map +1 -0
  32. package/dist/server/services/publications.js +772 -0
  33. package/dist/server/services/publications.js.map +1 -0
  34. package/dist/server/services/versions.d.ts +26 -1
  35. package/dist/server/services/versions.d.ts.map +1 -1
  36. package/dist/server/services/versions.js +44 -15
  37. package/dist/server/services/versions.js.map +1 -1
  38. package/migrations/0008_publications.sql +140 -0
  39. package/migrations/0009_public_asset_references.sql +35 -0
  40. package/migrations/meta/_journal.json +14 -0
  41. package/package.json +1 -1
  42. package/src/client/components/PublishDialog.svelte +920 -0
  43. package/src/client/components/SidebarRecents.svelte +17 -1
  44. package/src/client/i18n.ts +448 -0
  45. package/src/client/index.ts +21 -0
  46. package/src/client/mock.ts +436 -2
  47. package/src/client/pages/PageView.svelte +139 -0
  48. package/src/client/public-url.ts +76 -0
  49. package/src/client/query.ts +16 -0
  50. package/src/contract/models.ts +77 -0
  51. package/src/contract/permissions.ts +54 -1
  52. package/src/contract/router.ts +380 -1
@@ -0,0 +1,772 @@
1
+ /**
2
+ * Publishing a page, and everything under it, to a URL a signed-out stranger can open.
3
+ *
4
+ * This file holds both halves: the authenticated CRUD an author uses, and the read path an
5
+ * anonymous request takes. They are together because the second one is only safe if it agrees with
6
+ * the first about what "public" means, and the definition lives here once:
7
+ *
8
+ * **A page is public iff it is the publication's root or a descendant of it reached without
9
+ * passing through a page that is opted out, archived, trashed, unpublished or not a `page`; and
10
+ * it has a published version that has been rendered to HTML.**
11
+ *
12
+ * Note that the walk *prunes* rather than filters. A page whose parent is private is private, even
13
+ * though it is inside the subtree — otherwise excluding a section would leave every page under it
14
+ * reachable by anyone who guessed its address, and the nav would simply not mention them. The cost
15
+ * is that forgetting to publish a middle page hides its children, which is the safe direction to be
16
+ * wrong in and is worth saying in the interface.
17
+ *
18
+ * Three things about the anonymous path that the type system cannot hold:
19
+ *
20
+ * - it runs in a **read-only transaction** (`read`, below). Once the workspace is set, row-level
21
+ * security has stopped being a fence around an anonymous request — the whole workspace is inside
22
+ * it, which is right for reading a publication and wrong for everything else. Postgres refuses
23
+ * the write with 25006 instead of a code review catching it;
24
+ * - **every query carries the publication in its own `WHERE`**. Workspace scope is not publication
25
+ * scope, and RLS will happily return a page in another space that nobody published;
26
+ * - **nothing it returns carries an id.** Pages are addressed by path, the cache validator is a
27
+ * hash rather than the version id, and the rendered HTML is scrubbed of `id` and `data-id`
28
+ * before it leaves. An id in a public response is a string somebody can try somewhere else.
29
+ */
30
+ import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
31
+ import { promisify } from 'node:util';
32
+ import { KernError } from '@kernhq/kernel';
33
+ import { and, desc, eq, inArray, sql } from 'drizzle-orm';
34
+ import { PUBLIC_ASSET_SEGMENT } from '../../contract/index.js';
35
+ import { escapeHtml } from '../render.js';
36
+ import { pages, pageVersions, publications } from '../schema.js';
37
+ import { ASSET_REFERENCE_PREFIX } from './versions.js';
38
+ const scrypt = promisify(scryptCb);
39
+ /** How deep a published site may nest before the walk stops descending. */
40
+ const MAX_DEPTH = 32;
41
+ /** How many pages one `create`/`update` will render HTML for before giving up and logging. */
42
+ const MAX_BACKFILL = 200;
43
+ /** How long an unlock token is good for. Short, because there is nothing to revoke it with. */
44
+ const TOKEN_TTL_MS = 12 * 60 * 60_000;
45
+ /** How much of a page's prose a search hit shows around the match. */
46
+ const SNIPPET = 180;
47
+ /**
48
+ * The largest picture this surface will hand out, and how long a reader may keep one.
49
+ *
50
+ * The bytes come back through an anonymous procedure, so an object with no ceiling on it is a way
51
+ * to spend the server's memory from outside. Over the cap is the same 404 as a picture that is not
52
+ * there — an oversized image on a published page is a defect in the page rather than something to
53
+ * fail a request over. A version is immutable and its reference is sealed to one file, so the cache
54
+ * lifetime is as long as anything in Kern gets.
55
+ */
56
+ const MAX_ASSET_BYTES = 8 * 1024 * 1024;
57
+ const ASSET_MAX_AGE = 31_536_000;
58
+ /**
59
+ * What a picture is allowed to be, because the route layer serves these from the app's own origin.
60
+ *
61
+ * A stored content type is whatever an uploader declared, and a reference resolves on the same
62
+ * origin as the signed-in application — so anything the browser would treat as a document rather
63
+ * than as an image is a way to run script beside somebody's session. The node these references come
64
+ * from is an image node, so the list is the image types and nothing else, and an object claiming to
65
+ * be anything else is the same 404 as one that is not there.
66
+ *
67
+ * `image/svg+xml` is on the list and is the one that needs saying: an SVG *is* a document, and it
68
+ * can carry script. It is here because a diagram in a handbook is very often one, and it is safe
69
+ * only because the route layer is required to serve every one of these with `nosniff`, an inline
70
+ * disposition and a `default-src 'none'` policy — see the note on `PublicAsset` in the contract.
71
+ */
72
+ const ASSET_TYPES = new Set([
73
+ 'image/png',
74
+ 'image/jpeg',
75
+ 'image/gif',
76
+ 'image/webp',
77
+ 'image/avif',
78
+ 'image/svg+xml',
79
+ ]);
80
+ /**
81
+ * How many passwords one publication will weigh in a minute, and why the number is here.
82
+ *
83
+ * `public.unlock` is reachable by anyone on the internet with no account, and every attempt costs
84
+ * the server an scrypt at N=16384 — 600 of them measured at roughly a minute of single-thread work,
85
+ * bought with one unauthenticated burst. The platform's own limiter is a *shared* budget across the
86
+ * whole API rather than a password fence, so a publication needs one of its own.
87
+ *
88
+ * It is per process and therefore multiplied by however many copies of the host service are
89
+ * running, which is honest rather than ideal: a counter that has to be right across a cluster
90
+ * belongs in the platform limiter, and until it is there this is the difference between 864,000
91
+ * guesses a day from one address and 14,400.
92
+ */
93
+ const UNLOCK_ATTEMPTS = 10;
94
+ const UNLOCK_WINDOW_MS = 60_000;
95
+ /** Above this many publications tracked at once the window is dropped wholesale rather than grown. */
96
+ const UNLOCK_TRACKED_MAX = 5000;
97
+ /**
98
+ * A publication as a client sees it — with `hasPassword` in place of the hash.
99
+ *
100
+ * The hash never leaves this file. It is a hash, a salt and a cost, so shipping it to a browser
101
+ * turns an online guess, which the server can rate-limit, into an offline one, which it cannot.
102
+ * Row-level security is row-level: the column is inside every row the workspace can read, including
103
+ * on the public path once the handler has set the workspace, so "do not select it" is not a
104
+ * protection — mapping through this function is.
105
+ */
106
+ export function toPublication(row) {
107
+ return {
108
+ id: row.id,
109
+ workspaceId: row.workspaceId,
110
+ rootPageId: row.rootPageId,
111
+ includeDescendants: row.includeDescendants,
112
+ slug: row.slug,
113
+ hasPassword: row.passwordHash !== null,
114
+ expiresAt: row.expiresAt?.toISOString() ?? null,
115
+ seoTitle: row.seoTitle,
116
+ seoDescription: row.seoDescription,
117
+ ogImageUrl: row.ogImageUrl,
118
+ indexable: row.indexable,
119
+ theme: row.theme,
120
+ createdBy: row.createdBy,
121
+ createdAt: row.createdAt.toISOString(),
122
+ updatedAt: row.updatedAt.toISOString(),
123
+ };
124
+ }
125
+ /**
126
+ * A title reduced to a URL segment, keeping Unicode letters and digits.
127
+ *
128
+ * `[^\p{L}\p{N}]` rather than `[^a-z0-9]`: a Persian or Arabic title would otherwise slugify to
129
+ * nothing at all, and a handbook in Persian would be a tree of `untitled`, `untitled-2`,
130
+ * `untitled-3`. Percent-encoding makes the result URL-safe; readability in the address bar is the
131
+ * browser's job, and every browser shows it decoded.
132
+ */
133
+ export function slugifyTitle(title) {
134
+ const cleaned = title
135
+ .normalize('NFC')
136
+ .toLowerCase()
137
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
138
+ .replace(/^-+|-+$/g, '')
139
+ .slice(0, 60)
140
+ .replace(/-+$/g, '');
141
+ return cleaned || 'untitled';
142
+ }
143
+ /**
144
+ * Give every page in the walk its path, and nothing else its path.
145
+ *
146
+ * Siblings that slugify the same get `-2`, `-3` in `position` order, which is deterministic from the
147
+ * tree: two people looking at the same site get the same URLs, and nothing has to be stored. The
148
+ * rows arrive ordered by depth and then position, so a parent's path is always known before its
149
+ * children are reached.
150
+ */
151
+ export function withPaths(rows) {
152
+ const pathOf = new Map();
153
+ const takenUnder = new Map();
154
+ const out = [];
155
+ for (const row of rows) {
156
+ const parentPath = row.parent_id === null ? null : (pathOf.get(row.parent_id) ?? null);
157
+ // A child whose parent did not survive the walk cannot be addressed, so it is not public
158
+ // either. Unreachable while the walk prunes, and it stays correct if it ever stops.
159
+ if (row.parent_id !== null && parentPath === null)
160
+ continue;
161
+ if (row.depth === 0) {
162
+ pathOf.set(row.id, '');
163
+ out.push({ ...row, path: '', parentPath: null });
164
+ continue;
165
+ }
166
+ const taken = takenUnder.get(row.parent_id) ?? new Set();
167
+ takenUnder.set(row.parent_id, taken);
168
+ const base = slugifyTitle(row.title);
169
+ let slug = base;
170
+ for (let n = 2; taken.has(slug); n++)
171
+ slug = `${base}-${n}`;
172
+ taken.add(slug);
173
+ const path = parentPath === '' ? slug : `${parentPath}/${slug}`;
174
+ pathOf.set(row.id, path);
175
+ out.push({ ...row, path, parentPath });
176
+ }
177
+ return out;
178
+ }
179
+ /** Compare two public paths the way a URL bar does: trimmed of slashes, case-folded, NFC. */
180
+ const normalisePath = (path) => path
181
+ .normalize('NFC')
182
+ .replace(/^\/+|\/+$/g, '')
183
+ .toLowerCase();
184
+ /**
185
+ * The public rendering of a stored version.
186
+ *
187
+ * Two passes, in this order because the first needs what the second removes:
188
+ *
189
+ * 1. **Re-point page mentions.** `versions.html` writes `/quire/<space-key>/<page-id>`, which is
190
+ * an address inside the application: a stranger following it gets a sign-in screen, and the id
191
+ * in it belongs to a page that may not be public at all. A mention of a page in this
192
+ * publication becomes a link to its public path; anything else loses its `href` and stays as
193
+ * readable text.
194
+ * 2. **Strip every identifier.** `id` is the block anchor the editor wrote and `data-id` is the
195
+ * mentioned page's or person's id — a user id on a public page is a person's identifier handed
196
+ * to the internet for no reader's benefit.
197
+ *
198
+ * A regular expression over HTML is usually a mistake and is safe here for one specific reason: this
199
+ * HTML was produced by `render.ts`, which escapes every attribute value, so a `"` never appears
200
+ * inside one and `[^"]*` cannot run past the attribute it is in. Prose that happens to contain the
201
+ * text `id="x"` arrives as `id=&quot;x&quot;` and is left alone. `publications.int.test.ts` holds
202
+ * that with a code block written to look like markup.
203
+ */
204
+ const APP_LINK = /href="\/quire\/[^"]*"/g;
205
+ const APP_PAGE_LINK = /^href="\/quire\/[^"/]*\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:[/?#][^"]*)?"$/;
206
+ /**
207
+ * Pictures, held to the same rule as links: a `src` this module cannot account for does not go out.
208
+ *
209
+ * `render.ts` escapes every attribute value, so `>` never appears inside one and `[^>]*` cannot run
210
+ * past the tag it is in — the same property that makes the link pass above safe.
211
+ *
212
+ * Three shapes reach here and only one of them is servable. A **reference** is what the public
213
+ * render writes and it resolves to an address on this site. An **absolute off-site URL** is an
214
+ * author's own picture hosted somewhere else, and it is left alone. Everything else is dropped
215
+ * together with its `<img>`, and that is deliberately wide: a root-relative `src` is a link into
216
+ * the private application, and a *signed storage URL* — which is what versions published by 0.12.0
217
+ * have stored in them — is the tenant's workspace uuid and a file uuid written into a page on the
218
+ * public internet, with an hour before it stops working. `0009_public_asset_references.sql` rewrites
219
+ * the ones already in the database; this is what covers a row the migration did not reach.
220
+ */
221
+ const IMG_TAG = /<img\b[^>]*>/g;
222
+ const ASSET_SRC = new RegExp(` src="${ASSET_REFERENCE_PREFIX}([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"`);
223
+ const ANY_SRC = / src="([^"]*)"/;
224
+ const SIGNED_URL = /[?&](?:amp;)?X-Amz-(?:Signature|Credential)=/i;
225
+ export function publicHtml(html, basePath, resolve) {
226
+ /*
227
+ * Every link into the application, in one pass, and the page id is not anchored on the closing
228
+ * quote.
229
+ *
230
+ * A page mention is not the only thing that writes one of these. `safeHref` passes any
231
+ * root-relative path through — it has to, that is what an internal link is — so an author who
232
+ * pastes a page's address out of their own address bar gets `/quire/<space>/<id>#block-7` or
233
+ * `…?comment=1` or `…/history`, and a pattern that required `"` straight after the id matched
234
+ * none of them and left the whole href alone. That put a live deep link into the private
235
+ * application, carrying the uuid of a page this same API answers 404 for, on the public internet.
236
+ *
237
+ * So: match the whole href, then ask whether it names a page — and if the answer is no for any
238
+ * reason at all (no id in it, an id that is not public, an id with something glued to it), drop
239
+ * the href rather than keep it. One pass rather than two, because `basePath` may legally be
240
+ * `/quire/` and a second sweep would eat the links the first one had just written.
241
+ */
242
+ const linked = html.replace(APP_LINK, (match) => {
243
+ const pageId = APP_PAGE_LINK.exec(match)?.[1] ?? null;
244
+ const path = pageId === null ? null : resolve.pagePath(pageId);
245
+ return path === null ? '' : `href="${escapeHtml(basePath + encodeURI(path))}"`;
246
+ });
247
+ const pictured = linked.replace(IMG_TAG, (tag) => {
248
+ const fileId = ASSET_SRC.exec(tag)?.[1] ?? null;
249
+ if (fileId === null) {
250
+ const src = ANY_SRC.exec(tag)?.[1] ?? '';
251
+ return /^https?:\/\//i.test(src) && !SIGNED_URL.test(src) ? tag : '';
252
+ }
253
+ const href = resolve.assetHref(fileId);
254
+ // A function replacement, because `$&` and friends mean something in a replacement string and
255
+ // a base64url token is not somewhere to find that out.
256
+ return href === null ? '' : tag.replace(ASSET_SRC, () => ` src="${escapeHtml(href)}"`);
257
+ });
258
+ return pictured.replace(/ (?:data-)?id="[^"]*"/g, '');
259
+ }
260
+ /** A plain-text window around the first match, for a search result. */
261
+ export function snippetAround(text, query) {
262
+ const flat = text.replace(/\s+/g, ' ').trim();
263
+ const at = flat.toLowerCase().indexOf(query.toLowerCase());
264
+ if (at < 0)
265
+ return flat.slice(0, SNIPPET);
266
+ const from = Math.max(0, at - Math.floor(SNIPPET / 3));
267
+ const to = Math.min(flat.length, from + SNIPPET);
268
+ return `${from > 0 ? '…' : ''}${flat.slice(from, to)}${to < flat.length ? '…' : ''}`;
269
+ }
270
+ /** `%`, `_` and `\` mean something to `ILIKE`, so a reader searching for `50%` gets `50%`. */
271
+ const escapeLike = (value) => value.replace(/[\\%_]/g, (c) => `\\${c}`);
272
+ export function quirePublications(kernel, access, versions) {
273
+ /**
274
+ * The associated data an unlock token is sealed with.
275
+ *
276
+ * AES-GCM authenticates it, so a token minted for one publication cannot be presented to another
277
+ * — and a token from another instance cannot be presented at all, because the key is derived from
278
+ * that instance's own secret. This is what makes the token a capability rather than a session:
279
+ * there is nothing on the server to look up, nothing to expire on a schedule, and nothing that
280
+ * says who is holding it.
281
+ */
282
+ const aad = (workspaceId, publicationId) => `quire.publication.unlock:${workspaceId}:${publicationId}`;
283
+ /**
284
+ * The same mechanism, one scope wider, for a picture reference.
285
+ *
286
+ * Sealed to the **workspace** rather than to one publication, because a version is shared: the
287
+ * same page can be the root of two publications and its stored HTML is rendered once. Widening it
288
+ * costs nothing that matters — a reference only ever exists inside a page somebody has already
289
+ * been served, and resolving one still requires the file to be in the tree of the publication it
290
+ * is presented against.
291
+ */
292
+ const assetAad = (workspaceId) => `quire.publication.asset:${workspaceId.toLowerCase()}`;
293
+ /** Recent password attempts per publication, for the fence described at `UNLOCK_ATTEMPTS`. */
294
+ const unlockAttempts = new Map();
295
+ async function hashPassword(plain) {
296
+ const salt = randomBytes(16);
297
+ const key = await scrypt(plain.normalize('NFC'), salt, 32, { N: 16384, r: 8, p: 1 });
298
+ return `$scrypt$N=16384,r=8,p=1$${salt.toString('base64url')}$${key.toString('base64url')}`;
299
+ }
300
+ /**
301
+ * Constant-time in the comparison, and deliberately not in the parse: a malformed stored hash is
302
+ * a bug in this file rather than something an attacker can produce, and answering `false` for one
303
+ * is the safe direction.
304
+ */
305
+ async function verifyPassword(plain, phc) {
306
+ const parts = phc.split('$');
307
+ if (parts.length !== 5 || parts[1] !== 'scrypt')
308
+ return false;
309
+ const params = Object.fromEntries((parts[2] ?? '').split(',').map((pair) => {
310
+ const [k, v] = pair.split('=');
311
+ return [k ?? '', Number(v)];
312
+ }));
313
+ const salt = Buffer.from(parts[3] ?? '', 'base64url');
314
+ const expected = Buffer.from(parts[4] ?? '', 'base64url');
315
+ if (!Number.isInteger(params.N) || !Number.isInteger(params.r) || !Number.isInteger(params.p))
316
+ return false;
317
+ if (salt.length === 0 || expected.length === 0)
318
+ return false;
319
+ const actual = await scrypt(plain.normalize('NFC'), salt, expected.length, {
320
+ N: params.N,
321
+ r: params.r,
322
+ p: params.p,
323
+ });
324
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
325
+ }
326
+ /**
327
+ * The walk, as one recursive query.
328
+ *
329
+ * `cycle` is not paranoia: a page that had somehow become its own ancestor would otherwise hang
330
+ * the connection rather than fail, and this is the one endpoint where a hung connection is
331
+ * something a stranger can ask for. `depth < MAX_DEPTH` bounds the other direction.
332
+ */
333
+ async function walk(tx, workspaceId, pub, opts) {
334
+ const rendered = opts.requireHtml;
335
+ const res = await tx.execute(sql `
336
+ with recursive tree as (
337
+ select p.id, p.parent_id, p.title, p.icon, p.cover_url, p.position,
338
+ v.id as version_id, v.created_at as published_at,
339
+ (v.html is not null) as has_html, 0 as depth
340
+ from mod_quire.pages p
341
+ join mod_quire.page_versions v
342
+ on v.workspace_id = p.workspace_id and v.id = p.published_version_id
343
+ where p.workspace_id = ${workspaceId}::uuid
344
+ and p.id = ${pub.rootPageId}::uuid
345
+ and p.kind = 'page'
346
+ and p.database_id is null
347
+ and p.deleted_at is null
348
+ and p.archived_at is null
349
+ and p.excluded_from_public = false
350
+ and (${rendered}::boolean = false or v.html is not null)
351
+ union all
352
+ select c.id, c.parent_id, c.title, c.icon, c.cover_url, c.position,
353
+ cv.id, cv.created_at, (cv.html is not null), tree.depth + 1
354
+ from mod_quire.pages c
355
+ join tree on c.parent_id = tree.id
356
+ join mod_quire.page_versions cv
357
+ on cv.workspace_id = ${workspaceId}::uuid and cv.id = c.published_version_id
358
+ where c.workspace_id = ${workspaceId}::uuid
359
+ and ${pub.includeDescendants}::boolean
360
+ and c.kind = 'page'
361
+ and c.database_id is null
362
+ and c.deleted_at is null
363
+ and c.archived_at is null
364
+ and c.excluded_from_public = false
365
+ and tree.depth < ${MAX_DEPTH}
366
+ and (${rendered}::boolean = false or cv.html is not null)
367
+ ) cycle id set looped using cyclepath
368
+ select id, parent_id, title, icon, cover_url, position,
369
+ version_id, published_at, has_html, depth
370
+ from tree
371
+ order by depth asc, position asc
372
+ `);
373
+ return res.rows.map((row) => ({
374
+ id: row.id,
375
+ parent_id: row.parent_id,
376
+ title: row.title,
377
+ icon: row.icon,
378
+ cover_url: row.cover_url,
379
+ position: row.position,
380
+ version_id: row.version_id,
381
+ published_at: row.published_at instanceof Date ? row.published_at : new Date(row.published_at),
382
+ has_html: row.has_html === true,
383
+ depth: Number(row.depth),
384
+ }));
385
+ }
386
+ return {
387
+ /**
388
+ * The anonymous path's transaction: the workspace set, and nothing writable.
389
+ *
390
+ * `set transaction read only` is issued after `withWorkspace`'s own `set_config`, which
391
+ * Postgres allows — the access mode is fixed by the first *write*, not by the first statement —
392
+ * and covers `pages` and `page_versions` as well as `publications`. It is the second fence, not
393
+ * the first: the first is that every query below carries the publication.
394
+ */
395
+ read(workspaceId, fn) {
396
+ /*
397
+ * Lower-cased before it is set, and that is not tidiness.
398
+ *
399
+ * `withWorkspace` writes the caller's string into `app.workspace_id` verbatim, and every RLS
400
+ * policy compares it as **text** against `workspace_id::text`, which Postgres renders in
401
+ * lower case. So an upper-case uuid in a public URL sets a GUC no policy can ever match: the
402
+ * whole surface returns nothing on a correctly locked-down instance and serves the site
403
+ * normally in development and CI, where the role is a superuser and bypasses the policies
404
+ * altogether. That is the direction that hides a bug rather than the one that shows it, and
405
+ * this is the only anonymous entry point, so it is normalised here.
406
+ */
407
+ return kernel.database.withWorkspace(workspaceId.toLowerCase(), async (tx) => {
408
+ await tx.execute(sql `set transaction read only`);
409
+ return fn(tx);
410
+ }, { userId: null });
411
+ },
412
+ /**
413
+ * The publication behind a slug, or `notFound`.
414
+ *
415
+ * Expiry is checked here rather than by a sweep, so the URL stops working at the moment its
416
+ * author said it would even if nothing has run since. Everything that cannot be served answers
417
+ * the same 404: a slug nobody has taken, one that has expired, and one whose root page has
418
+ * since been trashed are indistinguishable from outside, which is the point.
419
+ */
420
+ async bySlug(tx, workspaceId, slug) {
421
+ const [row] = await tx
422
+ .select()
423
+ .from(publications)
424
+ .where(and(eq(publications.workspaceId, workspaceId), eq(publications.slug, slug)))
425
+ .limit(1);
426
+ if (!row)
427
+ throw new KernError('NOT_FOUND', 'There is no published site at this address');
428
+ if (row.expiresAt && row.expiresAt.getTime() <= Date.now())
429
+ throw new KernError('NOT_FOUND', 'There is no published site at this address');
430
+ return row;
431
+ },
432
+ /** Whether this request may see anything but the door. */
433
+ async unlocked(pub, token) {
434
+ if (!pub.passwordHash)
435
+ return true;
436
+ if (!token)
437
+ return false;
438
+ try {
439
+ const claims = JSON.parse(kernel.secrets.decrypt(token, aad(pub.workspaceId, pub.id)));
440
+ return typeof claims.exp === 'number' && claims.exp > Date.now();
441
+ }
442
+ catch {
443
+ // A forged, truncated, re-used-from-another-publication or simply stale token is not an
444
+ // error worth reporting to whoever sent it — it is a locked door.
445
+ return false;
446
+ }
447
+ },
448
+ async mintToken(pub) {
449
+ const exp = Date.now() + TOKEN_TTL_MS;
450
+ return {
451
+ token: kernel.secrets.encrypt(JSON.stringify({ exp }), aad(pub.workspaceId, pub.id)),
452
+ expiresAt: new Date(exp).toISOString(),
453
+ };
454
+ },
455
+ /**
456
+ * Weigh one password, and refuse to weigh too many.
457
+ *
458
+ * The counter is taken *before* the scrypt rather than after the answer, so a burst is stopped
459
+ * at the cost of a map lookup instead of buying the sender a key derivation each time. Only a
460
+ * publication that has a door is ever counted — a slug nobody has taken never reaches here, so
461
+ * the fence cannot be turned into a way of finding out which slugs exist.
462
+ */
463
+ async checkPassword(pub, password) {
464
+ if (!pub.passwordHash)
465
+ return false;
466
+ const key = `${pub.workspaceId}:${pub.id}`;
467
+ const now = Date.now();
468
+ if (unlockAttempts.size > UNLOCK_TRACKED_MAX)
469
+ unlockAttempts.clear();
470
+ const recent = (unlockAttempts.get(key) ?? []).filter((at) => at > now - UNLOCK_WINDOW_MS);
471
+ if (recent.length >= UNLOCK_ATTEMPTS) {
472
+ unlockAttempts.set(key, recent);
473
+ throw new KernError('RATE_LIMITED', 'Too many attempts. Wait a minute and try again');
474
+ }
475
+ recent.push(now);
476
+ unlockAttempts.set(key, recent);
477
+ return verifyPassword(password, pub.passwordHash);
478
+ },
479
+ /** The reference a published page carries in place of a picture's address. */
480
+ assetReferenceFor(workspaceId, fileId) {
481
+ return kernel.secrets.encrypt(fileId, assetAad(workspaceId));
482
+ },
483
+ /**
484
+ * The bytes of one referenced picture, or `notFound` for every way of not having them.
485
+ *
486
+ * Two questions, and the second is the one that matters. The reference decrypts to a file id —
487
+ * authenticated, so it cannot be forged or moved between instances or workspaces — and then the
488
+ * file has to be *used by a page that is public in this publication right now*. Without that
489
+ * second half a reference lifted from one site would resolve against another in the same
490
+ * workspace, and a page opted out of publishing would keep serving its illustrations after its
491
+ * prose had gone.
492
+ *
493
+ * The containment question is asked of the stored HTML rather than of the document, because the
494
+ * stored HTML is what a reader is actually served: if the reference is not in it, no published
495
+ * page ever asked for this file.
496
+ */
497
+ async asset(tx, workspaceId, nodes, reference) {
498
+ const gone = () => new KernError('NOT_FOUND', 'There is no such picture on this site');
499
+ const versionIds = nodes.map((n) => n.version_id);
500
+ if (versionIds.length === 0)
501
+ throw gone();
502
+ let fileId;
503
+ try {
504
+ fileId = kernel.secrets.decrypt(reference, assetAad(workspaceId));
505
+ }
506
+ catch {
507
+ throw gone();
508
+ }
509
+ if (!/^[0-9a-fA-F-]{36}$/.test(fileId))
510
+ throw gone();
511
+ const [used] = await tx
512
+ .select({ id: pageVersions.id })
513
+ .from(pageVersions)
514
+ .where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, versionIds), sql `${pageVersions.html} like ${`%${ASSET_REFERENCE_PREFIX}${fileId}%`}`))
515
+ .limit(1);
516
+ if (!used)
517
+ throw gone();
518
+ const file = await kernel
519
+ .call('core.files.get', { id: fileId })
520
+ .catch(() => null);
521
+ if (!file?.key)
522
+ throw gone();
523
+ const contentType = (file.mimeType || '').split(';')[0]?.trim().toLowerCase() ?? '';
524
+ if (!ASSET_TYPES.has(contentType))
525
+ throw gone();
526
+ const head = await kernel.storage.head(file.key).catch(() => null);
527
+ if ((head?.contentLength ?? 0) > MAX_ASSET_BYTES) {
528
+ kernel.log.warn({ fileId, bytes: head?.contentLength, cap: MAX_ASSET_BYTES }, 'a published picture is over the public cap and was not served');
529
+ throw gone();
530
+ }
531
+ const object = await kernel.storage.get(file.key).catch(() => null);
532
+ if (!object)
533
+ throw gone();
534
+ const chunks = [];
535
+ let size = 0;
536
+ for await (const chunk of object.body) {
537
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
538
+ size += buf.length;
539
+ // `head` is the fast refusal; this is the one that holds when the store did not answer it.
540
+ if (size > MAX_ASSET_BYTES) {
541
+ object.body.destroy();
542
+ throw gone();
543
+ }
544
+ chunks.push(buf);
545
+ }
546
+ return { contentType, bytes: Buffer.concat(chunks).toString('base64'), maxAge: ASSET_MAX_AGE };
547
+ },
548
+ /** Every publicly reachable page of this publication, addressed, root first. */
549
+ async tree(tx, workspaceId, pub) {
550
+ return withPaths(await walk(tx, workspaceId, pub, { requireHtml: true }));
551
+ },
552
+ /** The node at a public path, or `notFound`. Never an id — `path` names a place or nothing. */
553
+ find(nodes, path) {
554
+ const wanted = normalisePath(path);
555
+ const node = nodes.find((n) => normalisePath(n.path) === wanted);
556
+ if (!node)
557
+ throw new KernError('NOT_FOUND', 'There is no published page at this address');
558
+ return node;
559
+ },
560
+ /** The pinned version's stored HTML, scrubbed for the internet. */
561
+ async html(tx, workspaceId, node, nodes, basePath) {
562
+ const [row] = await tx
563
+ .select({ html: pageVersions.html })
564
+ .from(pageVersions)
565
+ .where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, node.version_id)))
566
+ .limit(1);
567
+ /*
568
+ * `=== null`, not falsy. `''` and NULL mean different things in this column and the
569
+ * difference is the whole reason it is nullable: NULL is "nobody has drawn this version",
570
+ * which is not servable, and `''` is "this version draws to nothing", which is a page
571
+ * somebody published while it was empty and is perfectly servable. Treating them the same
572
+ * way 404s a real page — it did, on every page in the fixture whose prose the renderer could
573
+ * not decode.
574
+ */
575
+ if (!row || row.html === null)
576
+ throw new KernError('NOT_FOUND', 'There is no published page at this address');
577
+ const pathById = new Map(nodes.map((n) => [n.id, n.path]));
578
+ return publicHtml(row.html, basePath, {
579
+ pagePath: (id) => pathById.get(id) ?? null,
580
+ // Minted per read rather than stored, so the envelope can be re-keyed and so nothing
581
+ // durable in the database is a capability. `basePath` already ends in a slash.
582
+ assetHref: (fileId) => `${basePath}${PUBLIC_ASSET_SEGMENT}/${encodeURIComponent(this.assetReferenceFor(workspaceId, fileId))}`,
583
+ });
584
+ },
585
+ /**
586
+ * Search the published text of this publication's pages.
587
+ *
588
+ * The ids come from the walk, so the search cannot reach outside the publication however the
589
+ * query is written — and it reads `page_versions.text`, which is the published copy, rather than
590
+ * `pages.text`, which mirrors the live document.
591
+ */
592
+ async search(tx, workspaceId, nodes, query, limit) {
593
+ const versionIds = nodes.map((n) => n.version_id);
594
+ if (versionIds.length === 0)
595
+ return [];
596
+ const pattern = `%${escapeLike(query.trim())}%`;
597
+ const rows = await tx
598
+ .select({ id: pageVersions.id, text: pageVersions.text })
599
+ .from(pageVersions)
600
+ .where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, versionIds), sql `${pageVersions.text} ilike ${pattern}`))
601
+ .limit(limit);
602
+ const byVersion = new Map(nodes.map((n) => [n.version_id, n]));
603
+ const hits = [];
604
+ for (const row of rows) {
605
+ const node = byVersion.get(row.id);
606
+ if (node)
607
+ hits.push({ node, snippet: snippetAround(row.text, query) });
608
+ }
609
+ // Titles are worth matching too, and they are already in hand rather than in the database.
610
+ for (const node of nodes) {
611
+ if (hits.length >= limit)
612
+ break;
613
+ if (hits.some((h) => h.node.id === node.id))
614
+ continue;
615
+ if (node.title.toLowerCase().includes(query.trim().toLowerCase()))
616
+ hits.push({ node, snippet: '' });
617
+ }
618
+ return hits.slice(0, limit);
619
+ },
620
+ /**
621
+ * A cache validator for a pinned version that is not the version's id.
622
+ *
623
+ * The id addresses `versions.get`, which asks a permission; a hash of it changes exactly when
624
+ * the pinned version does and can be tried nowhere.
625
+ */
626
+ etagFor(versionId) {
627
+ return createHash('sha256').update(`quire.public.v1:${versionId}`).digest('base64url').slice(0, 32);
628
+ },
629
+ // ---------------------------------------------------------------- authenticated side
630
+ /** Every publication rooted at a page of this space, newest first. */
631
+ async list(tx, workspaceId, spaceId) {
632
+ return tx
633
+ .select({ pub: publications })
634
+ .from(publications)
635
+ .innerJoin(pages, and(eq(pages.workspaceId, publications.workspaceId), eq(pages.id, publications.rootPageId)))
636
+ .where(and(eq(publications.workspaceId, workspaceId), eq(pages.spaceId, spaceId)))
637
+ .orderBy(desc(publications.createdAt))
638
+ .then((rows) => rows.map((r) => r.pub));
639
+ },
640
+ async row(tx, workspaceId, publicationId) {
641
+ const [row] = await tx
642
+ .select()
643
+ .from(publications)
644
+ .where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)))
645
+ .limit(1);
646
+ if (!row)
647
+ throw KernError.notFound('Publication');
648
+ return row;
649
+ },
650
+ async create(tx, principal, workspaceId, input) {
651
+ const root = await access.pageRow(tx, workspaceId, input.rootPageId);
652
+ if (root.kind !== 'page')
653
+ throw KernError.badRequest('Only a page can be published; a live doc and a database cannot');
654
+ const [row] = await tx
655
+ .insert(publications)
656
+ .values({
657
+ workspaceId,
658
+ rootPageId: input.rootPageId,
659
+ includeDescendants: input.includeDescendants,
660
+ slug: input.slug,
661
+ passwordHash: input.password ? await hashPassword(input.password) : null,
662
+ expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
663
+ seoTitle: input.seoTitle,
664
+ seoDescription: input.seoDescription,
665
+ ogImageUrl: input.ogImageUrl,
666
+ indexable: input.indexable,
667
+ theme: input.theme,
668
+ createdBy: principal.userId,
669
+ })
670
+ .returning()
671
+ .catch((err) => {
672
+ if (err.code === '23505')
673
+ throw KernError.conflict('That address is already taken in this workspace', 'quire.slug.taken');
674
+ throw err;
675
+ });
676
+ return row;
677
+ },
678
+ async update(tx, workspaceId, publicationId, patch) {
679
+ await this.row(tx, workspaceId, publicationId);
680
+ const values = { updatedAt: new Date() };
681
+ if (patch.slug !== undefined)
682
+ values.slug = patch.slug;
683
+ if (patch.includeDescendants !== undefined)
684
+ values.includeDescendants = patch.includeDescendants;
685
+ // Three-valued: a string sets, `null` removes, absent leaves alone. See the contract.
686
+ if (patch.password !== undefined)
687
+ values.passwordHash = patch.password === null ? null : await hashPassword(patch.password);
688
+ if (patch.expiresAt !== undefined)
689
+ values.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt);
690
+ if (patch.seoTitle !== undefined)
691
+ values.seoTitle = patch.seoTitle;
692
+ if (patch.seoDescription !== undefined)
693
+ values.seoDescription = patch.seoDescription;
694
+ if (patch.ogImageUrl !== undefined)
695
+ values.ogImageUrl = patch.ogImageUrl;
696
+ if (patch.indexable !== undefined)
697
+ values.indexable = patch.indexable;
698
+ if (patch.theme !== undefined)
699
+ values.theme = patch.theme;
700
+ const [row] = await tx
701
+ .update(publications)
702
+ .set(values)
703
+ .where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)))
704
+ .returning()
705
+ .catch((err) => {
706
+ if (err.code === '23505')
707
+ throw KernError.conflict('That address is already taken in this workspace', 'quire.slug.taken');
708
+ throw err;
709
+ });
710
+ return row;
711
+ },
712
+ async remove(tx, workspaceId, publicationId) {
713
+ await this.row(tx, workspaceId, publicationId);
714
+ await tx
715
+ .delete(publications)
716
+ .where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)));
717
+ },
718
+ /** "Never public", on the page rather than on any one publication. */
719
+ async setExcluded(tx, workspaceId, pageId, excluded) {
720
+ await access.pageRow(tx, workspaceId, pageId);
721
+ const [row] = await tx
722
+ .update(pages)
723
+ .set({ excludedFromPublic: excluded, updatedAt: new Date() })
724
+ .where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, pageId)))
725
+ .returning({ excluded: pages.excludedFromPublic });
726
+ return row?.excluded ?? excluded;
727
+ },
728
+ /**
729
+ * Draw one version and keep the drawing.
730
+ *
731
+ * Called when a page is published, so the work happens once per publish rather than once per
732
+ * anonymous read — the version is immutable, so every render of it would be identical. Already
733
+ * rendered is a no-op, which is what makes it safe to call from both `publish` and the backfill.
734
+ */
735
+ async renderVersion(tx, workspaceId, versionId) {
736
+ const [row] = await tx
737
+ .select({ id: pageVersions.id, state: pageVersions.state, html: pageVersions.html })
738
+ .from(pageVersions)
739
+ .where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, versionId)))
740
+ .limit(1);
741
+ if (!row || row.html !== null)
742
+ return;
743
+ // `referenced`, never `signed`: this drawing is *stored*, and a signed URL is the object's
744
+ // key with an hour on it. See the note on `html` in `versions.ts`.
745
+ const html = await versions.html(tx, workspaceId, row.state, { pictures: 'referenced' });
746
+ await tx
747
+ .update(pageVersions)
748
+ .set({ html })
749
+ .where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, versionId)));
750
+ },
751
+ /**
752
+ * Render whatever in this publication's subtree has never been rendered.
753
+ *
754
+ * Only ever needed for a page published before this feature existed — a publish since then
755
+ * writes the HTML as it goes. It runs on `create` and `update` because that is the moment an
756
+ * author is asking for the site to work, and it is bounded: past `MAX_BACKFILL` the rest are
757
+ * left to be rendered when they are next published, and the gap is a warning rather than a
758
+ * failed request. A page whose HTML is missing is simply not part of the site yet, which is the
759
+ * same rule as a page with no published version.
760
+ */
761
+ async backfill(tx, workspaceId, pub) {
762
+ const all = await walk(tx, workspaceId, pub, { requireHtml: false });
763
+ const missing = all.filter((r) => !r.has_html);
764
+ if (missing.length > MAX_BACKFILL)
765
+ kernel.log.warn({ publicationId: pub.id, pending: missing.length, cap: MAX_BACKFILL }, 'too many unrendered versions to publish in one request');
766
+ for (const row of missing.slice(0, MAX_BACKFILL))
767
+ await this.renderVersion(tx, workspaceId, row.version_id);
768
+ return Math.min(missing.length, MAX_BACKFILL);
769
+ },
770
+ };
771
+ }
772
+ //# sourceMappingURL=publications.js.map