@kernhq/module-quire 0.11.0 → 0.12.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 (48) 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 +34 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/router.d.ts +670 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +273 -2
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +770 -0
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +264 -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 +177 -0
  31. package/dist/server/services/publications.d.ts.map +1 -0
  32. package/dist/server/services/publications.js +553 -0
  33. package/dist/server/services/publications.js.map +1 -0
  34. package/dist/server/services/versions.d.ts +4 -0
  35. package/dist/server/services/versions.d.ts.map +1 -1
  36. package/migrations/0008_publications.sql +140 -0
  37. package/migrations/meta/_journal.json +7 -0
  38. package/package.json +1 -1
  39. package/src/client/components/PublishDialog.svelte +857 -0
  40. package/src/client/i18n.ts +434 -0
  41. package/src/client/index.ts +21 -0
  42. package/src/client/mock.ts +426 -2
  43. package/src/client/pages/PageView.svelte +196 -5
  44. package/src/client/public-url.ts +64 -0
  45. package/src/client/query.ts +16 -0
  46. package/src/contract/models.ts +77 -0
  47. package/src/contract/permissions.ts +53 -1
  48. package/src/contract/router.ts +302 -1
@@ -14,20 +14,24 @@ import {
14
14
  relativeTime,
15
15
  Skeleton,
16
16
  session,
17
+ Tooltip,
17
18
  toast,
18
19
  } from '@kernhq/ui'
19
20
  import { createQuery, useQueryClient } from '@tanstack/svelte-query'
21
+ import { untrack } from 'svelte'
20
22
  import { getQuireApi } from '../api-instance.js'
21
23
  import CommentsPanel from '../components/CommentsPanel.svelte'
22
24
  import ConfirmDialog from '../components/ConfirmDialog.svelte'
23
25
  import FavoriteStar from '../components/FavoriteStar.svelte'
24
26
  import PageEditor from '../components/PageEditor.svelte'
25
27
  import PageLabels from '../components/PageLabels.svelte'
28
+ import PublishDialog from '../components/PublishDialog.svelte'
26
29
  import VersionHistory from '../components/VersionHistory.svelte'
27
30
  import { type CoreApi, toPerson } from '../core-api.js'
28
31
  import DatabaseView from '../database/DatabaseView.svelte'
29
32
  import { t } from '../i18n.js'
30
33
  import { canQuire } from '../permissions.js'
34
+ import { publicSiteUrl } from '../public-url.js'
31
35
  import { quireKeys } from '../query.js'
32
36
 
33
37
  /**
@@ -148,19 +152,69 @@ $effect(() => {
148
152
  dirty = false
149
153
  })
150
154
 
155
+ /**
156
+ * The title saves as you type, not only when you leave the field.
157
+ *
158
+ * It used to save on `blur` alone, and nothing else — so typing a name and then going somewhere
159
+ * without blurring first (a keyboard shortcut, ⌘K, closing the tab, any programmatic navigation)
160
+ * threw the name away and left the page called "Untitled" for ever. Measured against the live
161
+ * stack: fifteen seconds after typing, `mod_quire.pages.title` was still `''`; it only ever became
162
+ * the typed value on blur.
163
+ *
164
+ * A page's *body* has never had this problem, because it is a Y.Doc the collab service persists on
165
+ * its own schedule. The title is not — it is a plain column behind `pages.update` — so the schedule
166
+ * has to be written here. `docs/adr/0006` says the title should live in the Y.Doc beside the body
167
+ * for exactly this reason, and because two people renaming at once currently clobber each other;
168
+ * that is a larger change and this is not a substitute for it.
169
+ */
170
+ const TITLE_SAVE_AFTER_MS = 700
171
+ let titleTimer: ReturnType<typeof setTimeout> | null = null
172
+ /* Set in the same tick as the call, because `isPending` arrives a render late and two saves would
173
+ race to write the same column in an order neither of them chose. */
174
+ let savingTitle = false
175
+
176
+ function queueTitleSave() {
177
+ if (titleTimer) clearTimeout(titleTimer)
178
+ titleTimer = setTimeout(() => {
179
+ titleTimer = null
180
+ void saveTitle()
181
+ }, TITLE_SAVE_AFTER_MS)
182
+ }
183
+
151
184
  async function saveTitle() {
152
- if (!doc || !dirty) return
185
+ if (titleTimer) {
186
+ clearTimeout(titleTimer)
187
+ titleTimer = null
188
+ }
189
+ if (!doc || !dirty || savingTitle) return
153
190
  const next = title.trim()
154
191
  if (next === doc.title) {
155
192
  dirty = false
156
193
  return
157
194
  }
158
- await api.pages.update({ workspaceId, pageId, title: next })
159
- dirty = false
160
- await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
161
- await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
195
+ savingTitle = true
196
+ try {
197
+ await api.pages.update({ workspaceId, pageId, title: next })
198
+ dirty = false
199
+ await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
200
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
201
+ } finally {
202
+ savingTitle = false
203
+ }
162
204
  }
