@kernhq/module-quire 0.11.1 → 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 +139 -0
- 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
|
@@ -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
|
}
|
package/src/contract/router.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { baseContract, Id, PageInput, page, UserId, WorkspaceId } from '@kernhq/contracts'
|
|
1
|
+
import { baseContract, Id, PageInput, page, Timestamp, UserId, WorkspaceId } from '@kernhq/contracts'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import {
|
|
4
4
|
CommentAnchor,
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
PageKind,
|
|
12
12
|
PageNode,
|
|
13
13
|
PageVersion,
|
|
14
|
+
Publication,
|
|
15
|
+
PublicationTheme,
|
|
14
16
|
RecentView,
|
|
15
17
|
RichDoc,
|
|
16
18
|
Space,
|
|
@@ -67,6 +69,122 @@ export const WatchState = z.object({
|
|
|
67
69
|
})
|
|
68
70
|
export type WatchState = z.infer<typeof WatchState>
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A published site addresses its pages by **path, never by id**, and that is a security decision
|
|
74
|
+
* rather than a matter of taste.
|
|
75
|
+
*
|
|
76
|
+
* Every id in a public response is a string somebody can try somewhere else, and the only way to be
|
|
77
|
+
* sure none of them opens a door is for there to be none. So the whole `public.*` surface below
|
|
78
|
+
* carries no page id, no space id, no version id, no user id and no workspace id — a nav entry, a
|
|
79
|
+
* breadcrumb, a search hit and a sitemap line are all `path`, and `path` is built from titles by the
|
|
80
|
+
* server. `publications.int.test.ts` walks every public response and fails on any uuid belonging to
|
|
81
|
+
* anything in the fixture, which is what keeps this true as the shapes grow.
|
|
82
|
+
*
|
|
83
|
+
* The root page's path is the empty string; a child's is its ancestors' slugs joined with `/`, each
|
|
84
|
+
* slug being its title reduced to letters, digits and dashes (Unicode letters, so a Persian title
|
|
85
|
+
* keeps a Persian slug) and suffixed `-2`, `-3` when two siblings would collide.
|
|
86
|
+
*/
|
|
87
|
+
export const PublicNavEntry = z.object({
|
|
88
|
+
/** '' is the front page; everything else is `parent/child`, already URL-safe once encoded */
|
|
89
|
+
path: z.string(),
|
|
90
|
+
/** null only for the front page — the nav is flat, like `pages.tree`, and rebuilt into a tree */
|
|
91
|
+
parentPath: z.string().nullable(),
|
|
92
|
+
title: z.string(),
|
|
93
|
+
icon: z.string().nullable(),
|
|
94
|
+
})
|
|
95
|
+
export type PublicNavEntry = z.infer<typeof PublicNavEntry>
|
|
96
|
+
|
|
97
|
+
/** What is worth saying about a published site once a reader is allowed to see it. */
|
|
98
|
+
export const PublicSiteDetail = z.object({
|
|
99
|
+
title: z.string(),
|
|
100
|
+
description: z.string(),
|
|
101
|
+
ogImageUrl: z.string().nullable(),
|
|
102
|
+
/** false means the route layer sends `noindex`. Public and findable are different requests. */
|
|
103
|
+
indexable: z.boolean(),
|
|
104
|
+
/**
|
|
105
|
+
* The newest published version in the whole site.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately *not* the newest `pages.updated_at`: that column moves on every keystroke in a
|
|
108
|
+
* draft, so publishing it would tell the internet when somebody was working on an unpublished
|
|
109
|
+
* change. A published site's freshness is when something was last published.
|
|
110
|
+
*/
|
|
111
|
+
updatedAt: Timestamp,
|
|
112
|
+
nav: z.array(PublicNavEntry),
|
|
113
|
+
})
|
|
114
|
+
export type PublicSiteDetail = z.infer<typeof PublicSiteDetail>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A published site, or the door to it.
|
|
118
|
+
*
|
|
119
|
+
* `locked` is the one thing a password-protected site admits before the password: that there is a
|
|
120
|
+
* door. Everything else — the title, the description, the shape of the tree — is behind it, because
|
|
121
|
+
* a nav tree is a table of contents and a table of contents is most of what a private handbook is.
|
|
122
|
+
* `theme` comes out anyway so the challenge screen is not white on a site that is not.
|
|
123
|
+
*/
|
|
124
|
+
export const PublicSite = z.object({
|
|
125
|
+
slug: z.string(),
|
|
126
|
+
theme: PublicationTheme,
|
|
127
|
+
locked: z.boolean(),
|
|
128
|
+
/** null exactly when `locked` */
|
|
129
|
+
site: PublicSiteDetail.nullable(),
|
|
130
|
+
})
|
|
131
|
+
export type PublicSite = z.infer<typeof PublicSite>
|
|
132
|
+
|
|
133
|
+
export const PublicBreadcrumb = z.object({ path: z.string(), title: z.string() })
|
|
134
|
+
export type PublicBreadcrumb = z.infer<typeof PublicBreadcrumb>
|
|
135
|
+
|
|
136
|
+
export const PublicPage = z.object({
|
|
137
|
+
path: z.string(),
|
|
138
|
+
title: z.string(),
|
|
139
|
+
icon: z.string().nullable(),
|
|
140
|
+
coverUrl: z.string().nullable(),
|
|
141
|
+
/**
|
|
142
|
+
* The pinned published version, drawn once and stored on the version — never the live document
|
|
143
|
+
* and never the draft — then passed through the public scrub: every `id` and `data-id` attribute
|
|
144
|
+
* removed, and every page mention either re-pointed at its public path or left as plain text.
|
|
145
|
+
*/
|
|
146
|
+
html: z.string(),
|
|
147
|
+
publishedAt: Timestamp,
|
|
148
|
+
/**
|
|
149
|
+
* A cache validator for the pinned version, and **not the version's id**.
|
|
150
|
+
*
|
|
151
|
+
* A published page is immutable, so the response is a static read that should be cached until the
|
|
152
|
+
* page is published again — which is what an entity tag is for. It is a hash rather than the id
|
|
153
|
+
* because the id addresses `versions.get`, a procedure that asks a permission: handing it to the
|
|
154
|
+
* internet turns a cache key into something to try.
|
|
155
|
+
*/
|
|
156
|
+
etag: z.string(),
|
|
157
|
+
breadcrumbs: z.array(PublicBreadcrumb),
|
|
158
|
+
})
|
|
159
|
+
export type PublicPage = z.infer<typeof PublicPage>
|
|
160
|
+
|
|
161
|
+
export const PublicSearchHit = z.object({
|
|
162
|
+
path: z.string(),
|
|
163
|
+
title: z.string(),
|
|
164
|
+
/** plain text from the published version, never from the draft */
|
|
165
|
+
snippet: z.string(),
|
|
166
|
+
})
|
|
167
|
+
export type PublicSearchHit = z.infer<typeof PublicSearchHit>
|
|
168
|
+
|
|
169
|
+
export const PublicSitemapEntry = z.object({ path: z.string(), lastModified: Timestamp })
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The URL prefix the route layer serves this site under, so a link between two published pages is a
|
|
173
|
+
* link and not a dead mention.
|
|
174
|
+
*
|
|
175
|
+
* The module knows a page's path inside its publication and nothing about the address it is served
|
|
176
|
+
* at — one instance mounts a site at `/p/<workspace>/<slug>/`, another at the root of its own
|
|
177
|
+
* domain. Rather than guess, it takes the prefix and refuses anything that is not one: it has to
|
|
178
|
+
* start and end with `/`, and its segments are unreserved characters only. That refusal is the
|
|
179
|
+
* point. `//evil.example/` is a protocol-relative URL wearing the costume of a local path, and a
|
|
180
|
+
* caller that could set it would have every link on somebody's published site point off-site.
|
|
181
|
+
*/
|
|
182
|
+
export const PublicBasePath = z
|
|
183
|
+
.string()
|
|
184
|
+
.max(200)
|
|
185
|
+
.regex(/^\/(?:[A-Za-z0-9._~-]+\/)*$/, 'an absolute path ending in a slash')
|
|
186
|
+
.default('/')
|
|
187
|
+
|
|
70
188
|
export const quireContract = {
|
|
71
189
|
spaces: {
|
|
72
190
|
list: baseContract
|
|
@@ -519,6 +637,189 @@ export const quireContract = {
|
|
|
519
637
|
.input(ws.extend({ pageId: Id }))
|
|
520
638
|
.output(Page),
|
|
521
639
|
},
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Who has published what, from the inside. Every procedure here is authenticated and asks
|
|
643
|
+
* `quire.page.publish` about the **root page**, because that is the page whose subtree is being
|
|
644
|
+
* handed to the internet.
|
|
645
|
+
*/
|
|
646
|
+
publications: {
|
|
647
|
+
list: baseContract
|
|
648
|
+
.route({ method: 'GET', path: '/spaces/{spaceId}/publications', ...t('publications') })
|
|
649
|
+
.input(ws.extend({ spaceId: Id }))
|
|
650
|
+
.output(z.array(Publication)),
|
|
651
|
+
get: baseContract
|
|
652
|
+
.route({ method: 'GET', path: '/publications/{publicationId}', ...t('publications') })
|
|
653
|
+
.input(ws.extend({ publicationId: Id }))
|
|
654
|
+
.output(Publication),
|
|
655
|
+
create: baseContract
|
|
656
|
+
.route({ method: 'POST', path: '/publications', ...t('publications') })
|
|
657
|
+
.input(
|
|
658
|
+
ws.extend({
|
|
659
|
+
rootPageId: Id,
|
|
660
|
+
slug: Publication.shape.slug,
|
|
661
|
+
includeDescendants: z.boolean().default(true),
|
|
662
|
+
/** the password itself, once; the server keeps a hash and never gives one back */
|
|
663
|
+
password: z.string().min(6).max(200).nullable().default(null),
|
|
664
|
+
expiresAt: Timestamp.nullable().default(null),
|
|
665
|
+
seoTitle: z.string().max(200).default(''),
|
|
666
|
+
seoDescription: z.string().max(500).default(''),
|
|
667
|
+
ogImageUrl: z.string().max(2048).nullable().default(null),
|
|
668
|
+
indexable: z.boolean().default(true),
|
|
669
|
+
theme: PublicationTheme.default('auto'),
|
|
670
|
+
}),
|
|
671
|
+
)
|
|
672
|
+
.output(Publication),
|
|
673
|
+
/**
|
|
674
|
+
* `password` is three-valued on purpose: a string sets one, `null` removes it, and leaving the
|
|
675
|
+
* key out changes nothing. A two-valued field would make every "rename the site" request also
|
|
676
|
+
* an "unlock the site" request, which is the shape that quietly takes the door off a handbook.
|
|
677
|
+
*/
|
|
678
|
+
update: baseContract
|
|
679
|
+
.route({ method: 'PATCH', path: '/publications/{publicationId}', ...t('publications') })
|
|
680
|
+
.input(
|
|
681
|
+
ws.extend({
|
|
682
|
+
publicationId: Id,
|
|
683
|
+
slug: Publication.shape.slug.optional(),
|
|
684
|
+
includeDescendants: z.boolean().optional(),
|
|
685
|
+
password: z.string().min(6).max(200).nullable().optional(),
|
|
686
|
+
expiresAt: Timestamp.nullable().optional(),
|
|
687
|
+
seoTitle: z.string().max(200).optional(),
|
|
688
|
+
seoDescription: z.string().max(500).optional(),
|
|
689
|
+
ogImageUrl: z.string().max(2048).nullable().optional(),
|
|
690
|
+
indexable: z.boolean().optional(),
|
|
691
|
+
theme: PublicationTheme.optional(),
|
|
692
|
+
}),
|
|
693
|
+
)
|
|
694
|
+
.output(Publication),
|
|
695
|
+
/** The row is the grant, so removing it takes the site down. Nothing else is deleted. */
|
|
696
|
+
remove: baseContract
|
|
697
|
+
.route({ method: 'DELETE', path: '/publications/{publicationId}', ...t('publications') })
|
|
698
|
+
.input(ws.extend({ publicationId: Id }))
|
|
699
|
+
.output(Ok),
|
|
700
|
+
/**
|
|
701
|
+
* Keep one page — and therefore everything under it — out of every publication, present and
|
|
702
|
+
* future.
|
|
703
|
+
*
|
|
704
|
+
* Absolute rather than per-publication, and that is the whole reason it is a flag on the page:
|
|
705
|
+
* an opt-out recorded against one publication says nothing about a publication somebody roots
|
|
706
|
+
* above the page next month, and its author would never see it. This one holds against
|
|
707
|
+
* publications that do not exist yet.
|
|
708
|
+
*/
|
|
709
|
+
optOut: baseContract
|
|
710
|
+
.route({ method: 'POST', path: '/pages/{pageId}/public-opt-out', ...t('publications') })
|
|
711
|
+
.input(ws.extend({ pageId: Id, excluded: z.boolean().default(true) }))
|
|
712
|
+
.output(z.object({ pageId: Id, excluded: z.boolean() })),
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The signed-out surface. **This is the only part of Kern with no principal behind it.**
|
|
717
|
+
*
|
|
718
|
+
* Five rules hold here, and each of them is a test in `publications.int.test.ts`:
|
|
719
|
+
*
|
|
720
|
+
* 1. A page is public only if it is inside the publication's subtree, not opted out, not
|
|
721
|
+
* archived, not trashed, and has a published version that has been rendered. Failing any one
|
|
722
|
+
* of those is **404, never 403** — a refusal that distinguishes "not yours" from "not there"
|
|
723
|
+
* confirms the page exists to whoever is guessing.
|
|
724
|
+
* 2. Guessing a sibling, a parent, a page in another space or another workspace gets 404 too,
|
|
725
|
+
* which is free here because nothing is addressed by id: `path` names a place in *this*
|
|
726
|
+
* publication's tree or it names nothing.
|
|
727
|
+
* 3. No response carries a draft, a comment, an author, a version list, or any id.
|
|
728
|
+
* 4. A password-protected publication answers a challenge and nothing else. `unlock` mints a
|
|
729
|
+
* **capability token, not a session**: an AES-GCM envelope sealed with the instance secret,
|
|
730
|
+
* bound by its associated data to this one publication, carrying an expiry and no identity.
|
|
731
|
+
* The server keeps nothing. The route layer is expected to hold it in an HttpOnly cookie and
|
|
732
|
+
* pass it back — never to put it in a link, because a token in a URL is a token in a referrer
|
|
733
|
+
* header and in somebody's access log.
|
|
734
|
+
* 5. An expired publication is 404, checked on the request rather than by a sweep.
|
|
735
|
+
*
|
|
736
|
+
* `workspaceId` is in the path because *anonymous means no principal, not no tenant*: the request
|
|
737
|
+
* has to name a workspace before anything touches `mod_quire`, or row-level security has nothing
|
|
738
|
+
* to fence with. See the note at the top of `migrations/0008_publications.sql`.
|
|
739
|
+
*/
|
|
740
|
+
public: {
|
|
741
|
+
site: baseContract
|
|
742
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}', ...t('public') })
|
|
743
|
+
.input(
|
|
744
|
+
z.object({
|
|
745
|
+
workspaceId: WorkspaceId,
|
|
746
|
+
slug: Publication.shape.slug,
|
|
747
|
+
token: z.string().max(4096).nullable().default(null),
|
|
748
|
+
}),
|
|
749
|
+
)
|
|
750
|
+
.output(PublicSite),
|
|
751
|
+
/**
|
|
752
|
+
* One page of a published site.
|
|
753
|
+
*
|
|
754
|
+
* `path` is a query parameter rather than a path segment because it contains slashes: a nested
|
|
755
|
+
* page's address is `guide/install`, and oRPC's route matcher takes one segment per parameter.
|
|
756
|
+
*/
|
|
757
|
+
page: baseContract
|
|
758
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/page', ...t('public') })
|
|
759
|
+
.input(
|
|
760
|
+
z.object({
|
|
761
|
+
workspaceId: WorkspaceId,
|
|
762
|
+
slug: Publication.shape.slug,
|
|
763
|
+
/** '' is the front page */
|
|
764
|
+
path: z.string().max(1024).default(''),
|
|
765
|
+
basePath: PublicBasePath,
|
|
766
|
+
token: z.string().max(4096).nullable().default(null),
|
|
767
|
+
}),
|
|
768
|
+
)
|
|
769
|
+
.output(PublicPage),
|
|
770
|
+
/**
|
|
771
|
+
* Search inside this publication and nowhere else.
|
|
772
|
+
*
|
|
773
|
+
* It reads the **published version's** flattened text, not `pages.text`. That column mirrors the
|
|
774
|
+
* live document, so searching it would put a sentence somebody has not published yet into a
|
|
775
|
+
* snippet on the public internet — the one place in this module where the draft and the
|
|
776
|
+
* published copy differ and the difference is the whole point.
|
|
777
|
+
*/
|
|
778
|
+
search: baseContract
|
|
779
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/search', ...t('public') })
|
|
780
|
+
.input(
|
|
781
|
+
z.object({
|
|
782
|
+
workspaceId: WorkspaceId,
|
|
783
|
+
slug: Publication.shape.slug,
|
|
784
|
+
q: z.string().min(2).max(200),
|
|
785
|
+
limit: z.number().int().min(1).max(50).default(20),
|
|
786
|
+
token: z.string().max(4096).nullable().default(null),
|
|
787
|
+
}),
|
|
788
|
+
)
|
|
789
|
+
.output(z.object({ items: z.array(PublicSearchHit) })),
|
|
790
|
+
/**
|
|
791
|
+
* What a crawler may index, which is not the same list as what a reader may open: a site behind
|
|
792
|
+
* a password, or marked `indexable: false`, has an empty sitemap rather than a private one.
|
|
793
|
+
*/
|
|
794
|
+
sitemap: baseContract
|
|
795
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/sitemap', ...t('public') })
|
|
796
|
+
.input(z.object({ workspaceId: WorkspaceId, slug: Publication.shape.slug }))
|
|
797
|
+
.output(z.object({ entries: z.array(PublicSitemapEntry) })),
|
|
798
|
+
/**
|
|
799
|
+
* The one procedure here that never distinguishes one slug from another.
|
|
800
|
+
*
|
|
801
|
+
* A crawler asking about a slug that does not exist, one that has expired, and one behind a
|
|
802
|
+
* password must all get the same answer, or `robots` becomes the oracle every other procedure
|
|
803
|
+
* refuses to be. So it succeeds for all three and says "do not index". (A workspace with the
|
|
804
|
+
* module switched off is still a 404, from the middleware — that is a statement about the
|
|
805
|
+
* workspace, which the path already named, and not about any slug.)
|
|
806
|
+
*/
|
|
807
|
+
robots: baseContract
|
|
808
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/robots', ...t('public') })
|
|
809
|
+
.input(z.object({ workspaceId: WorkspaceId, slug: Publication.shape.slug }))
|
|
810
|
+
.output(z.object({ indexable: z.boolean(), sitemapPath: z.string().nullable() })),
|
|
811
|
+
/** Present the password, get a token. A site with no password has no door, and answers 404. */
|
|
812
|
+
unlock: baseContract
|
|
813
|
+
.route({ method: 'POST', path: '/public/{workspaceId}/{slug}/unlock', ...t('public') })
|
|
814
|
+
.input(
|
|
815
|
+
z.object({
|
|
816
|
+
workspaceId: WorkspaceId,
|
|
817
|
+
slug: Publication.shape.slug,
|
|
818
|
+
password: z.string().min(1).max(200),
|
|
819
|
+
}),
|
|
820
|
+
)
|
|
821
|
+
.output(z.object({ token: z.string(), expiresAt: Timestamp })),
|
|
822
|
+
},
|
|
522
823
|
} as const
|
|
523
824
|
export type QuireContract = typeof quireContract
|
|
524
825
|
|