@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.
- package/dist/contract/models.d.ts +46 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +73 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +17 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +34 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +670 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +273 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +770 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +264 -2
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/schema.d.ts +318 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +86 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/access.d.ts +1 -0
- package/dist/server/services/access.d.ts.map +1 -1
- package/dist/server/services/index.d.ts +3 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +5 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +6 -0
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/publications.d.ts +177 -0
- package/dist/server/services/publications.d.ts.map +1 -0
- package/dist/server/services/publications.js +553 -0
- package/dist/server/services/publications.js.map +1 -0
- package/dist/server/services/versions.d.ts +4 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/migrations/0008_publications.sql +140 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/PublishDialog.svelte +857 -0
- package/src/client/i18n.ts +434 -0
- package/src/client/index.ts +21 -0
- package/src/client/mock.ts +426 -2
- package/src/client/pages/PageView.svelte +196 -5
- package/src/client/public-url.ts +64 -0
- package/src/client/query.ts +16 -0
- package/src/contract/models.ts +77 -0
- package/src/contract/permissions.ts +53 -1
- 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 (
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
package/src/client/query.ts
CHANGED
|
@@ -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,
|
package/src/contract/models.ts
CHANGED
|
@@ -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
|
}
|