163
205
 
206
+ /* Leaving the page mid-word must not lose the word. */
207
+ $effect(() => {
208
+ void pageId
209
+ return () => {
210
+ if (titleTimer) {
211
+ clearTimeout(titleTimer)
212
+ titleTimer = null
213
+ }
214
+ if (untrack(() => dirty)) void saveTitle()
215
+ }
216
+ })
217
+
164
218
  async function archive(archived: boolean) {
165
219
  if (!doc) return
166
220
  await api.pages.archive({ workspaceId, pageId, archived })
@@ -195,6 +249,73 @@ async function revert() {
195
249
  }
196
250
  }
197
251
 
252
+ // -----------------------------------------------------------------------------------------------
253
+ // Whether this page is on the internet
254
+ // -----------------------------------------------------------------------------------------------
255
+
256
+ /**
257
+ * The share dialog owns publishing; this owns the one thing the *page* has to say about it.
258
+ *
259
+ * Both queries are gated on `quire.page.publish` because `publications.list` asks for it, and a
260
+ * query that is certain to be refused is a 403 in everybody else's network tab and an indicator
261
+ * that never appears either way. The consequence is worth stating plainly: **somebody who cannot
262
+ * publish does not see the "Public" chip.** That is the wrong way round for a reader who would
263
+ * like to know, and it is the only shape available until there is a procedure that answers "is this
264
+ * page public" without asking to publish it — a chip is not worth a new public surface.
265
+ */
266
+ let shareOpen = $state(false)
267
+ const canPublish = $derived(canQuire('pagePublish'))
268
+
269
+ const publicationsQuery = createQuery(() => ({
270
+ queryKey: quireKeys.publications(workspaceId, doc?.spaceId ?? ''),
271
+ enabled: canPublish && Boolean(workspaceId && doc?.spaceId),
272
+ queryFn: () => api.publications.list({ workspaceId, spaceId: doc?.spaceId ?? '' }),
273
+ }))
274
+
275
+ /**
276
+ * The space's tree, only once something in it has been published.
277
+ *
278
+ * Same key as the sidebar's, so on a page reached through the sidebar this costs nothing at all;
279
+ * and a space with no published site never asks for it.
280
+ */
281
+ const spaceTreeQuery = createQuery(() => ({
282
+ queryKey: quireKeys.tree(workspaceId, doc?.spaceId ?? ''),
283
+ enabled: canPublish && (publicationsQuery.data?.length ?? 0) > 0 && Boolean(workspaceId && doc?.spaceId),
284
+ queryFn: () => api.pages.tree({ workspaceId, spaceId: doc?.spaceId ?? '', includeArchived: false }),
285
+ }))
286
+
287
+ /**
288
+ * The publication a signed-out stranger could read *this* page through, or null.
289
+ *
290
+ * It reproduces the server's prune rather than asking whether the page is somewhere under a root:
291
+ * every page on the way up — this one, each ancestor, and the root itself — has to be a `page`,
292
+ * unarchived, not opted out and actually published, because that is exactly what the recursive walk
293
+ * behind `public.site` descends through. Getting this looser would put a "Public" chip on a page
294
+ * strangers cannot open, which is the mistake that teaches somebody the chip means nothing.
295
+ */
296
+ const publicVia = $derived.by((): { slug: string; root: boolean } | null => {
297
+ const rows = publicationsQuery.data ?? []
298
+ const here = doc
299
+ if (!here || rows.length === 0) return null
300
+ if (here.kind !== 'page' || here.archivedAt || here.deletedAt || !here.publishedVersionId) return null
301
+ const nodes = spaceTreeQuery.data
302
+ const byId = new Map((nodes ?? []).map((node) => [node.id, node]))
303
+ for (const row of rows) {
304
+ if (row.rootPageId === here.id) return { slug: row.slug, root: true }
305
+ if (!row.includeDescendants || !nodes) continue
306
+ let at = byId.get(here.id)
307
+ let guard = 0
308
+ while (at && guard++ < 1000) {
309
+ if (at.kind !== 'page' || at.archivedAt || at.excludedFromPublic || !at.hasPublishedVersion) break
310
+ if (at.id === row.rootPageId) return { slug: row.slug, root: false }
311
+ at = at.parentId ? byId.get(at.parentId) : undefined
312
+ }
313
+ }
314
+ return null
315
+ })
316
+
317
+ const publicUrl = $derived(publicVia ? publicSiteUrl({ workspaceSlug, slug: publicVia.slug }) : '')
318
+
198
319
  // -----------------------------------------------------------------------------------------------
