@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
-- Publishing a page, and everything under it, to a URL a signed-out stranger can open.
|
|
2
|
+
--
|
|
3
|
+
-- Hand-written rather than generated, like 0001 and 0006, because drizzle-kit writes neither the
|
|
4
|
+
-- policy nor the guards. Every statement is idempotent: `create table`, `create index` and
|
|
5
|
+
-- `create policy` throw on a replay, and a module migration that throws takes down the **whole host
|
|
6
|
+
-- service** rather than its own module — `core` hosts five. Drizzle keys applied migrations by
|
|
7
|
+
-- content hash, so regenerating the journal replays every file against a schema that already has its
|
|
8
|
+
-- objects. `add column if not exists` covers the two columns added to existing tables.
|
|
9
|
+
--
|
|
10
|
+
--
|
|
11
|
+
-- HOW AN ANONYMOUS READ REACHES THIS TABLE
|
|
12
|
+
--
|
|
13
|
+
-- This is the question the table exists to answer, so it is answered here rather than left to be
|
|
14
|
+
-- rediscovered. A public endpoint is the only surface in Kern with no principal behind it, and the
|
|
15
|
+
-- fence every other table leans on is `current_setting('app.workspace_id')`, which a session sets
|
|
16
|
+
-- from the principal it authenticated. There is no principal here. So one of three things is true,
|
|
17
|
+
-- and which one it is has to be a decision rather than an accident:
|
|
18
|
+
--
|
|
19
|
+
-- 1. `app.workspace_id` is unset, the policy matches nothing, and the endpoint returns an empty
|
|
20
|
+
-- page for every publication that exists.
|
|
21
|
+
-- 2. Somebody notices (1), writes the public query without the workspace, and it returns every
|
|
22
|
+
-- row in every tenant. This is the failure that matters. It does not look like a bug: the page
|
|
23
|
+
-- renders, the content is real, and it belongs to a customer who never published it.
|
|
24
|
+
-- 3. The request carries a workspace, and the ordinary policy does its ordinary job.
|
|
25
|
+
--
|
|
26
|
+
-- **Kern takes (3), and (1) is what a mistake degrades to.** *Anonymous* means no principal; it does
|
|
27
|
+
-- not mean no tenant. A public URL is workspace-qualified — `/api/quire/public/:workspace/:slug` —
|
|
28
|
+
-- so the handler resolves the workspace segment against `core`'s own workspaces (outside this schema
|
|
29
|
+
-- and outside this module) and calls `withWorkspace(id, …)` before it touches `mod_quire` at all.
|
|
30
|
+
-- From that point the policy below is the plain workspace policy every other table has, and nothing
|
|
31
|
+
-- about the public path is special at the RLS layer. That is the point: a surface with no principal
|
|
32
|
+
-- is not the place to invent a second isolation mechanism.
|
|
33
|
+
--
|
|
34
|
+
-- The degradation is safe by construction, and it is worth knowing why. `withWorkspace(null, …)`
|
|
35
|
+
-- sets the GUC to the empty string, not to NULL, and `workspace_id::text` is a uuid — never `''`.
|
|
36
|
+
-- So a quire query that reaches the database with no workspace resolved returns **zero rows**, on
|
|
37
|
+
-- every table in this schema. A broken public path shows an empty page. It does not show somebody
|
|
38
|
+
-- else's.
|
|
39
|
+
--
|
|
40
|
+
-- Two rules follow, and neither is optional:
|
|
41
|
+
--
|
|
42
|
+
-- * **Do not widen this policy with an anonymous clause.** The obvious shape — `OR slug =
|
|
43
|
+
-- current_setting('app.quire_publication_slug', true)` — reads like a tightening and is not one.
|
|
44
|
+
-- `DELETE` is governed by `USING` alone (there is no `WITH CHECK` for delete), so any clause
|
|
45
|
+
-- that makes a row visible to a session with no workspace also makes it deletable by one. The
|
|
46
|
+
-- asymmetry is easy to miss because `INSERT` and `UPDATE` *are* covered by `WITH CHECK`. Splitting
|
|
47
|
+
-- it into a `FOR SELECT` policy plus a `FOR ALL` policy would close that, and `migrations.test.ts`
|
|
48
|
+
-- requires exactly one policy per tenant table, so it is not available either.
|
|
49
|
+
-- * **Run the public path in a `READ ONLY` transaction.** Once the handler has set
|
|
50
|
+
-- `app.workspace_id`, RLS has stopped being a fence around the public request — the whole
|
|
51
|
+
-- workspace is inside it, which is exactly right for reading a publication and exactly wrong for
|
|
52
|
+
-- everything else. `set transaction read only` makes every write in that transaction fail with
|
|
53
|
+
-- 25006, in Postgres rather than in a code review, and it covers `pages` and `page_versions` too.
|
|
54
|
+
--
|
|
55
|
+
-- And the rule the fence cannot enforce: **every query on the public path carries the publication in
|
|
56
|
+
-- its own `WHERE`.** The workspace is set, so RLS will happily return a page in another space that
|
|
57
|
+
-- nobody published. Scope by publication — the root page, its descendants when `include_descendants`,
|
|
58
|
+
-- `excluded_from_public` false, and a non-null `published_version_id` — not by workspace and hope.
|
|
59
|
+
CREATE TABLE IF NOT EXISTS "mod_quire"."publications" (
|
|
60
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
61
|
+
"workspace_id" uuid NOT NULL,
|
|
62
|
+
"root_page_id" uuid NOT NULL,
|
|
63
|
+
"include_descendants" boolean DEFAULT true NOT NULL,
|
|
64
|
+
"slug" text NOT NULL,
|
|
65
|
+
"password_hash" text,
|
|
66
|
+
"expires_at" timestamp with time zone,
|
|
67
|
+
"seo_title" text DEFAULT '' NOT NULL,
|
|
68
|
+
"seo_description" text DEFAULT '' NOT NULL,
|
|
69
|
+
"og_image_url" text,
|
|
70
|
+
"indexable" boolean DEFAULT true NOT NULL,
|
|
71
|
+
"theme" text DEFAULT 'auto' NOT NULL,
|
|
72
|
+
"created_by" uuid,
|
|
73
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
74
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
75
|
+
);
|
|
76
|
+
--> statement-breakpoint
|
|
77
|
+
|
|
78
|
+
-- The per-page opt-out, so one child can stay private under a public parent.
|
|
79
|
+
--
|
|
80
|
+
-- A column on the page rather than a `publication_exclusions` join table, and the reason is which
|
|
81
|
+
-- way each one fails. An exclusion row is written against *one* publication, so publishing a new
|
|
82
|
+
-- root above the page later re-exposes it — silently, to whoever creates that publication, who never
|
|
83
|
+
-- saw the opt-out because it belonged to a different row. A column travels with the page: it means
|
|
84
|
+
-- "never public", it holds against publications that do not exist yet, and the tree walk reads it
|
|
85
|
+
-- from the row it already has instead of remembering an anti-join on the one surface where a
|
|
86
|
+
-- forgotten join is a leak. What it costs is per-publication precision — a page cannot be public in
|
|
87
|
+
-- the handbook and private in the onboarding guide. That is a real limitation and the safer half of
|
|
88
|
+
-- the trade; a table can be added *beside* this column later, with the column keeping its meaning as
|
|
89
|
+
-- the absolute one.
|
|
90
|
+
--
|
|
91
|
+
-- Backward compatible: an image without this migration reads `pages` exactly as it did.
|
|
92
|
+
ALTER TABLE "mod_quire"."pages" ADD COLUMN IF NOT EXISTS "excluded_from_public" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
93
|
+
|
|
94
|
+
-- What the version looked like, rendered once at publish time.
|
|
95
|
+
--
|
|
96
|
+
-- A public read must not decode a CRDT. `versions.html` renders a Y.Doc through the Tiptap schema on
|
|
97
|
+
-- every call, which is the right answer for a signed-in reader looking at history and the wrong one
|
|
98
|
+
-- for an endpoint an anonymous crawler hits — the work is identical every time because the version
|
|
99
|
+
-- is immutable. Storing it makes a public page a single indexed row read, and it is what lets the
|
|
100
|
+
-- response be cached by version id.
|
|
101
|
+
--
|
|
102
|
+
-- Nullable, and deliberately not backfilled to `''`. An empty string would claim "this version
|
|
103
|
+
-- renders to nothing"; NULL says "nobody has rendered it", which is the truth for every version
|
|
104
|
+
-- written before this migration. A publication whose pinned version has no HTML is not servable —
|
|
105
|
+
-- which lines up with the rule that a page with no published version is not public at all.
|
|
106
|
+
ALTER TABLE "mod_quire"."page_versions" ADD COLUMN IF NOT EXISTS "html" text;--> statement-breakpoint
|
|
107
|
+
|
|
108
|
+
-- The public path lookup, and the uniqueness rule, in one index.
|
|
109
|
+
--
|
|
110
|
+
-- `where workspace_id = $1 and slug = $2` is the first query of every anonymous request, so it is a
|
|
111
|
+
-- two-column equality probe rather than anything that touches a heap page it did not have to. The
|
|
112
|
+
-- slug is unique per workspace and not beyond it: the workspace is in the URL, so two customers both
|
|
113
|
+
-- wanting `handbook` is not a collision, and making it instance-wide would let whoever published
|
|
114
|
+
-- first take the word from everybody else.
|
|
115
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "publications_ws_slug_uq" ON "mod_quire"."publications" USING btree ("workspace_id","slug");--> statement-breakpoint
|
|
116
|
+
|
|
117
|
+
-- "Is this page published, and where?" — asked on every page an author opens, and again whenever a
|
|
118
|
+
-- page is archived, moved or deleted, because a publication rooted at it has to be dealt with.
|
|
119
|
+
CREATE INDEX IF NOT EXISTS "publications_ws_root_idx" ON "mod_quire"."publications" USING btree ("workspace_id","root_page_id");--> statement-breakpoint
|
|
120
|
+
CREATE INDEX IF NOT EXISTS "publications_ws_created_idx" ON "mod_quire"."publications" USING btree ("workspace_id","created_at");--> statement-breakpoint
|
|
121
|
+
|
|
122
|
+
-- Row-level security, the same triple every tenant table gets, and no more than that — see the note
|
|
123
|
+
-- at the top of this file for why the anonymous case does not get a clause of its own.
|
|
124
|
+
--
|
|
125
|
+
-- `force` matters: without it the table owner bypasses the policy, and the owner is the role the
|
|
126
|
+
-- service connects as. A *superuser* bypasses RLS whatever this says, and the development and CI
|
|
127
|
+
-- roles are superusers — so a test that does not connect as an unprivileged NOBYPASSRLS role proves
|
|
128
|
+
-- nothing about isolation.
|
|
129
|
+
--
|
|
130
|
+
-- One thing this policy does not do is protect a column. RLS is row-level, so `password_hash` is
|
|
131
|
+
-- inside every row the workspace can read, including on the public path once the handler has set the
|
|
132
|
+
-- workspace. Select it into the verification step and nowhere else — never into a response body, an
|
|
133
|
+
-- event payload or a log line. It is a PHC string (`$argon2id$…`), which is a hash and a salt and a
|
|
134
|
+
-- cost, and it is still the one column here that must not leave the server.
|
|
135
|
+
ALTER TABLE "mod_quire"."publications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
136
|
+
ALTER TABLE "mod_quire"."publications" FORCE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
137
|
+
DROP POLICY IF EXISTS "publications_ws_isolation" ON "mod_quire"."publications";--> statement-breakpoint
|
|
138
|
+
CREATE POLICY "publications_ws_isolation" ON "mod_quire"."publications"
|
|
139
|
+
USING (workspace_id::text = current_setting('app.workspace_id', true))
|
|
140
|
+
WITH CHECK (workspace_id::text = current_setting('app.workspace_id', true));
|
package/package.json
CHANGED