@glw907/cairn-cms 0.40.0 → 0.50.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/CHANGELOG.md +76 -0
- package/README.md +3 -3
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +18 -10
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +47 -19
- package/dist/components/EditPage.svelte.d.ts +4 -9
- package/dist/components/EditorToolbar.svelte +4 -0
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +2 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -0
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +1 -1
- package/dist/env.js +3 -2
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +34 -14
- package/dist/sveltekit/content-routes.js +78 -44
- package/dist/sveltekit/guard.js +15 -3
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +19 -10
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/package.json +10 -4
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +18 -10
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +47 -19
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +2 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -0
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +3 -2
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/github/types.ts +5 -0
- package/src/lib/log/events.ts +2 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +121 -55
- package/src/lib/sveltekit/guard.ts +16 -3
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +21 -11
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Result constructors, so a check body reads one outcome per line instead of object literals. */
|
|
2
|
+
export function pass(detail) {
|
|
3
|
+
return { status: 'pass', detail };
|
|
4
|
+
}
|
|
5
|
+
export function fail(detail) {
|
|
6
|
+
return { status: 'fail', detail };
|
|
7
|
+
}
|
|
8
|
+
export function skip(detail) {
|
|
9
|
+
return { status: 'skip', detail };
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DoctorContext } from './types.js';
|
|
2
|
+
export interface WranglerFacts {
|
|
3
|
+
/** A send_email binding named EMAIL is declared. */
|
|
4
|
+
hasEmailBinding: boolean;
|
|
5
|
+
/** A d1_databases binding named AUTH_DB is declared. */
|
|
6
|
+
hasAuthDb: boolean;
|
|
7
|
+
/** The AUTH_DB database_id, when declared; the D1 check queries it. */
|
|
8
|
+
authDbId?: string;
|
|
9
|
+
/** observability.enabled is true. */
|
|
10
|
+
observabilityEnabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function readWranglerConfig(readFile: DoctorContext['readFile']): Promise<WranglerFacts | null>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export async function readWranglerConfig(readFile) {
|
|
2
|
+
const jsonc = await readFile('wrangler.jsonc');
|
|
3
|
+
if (jsonc !== null)
|
|
4
|
+
return factsFromJsonc(jsonc);
|
|
5
|
+
const toml = await readFile('wrangler.toml');
|
|
6
|
+
if (toml !== null)
|
|
7
|
+
return factsFromToml(toml);
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
// Strip // and /* */ comments outside string literals, character by character, so a URL
|
|
11
|
+
// inside a string survives. Trailing commas go by regex afterward; a string containing
|
|
12
|
+
// ",}" would be mangled, an accepted gap in a tolerant reader.
|
|
13
|
+
function stripJsonc(text) {
|
|
14
|
+
let out = '';
|
|
15
|
+
let inString = false;
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < text.length) {
|
|
18
|
+
const ch = text[i];
|
|
19
|
+
if (inString) {
|
|
20
|
+
out += ch;
|
|
21
|
+
if (ch === '\\') {
|
|
22
|
+
out += text[i + 1] ?? '';
|
|
23
|
+
i += 2;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (ch === '"')
|
|
27
|
+
inString = false;
|
|
28
|
+
i += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (ch === '"') {
|
|
32
|
+
inString = true;
|
|
33
|
+
out += ch;
|
|
34
|
+
i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
38
|
+
const end = text.indexOf('\n', i);
|
|
39
|
+
i = end === -1 ? text.length : end;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
43
|
+
const end = text.indexOf('*/', i + 2);
|
|
44
|
+
i = end === -1 ? text.length : end + 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out += ch;
|
|
48
|
+
i += 1;
|
|
49
|
+
}
|
|
50
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
51
|
+
}
|
|
52
|
+
function factsFromJsonc(text) {
|
|
53
|
+
let config;
|
|
54
|
+
try {
|
|
55
|
+
config = JSON.parse(stripJsonc(text));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// V8's SyntaxError embeds a source snippet, which would land verbatim in the report;
|
|
59
|
+
// a file that exists but does not parse is a fail with a clean message instead.
|
|
60
|
+
throw new Error('wrangler.jsonc did not parse');
|
|
61
|
+
}
|
|
62
|
+
const sendEmail = Array.isArray(config.send_email) ? config.send_email : [];
|
|
63
|
+
const hasEmailBinding = sendEmail.some((entry) => typeof entry === 'object' && entry !== null && entry.name === 'EMAIL');
|
|
64
|
+
const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
|
|
65
|
+
const authDb = databases.find((entry) => typeof entry === 'object' && entry !== null && entry.binding === 'AUTH_DB');
|
|
66
|
+
const observability = config.observability;
|
|
67
|
+
const facts = {
|
|
68
|
+
hasEmailBinding,
|
|
69
|
+
hasAuthDb: authDb !== undefined,
|
|
70
|
+
observabilityEnabled: observability?.enabled === true,
|
|
71
|
+
};
|
|
72
|
+
if (typeof authDb?.database_id === 'string')
|
|
73
|
+
facts.authDbId = authDb.database_id;
|
|
74
|
+
return facts;
|
|
75
|
+
}
|
|
76
|
+
// The toml read is deliberately shallow: line-anchored matching for the three facts, not a
|
|
77
|
+
// TOML parser. The remediation tells the operator exactly what to add, so full fidelity
|
|
78
|
+
// buys nothing here. A table header opens a section; the relevant key lines are matched
|
|
79
|
+
// within it and the d1 table flushes on the next header.
|
|
80
|
+
function factsFromToml(text) {
|
|
81
|
+
const facts = {
|
|
82
|
+
hasEmailBinding: false,
|
|
83
|
+
hasAuthDb: false,
|
|
84
|
+
observabilityEnabled: false,
|
|
85
|
+
};
|
|
86
|
+
let section = '';
|
|
87
|
+
let d1Binding;
|
|
88
|
+
let d1Id;
|
|
89
|
+
const flushD1 = () => {
|
|
90
|
+
if (d1Binding === 'AUTH_DB') {
|
|
91
|
+
facts.hasAuthDb = true;
|
|
92
|
+
if (d1Id !== undefined)
|
|
93
|
+
facts.authDbId = d1Id;
|
|
94
|
+
}
|
|
95
|
+
d1Binding = undefined;
|
|
96
|
+
d1Id = undefined;
|
|
97
|
+
};
|
|
98
|
+
for (const line of text.split('\n')) {
|
|
99
|
+
const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
|
|
100
|
+
if (header) {
|
|
101
|
+
flushD1();
|
|
102
|
+
section = header[1];
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const kv = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
|
|
106
|
+
if (!kv)
|
|
107
|
+
continue;
|
|
108
|
+
const [, key, value] = kv;
|
|
109
|
+
const str = value.match(/^["'](.*)["']/)?.[1];
|
|
110
|
+
if (section === '[[send_email]]' && key === 'name' && str === 'EMAIL') {
|
|
111
|
+
facts.hasEmailBinding = true;
|
|
112
|
+
}
|
|
113
|
+
else if (section === '[[d1_databases]]') {
|
|
114
|
+
if (key === 'binding')
|
|
115
|
+
d1Binding = str;
|
|
116
|
+
if (key === 'database_id')
|
|
117
|
+
d1Id = str;
|
|
118
|
+
}
|
|
119
|
+
else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
|
|
120
|
+
facts.observabilityEnabled = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flushD1();
|
|
124
|
+
return facts;
|
|
125
|
+
}
|
package/dist/email.js
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { CairnError } from './diagnostics/index.js';
|
|
2
|
-
|
|
3
|
-
function escapeHtml(value) {
|
|
4
|
-
return value
|
|
5
|
-
.replaceAll('&', '&')
|
|
6
|
-
.replaceAll('<', '<')
|
|
7
|
-
.replaceAll('>', '>')
|
|
8
|
-
.replaceAll('"', '"')
|
|
9
|
-
.replaceAll("'", ''');
|
|
10
|
-
}
|
|
2
|
+
import { escapeHtml } from './escape.js';
|
|
11
3
|
/** Build the confirmation email. The link is the only action; the copy stays plain. */
|
|
12
4
|
export function buildMagicLinkMessage(input) {
|
|
13
5
|
const { to, branding, link } = input;
|
|
@@ -20,8 +12,9 @@ export function buildMagicLinkMessage(input) {
|
|
|
20
12
|
}
|
|
21
13
|
/** The production send: Cloudflare Email Sending through the EMAIL binding. */
|
|
22
14
|
export const cloudflareSend = async (env, message) => {
|
|
23
|
-
if (!env.EMAIL)
|
|
24
|
-
throw new
|
|
15
|
+
if (!env.EMAIL) {
|
|
16
|
+
throw new CairnError('config.bindings-missing', { message: 'EMAIL binding is not configured' });
|
|
17
|
+
}
|
|
25
18
|
await env.EMAIL.send(message);
|
|
26
19
|
};
|
|
27
20
|
/**
|
package/dist/env.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function requireOrigin(env: {
|
|
|
16
16
|
* The handlers read D1 off `event.platform.env`; without this a misconfigured binding
|
|
17
17
|
* surfaces as a raw `TypeError` deep in a store call. This gives the failure a name.
|
|
18
18
|
*
|
|
19
|
-
* @throws
|
|
19
|
+
* @throws CairnError (`config.bindings-missing`) when `AUTH_DB` is missing.
|
|
20
20
|
*/
|
|
21
21
|
export declare function requireDb(env: {
|
|
22
22
|
AUTH_DB?: D1Database;
|
package/dist/env.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CairnError } from './diagnostics/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* Returns the site's public origin from configuration.
|
|
3
4
|
*
|
|
@@ -33,11 +34,11 @@ export function requireOrigin(env) {
|
|
|
33
34
|
* The handlers read D1 off `event.platform.env`; without this a misconfigured binding
|
|
34
35
|
* surfaces as a raw `TypeError` deep in a store call. This gives the failure a name.
|
|
35
36
|
*
|
|
36
|
-
* @throws
|
|
37
|
+
* @throws CairnError (`config.bindings-missing`) when `AUTH_DB` is missing.
|
|
37
38
|
*/
|
|
38
39
|
export function requireDb(env) {
|
|
39
40
|
if (!env.AUTH_DB) {
|
|
40
|
-
throw new
|
|
41
|
+
throw new CairnError('config.bindings-missing', { message: 'AUTH_DB binding is not configured' });
|
|
41
42
|
}
|
|
42
43
|
return env.AUTH_DB;
|
|
43
44
|
}
|
package/dist/escape.d.ts
ADDED
package/dist/escape.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// cairn-cms: the one HTML text escape. A leaf module with no imports, so the email builder and
|
|
2
|
+
// the edge-served admin pages share it without either arm reaching into the other.
|
|
3
|
+
/** Escape the five HTML-significant characters for text and quoted attribute values. */
|
|
4
|
+
export function escapeHtml(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replaceAll('&', '&')
|
|
7
|
+
.replaceAll('<', '<')
|
|
8
|
+
.replaceAll('>', '>')
|
|
9
|
+
.replaceAll('"', '"')
|
|
10
|
+
.replaceAll("'", ''');
|
|
11
|
+
}
|
|
@@ -6,6 +6,7 @@ export interface GithubKeyEnv {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
|
|
9
|
-
* installation) and the Worker's private-key secret. Throws
|
|
9
|
+
* installation) and the Worker's private-key secret. Throws a CairnError naming
|
|
10
|
+
* `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
|
|
10
11
|
*/
|
|
11
12
|
export declare function appCredentials(backend: Pick<BackendConfig, 'appId' | 'installationId'>, env: GithubKeyEnv): AppCredentials;
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
// cairn-cms: the bridge from the adapter's backend config and the Worker's secret to the
|
|
2
|
+
// App signer's input. One tested place owns the join and the missing-secret failure, so the
|
|
3
|
+
// save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
|
|
4
|
+
// TypeError. Mirrors requireDb/requireOrigin in env.ts.
|
|
5
|
+
import { CairnError } from '../diagnostics/index.js';
|
|
1
6
|
/**
|
|
2
7
|
* Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
|
|
3
|
-
* installation) and the Worker's private-key secret. Throws
|
|
8
|
+
* installation) and the Worker's private-key secret. Throws a CairnError naming
|
|
9
|
+
* `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
|
|
4
10
|
*/
|
|
5
11
|
export function appCredentials(backend, env) {
|
|
6
12
|
const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
|
|
7
13
|
if (!privateKeyB64) {
|
|
8
|
-
throw new
|
|
14
|
+
throw new CairnError('github.app-unreachable', {
|
|
15
|
+
message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
|
|
16
|
+
});
|
|
9
17
|
}
|
|
10
18
|
return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
|
|
11
19
|
}
|
package/dist/github/signing.d.ts
CHANGED
|
@@ -9,7 +9,9 @@ export declare function installationToken(creds: AppCredentials): Promise<string
|
|
|
9
9
|
* instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
|
|
10
10
|
* which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
|
|
11
11
|
* tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
|
|
12
|
-
* lifetime, so a fixed margin avoids parsing the API expiry.
|
|
12
|
+
* lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
|
|
13
|
+
* promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
|
|
14
|
+
* a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
|
|
13
15
|
* cache is testable with no network call and no real clock.
|
|
14
16
|
*/
|
|
15
17
|
export declare function createInstallationTokenCache(mint?: (creds: AppCredentials) => Promise<string>, now?: () => number, ttlMs?: number): (creds: AppCredentials) => Promise<string>;
|
package/dist/github/signing.js
CHANGED
|
@@ -65,18 +65,26 @@ export async function installationToken(creds) {
|
|
|
65
65
|
* instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
|
|
66
66
|
* which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
|
|
67
67
|
* tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
|
|
68
|
-
* lifetime, so a fixed margin avoids parsing the API expiry.
|
|
68
|
+
* lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
|
|
69
|
+
* promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
|
|
70
|
+
* a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
|
|
69
71
|
* cache is testable with no network call and no real clock.
|
|
70
72
|
*/
|
|
71
73
|
export function createInstallationTokenCache(mint = installationToken, now = () => Date.now(), ttlMs = 55 * 60 * 1000) {
|
|
72
74
|
const cache = new Map();
|
|
73
|
-
return
|
|
75
|
+
return function get(creds) {
|
|
74
76
|
const hit = cache.get(creds.installationId);
|
|
75
77
|
if (hit && hit.expiresAt > now())
|
|
76
78
|
return hit.token;
|
|
77
|
-
const
|
|
78
|
-
cache.set(creds.installationId,
|
|
79
|
-
|
|
79
|
+
const entry = { token: mint(creds), expiresAt: now() + ttlMs };
|
|
80
|
+
cache.set(creds.installationId, entry);
|
|
81
|
+
// Evict only this entry on rejection: a newer entry that replaced it must survive. The
|
|
82
|
+
// caller's await surfaces the rejection itself, so this side handler swallows nothing.
|
|
83
|
+
entry.token.catch(() => {
|
|
84
|
+
if (cache.get(creds.installationId) === entry)
|
|
85
|
+
cache.delete(creds.installationId);
|
|
86
|
+
});
|
|
87
|
+
return entry.token;
|
|
80
88
|
};
|
|
81
89
|
}
|
|
82
90
|
/** The shared installation-token cache, one instance per Worker isolate. */
|
package/dist/github/types.d.ts
CHANGED
|
@@ -32,3 +32,5 @@ export declare class CommitConflictError extends Error {
|
|
|
32
32
|
readonly path: string;
|
|
33
33
|
constructor(path: string);
|
|
34
34
|
}
|
|
35
|
+
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
36
|
+
export declare function isConflict(err: unknown): boolean;
|
package/dist/github/types.js
CHANGED
|
@@ -16,3 +16,7 @@ export class CommitConflictError extends Error {
|
|
|
16
16
|
this.name = 'CommitConflictError';
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
20
|
+
export function isConflict(err) {
|
|
21
|
+
return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
|
|
22
|
+
}
|
package/dist/log/events.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'guard.rejected';
|
|
1
|
+
export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';
|
|
@@ -37,6 +37,8 @@ export interface SiteConfig {
|
|
|
37
37
|
[key: string]: unknown;
|
|
38
38
|
}
|
|
39
39
|
export declare class SiteConfigError extends Error {
|
|
40
|
+
/** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
|
|
41
|
+
readonly conditionId = "config.site-config-invalid";
|
|
40
42
|
constructor(message: string);
|
|
41
43
|
}
|
|
42
44
|
/** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
|
package/dist/nav/site-config.js
CHANGED
|
@@ -60,6 +60,8 @@ export function validateNavTree(value, maxDepth) {
|
|
|
60
60
|
return walk(value, 1);
|
|
61
61
|
}
|
|
62
62
|
export class SiteConfigError extends Error {
|
|
63
|
+
/** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
|
|
64
|
+
conditionId = 'config.site-config-invalid';
|
|
63
65
|
constructor(message) {
|
|
64
66
|
super(message);
|
|
65
67
|
this.name = 'SiteConfigError';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
2
|
+
/** The views the single-mount admin can render, discriminated for the dispatcher's switch. */
|
|
3
|
+
export type AdminView = {
|
|
4
|
+
view: 'index';
|
|
5
|
+
} | {
|
|
6
|
+
view: 'login';
|
|
7
|
+
} | {
|
|
8
|
+
view: 'confirm';
|
|
9
|
+
} | {
|
|
10
|
+
view: 'list';
|
|
11
|
+
concept: ConceptDescriptor;
|
|
12
|
+
} | {
|
|
13
|
+
view: 'edit';
|
|
14
|
+
concept: ConceptDescriptor;
|
|
15
|
+
id: string;
|
|
16
|
+
} | {
|
|
17
|
+
view: 'editors';
|
|
18
|
+
} | {
|
|
19
|
+
view: 'nav';
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
|
|
23
|
+
* param) into the admin view it names. A single trailing slash is tolerated everywhere; empty
|
|
24
|
+
* internal segments are not. Each segment is percent-decoded individually, so an encoded slash
|
|
25
|
+
* stays inside its segment, where it can never match a concept id or pass `isValidId` and so
|
|
26
|
+
* falls through to null.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseAdminPath(pathname: string, concepts: ConceptDescriptor[]): AdminView | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { findConcept } from '../content/concepts.js';
|
|
2
|
+
import { isValidId } from '../content/ids.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fixed first segments that never resolve as concepts. The engine only allows posts and pages
|
|
5
|
+
* today, so no collision is possible, but the parser does not depend on that: a reserved
|
|
6
|
+
* segment wins before concept lookup. `settings` has no view yet; AdminLayout already links
|
|
7
|
+
* the sidebar to /admin/settings, so the URL is spoken for.
|
|
8
|
+
*/
|
|
9
|
+
const RESERVED_SEGMENTS = new Set(['login', 'auth', 'editors', 'nav', 'settings']);
|
|
10
|
+
/**
|
|
11
|
+
* Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
|
|
12
|
+
* param) into the admin view it names. A single trailing slash is tolerated everywhere; empty
|
|
13
|
+
* internal segments are not. Each segment is percent-decoded individually, so an encoded slash
|
|
14
|
+
* stays inside its segment, where it can never match a concept id or pass `isValidId` and so
|
|
15
|
+
* falls through to null.
|
|
16
|
+
*/
|
|
17
|
+
export function parseAdminPath(pathname, concepts) {
|
|
18
|
+
if (pathname !== '/admin' && !pathname.startsWith('/admin/'))
|
|
19
|
+
return null;
|
|
20
|
+
let rest = pathname.slice('/admin'.length);
|
|
21
|
+
// Tolerate exactly one trailing slash; a doubled one leaves an empty segment behind.
|
|
22
|
+
if (rest.endsWith('/'))
|
|
23
|
+
rest = rest.slice(0, -1);
|
|
24
|
+
if (rest === '')
|
|
25
|
+
return { view: 'index' };
|
|
26
|
+
const rawSegments = rest.slice(1).split('/');
|
|
27
|
+
if (rawSegments.includes(''))
|
|
28
|
+
return null;
|
|
29
|
+
let segments;
|
|
30
|
+
try {
|
|
31
|
+
segments = rawSegments.map((segment) => decodeURIComponent(segment));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Malformed percent encoding is an unrecognized shape, not a server error.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (segments.length === 1) {
|
|
38
|
+
const [head] = segments;
|
|
39
|
+
if (head === 'login')
|
|
40
|
+
return { view: 'login' };
|
|
41
|
+
if (head === 'editors')
|
|
42
|
+
return { view: 'editors' };
|
|
43
|
+
if (head === 'nav')
|
|
44
|
+
return { view: 'nav' };
|
|
45
|
+
if (RESERVED_SEGMENTS.has(head))
|
|
46
|
+
return null;
|
|
47
|
+
const concept = findConcept(concepts, head);
|
|
48
|
+
return concept ? { view: 'list', concept } : null;
|
|
49
|
+
}
|
|
50
|
+
if (segments.length === 2) {
|
|
51
|
+
const [head, tail] = segments;
|
|
52
|
+
if (head === 'auth')
|
|
53
|
+
return tail === 'confirm' ? { view: 'confirm' } : null;
|
|
54
|
+
if (RESERVED_SEGMENTS.has(head))
|
|
55
|
+
return null;
|
|
56
|
+
const concept = findConcept(concepts, head);
|
|
57
|
+
if (!concept || !isValidId(tail))
|
|
58
|
+
return null;
|
|
59
|
+
return { view: 'edit', concept, id: tail };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData } from './content-routes.js';
|
|
2
|
+
import { type NavLoadData } from './nav-routes.js';
|
|
3
|
+
import type { AuthBranding, SendMagicLink } from '../email.js';
|
|
4
|
+
import type { AuthEnv, Editor } from '../auth/types.js';
|
|
5
|
+
import type { GithubKeyEnv } from '../github/credentials.js';
|
|
6
|
+
import type { CairnRuntime } from '../content/types.js';
|
|
7
|
+
import type { CookieJar, EventBase } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* The structural event the single-mount load reads: the union of what the wrapped loads need
|
|
10
|
+
* (ContentEvent minus params, which the dispatcher synthesizes, plus RequestContext's cookies
|
|
11
|
+
* and setHeaders). A real SvelteKit RequestEvent satisfies it.
|
|
12
|
+
*/
|
|
13
|
+
export interface AdminEvent extends EventBase<GithubKeyEnv & AuthEnv> {
|
|
14
|
+
cookies: CookieJar;
|
|
15
|
+
setHeaders(headers: Record<string, string>): void;
|
|
16
|
+
}
|
|
17
|
+
/** Injectable dependencies. Branding defaults from the runtime's siteName and sender, so a
|
|
18
|
+
* site overrides it only to change the magic-link email identity; `send` and `mintToken`
|
|
19
|
+
* are the same seams the underlying factories take. */
|
|
20
|
+
export interface CairnAdminDeps {
|
|
21
|
+
branding?: AuthBranding;
|
|
22
|
+
send?: SendMagicLink;
|
|
23
|
+
mintToken?: ContentRoutesDeps['mintToken'];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One admin view's data, discriminated for the admin page component's switch. The public
|
|
27
|
+
* views (login, confirm) carry no layout; every authed view pairs the shared layout with its
|
|
28
|
+
* page data, the same shapes the per-surface loads have always returned.
|
|
29
|
+
*/
|
|
30
|
+
export type AdminData = {
|
|
31
|
+
view: 'login';
|
|
32
|
+
page: {
|
|
33
|
+
siteName: string;
|
|
34
|
+
error: string | null;
|
|
35
|
+
csrf: string;
|
|
36
|
+
};
|
|
37
|
+
} | {
|
|
38
|
+
view: 'confirm';
|
|
39
|
+
page: {
|
|
40
|
+
token: string;
|
|
41
|
+
siteName: string;
|
|
42
|
+
error: string | null;
|
|
43
|
+
csrf: string;
|
|
44
|
+
};
|
|
45
|
+
} | {
|
|
46
|
+
view: 'list';
|
|
47
|
+
layout: LayoutData;
|
|
48
|
+
page: ListData;
|
|
49
|
+
} | {
|
|
50
|
+
view: 'edit';
|
|
51
|
+
layout: LayoutData;
|
|
52
|
+
page: EditData;
|
|
53
|
+
} | {
|
|
54
|
+
view: 'editors';
|
|
55
|
+
layout: LayoutData;
|
|
56
|
+
page: {
|
|
57
|
+
editors: Editor[];
|
|
58
|
+
self: string;
|
|
59
|
+
};
|
|
60
|
+
} | {
|
|
61
|
+
view: 'nav';
|
|
62
|
+
layout: LayoutData;
|
|
63
|
+
page: NavLoadData;
|
|
64
|
+
};
|
|
65
|
+
export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdminDeps): {
|
|
66
|
+
load: (event: AdminEvent) => Promise<AdminData>;
|
|
67
|
+
actions: {
|
|
68
|
+
request: (event: AdminEvent) => Promise<import("./auth-routes.js").RequestResult>;
|
|
69
|
+
confirm: (event: AdminEvent) => Promise<never>;
|
|
70
|
+
logout: (event: AdminEvent) => Promise<never>;
|
|
71
|
+
create: (event: AdminEvent) => Promise<never>;
|
|
72
|
+
save: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
73
|
+
publish: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
74
|
+
discard: (event: AdminEvent) => Promise<never>;
|
|
75
|
+
rename: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
76
|
+
delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
77
|
+
publishAll: (event: AdminEvent) => Promise<never>;
|
|
78
|
+
addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
|
|
79
|
+
error: string;
|
|
80
|
+
}> | {
|
|
81
|
+
ok: true;
|
|
82
|
+
}>;
|
|
83
|
+
removeEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
|
|
84
|
+
error: string;
|
|
85
|
+
}> | {
|
|
86
|
+
ok: true;
|
|
87
|
+
}>;
|
|
88
|
+
setRole: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
|
|
89
|
+
error: string;
|
|
90
|
+
}> | {
|
|
91
|
+
ok: true;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The single-mount admin facade. One factory closes over the composed runtime, instantiates
|
|
2
|
+
// the existing per-surface route factories (auth, content, editors, nav), and serves every
|
|
3
|
+
// admin view through the one load and one actions record a site's catch-all /admin/[...path]
|
|
4
|
+
// route exports. The path authority is admin-dispatch's parseAdminPath; this module only maps
|
|
5
|
+
// each view to the wrapped load it delegates to, and each named action validates that the
|
|
6
|
+
// parsed view supports it before delegating to the same wrapped factories.
|
|
7
|
+
import { error } from '@sveltejs/kit';
|
|
8
|
+
import { parseAdminPath } from './admin-dispatch.js';
|
|
9
|
+
import { createAuthRoutes } from './auth-routes.js';
|
|
10
|
+
import { createContentRoutes, } from './content-routes.js';
|
|
11
|
+
import { createEditorRoutes } from './editors-routes.js';
|
|
12
|
+
import { createNavRoutes } from './nav-routes.js';
|
|
13
|
+
export function createCairnAdmin(runtime, deps = {}) {
|
|
14
|
+
// The runtime already composes the site name and the sender identity, so the magic-link
|
|
15
|
+
// branding needs no second copy of either unless a site overrides it.
|
|
16
|
+
const branding = deps.branding ?? {
|
|
17
|
+
siteName: runtime.siteName,
|
|
18
|
+
from: runtime.sender.from,
|
|
19
|
+
replyTo: runtime.sender.replyTo,
|
|
20
|
+
};
|
|
21
|
+
const auth = createAuthRoutes({ branding, send: deps.send });
|
|
22
|
+
const content = createContentRoutes(runtime, { mintToken: deps.mintToken });
|
|
23
|
+
const editors = createEditorRoutes();
|
|
24
|
+
// The nav surface exists only when the site configures a menu; without one its view is a 404.
|
|
25
|
+
const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
|
|
26
|
+
/** Build the event a wrapped content load reads. The catch-all route carries only a rest
|
|
27
|
+
* param, so `concept` and `id` are synthesized from the parsed view. The override names
|
|
28
|
+
* each field explicitly rather than spreading: a real RequestEvent's fields can sit behind
|
|
29
|
+
* getters a bare spread copies poorly, and the structural ContentEvent contract needs only
|
|
30
|
+
* these. */
|
|
31
|
+
function contentEvent(event, params) {
|
|
32
|
+
return {
|
|
33
|
+
url: event.url,
|
|
34
|
+
params,
|
|
35
|
+
request: event.request,
|
|
36
|
+
locals: event.locals,
|
|
37
|
+
platform: event.platform,
|
|
38
|
+
cookies: event.cookies,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Serve the admin view the pathname names, or a 404 for any shape the parser refuses.
|
|
42
|
+
* The authed views run the layout load and the view load concurrently; both mint a GitHub
|
|
43
|
+
* token, and the installation-token cache coalesces the mints into one signing. */
|
|
44
|
+
async function load(event) {
|
|
45
|
+
const view = parseAdminPath(event.url.pathname, runtime.concepts);
|
|
46
|
+
if (!view)
|
|
47
|
+
throw error(404, 'Not found');
|
|
48
|
+
switch (view.view) {
|
|
49
|
+
case 'index':
|
|
50
|
+
return content.indexRedirect();
|
|
51
|
+
case 'login':
|
|
52
|
+
return { view: 'login', page: auth.loginLoad(event) };
|
|
53
|
+
case 'confirm':
|
|
54
|
+
return { view: 'confirm', page: auth.confirmLoad(event) };
|
|
55
|
+
case 'list': {
|
|
56
|
+
const delegated = contentEvent(event, { concept: view.concept.id });
|
|
57
|
+
const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.listLoad(delegated)]);
|
|
58
|
+
return { view: 'list', layout, page };
|
|
59
|
+
}
|
|
60
|
+
case 'edit': {
|
|
61
|
+
const delegated = contentEvent(event, { concept: view.concept.id, id: view.id });
|
|
62
|
+
const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.editLoad(delegated)]);
|
|
63
|
+
return { view: 'edit', layout, page };
|
|
64
|
+
}
|
|
65
|
+
case 'editors': {
|
|
66
|
+
// editorsLoad gates itself with requireOwner, so the dispatcher adds no second gate.
|
|
67
|
+
const [layout, page] = await Promise.all([
|
|
68
|
+
content.layoutLoad(contentEvent(event, {})),
|
|
69
|
+
editors.editorsLoad(event),
|
|
70
|
+
]);
|
|
71
|
+
return { view: 'editors', layout, page };
|
|
72
|
+
}
|
|
73
|
+
case 'nav': {
|
|
74
|
+
if (!nav)
|
|
75
|
+
throw error(404, 'Not found');
|
|
76
|
+
const delegated = contentEvent(event, {});
|
|
77
|
+
const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
|
|
78
|
+
return { view: 'nav', layout, page };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
|
|
83
|
+
* as load does, 404 on a null parse or a view outside the allowed set, then hand the
|
|
84
|
+
* narrowed view to the delegate. */
|
|
85
|
+
function viewAction(allowed, delegate) {
|
|
86
|
+
return async (event) => {
|
|
87
|
+
const view = parseAdminPath(event.url.pathname, runtime.concepts);
|
|
88
|
+
if (!view || !allowed.includes(view.view))
|
|
89
|
+
throw error(404, 'Not found');
|
|
90
|
+
// The includes check above proves the membership the cast asserts.
|
|
91
|
+
return delegate(event, view);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// The topbar posts publishAll from every authed admin page; login and confirm may not.
|
|
95
|
+
const authedViews = ['list', 'edit', 'editors', 'nav'];
|
|
96
|
+
// An editor signs out from wherever they are, so logout accepts any parsed view.
|
|
97
|
+
const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'];
|
|
98
|
+
/** The full admin action vocabulary, one named async function per action, so a site's
|
|
99
|
+
* catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
|
|
100
|
+
* validate the view, synthesize the params the wrapped action reads, delegate. The
|
|
101
|
+
* editor actions gate themselves with requireOwner, so no second gate is added here. */
|
|
102
|
+
const actions = {
|
|
103
|
+
request: viewAction(['login'], (event) => auth.requestAction(event)),
|
|
104
|
+
confirm: viewAction(['confirm'], (event) => auth.confirmAction(event)),
|
|
105
|
+
logout: viewAction(anyView, (event) => auth.logoutAction(event)),
|
|
106
|
+
create: viewAction(['list'], (event, view) => content.createAction(contentEvent(event, { concept: view.concept.id }))),
|
|
107
|
+
save: viewAction(['edit', 'nav'], (event, view) => {
|
|
108
|
+
if (view.view === 'edit')
|
|
109
|
+
return content.saveAction(contentEvent(event, { concept: view.concept.id, id: view.id }));
|
|
110
|
+
if (!nav)
|
|
111
|
+
throw error(404, 'Not found');
|
|
112
|
+
return nav.navSave(contentEvent(event, {}));
|
|
113
|
+
}),
|
|
114
|
+
publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
115
|
+
discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
116
|
+
rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
117
|
+
delete: viewAction(['edit', 'list'], (event, view) => view.view === 'edit'
|
|
118
|
+
? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
|
|
119
|
+
: content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
|
|
120
|
+
publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
|
|
121
|
+
addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
|
|
122
|
+
removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
|
|
123
|
+
setRole: viewAction(['editors'], (event) => editors.setRoleAction(event)),
|
|
124
|
+
};
|
|
125
|
+
return { load, actions };
|
|
126
|
+
}
|