@kernhq/module-quire 0.10.9 → 0.11.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 (53) hide show
  1. package/dist/contract/models.d.ts +87 -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.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/router.d.ts +645 -0
  9. package/dist/contract/router.d.ts.map +1 -1
  10. package/dist/contract/router.js +137 -2
  11. package/dist/contract/router.js.map +1 -1
  12. package/dist/server/_impl.d.ts +877 -0
  13. package/dist/server/_impl.d.ts.map +1 -1
  14. package/dist/server/_impl.js +127 -1
  15. package/dist/server/_impl.js.map +1 -1
  16. package/dist/server/schema.d.ts +482 -1
  17. package/dist/server/schema.d.ts.map +1 -1
  18. package/dist/server/schema.js +115 -1
  19. package/dist/server/schema.js.map +1 -1
  20. package/dist/server/services/index.d.ts +3 -0
  21. package/dist/server/services/index.d.ts.map +1 -1
  22. package/dist/server/services/index.js +3 -0
  23. package/dist/server/services/index.js.map +1 -1
  24. package/dist/server/services/organisation.d.ts +117 -0
  25. package/dist/server/services/organisation.d.ts.map +1 -0
  26. package/dist/server/services/organisation.js +319 -0
  27. package/dist/server/services/organisation.js.map +1 -0
  28. package/dist/server/services/pages.d.ts +16 -1
  29. package/dist/server/services/pages.d.ts.map +1 -1
  30. package/dist/server/services/pages.js +62 -3
  31. package/dist/server/services/pages.js.map +1 -1
  32. package/migrations/0007_organisation.sql +114 -0
  33. package/migrations/meta/0007_snapshot.json +1588 -0
  34. package/migrations/meta/_journal.json +7 -0
  35. package/package.json +1 -1
  36. package/src/client/components/ConfirmDialog.svelte +123 -0
  37. package/src/client/components/FavoriteStar.svelte +81 -0
  38. package/src/client/components/LabelChip.svelte +46 -0
  39. package/src/client/components/LabelManager.svelte +336 -0
  40. package/src/client/components/PageLabels.svelte +158 -0
  41. package/src/client/components/SidebarFavorites.svelte +262 -0
  42. package/src/client/components/SidebarRecents.svelte +98 -0
  43. package/src/client/components/SidebarSpaces.svelte +266 -1
  44. package/src/client/i18n.ts +387 -0
  45. package/src/client/index.ts +8 -0
  46. package/src/client/mock.ts +306 -0
  47. package/src/client/module.ts +18 -0
  48. package/src/client/pages/PageView.svelte +227 -4
  49. package/src/client/pages/TrashPage.svelte +378 -0
  50. package/src/client/query.ts +24 -0
  51. package/src/contract/models.ts +83 -0
  52. package/src/contract/permissions.ts +36 -0
  53. package/src/contract/router.ts +153 -1