199
320
  // Watching, recording, and the way this page is deleted
200
321
  // -----------------------------------------------------------------------------------------------
@@ -402,6 +523,7 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
402
523
  oninput={(e) => {
403
524
  title = (e.currentTarget as HTMLInputElement).value
404
525
  dirty = true
526
+ queueTitleSave()
405
527
  }}
406
528
  onblur={saveTitle}
407
529
  onkeydown={(e) => {
@@ -451,6 +573,25 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
451
573
  },
452
574
  ]
453
575
  : []),
576
+ /*
577
+ * Only a `page`, and only for somebody who may publish.
578
+ *
579
+ * A live doc and a database have no published version — `publishing.publish` refuses
580
+ * them — so a site rooted at one would serve nothing, and an entry that opens a dialog
581
+ * whose only outcome is an empty site is worse than an absent one. `page.publish` rather
582
+ * than `page.edit`, matching every procedure the dialog calls: in a space where writing
583
+ * is open and publishing is not, those are different people.
584
+ */
585
+ ...(doc.kind === 'page' && canPublish
586
+ ? [
587
+ {
588
+ id: 'share-web',
589
+ label: t('share_web'),
590
+ icon: 'globe',
591
+ onSelect: () => (shareOpen = true),
592
+ },
593
+ ]
594
+ : []),
454
595
  {
455
596
  id: 'watch',
456
597
  label: watching ? t('watch_stop') : t('watch'),
@@ -511,6 +652,30 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
511
652
  ? t('edited_ago_by', { when: relativeTime(doc.updatedAt), who: editor.name })
512
653
  : t('edited_ago', { when: relativeTime(doc.updatedAt) })}
513
654
  </span>
