@kernhq/module-quire 0.11.1 → 0.13.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 +35 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +756 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +346 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +826 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +336 -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 +191 -0
- package/dist/server/services/publications.d.ts.map +1 -0
- package/dist/server/services/publications.js +772 -0
- package/dist/server/services/publications.js.map +1 -0
- package/dist/server/services/versions.d.ts +26 -1
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +44 -15
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0008_publications.sql +140 -0
- package/migrations/0009_public_asset_references.sql +35 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/client/components/PublishDialog.svelte +920 -0
- package/src/client/components/SidebarRecents.svelte +17 -1
- package/src/client/i18n.ts +448 -0
- package/src/client/index.ts +21 -0
- package/src/client/mock.ts +436 -2
- package/src/client/pages/PageView.svelte +139 -0
- package/src/client/public-url.ts +76 -0
- package/src/client/query.ts +16 -0
- package/src/contract/models.ts +77 -0
- package/src/contract/permissions.ts +54 -1
- package/src/contract/router.ts +380 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"_impl.d.ts","sourceRoot":"","sources":["../../src/server/_impl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"_impl.d.ts","sourceRoot":"","sources":["../../src/server/_impl.ts"],"names":[],"mappings":"AACA,OAAO,EACL,YAAY,EACZ,kBAAkB,EAElB,KAAK,MAAM,EAEX,cAAc,EACd,KAAK,cAAc,EAIpB,MAAM,gBAAgB,CAAA;AAWvB;;;;;;;GAOG;AACH,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,cAAc,EAAE,CAAA;AAuE3D,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2uCxC"}
|
package/dist/server/_impl.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ANONYMOUS } from '@kernhq/contracts';
|
|
2
|
+
import { defineModule, defineServerModule, KernError, o, packageVersion, requires, workspaceScoped, } from '@kernhq/kernel';
|
|
2
3
|
import { implement } from '@orpc/server';
|
|
3
4
|
import { MODULE_ID, quireContract, quireEvents } from '../contract/index.js';
|
|
4
5
|
import { toComment } from './services/comments.js';
|
|
5
6
|
import { quireServices } from './services/index.js';
|
|
6
7
|
import { createNotify } from './services/notify.js';
|
|
7
8
|
import { documentNameOf, toPage } from './services/pages.js';
|
|
9
|
+
import { toPublication } from './services/publications.js';
|
|
8
10
|
import { toVersion } from './services/versions.js';
|
|
9
11
|
/**
|
|
10
12
|
* The router, kept apart from `index.ts` so `module.test.ts` can walk it without booting a kernel.
|
|
@@ -16,10 +18,91 @@ import { toVersion } from './services/versions.js';
|
|
|
16
18
|
*/
|
|
17
19
|
export { defineModule, defineServerModule, packageVersion };
|
|
18
20
|
const os = implement(quireContract).$context();
|
|
21
|
+
/**
|
|
22
|
+
* The anonymous counterpart of `workspaceScoped`, for the `public.*` surface and nothing else.
|
|
23
|
+
*
|
|
24
|
+
* `workspaceScoped` rejects an anonymous principal outright, which is exactly right for every other
|
|
25
|
+
* procedure in this module and impossible for these six. What is still worth keeping from it is the
|
|
26
|
+
* module switch: a workspace that has turned Quire off must not keep serving its published sites,
|
|
27
|
+
* and the answer is **404 rather than `MODULE_DISABLED`**, because a signed-out stranger asking for
|
|
28
|
+
* a URL is owed "there is nothing here" and not "there is something here that this customer has
|
|
29
|
+
* switched off". The one thing it must not do is `requireMember`.
|
|
30
|
+
*/
|
|
31
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
32
|
+
const publicSurface = o.middleware(async ({ context, next }, input) => {
|
|
33
|
+
const { workspaceId } = input;
|
|
34
|
+
/*
|
|
35
|
+
* A middleware runs *before* the contract validates its input, so this is the first and only
|
|
36
|
+
* thing that sees the workspace segment — and it used to hand whatever arrived to
|
|
37
|
+
* `kernel.isModuleEnabled`, whose own schema is `z.uuid()`. So `/api/quire/public/not-a-uuid/x`
|
|
38
|
+
* came back as a ZodError this module has no case for: **HTTP 500** on the one surface that
|
|
39
|
+
* promises everything unresolvable is a 404, and a stack trace written at error level for every
|
|
40
|
+
* such request, on an endpoint anyone on the internet can reach as fast as they like.
|
|
41
|
+
*
|
|
42
|
+
* The shape is checked here instead. Anything that is not a workspace id is the same "there is
|
|
43
|
+
* nothing here" as a workspace that has switched Quire off — which is the right answer, because a
|
|
44
|
+
* signed-out stranger is owed no more than that. `.catch` covers the rest for the same reason: a
|
|
45
|
+
* settings lookup that fails must not become a 500 either.
|
|
46
|
+
*/
|
|
47
|
+
if (typeof workspaceId !== 'string' || !UUID.test(workspaceId))
|
|
48
|
+
throw new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
49
|
+
const enabled = await context.kernel.isModuleEnabled(workspaceId, MODULE_ID).catch(() => false);
|
|
50
|
+
if (!enabled)
|
|
51
|
+
throw new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
52
|
+
return next();
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Serve a public procedure to its author exactly as it is served to a stranger.
|
|
56
|
+
*
|
|
57
|
+
* The principal is replaced, not merely ignored. A signed-in author opening their own published URL
|
|
58
|
+
* is the *only* person who ever checks whether a public site works, so a handler that could quietly
|
|
59
|
+
* consult `context.principal` would show them a site nobody else can see — and the failure is
|
|
60
|
+
* invisible in exactly the situation where somebody is looking for it. With the principal gone
|
|
61
|
+
* there is nothing to consult: `userId` is null, there are no memberships, and any permission
|
|
62
|
+
* question a future handler asked would be answered for a stranger.
|
|
63
|
+
*
|
|
64
|
+
* `authz.int.test.ts` asserts the consequence rather than the mechanism — every `check: 'public'`
|
|
65
|
+
* procedure must answer a denied administrator and an anonymous request identically.
|
|
66
|
+
*/
|
|
67
|
+
const anonymousOnly = o.middleware(({ context, next }) => next({ context: { ...context, principal: ANONYMOUS } }));
|
|
68
|
+
/**
|
|
69
|
+
* The one refusal every unservable publication shares, in one place so it cannot drift.
|
|
70
|
+
*
|
|
71
|
+
* It drifted. A publication whose root page had since been trashed was 404 from `site` and `page`,
|
|
72
|
+
* **200 with an empty body** from `search` and `sitemap`, and `indexable: true` from `robots` — so
|
|
73
|
+
* four procedures written to be indistinguishable from "no such slug" were, between them, an
|
|
74
|
+
* existence oracle for a fifth state nobody had enumerated. And `page` said "page" where the others
|
|
75
|
+
* said "site", which separated a locked publication from a missing one on the endpoint least able
|
|
76
|
+
* to afford it.
|
|
77
|
+
*
|
|
78
|
+
* The rule the wording now follows: **a refusal about the publication says "site", and a refusal
|
|
79
|
+
* about a path inside a servable one says "page".** A reader who mistypes a page of a real handbook
|
|
80
|
+
* is the only person who ever sees the second sentence.
|
|
81
|
+
*/
|
|
82
|
+
const noSite = () => new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
19
83
|
export function implement_(kernel) {
|
|
20
84
|
const scoped = os.use(workspaceScoped(MODULE_ID));
|
|
85
|
+
const open = os.use(publicSurface).use(anonymousOnly);
|
|
21
86
|
const svc = quireServices(kernel);
|
|
22
87
|
const notify = createNotify(kernel);
|
|
88
|
+
/**
|
|
89
|
+
* A publication that can actually be served, and its whole tree, or `noSite`.
|
|
90
|
+
*
|
|
91
|
+
* Every anonymous read but `site` and `robots` starts here, so the four ways of not being
|
|
92
|
+
* servable — no such slug, expired, still locked, nothing left under the root — are one answer
|
|
93
|
+
* written once rather than four handlers agreeing by hand. `site` needs its own shape because
|
|
94
|
+
* `locked` is a state it reports rather than refuses, and `robots` because it never refuses at
|
|
95
|
+
* all.
|
|
96
|
+
*/
|
|
97
|
+
const servable = async (tx, workspaceId, slug, token) => {
|
|
98
|
+
const pub = await svc.publications.bySlug(tx, workspaceId, slug);
|
|
99
|
+
if (!(await svc.publications.unlocked(pub, token)))
|
|
100
|
+
throw noSite();
|
|
101
|
+
const nodes = await svc.publications.tree(tx, workspaceId, pub);
|
|
102
|
+
if (!nodes[0])
|
|
103
|
+
throw noSite();
|
|
104
|
+
return { pub, nodes };
|
|
105
|
+
};
|
|
23
106
|
const run = (context, workspaceId, fn) => kernel.database.withWorkspace(workspaceId, fn, { userId: context.principal.userId });
|
|
24
107
|
/**
|
|
25
108
|
* "May you do this to *that* page", answered through the page's ancestor chain.
|
|
@@ -666,7 +749,18 @@ export function implement_(kernel) {
|
|
|
666
749
|
const row = await run(context, input.workspaceId, async (tx) => {
|
|
667
750
|
const scope = await svc.access.scopeOf(tx, input.workspaceId, input.pageId);
|
|
668
751
|
await svc.access.requirePage(context.principal, 'quire.page.publish', input.workspaceId, scope);
|
|
669
|
-
|
|
752
|
+
const updated = await svc.versions.publish(tx, context.principal, input.workspaceId, input.pageId, input.label);
|
|
753
|
+
/*
|
|
754
|
+
* Draw the version now, while there is a principal and a writable transaction.
|
|
755
|
+
*
|
|
756
|
+
* A version is immutable, so every rendering of it is identical — and the public path
|
|
757
|
+
* is read-only and has nobody to sign a picture on behalf of. Doing it here is what
|
|
758
|
+
* makes an anonymous read a single indexed row read instead of a CRDT decode, and it is
|
|
759
|
+
* why `page_versions.html` exists at all.
|
|
760
|
+
*/
|
|
761
|
+
if (updated.publishedVersionId)
|
|
762
|
+
await svc.publications.renderVersion(tx, input.workspaceId, updated.publishedVersionId);
|
|
763
|
+
return updated;
|
|
670
764
|
});
|
|
671
765
|
await kernel.emit(quireEvents.pagePublished, {
|
|
672
766
|
pageId: input.pageId,
|
|
@@ -689,6 +783,246 @@ export function implement_(kernel) {
|
|
|
689
783
|
return toPage(row);
|
|
690
784
|
}),
|
|
691
785
|
},
|
|
786
|
+
/**
|
|
787
|
+
* Publishing to the internet, from the inside.
|
|
788
|
+
*
|
|
789
|
+
* Every one of these asks `quire.page.publish` about the publication's **root page**, because
|
|
790
|
+
* the root is the page whose whole subtree is being handed out. A publication id carries no
|
|
791
|
+
* scope of its own, so the row is read first and the question is asked about what it points at
|
|
792
|
+
* — the same shape as a `databases.*` procedure resolving its host page.
|
|
793
|
+
*/
|
|
794
|
+
publications: {
|
|
795
|
+
list: scoped.publications.list.use(requires('quire.page.publish')).handler(({ input, context }) => run(context, input.workspaceId, async (tx) => {
|
|
796
|
+
await svc.access.spaceRow(tx, input.workspaceId, input.spaceId);
|
|
797
|
+
await svc.access.requireSpace(context.principal, 'quire.page.publish', input.workspaceId, input.spaceId);
|
|
798
|
+
/*
|
|
799
|
+
* Filtered as well as gated, for the reason `pages.trash` is: the procedure is reached by
|
|
800
|
+
* being allowed to publish *somewhere* in the space, and a publication names a root page
|
|
801
|
+
* — so an unfiltered row would hand its slug and its root's existence to somebody a
|
|
802
|
+
* page-scoped DENY has closed that page to.
|
|
803
|
+
*/
|
|
804
|
+
const rows = await svc.publications.list(tx, input.workspaceId, input.spaceId);
|
|
805
|
+
const kept = [];
|
|
806
|
+
for (const row of rows) {
|
|
807
|
+
const scope = await svc.access.scopeOf(tx, input.workspaceId, row.rootPageId).catch(() => null);
|
|
808
|
+
if (!scope)
|
|
809
|
+
continue;
|
|
810
|
+
if (await svc.access.canPage(context.principal, 'quire.page.publish', input.workspaceId, scope))
|
|
811
|
+
kept.push(toPublication(row));
|
|
812
|
+
}
|
|
813
|
+
return kept;
|
|
814
|
+
})),
|
|
815
|
+
get: scoped.publications.get.use(requires('quire.page.publish')).handler(({ input, context }) => run(context, input.workspaceId, async (tx) => {
|
|
816
|
+
const row = await svc.publications.row(tx, input.workspaceId, input.publicationId);
|
|
817
|
+
await requirePage(tx, context, input.workspaceId, row.rootPageId, 'quire.page.publish');
|
|
818
|
+
return toPublication(row);
|
|
819
|
+
})),
|
|
820
|
+
create: scoped.publications.create
|
|
821
|
+
.use(requires('quire.page.publish'))
|
|
822
|
+
.handler(async ({ input, context }) => {
|
|
823
|
+
const row = await run(context, input.workspaceId, async (tx) => {
|
|
824
|
+
await requirePage(tx, context, input.workspaceId, input.rootPageId, 'quire.page.publish');
|
|
825
|
+
const created = await svc.publications.create(tx, context.principal, input.workspaceId, input);
|
|
826
|
+
// Only ever does anything for pages published before this feature existed; a publish
|
|
827
|
+
// since then rendered its own HTML. Bounded, and a shortfall is a log line rather than a
|
|
828
|
+
// failed request — see `backfill`.
|
|
829
|
+
await svc.publications.backfill(tx, input.workspaceId, created);
|
|
830
|
+
return created;
|
|
831
|
+
});
|
|
832
|
+
await announce(input.workspaceId, 'publication', row.id, 'created');
|
|
833
|
+
return toPublication(row);
|
|
834
|
+
}),
|
|
835
|
+
update: scoped.publications.update
|
|
836
|
+
.use(requires('quire.page.publish'))
|
|
837
|
+
.handler(async ({ input, context }) => {
|
|
838
|
+
const { workspaceId, publicationId, ...patch } = input;
|
|
839
|
+
const row = await run(context, workspaceId, async (tx) => {
|
|
840
|
+
const existing = await svc.publications.row(tx, workspaceId, publicationId);
|
|
841
|
+
await requirePage(tx, context, workspaceId, existing.rootPageId, 'quire.page.publish');
|
|
842
|
+
const updated = await svc.publications.update(tx, workspaceId, publicationId, patch);
|
|
843
|
+
await svc.publications.backfill(tx, workspaceId, updated);
|
|
844
|
+
return updated;
|
|
845
|
+
});
|
|
846
|
+
await announce(workspaceId, 'publication', row.id, 'updated');
|
|
847
|
+
return toPublication(row);
|
|
848
|
+
}),
|
|
849
|
+
remove: scoped.publications.remove
|
|
850
|
+
.use(requires('quire.page.publish'))
|
|
851
|
+
.handler(async ({ input, context }) => {
|
|
852
|
+
await run(context, input.workspaceId, async (tx) => {
|
|
853
|
+
const existing = await svc.publications.row(tx, input.workspaceId, input.publicationId);
|
|
854
|
+
await requirePage(tx, context, input.workspaceId, existing.rootPageId, 'quire.page.publish');
|
|
855
|
+
await svc.publications.remove(tx, input.workspaceId, input.publicationId);
|
|
856
|
+
});
|
|
857
|
+
await announce(input.workspaceId, 'publication', input.publicationId, 'deleted');
|
|
858
|
+
return { ok: true };
|
|
859
|
+
}),
|
|
860
|
+
optOut: scoped.publications.optOut
|
|
861
|
+
.use(requires('quire.page.publish'))
|
|
862
|
+
.handler(async ({ input, context }) => {
|
|
863
|
+
const excluded = await run(context, input.workspaceId, async (tx) => {
|
|
864
|
+
await requirePage(tx, context, input.workspaceId, input.pageId, 'quire.page.publish');
|
|
865
|
+
return svc.publications.setExcluded(tx, input.workspaceId, input.pageId, input.excluded);
|
|
866
|
+
});
|
|
867
|
+
await announce(input.workspaceId, 'page', input.pageId, 'updated');
|
|
868
|
+
return { pageId: input.pageId, excluded };
|
|
869
|
+
}),
|
|
870
|
+
},
|
|
871
|
+
/**
|
|
872
|
+
* The signed-out surface.
|
|
873
|
+
*
|
|
874
|
+
* Every handler here has the same three lines at the top and they are the whole security model:
|
|
875
|
+
* resolve the **publication** from the slug (which fails closed on a slug nobody has taken and
|
|
876
|
+
* on one that has expired), check the door, and then walk the tree *from the publication's
|
|
877
|
+
* root*. Nothing is looked up by an id a caller supplied, because nothing here takes one — so
|
|
878
|
+
* "guessing a sibling id" is not a request this API can express.
|
|
879
|
+
*
|
|
880
|
+
* `open` is `publicSurface` + `anonymousOnly`: no membership check, no permission check, and no
|
|
881
|
+
* principal to accidentally consult. Reads run in `svc.publications.read`, which is a read-only
|
|
882
|
+
* transaction, so a write that slipped in here fails in Postgres rather than in a review.
|
|
883
|
+
*/
|
|
884
|
+
public: {
|
|
885
|
+
site: open.public.site.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
886
|
+
const pub = await svc.publications.bySlug(tx, input.workspaceId, input.slug);
|
|
887
|
+
const theme = pub.theme;
|
|
888
|
+
if (!(await svc.publications.unlocked(pub, input.token)))
|
|
889
|
+
return { slug: pub.slug, theme, locked: true, site: null };
|
|
890
|
+
const nodes = await svc.publications.tree(tx, input.workspaceId, pub);
|
|
891
|
+
// No root means the root page is archived, trashed, opted out, unpublished or not a page.
|
|
892
|
+
// The publication row survives that; the site does not.
|
|
893
|
+
const root = nodes[0];
|
|
894
|
+
if (!root)
|
|
895
|
+
throw noSite();
|
|
896
|
+
const newest = nodes.reduce((at, node) => Math.max(at, node.published_at.getTime()), root.published_at.getTime());
|
|
897
|
+
return {
|
|
898
|
+
slug: pub.slug,
|
|
899
|
+
theme,
|
|
900
|
+
locked: false,
|
|
901
|
+
site: {
|
|
902
|
+
title: pub.seoTitle || root.title || 'Untitled',
|
|
903
|
+
description: pub.seoDescription,
|
|
904
|
+
ogImageUrl: pub.ogImageUrl,
|
|
905
|
+
indexable: pub.indexable,
|
|
906
|
+
updatedAt: new Date(newest).toISOString(),
|
|
907
|
+
nav: nodes.map((node) => ({
|
|
908
|
+
path: node.path,
|
|
909
|
+
parentPath: node.parentPath,
|
|
910
|
+
title: node.title || 'Untitled',
|
|
911
|
+
icon: node.icon,
|
|
912
|
+
})),
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
})),
|
|
916
|
+
page: open.public.page.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
917
|
+
const { nodes } = await servable(tx, input.workspaceId, input.slug, input.token);
|
|
918
|
+
const node = svc.publications.find(nodes, input.path);
|
|
919
|
+
return {
|
|
920
|
+
path: node.path,
|
|
921
|
+
title: node.title || 'Untitled',
|
|
922
|
+
icon: node.icon,
|
|
923
|
+
coverUrl: node.cover_url,
|
|
924
|
+
html: await svc.publications.html(tx, input.workspaceId, node, nodes, input.basePath),
|
|
925
|
+
/*
|
|
926
|
+
* The version's timestamp, not the page's. `pages.updated_at` moves every time somebody
|
|
927
|
+
* types in the draft, so publishing it would tell the internet when an unpublished
|
|
928
|
+
* change was being worked on.
|
|
929
|
+
*/
|
|
930
|
+
publishedAt: node.published_at.toISOString(),
|
|
931
|
+
etag: svc.publications.etagFor(node.version_id),
|
|
932
|
+
breadcrumbs: trailTo(nodes, node),
|
|
933
|
+
};
|
|
934
|
+
})),
|
|
935
|
+
search: open.public.search.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
936
|
+
const { nodes } = await servable(tx, input.workspaceId, input.slug, input.token);
|
|
937
|
+
const hits = await svc.publications.search(tx, input.workspaceId, nodes, input.q, input.limit);
|
|
938
|
+
return {
|
|
939
|
+
items: hits.map((hit) => ({
|
|
940
|
+
path: hit.node.path,
|
|
941
|
+
title: hit.node.title || 'Untitled',
|
|
942
|
+
snippet: hit.snippet,
|
|
943
|
+
})),
|
|
944
|
+
};
|
|
945
|
+
})),
|
|
946
|
+
sitemap: open.public.sitemap.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
947
|
+
const pub = await svc.publications.bySlug(tx, input.workspaceId, input.slug);
|
|
948
|
+
// A sitemap exists for crawlers, so a site nobody is meant to crawl has an empty one
|
|
949
|
+
// rather than a private one. Answering with the tree here would put every path of a
|
|
950
|
+
// password-protected handbook in a file whose whole purpose is to be fetched by robots.
|
|
951
|
+
if (pub.passwordHash || !pub.indexable)
|
|
952
|
+
return { entries: [] };
|
|
953
|
+
const nodes = await svc.publications.tree(tx, input.workspaceId, pub);
|
|
954
|
+
// A publication whose root has since been trashed serves nothing, so it is the same 404
|
|
955
|
+
// as a slug nobody took — an empty sitemap here would say "this address is a site" to a
|
|
956
|
+
// crawler that has just been told the opposite by every other procedure.
|
|
957
|
+
if (!nodes[0])
|
|
958
|
+
throw noSite();
|
|
959
|
+
return {
|
|
960
|
+
entries: nodes.map((node) => ({
|
|
961
|
+
path: node.path,
|
|
962
|
+
lastModified: node.published_at.toISOString(),
|
|
963
|
+
})),
|
|
964
|
+
};
|
|
965
|
+
})),
|
|
966
|
+
robots: open.public.robots.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
967
|
+
/*
|
|
968
|
+
* The one place a missing publication is not an error: a crawler asking about a slug
|
|
969
|
+
* nobody has taken, one that has expired, and one behind a password have to get the same
|
|
970
|
+
* answer, or this becomes the oracle every other procedure refuses to be.
|
|
971
|
+
*
|
|
972
|
+
* There is a **fourth** state and this comment used to list three. A publication whose
|
|
973
|
+
* root page has since been trashed, archived, opted out or unpublished still has its row:
|
|
974
|
+
* `site`, `page`, `search` and `sitemap` all answer 404 for it, and robots answered
|
|
975
|
+
* `indexable: true` with a sitemap path — so the one procedure written to never
|
|
976
|
+
* distinguish anything was the only one admitting that the slug existed. It is the walk
|
|
977
|
+
* rather than the row that decides, which is why the tree is read here.
|
|
978
|
+
*/
|
|
979
|
+
const pub = await svc.publications.bySlug(tx, input.workspaceId, input.slug).catch(() => null);
|
|
980
|
+
if (!pub || pub.passwordHash || !pub.indexable)
|
|
981
|
+
return { indexable: false, sitemapPath: null };
|
|
982
|
+
const nodes = await svc.publications.tree(tx, input.workspaceId, pub);
|
|
983
|
+
if (!nodes[0])
|
|
984
|
+
return { indexable: false, sitemapPath: null };
|
|
985
|
+
// Relative to whatever prefix the route layer serves this site under; the module has no
|
|
986
|
+
// way to know that, and guessing would put a wrong absolute URL in a robots file.
|
|
987
|
+
return { indexable: true, sitemapPath: 'sitemap.xml' };
|
|
988
|
+
})),
|
|
989
|
+
/**
|
|
990
|
+
* The bytes of one picture on a published page.
|
|
991
|
+
*
|
|
992
|
+
* It goes through `servable` like every other read, so a picture is behind the same door, the
|
|
993
|
+
* same expiry and the same walk as the prose it sits in — and `svc.publications.asset` then
|
|
994
|
+
* insists the file is referenced by a version *in this tree* before it fetches anything. That
|
|
995
|
+
* second check is what stops a reference lifted out of one published site from resolving
|
|
996
|
+
* against another in the same workspace.
|
|
997
|
+
*/
|
|
998
|
+
asset: open.public.asset.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
999
|
+
const { nodes } = await servable(tx, input.workspaceId, input.slug, input.token);
|
|
1000
|
+
return svc.publications.asset(tx, input.workspaceId, nodes, input.asset);
|
|
1001
|
+
})),
|
|
1002
|
+
unlock: open.public.unlock.handler(({ input }) => svc.publications.read(input.workspaceId, async (tx) => {
|
|
1003
|
+
const pub = await svc.publications.bySlug(tx, input.workspaceId, input.slug);
|
|
1004
|
+
// A site with no password has no door to open, and saying so would confirm the slug
|
|
1005
|
+
// exists to somebody who has not been told anything else about it.
|
|
1006
|
+
if (!pub.passwordHash)
|
|
1007
|
+
throw new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
1008
|
+
if (!(await svc.publications.checkPassword(pub, input.password)))
|
|
1009
|
+
throw new KernError('UNAUTHORIZED', 'That password does not open this site');
|
|
1010
|
+
return svc.publications.mintToken(pub);
|
|
1011
|
+
})),
|
|
1012
|
+
},
|
|
692
1013
|
});
|
|
693
1014
|
}
|
|
1015
|
+
/** The path from the front page down to this page's parent, for a breadcrumb trail. */
|
|
1016
|
+
function trailTo(nodes, node) {
|
|
1017
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
1018
|
+
const trail = [];
|
|
1019
|
+
const seen = new Set([node.id]);
|
|
1020
|
+
let at = node.parent_id === null ? null : (byId.get(node.parent_id) ?? null);
|
|
1021
|
+
while (at && !seen.has(at.id)) {
|
|
1022
|
+
seen.add(at.id);
|
|
1023
|
+
trail.push({ path: at.path, title: at.title || 'Untitled' });
|
|
1024
|
+
at = at.parent_id === null ? null : (byId.get(at.parent_id) ?? null);
|
|
1025
|
+
}
|
|
1026
|
+
return trail.reverse();
|
|
1027
|
+
}
|
|
694
1028
|
//# sourceMappingURL=_impl.js.map
|