@@ -0,0 +1,378 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Button,
5
+ coreApi,
6
+ EmptyState,
7
+ formatCount,
8
+ formatDateTime,
9
+ Icon,
10
+ IconButton,
11
+ keys,
12
+ navigation,
13
+ Page,
14
+ PageHeader,
15
+ relativeTime,
16
+ Skeleton,
17
+ session,
18
+ Table,
19
+ TableCell,
20
+ TableHeader,
21
+ TableRow,
22
+ toast,
23
+ } from '@kernhq/ui'
24
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
25
+ import type { Page as QuirePage } from '../../contract/index.js'
26
+ import { getQuireApi } from '../api-instance.js'
27
+ import ConfirmDialog from '../components/ConfirmDialog.svelte'
28
+ import { type CoreApi, toPerson } from '../core-api.js'
29
+ import { t } from '../i18n.js'
30
+ import { canQuire } from '../permissions.js'
31
+ import { quireKeys } from '../query.js'
32
+
33
+ /**
34
+ * The way back.
35
+ *
36
+ * "Move to trash" took a page and every page under it, with no confirmation and nowhere to look
37
+ * afterwards — deleting "Working here" silently took "Your first week" and "Time off" with it, and
38
+ * the only trace was that they had stopped being in the sidebar. `pages.trash` has always listed
39
+ * what was taken; nothing drew it. This is that screen.
40
+ *
41
+ * **Rows are subtrees, not pages.** The listing is flat — trashing a parent marks each descendant —
42
+ * so drawing it row-for-row would show three entries for one act and offer to restore each of them
43
+ * separately, which is exactly the confusion that made the loss invisible in the first place. A row
44
+ * is a page whose parent is *not* also in the trash, and it says how many pages went with it. That
45
+ * matches what restore and purge actually do: both act on the subtree.
46
+ *
47
+ * **When and by whom** is as honest as the data allows. `deleted_at` is a column; there is no
48
+ * `deleted_by`, so the person shown is the page's last editor and the column says so rather than
49
+ * implying they were the one who deleted it.
50
+ */
51
+ interface Props {
52
+ params?: Record<string, string>
53
+ spaceKey?: string
54
+ }
55
+ const { params, spaceKey: spaceKeyProp }: Props = $props()
56
+ const spaceKey = $derived(spaceKeyProp ?? params?.space ?? '')
57
+
58
+ const api = getQuireApi()
59
+ const core = coreApi<CoreApi>()
60
+ const client = useQueryClient()
61
+
62
+ const workspaceSlug = $derived(navigation.workspaceSlug)
63
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
64
+ const workspaceId = $derived(workspace?.id ?? '')
65
+
66
+ const spacesQuery = createQuery(() => ({
67
+ queryKey: quireKeys.spaces(workspaceId),
68
+ enabled: Boolean(workspaceId),
69
+ queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
70
+ }))
71
+ const space = $derived((spacesQuery.data ?? []).find((s) => s.key === spaceKey) ?? null)
72
+
73
+ /**
74
+ * How many pages of the listing to walk.
75
+ *
76
+ * The trash is cursor-paginated and a subtree can straddle a page boundary, so grouping has to be
77
+ * done over the whole listing rather than over one batch — a batch that happens to hold a child
78
+ * and not its parent would draw the child as a subtree of its own. So the query walks the cursor
79
+ * itself. The cap is in the query key: when it is reached, **Show more** raises it and the query
80
+ * re-runs, rather than the screen quietly pretending there was nothing else.
81
+ */
82
+ const BATCH = 200
83
+ let rounds = $state(3)
84
+
85
+ const query = createQuery(() => ({
86
+ queryKey: [...quireKeys.trash(workspaceId, space?.id ?? ''), rounds],
87
+ enabled: Boolean(workspaceId && space),
88
+ queryFn: async () => {
89
+ const items: QuirePage[] = []
90
+ let cursor: string | undefined
91
+ let capped = false
92
+ for (let round = 0; round < rounds; round++) {
93
+ const batch = await api.pages.trash({
94
+ workspaceId,
95
+ spaceId: space?.id ?? '',
96
+ limit: BATCH,
97
+ ...(cursor ? { cursor } : {}),
98
+ })
99
+ items.push(...batch.items)
100
+ cursor = batch.nextCursor ?? undefined
101
+ if (!cursor) break
102
+ capped = round === rounds - 1
103
+ }
104
+ return { items, capped }
105
+ },
106
+ }))
107
+
108
+ const members = createQuery(() => ({
109
+ queryKey: keys.members(workspaceId),
110
+ enabled: Boolean(workspaceId),
111
+ queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
112
+ }))
113
+ const people = $derived(new Map((members.data?.items ?? []).map(toPerson).map((p) => [p.id, p])))
114
+
115
+ const items = $derived(query.data?.items ?? [])
116
+
117
+ /** Every page in the trash, so "is my parent here too" is a lookup rather than a scan. */
118
+ const trashed = $derived(new Set(items.map((p) => p.id)))
119
+
120
+ interface Group {
121
+ page: QuirePage
122
+ /** how many pages go with it, itself included — the number restore and purge both act on */
123
+ size: number
124
+ }
125
+
126
+ const groups = $derived.by((): Group[] => {
127
+ const children = new Map<string, QuirePage[]>()
128
+ for (const p of items) {
129
+ if (!p.parentId) continue
130
+ children.set(p.parentId, [...(children.get(p.parentId) ?? []), p])
131
+ }
132
+ const sizeOf = (page: QuirePage): number => {
133
+ let total = 1
134
+ let guard = 0
135
+ const stack = [page.id]
136
+ while (stack.length > 0 && guard++ < 5000) {
137
+ const id = stack.pop() as string
138
+ for (const child of children.get(id) ?? []) {
139
+ total++
140
+ stack.push(child.id)
141
+ }
142
+ }
143
+ return total
144
+ }
145
+ return items
146
+ .filter((p) => !p.parentId || !trashed.has(p.parentId))
147
+ .sort((a, b) => ((a.deletedAt ?? '') < (b.deletedAt ?? '') ? 1 : -1))
148
+ .map((page) => ({ page, size: sizeOf(page) }))
149
+ })
150
+
151
+ const titleOf = (page: QuirePage) => page.title.trim() || t('untitled')
152
+
153
+ const iconFor = (page: QuirePage) =>
154
+ page.kind === 'live' ? 'square-pen' : page.kind === 'database' ? 'database' : 'file-text'
155
+
156
+ let busy = $state(false)
157
+ let purging = $state<Group | null>(null)
158
+ const purgeOpen = $derived(purging !== null)
159
+
160
+ /**
161
+ * The favourites and recents lists move with the page.
162
+ *
163
+ * Both are composed by joining to `pages`, so restoring a page puts it back into them and purging
164
+ * one takes it out — and neither would notice on its own: they live under their own query prefix,
165
+ * and a page change invalidates the `page` one.
166
+ */
167
+ const refresh = async () => {
168
+ await client.invalidateQueries({ queryKey: quireKeys.trash(workspaceId, space?.id ?? '') })
169
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, space?.id ?? '') })
170
+ await client.invalidateQueries({ queryKey: quireKeys.favorites(workspaceId) })
171
+ await client.invalidateQueries({ queryKey: quireKeys.recents(workspaceId) })
172
+ }
173
+
174
+ async function restore(group: Group) {
175
+ if (busy) return
176
+ busy = true
177
+ try {
178
+ await api.pages.restore({ workspaceId, pageId: group.page.id })
179
+ await refresh()
180
+ toast.success(t('trash_restore_done', { title: titleOf(group.page) }))
181
+ } finally {
182
+ busy = false
183
+ }
184
+ }
185
+
186
+ async function purge(group: Group) {
187
+ const answer = await api.pages.purge({ workspaceId, pageId: group.page.id })
188
+ purging = null
189
+ await refresh()
190
+ toast.success(t('trash_purge_done', { count: answer.count }))
191
+ }
192
+ </script>
193
+
194
+ <PageHeader
195
+ crumbs={[
196
+ { label: workspace?.name ?? '' },
197
+ { label: t('title'), href: `/${workspaceSlug}/quire` },
198
+ ...(space
199
+ ? [{ label: space.name, href: `/${workspaceSlug}/quire/${encodeURIComponent(space.key)}` }]
200
+ : []),
201
+ { label: t('trash') },
202
+ ]}
203
+ title={t('trash')}
204
+ subtitle={t('trash_subtitle')}
205
+ />
206
+
207
+ <Page>
208
+ {#if spacesQuery.isLoading || (query.isLoading && Boolean(space))}
209
+ <div class="rows">
210
+ {#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
211
+ </div>
212
+ {:else if !space}
213
+ <EmptyState icon="scroll-text" title={t('space_missing')} description={t('space_missing_desc')} />
214
+ {:else if query.isError}
215
+ <EmptyState icon="triangle-alert" title={t('trash_error')} description={t('page_error_desc')}>
216
+ {#snippet actions()}
217
+ <Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
218
+ {/snippet}
219
+ </EmptyState>
220
+ {:else if groups.length === 0}
221
+ <EmptyState icon="trash-2" title={t('trash_empty')} description={t('trash_empty_desc')} />
222
+ {:else}
223
+ <!--
224
+ The two text columns have a floor, and that is what makes the scroll box below do anything.
225
+
226
+ `minmax(0, …)` lets a column shrink to *nothing* rather than overflow, so at 390px the page
227
+ column collapsed to zero and the row drew the icon and "with 1 page inside it" and none of
228
+ the page's actual name — while the scroll box reported nothing to scroll, because nothing had
229
+ overflowed. Measured at 390px in en, fa and ar alike: the name was 0 wide in all three.
230
+ A minimum turns it back into an overflow, which is a thing a finger can move.
231
+
232
+ 260px rather than something rounder, because the page cell holds three things: the icon, the
233
+ title, and "with 1 page inside it" — which is `flex: none` and around 106px, so a floor that
234
+ only clears the fixed pair leaves the title 33px again.
235
+ -->
236
+ <Table
237
+ columns="minmax(260px, 2fr) 110px minmax(90px, 1fr) 92px"
238
+ ariaLabel={t('trash')}
239
+ class="trash-table"
240
+ >
241
+ <TableHeader>
242
+ <TableCell header>{t('trash_col_page')}</TableCell>
243
+ <TableCell header>{t('trash_col_deleted')}</TableCell>
244
+ <TableCell header>{t('trash_col_editor')}</TableCell>
245
+ <TableCell header end>{formatCount(groups.length)}</TableCell>
246
+ </TableHeader>
247
+
248
+ {#each groups as group (group.page.id)}
249
+ {@const person = group.page.updatedBy ? people.get(group.page.updatedBy) : undefined}
250
+ <TableRow>
251
+ <TableCell>
252
+ <span class="ic"><Icon name={iconFor(group.page)} size={15} strokeWidth={1.6} /></span>
253
+ <span class="name">{titleOf(group.page)}</span>
254
+ {#if group.size > 1}
255
+ <span class="inside">{t('trash_inside', { count: group.size - 1 })}</span>
256
+ {/if}
257
+ </TableCell>
258
+
259
+ <TableCell>
260
+ <!--
261
+ A relative time with the exact one on hover: "3d" is what this column is read for, and
262
+ the full date is what somebody deciding whether to purge actually needs.
263
+
264
+ Through `formatDateTime`, not the raw column. `deletedAt` is an ISO 8601 UTC string,
265
+ so the tooltip read `2026-08-25T19:45:47.634Z` in every language — Latin digits and a
266
+ machine's calendar hanging off a cell that had just said پریروز. It is the same defect
267
+ the date column had, one layer up: the one untranslated thing on the page.
268
+ -->
269
+ <span class="when" title={group.page.deletedAt ? formatDateTime(group.page.deletedAt) : ''}>
270
+ {group.page.deletedAt ? relativeTime(group.page.deletedAt) : ''}
271
+ </span>
272
+ </TableCell>
273
+
274
+ <TableCell>
275
+ {#if person}
276
+ <Avatar id={person.id} name={person.name} src={person.avatarUrl ?? undefined} size={20} />
277
+ <span class="who">{person.name}</span>
278
+ {:else}
279
+ <span class="who">{t('comment_someone')}</span>
280
+ {/if}
281
+ </TableCell>
282
+
283
+ <TableCell end>
284
+ <IconButton
285
+ icon="rotate-ccw"
286
+ size={26}
287
+ variant="ghost"
288
+ label={t('restore')}
289
+ aria-busy={busy}
290
+ onclick={() => void restore(group)}
291
+ />
292
+ {#if canQuire('pageDelete')}
293
+ <IconButton
294
+ icon="trash-2"
295
+ size={26}
296
+ variant="ghost"
297
+ label={t('trash_purge_title', { title: titleOf(group.page) })}
298
+ onclick={() => (purging = group)}
299
+ />
300
+ {/if}
301
+ </TableCell>
302
+ </TableRow>
303
+ {/each}
304
+ </Table>
305
+
306
+ {#if query.data?.capped}
307
+ <div class="more">
308
+ <Button variant="secondary" size="sm" onclick={() => (rounds += 3)}>{t('trash_more')}</Button>
309
+ </div>
310
+ {/if}
311
+ {/if}
312
+ </Page>
313
+
314
+ <ConfirmDialog
315
+ open={purgeOpen}
316
+ title={t('trash_purge_title', { title: purging ? titleOf(purging.page) : '' })}
317
+ body={t('trash_purge_body', { count: purging?.size ?? 1 })}
318
+ confirmLabel={t('trash_purge')}
319
+ danger
320
+ onCancel={() => (purging = null)}
321
+ onConfirm={async () => {
322
+ if (purging) await purge(purging)
323
+ }}
324
+ />
325
+
326
+ <style>
327
+ .rows {
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 6px;
331
+ }
332
+ .ic {
333
+ display: inline-flex;
334
+ color: var(--kern-ink-400);
335
+ flex: none;
336
+ }
337
+ .name {
338
+ color: var(--kern-ink-900);
339
+ overflow: hidden;
340
+ text-overflow: ellipsis;
341
+ white-space: nowrap;
342
+ }
343
+ /*
344
+ * "with 2 pages inside it" is the whole point of the row, so it is a colour rather than a fade —
345
+ * `opacity` here would put the one fact a person came to read below the contrast floor.
346
+ */
347
+ .inside {
348
+ flex: none;
349
+ font-size: 12px;
350
+ color: var(--kern-ink-450);
351
+ white-space: nowrap;
352
+ }
353
+ .when {
354
+ font-family: var(--kern-font-mono);
355
+ font-size: 12px;
356
+ color: var(--kern-ink-500);
357
+ letter-spacing: -0.01em;
358
+ }
359
+ .who {
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ white-space: nowrap;
363
+ color: var(--kern-ink-600);
364
+ }
365
+ .more {
366
+ display: flex;
367
+ justify-content: center;
368
+ padding-block-start: 14px;
369
+ }
370
+ /*
371
+ * The table is the only thing on this screen wide enough to overflow a narrow window, so it
372
+ * scrolls inside itself rather than taking the page sideways with it — which is how a Persian
373
+ * layout ends up with a horizontal scrollbar under everything.
374
+ */
375
+ :global(.trash-table) {
376
+ overflow-x: auto;
377
+ }
378
+ </style>
@@ -14,6 +14,30 @@ export const quireKeys = {
14
14
  page: (workspaceId: string, pageId: string) => ['quire', 'page', workspaceId, pageId] as const,
15
15
  trash: (workspaceId: string, spaceId: string) => ['quire', 'page', workspaceId, 'trash', spaceId] as const,
16
16
 
17
+ /**
18
+ * A space's vocabulary, and what one page wears out of it.
19
+ *
20
+ * `label` is the entity the server announces when one is created, renamed or removed, so a
21
+ * rename reaches every chip drawing that label without anybody wiring an invalidation. Putting
22
+ * labels *on* a page announces `page` instead — that is a change to the page — so
23
+ * `pages.setLabels` invalidates this key itself.
24
+ */
25
+ labels: (workspaceId: string, spaceId: string) => ['quire', 'label', workspaceId, spaceId] as const,
26
+ pageLabels: (workspaceId: string, pageId: string) =>
27
+ ['quire', 'label', workspaceId, 'page', pageId] as const,
28
+
29
+ /**
30
+ * The three personal lists.
31
+ *
32
+ * Nothing on the server announces a change for these, and nothing should: one person starring a
33
+ * page is not news to anybody else's open tab, and a `change` is broadcast to the whole
34
+ * workspace. Their entities are therefore cache names rather than realtime names — the mutations
35
+ * that write them answer with the whole list, so a screen redraws from the reply.
36
+ */
37
+ favorites: (workspaceId: string) => ['quire', 'favorite', workspaceId] as const,
38
+ recents: (workspaceId: string) => ['quire', 'recent', workspaceId] as const,
39
+ watchers: (workspaceId: string, pageId: string) => ['quire', 'watcher', workspaceId, pageId] as const,
40
+
17
41
  /** the schema — properties and views — which every open tab of a database is drawing */
18
42
  database: (workspaceId: string, databaseId: string) =>
19
43
  ['quire', 'database', workspaceId, databaseId] as const,
@@ -161,4 +161,87 @@ export const CommentThread = z.object({
161
161
  })
162
162
  export type CommentThread = z.infer<typeof CommentThread>
163
163
 
164
+ /**
165
+ * The colours a label may wear, closed on purpose.
166
+ *
167
+ * `SelectOption.colour` next door is free text, and the comment on `client/database/colours.ts`
168
+ * explains what that costs: one typo renders a chip with no background, and — worse — a colour pair
169
+ * nobody has measured for contrast. These are exactly the keys of `TONES`, so every one of them is a
170
+ * pair the design tokens already tuned for light and dark. A label is picked from a menu rather than
171
+ * typed, so there is no reason to leave the door open here.
172
+ */
173
+ export const LabelColour = z.enum([
174
+ 'grey',
175
+ 'slate',
176
+ 'accent',
177
+ 'success',
178
+ 'warning',
179
+ 'danger',
180
+ 'info',
181
+ 'purple',
182
+ ])
183
+ export type LabelColour = z.infer<typeof LabelColour>
184
+
185
+ /**
186
+ * A word a space puts on pages, so they can be gathered by something other than where they sit.
187
+ *
188
+ * Scoped to a space, not to the workspace: two teams both wanting "Draft" should not have to agree
189
+ * on what it means, and a label list spanning every space is a list nobody can read. Names are
190
+ * unique per space case-insensitively — "Draft" and "draft" in one picker are broken data.
191
+ */
192
+ export const Label = z.object({
193
+ id: Id,
194
+ workspaceId: WorkspaceId,
195
+ spaceId: Id,
196
+ name: z.string().min(1).max(60),
197
+ colour: LabelColour,
198
+ createdAt: Timestamp,
199
+ })
200
+ export type Label = z.infer<typeof Label>
201
+
202
+ /**
203
+ * A page somebody put in their own sidebar, and where they put it.
204
+ *
205
+ * `position` is a fractional index, not an integer, for the same reason `Page.position` is: dragging
206
+ * one favourite between two others must not renumber the rest, or two reorders at once write
207
+ * different numbers for the same rows.
208
+ */
209
+ export const Favorite = z.object({
210
+ workspaceId: WorkspaceId,
211
+ userId: UserId,
212
+ pageId: Id,
213
+ position: z.string().min(1).max(256),
214
+ createdAt: Timestamp,
215
+ })
216
+ export type Favorite = z.infer<typeof Favorite>
217
+
218
+ /**
219
+ * The last time somebody opened a page.
220
+ *
221
+ * One row per person per page, bumped in place — not a visit log. A log grows without bound to
222
+ * answer a question that only ever wants the most recent handful, and needs pruning nobody runs.
223
+ */
224
+ export const RecentView = z.object({
225
+ workspaceId: WorkspaceId,
226
+ userId: UserId,
227
+ pageId: Id,
228
+ viewedAt: Timestamp,
229
+ })
230
+ export type RecentView = z.infer<typeof RecentView>
231
+
232
+ /**
233
+ * Somebody who asked to hear about a page.
234
+ *
235
+ * Deliberately not the same thing as a favourite: "I want this to hand" and "tell me when this
236
+ * changes" are different requests, and collapsing them gives you either a sidebar full of pages
237
+ * somebody only wanted news about or a notification for every shortcut they made.
238
+ */
239
+ export const Watcher = z.object({
240
+ workspaceId: WorkspaceId,
241
+ userId: UserId,
242
+ pageId: Id,
243
+ createdAt: Timestamp,
244
+ })
245
+ export type Watcher = z.infer<typeof Watcher>
246
+
164
247
  export const Ok = z.object({ ok: z.literal(true) })
@@ -124,6 +124,42 @@ export const quireProcedureAuthz: Record<string, ProcedureAuthz> = {
124
124
  'pages.trashPage': { check: 'page', permission: 'quire.page.edit' },
125
125
  'pages.restore': { check: 'page', permission: 'quire.page.edit' },
126
126
  'pages.purge': { check: 'page', permission: 'quire.page.delete' },
127
+ 'pages.setLabels': { check: 'page', permission: 'quire.page.edit' },
128
+
129
+ // A label is the space's vocabulary, not one page's content: renaming "Draft" changes what it
130
+ // means everywhere it is worn, so writing one is `space.manage` while reading is `space.view`.
131
+ 'labels.list': { check: 'space', permission: 'quire.space.view' },
132
+ 'labels.forPage': { check: 'page', permission: 'quire.page.view' },
133
+ 'labels.create': { check: 'space', permission: 'quire.space.manage' },
134
+ 'labels.update': { check: 'space', permission: 'quire.space.manage' },
135
+ 'labels.remove': { check: 'space', permission: 'quire.space.manage' },
136
+
137
+ /*
138
+ * Favourites, watches and recent views are one person's own, and that is a *filter inside the
139
+ * query*, not a permission — RLS fences the workspace, which is the tenant boundary rather than a
140
+ * privacy one, so `user_id` in each query is what keeps a sidebar personal.
141
+ *
142
+ * What the permission still decides is which pages may enter that list at all. Anything naming a
143
+ * page checks the page: you have to be able to read a page to bookmark it, to watch it, or to
144
+ * record having opened it — otherwise a page a space has closed to you is one you can still put
145
+ * in your own sidebar and be told about.
146
+ *
147
+ * The three that name no page are `workspace`, honestly: "my whole list" has no narrower scope to
148
+ * resolve. Each still drops the entries whose pages the caller may no longer read, and taking
149
+ * your own bookmark back is deliberately not gated on the page — a shortcut you can no longer
150
+ * open is exactly the one you want to be rid of, and needing read access to delete it would
151
+ * strand it there for good.
152
+ */
153
+ 'favorites.list': { check: 'workspace', permission: 'quire.page.view' },
154
+ 'favorites.add': { check: 'page', permission: 'quire.page.view' },
155
+ 'favorites.remove': { check: 'workspace', permission: 'quire.page.view' },
156
+ 'favorites.reorder': { check: 'workspace', permission: 'quire.page.view' },
157
+
158
+ 'watchers.get': { check: 'page', permission: 'quire.page.view' },
159
+ 'watchers.set': { check: 'page', permission: 'quire.page.view' },
160
+
161
+ 'recents.list': { check: 'workspace', permission: 'quire.page.view' },
162
+ 'recents.record': { check: 'page', permission: 'quire.page.view' },
127
163
 
128
164
  'versions.list': { check: 'page', permission: 'quire.page.view' },
129
165
  'versions.get': { check: 'page', permission: 'quire.page.view' },