655
+ <!--
656
+ Quiet, and a link.
657
+
658
+ "This page is on the internet" is a fact about the page, so it sits with the other facts
659
+ under the title rather than as a banner — but it is the only one of them worth clicking,
660
+ because the useful thing to do with it is to go and look at what strangers actually see.
661
+ The sentence explaining it is a tooltip rather than the label: the label has to survive
662
+ being one chip in a row of them, and the explanation is a whole sentence.
663
+ -->
664
+ {#if publicVia}
665
+ <Tooltip text={publicVia.root ? t('public_chip_root') : t('public_chip_child')}>
666
+ {#snippet children(props: Record<string, unknown>)}
667
+ <a
668
+ class="chip public"
669
+ href={publicUrl}
670
+ target="_blank"
671
+ rel="noreferrer noopener"
672
+ {...props}
673
+ >
674
+ <Icon name="globe" size={12} /> {t('public_chip')}
675
+ </a>
676
+ {/snippet}
677
+ </Tooltip>
678
+ {/if}
514
679
  {#if doc.kind === 'live'}
515
680
  <span class="chip"><Icon name="square-pen" size={12} /> {t('kind_live')}</span>
516
681
  {/if}
@@ -590,6 +755,14 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
590
755
  publishedVersionId={doc.publishedVersionId}
591
756
  />
592
757
 
758
+ <PublishDialog
759
+ bind:open={shareOpen}
760
+ {workspaceId}
761
+ {workspaceSlug}
762
+ spaceId={doc.spaceId}
763
+ page={doc}
764
+ />
765
+
593
766
  <!--
594
767
  The body says nothing about numbers until it knows them. Naming a count before the tree has
595
768
  loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
@@ -660,6 +833,24 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
660
833
  align-items: center;
661
834
  gap: 4px;
662
835
  }
836
+ /*
837
+ * The one chip that is a control, so it is the one that reads as one — a tinted pill with a hit
838
+ * area a finger can find. `--kern-accent-text` rather than `--kern-accent`: the flat accent is a
839
+ * fill colour and does not clear 4.5:1 as text on its own tint in either theme.
840
+ */
841
+ .chip.public {
842
+ gap: 5px;
843
+ min-height: 22px;
844
+ padding-inline: 8px;
845
+ border-radius: var(--kern-r-full);
846
+ background: var(--kern-accent-tint);
847
+ color: var(--kern-accent-text);
848
+ font-weight: 500;
849
+ text-decoration: none;
850
+ }
851
+ .chip.public:hover {
852
+ background: var(--kern-accent-tint-2);
853
+ }
663
854
  .body {
664
855
  margin-block-start: 22px;
665
856
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Where a published site lives, decided in exactly one place.
3
+ *
4
+ * The module's `public.*` procedures deliberately know nothing about this. They answer `path`
5
+ * relative to a publication — `''` for the front page, `guide/install` for a nested one — and take
6
+ * `basePath` as an argument, because one instance may serve a site under this prefix and another
7
+ * under a domain of its own. That is the right call on the server and it leaves somebody having to
8
+ * decide the address, so this file is that somebody: the share dialog shows what it returns, the
9
+ * header link opens it, and whatever route eventually renders a published page has to match it.
10
+ *
11
+ * **`/p/` is a literal segment before the workspace, not after it.** Every other Kern URL starts
12
+ * with the workspace — `/{workspace}/quire/{space}/{page}` — and a published site cannot, because
13
+ * the shell's top-level route is `[ws]`: `/{workspace}/p/…` would be inside the signed-in app,
14
+ * behind its guard, which is the one thing a public URL must not be. A static first segment sorts
15
+ * ahead of a dynamic one in SvelteKit, so `/p/…` is reachable signed out and cannot be shadowed by
16
+ * a workspace. The cost is that a workspace whose slug is exactly `p` would collide, which is why
17
+ * the segment is here as a constant rather than typed out at three call sites.
18
+ *
19
+ * The workspace is named by **slug, not id**. Both are equally public — the id is already in the
20
+ * API path this resolves to — but one of them is a uuid, and a customer publishing a handbook is
21
+ * publishing a URL they will print. Resolving the slug is one lookup the route layer already does
22
+ * for every other page in the product.
23
+ */
24
+ export const PUBLIC_SITE_PREFIX = 'p'
25
+
26
+ export interface PublicSiteAddress {
27
+ /** the workspace's slug, as it appears in every other Kern URL */
28
+ workspaceSlug: string
29
+ /** the publication's slug */
30
+ slug: string
31
+ /**
32
+ * A page's path *inside* the publication, as `public.site` and `public.page` report it. `''` is
33
+ * the front page, which is the whole of what the share dialog ever shows.
34
+ */
35
+ path?: string
36
+ }
37
+
38
+ /**
39
+ * The `basePath` argument `public.page` validates and builds its inter-page links from.
40
+ *
41
+ * Starts and ends with `/`, unreserved segments only — the contract refuses anything else, and the
42
+ * refusal is the point: `//evil.example/` is a protocol-relative URL wearing the costume of a local
43
+ * path, and a caller who could set it would repoint every link on somebody's published site.
44
+ */
45
+ export function publicSiteBasePath({ workspaceSlug, slug }: PublicSiteAddress): string {
46
+ return `/${PUBLIC_SITE_PREFIX}/${encodeURIComponent(workspaceSlug)}/${encodeURIComponent(slug)}/`
47
+ }
48
+
49
+ /**
50
+ * The address to show somebody, absolute when there is an origin to be absolute against.
51
+ *
52
+ * `location` is read defensively rather than assumed: this module's client is source, built by the
53
+ * consumer, and a consumer that renders a screen on the server has no `location` at all. A relative
54
+ * address is still correct there — it is only the *copyable* one that has to be absolute.
55
+ */
56
+ export function publicSiteUrl(address: PublicSiteAddress, origin?: string): string {
57
+ const root = origin ?? (typeof location === 'undefined' ? '' : location.origin.replace(/\/+$/, ''))
58
+ const trail = (address.path ?? '')
59
+ .split('/')
60
+ .filter((segment) => segment.length > 0)
61
+ .map(encodeURIComponent)
62
+ .join('/')
63
+ return `${root}${publicSiteBasePath(address)}${trail}`
64
+ }
@@ -38,6 +38,22 @@ export const quireKeys = {
38
38
  recents: (workspaceId: string) => ['quire', 'recent', workspaceId] as const,
39
39
  watchers: (workspaceId: string, pageId: string) => ['quire', 'watcher', workspaceId, pageId] as const,
40
40
 
41
+ /**
42
+ * Who has published what.
43
+ *
44
+ * `publication` is the entity `publications.create|update|remove` announce, so every screen
45
+ * holding one of these keys redraws when somebody else publishes or takes a site down — which
46
+ * matters more here than anywhere else in the module, because the thing that changed is whether
47
+ * strangers can read a page.
48
+ *
49
+ * `site` is under the same entity on purpose. It is the *anonymous* read of a published site —
50
+ * what the share dialog checks the URL against — and it is stale the instant the publication
51
+ * changes, so it must be invalidated by the same announcement rather than by remembering to.
52
+ */
53
+ publications: (workspaceId: string, spaceId: string) =>
54
+ ['quire', 'publication', workspaceId, spaceId] as const,
55
+ site: (workspaceId: string, slug: string) => ['quire', 'publication', workspaceId, 'site', slug] as const,
56
+
41
57
  /** the schema — properties and views — which every open tab of a database is drawing */
42
58
  database: (workspaceId: string, databaseId: string) =>
43
59
  ['quire', 'database', workspaceId, databaseId] as const,
@@ -87,6 +87,30 @@ export const PageNode = z.object({
87
87
  icon: z.string().nullable(),
88
88
  hasChildren: z.boolean(),
89
89
  archivedAt: Timestamp.nullable(),
90
+ /**
91
+ * Kept out of every publication, present and future — `publications.optOut` sets it.
92
+ *
93
+ * On the node rather than on `Page` because the only screen that reads it is a *list*: the share
94
+ * dialog draws one row per descendant with a switch on it, and a switch whose state has to be
95
+ * fetched page by page is a screen that opens with everything wrong and corrects itself. Two more
96
+ * booleans on a row the sidebar already loads for the whole space is the cheap end of that trade.
97
+ *
98
+ * `.default(false)` so a tree drawn by a client newer than its server still parses; the inferred
99
+ * output type is required either way, which is what makes both constructors supply it.
100
+ */
101
+ excludedFromPublic: z.boolean().default(false),
102
+ /**
103
+ * Whether a reader without edit rights has anything to be served — `publishedVersionId != null`,
104
+ * as a boolean, because the id itself addresses `versions.get` and a tree row has no business
105
+ * carrying it.
106
+ *
107
+ * Here for the same list as the flag above, and it is the half that stops that list lying. An
108
+ * opt-out switch on its own says "this page is public" about a page nobody has ever published,
109
+ * which is the wrong answer in the safe direction — and a screen that is wrong in the safe
110
+ * direction today is one nobody checks tomorrow. Always false for a `live` doc and a `database`:
111
+ * neither has a published version, because `publishing.publish` refuses anything but a `page`.
112
+ */
113
+ hasPublishedVersion: z.boolean().default(false),
90
114
  })
91
115
  export type PageNode = z.infer<typeof PageNode>
92
116
 
@@ -244,4 +268,57 @@ export const Watcher = z.object({
244
268
  })
245
269
  export type Watcher = z.infer<typeof Watcher>
246
270
 
271
+ /**
272
+ * How a published site is coloured. `auto` follows the reader's own setting rather than the
273
+ * author's, which is the only one of the three that is a preference and not an instruction.
274
+ */
275
+ export const PublicationTheme = z.enum(['auto', 'light', 'dark'])
276
+ export type PublicationTheme = z.infer<typeof PublicationTheme>
277
+
278
+ /**
279
+ * A page, and everything under it, at a URL a signed-out stranger can open.
280
+ *
281
+ * The row *is* the grant: no publication, no public page, and deleting it takes the site down. What
282
+ * a reader is served is the **pinned published version** of each page — `Page.publishedVersionId`
283
+ * and the HTML rendered onto it — never the live document and never the draft. A page with no
284
+ * published version is not public, whatever the tree says.
285
+ *
286
+ * There is no `passwordHash` here and there never should be. `hasPassword` is the whole of what a
287
+ * client needs: whether to ask. A hash is a hash, a salt and a cost, and shipping it to a browser
288
+ * turns an online guess into an offline one.
289
+ */
290
+ export const Publication = z.object({
291
+ id: Id,
292
+ workspaceId: WorkspaceId,
293
+ /** the page the site is rooted at; its own published version is the front page */
294
+ rootPageId: Id,
295
+ /** false publishes exactly one page, which is what a single shared document wants */
296
+ includeDescendants: z.boolean(),
297
+ /**
298
+ * The URL segment. Unique per workspace and not beyond it — the public URL carries the workspace,
299
+ * so two customers both wanting `handbook` is not a collision. Lowercase by the same rule as
300
+ * `Space.key`: a URL that differs only in case is one URL to a person and two rows to Postgres.
301
+ */
302
+ slug: z
303
+ .string()
304
+ .min(2)
305
+ .max(64)
306
+ .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, 'lowercase letters, digits and dashes'),
307
+ /** whether a password is set — never the hash, and never the password */
308
+ hasPassword: z.boolean(),
309
+ /** null never expires; past means the URL is gone */
310
+ expiresAt: Timestamp.nullable(),
311
+ /** what a search result and a link preview say; empty falls back to the root page's own title */
312
+ seoTitle: z.string().max(200),
313
+ seoDescription: z.string().max(500),
314
+ ogImageUrl: z.string().max(2048).nullable(),
315
+ /** false sends `noindex`. Public and findable are different requests, and people mean both. */
316
+ indexable: z.boolean(),
317
+ theme: PublicationTheme,
318
+ createdBy: UserId.nullable(),
319
+ createdAt: Timestamp,
320
+ updatedAt: Timestamp,
321
+ })
322
+ export type Publication = z.infer<typeof Publication>
323
+
247
324
  export const Ok = z.object({ ok: z.literal(true) })
@@ -101,9 +101,25 @@ export const quirePermissions = definePermissions([
101
101
  * scope yet: `spaces.create` has no space to be scoped to.
102
102
  * - `filter` — a list that omits what you may not see rather than refusing. "You may not open it"
103
103
  * is a worse answer than not showing it, and `spaces.list` is deliberately the second.
104
+ * - `public` — there is no principal to ask about. See below.
105
+ *
106
+ * **`public` is the one that needs saying out loud.** Every other value here answers "which scope is
107
+ * this permission resolved at"; `public` answers "this procedure asks nobody anything", which is a
108
+ * different kind of statement and the only one in this module that can leak a customer's private
109
+ * pages to the internet. It is spelled as a value rather than an omission so that adding one is a
110
+ * line in a review instead of a missing entry nobody sees, and `authz.int.test.ts` treats it as its
111
+ * own case: it calls the procedure once as a principal denied everything and once as a genuine
112
+ * anonymous stranger, and fails unless the two answers are byte-for-byte identical. That is the
113
+ * property that matters — a public surface that quietly shows an author more than it shows a
114
+ * stranger is one whose author tests it and never sees what the world sees.
115
+ *
116
+ * `permission` is still required for a `public` entry, and it names the permission that had to be
117
+ * held to *create the grant* — `quire.page.publish`, the one somebody used to make the publication.
118
+ * It is not a check this procedure performs. `module.test.ts` holds every entry to a permission the
119
+ * module declares, and there is no honest way to write "none" that keeps the rest of that check.
104
120
  */
105
121
  export interface ProcedureAuthz {
106
- check: 'page' | 'space' | 'workspace' | 'filter'
122
+ check: 'page' | 'space' | 'workspace' | 'filter' | 'public'
107
123
  permission: string
108
124
  }
109
125
 
@@ -191,4 +207,40 @@ export const quireProcedureAuthz: Record<string, ProcedureAuthz> = {
191
207
 
192
208
  'publishing.publish': { check: 'page', permission: 'quire.page.publish' },
193
209
  'publishing.revert': { check: 'page', permission: 'quire.page.edit' },
210
+
211
+ /*
212
+ * A publication hands a page's whole subtree to the internet, so the question every one of these
213
+ * asks is about the **root page** — not the space, and not the workspace. `quire.page.publish` is
214
+ * already the permission that decides which version readers are served; deciding that the readers
215
+ * include everybody is the same decision one step further.
216
+ *
217
+ * `list` is the exception and is space-scoped, because "what has this space published" has no one
218
+ * page to resolve against. It filters as well: a publication whose root page the caller may not
219
+ * read is not named in the answer, for the same reason `pages.trash` does not name a title.
220
+ *
221
+ * `optOut` is `quire.page.publish` rather than `quire.page.edit` on purpose. Marking a page
222
+ * "never public" is a publishing decision about who may read it, not a change to what it says —
223
+ * and the two permissions are held by different people in a space where writing is open and
224
+ * publishing is not.
225
+ */
226
+ 'publications.list': { check: 'space', permission: 'quire.page.publish' },
227
+ 'publications.get': { check: 'page', permission: 'quire.page.publish' },
228
+ 'publications.create': { check: 'page', permission: 'quire.page.publish' },
229
+ 'publications.update': { check: 'page', permission: 'quire.page.publish' },
230
+ 'publications.remove': { check: 'page', permission: 'quire.page.publish' },
231
+ 'publications.optOut': { check: 'page', permission: 'quire.page.publish' },
232
+
233
+ /*
234
+ * The signed-out surface. Nothing here asks a permission, because there is nobody to ask about —
235
+ * see the note on `check: 'public'` above. What stands in for the permission check is that every
236
+ * query is scoped by the **publication**: its root page, the descendants that survive the prune,
237
+ * `excluded_from_public` false and a rendered published version. Workspace scope is what
238
+ * row-level security gives, and workspace scope is not publication scope.
239
+ */
240
+ 'public.site': { check: 'public', permission: 'quire.page.publish' },
241
+ 'public.page': { check: 'public', permission: 'quire.page.publish' },
242
+ 'public.search': { check: 'public', permission: 'quire.page.publish' },
243
+ 'public.sitemap': { check: 'public', permission: 'quire.page.publish' },
244
+ 'public.robots': { check: 'public', permission: 'quire.page.publish' },
245
+ 'public.unlock': { check: 'public', permission: 'quire.page.publish' },
194
246
  }