@cosmicdrift/kumiko-samples 0.401.0 → 0.402.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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +177 -42
- package/packages/bundled-features/src/agent-tools/changes.json +19 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +8 -15
- package/packages/bundled-features/src/agent-tools/types.ts +11 -10
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +3 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +5 -5
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +5 -7
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +9 -1
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +66 -12
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -2
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +2 -19
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +2 -2
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +0 -1
- package/packages/bundled-features/src/readiness/changes.json +6 -0
- package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +2 -1
- package/packages/bundled-features/src/readiness/run-view.ts +4 -5
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +2 -2
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +18 -1
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -2
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +41 -33
- package/packages/bundled-features/src/user-data-rights/boot-rules.ts +19 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/web/request-deletion-screen.tsx +20 -18
- package/samples/apps/use-all-bundled/src/app/collection-labels-feature.ts +3 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
|
@@ -17,11 +17,9 @@
|
|
|
17
17
|
// db,
|
|
18
18
|
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
19
19
|
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
20
|
-
// //
|
|
21
|
-
// //
|
|
22
|
-
// //
|
|
23
|
-
// // for a single-process deployment — every active account gets
|
|
24
|
-
// // watched locally, as before.
|
|
20
|
+
// // One DistributedLock (same Redis, same key prefix) shared across
|
|
21
|
+
// // every worker process, so only one of them holds the IMAP IDLE
|
|
22
|
+
// // connection per account.
|
|
25
23
|
// lock: createDistributedLock(redis, `${RedisKeys.lock}inbound-mail:watch:`),
|
|
26
24
|
// });
|
|
27
25
|
// await supervisor.start();
|
|
@@ -34,7 +32,7 @@
|
|
|
34
32
|
// **Multi-worker coordination:** the reconciliation poll remains deliberately
|
|
35
33
|
// N-fold: every worker polls every active account, which is idempotent and
|
|
36
34
|
// cheap. Only `plugin.watch()` holds a long-lived connection, and only one
|
|
37
|
-
// worker may hold it per account.
|
|
35
|
+
// worker may hold it per account. `ensureWatcher` claims a
|
|
38
36
|
// TTL lease (`lock.acquire`) before connecting; a worker that fails to claim
|
|
39
37
|
// it leaves the account to its current holder, while the poll still covers
|
|
40
38
|
// it. The holder renews the lease via heartbeat (`lock.renew`, every ttl/3)
|
|
@@ -435,7 +433,7 @@ export function createInboundMailSupervisor(
|
|
|
435
433
|
log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
|
|
436
434
|
}
|
|
437
435
|
}
|
|
438
|
-
if (
|
|
436
|
+
if (lockToken) {
|
|
439
437
|
try {
|
|
440
438
|
await deps.lock.release(accountId, lockToken);
|
|
441
439
|
} catch (err) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The public render caches a sanitized document body per page version",
|
|
6
|
+
"detail": "The render cache holds up to 500 entries keyed on tenant, slug, language, version and format. Bodies larger than 256 KB are sanitized on every render and not cached."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.395.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -263,7 +263,15 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
263
263
|
|
|
264
264
|
const html = wrapLayout({
|
|
265
265
|
title: data.title,
|
|
266
|
-
bodyHtml: renderPageBody({
|
|
266
|
+
bodyHtml: renderPageBody({
|
|
267
|
+
tenantId,
|
|
268
|
+
slug,
|
|
269
|
+
lang,
|
|
270
|
+
version: data.version,
|
|
271
|
+
updatedAt: data.updatedAt,
|
|
272
|
+
body: data.body,
|
|
273
|
+
bodyFormat: data.bodyFormat,
|
|
274
|
+
}),
|
|
267
275
|
lang: data.lang,
|
|
268
276
|
slug,
|
|
269
277
|
description: data.description,
|
|
@@ -7,22 +7,76 @@ type WarnLogger = Pick<Logger, "warn">;
|
|
|
7
7
|
|
|
8
8
|
const defaultLog = createFallbackLogger("managed-pages");
|
|
9
9
|
|
|
10
|
+
const MAX_CACHED_BODIES = 500;
|
|
11
|
+
// Bounds the cache memory: 500 entries of arbitrary size could otherwise pin
|
|
12
|
+
// hundreds of MB. A bigger body is still served, just re-rendered per request.
|
|
13
|
+
const MAX_CACHED_BODY_LENGTH = 256 * 1024;
|
|
14
|
+
|
|
10
15
|
export type RenderablePageBody = {
|
|
16
|
+
readonly tenantId: string;
|
|
11
17
|
readonly slug: string;
|
|
18
|
+
readonly lang: string;
|
|
19
|
+
readonly version: number;
|
|
20
|
+
readonly updatedAt: string;
|
|
12
21
|
readonly body: string;
|
|
13
22
|
readonly bodyFormat: BodyFormat;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
export type PageBodyRendererDeps = {
|
|
26
|
+
readonly sanitizeDocument?: typeof sanitizeDocumentHtml;
|
|
27
|
+
readonly renderMarkdown?: (markdown: string) => string;
|
|
28
|
+
readonly maxEntries?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Every write bumps the version, so a (page, version) key never serves stale
|
|
32
|
+
// HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
|
|
33
|
+
// key the least recently used one.
|
|
34
|
+
function cacheKeyOf(page: RenderablePageBody): string {
|
|
35
|
+
return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join(
|
|
36
|
+
"\u0000",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Writes store the rebuilt, sanitized HTML, but rows can predate that validation
|
|
41
|
+
// or arrive through other writers, so a render still sanitizes — once per page
|
|
42
|
+
// version instead of once per request. A rejected document renders as an empty
|
|
43
|
+
// body (never the raw HTML), is logged on the miss, and the empty result is cached.
|
|
44
|
+
export function createPageBodyRenderer(
|
|
45
|
+
deps: PageBodyRendererDeps = {},
|
|
46
|
+
): (page: RenderablePageBody, log?: WarnLogger) => string {
|
|
47
|
+
const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
|
|
48
|
+
const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
|
|
49
|
+
const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
|
|
50
|
+
const cache = new Map<string, string>();
|
|
51
|
+
|
|
52
|
+
function renderUncached(page: RenderablePageBody, log: WarnLogger): string {
|
|
53
|
+
if (page.bodyFormat === "markdown") return renderMarkdown(page.body);
|
|
54
|
+
const result = sanitizeDocument(page.body);
|
|
55
|
+
if (result.ok) return result.html;
|
|
56
|
+
log.warn("managed page document body rejected at render, serving an empty body", {
|
|
57
|
+
slug: page.slug,
|
|
58
|
+
violations: result.violations.map((violation) => violation.code),
|
|
59
|
+
});
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (page, log = defaultLog) => {
|
|
64
|
+
const key = cacheKeyOf(page);
|
|
65
|
+
const cached = cache.get(key);
|
|
66
|
+
if (cached !== undefined) {
|
|
67
|
+
cache.delete(key);
|
|
68
|
+
cache.set(key, cached);
|
|
69
|
+
return cached;
|
|
70
|
+
}
|
|
71
|
+
const html = renderUncached(page, log);
|
|
72
|
+
if (html.length > MAX_CACHED_BODY_LENGTH) return html;
|
|
73
|
+
cache.set(key, html);
|
|
74
|
+
if (cache.size > maxEntries) {
|
|
75
|
+
const oldest = cache.keys().next();
|
|
76
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
77
|
+
}
|
|
78
|
+
return html;
|
|
79
|
+
};
|
|
28
80
|
}
|
|
81
|
+
|
|
82
|
+
export const renderPageBody = createPageBodyRenderer();
|
|
@@ -57,11 +57,10 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
57
57
|
"managed-pages:body.lossy.warning": {
|
|
58
58
|
en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
|
|
59
59
|
},
|
|
60
|
-
"managed-pages:body.lossy.image": { en: "images" },
|
|
60
|
+
"managed-pages:body.lossy.image": { en: "external images" },
|
|
61
61
|
"managed-pages:body.lossy.blockquote": { en: "block quotes" },
|
|
62
62
|
"managed-pages:body.lossy.code": { en: "code" },
|
|
63
63
|
"managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
|
|
64
|
-
"managed-pages:body.lossy.relativeLink": { en: "relative links" },
|
|
65
64
|
|
|
66
65
|
// Row-Actions + Confirm (pageListScreen)
|
|
67
66
|
"managed-pages:actions.edit": { en: "Edit" },
|
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
|
|
2
1
|
import { Lexer, type Token } from "marked";
|
|
3
2
|
|
|
4
|
-
export const LOSSY_MARKDOWN_FEATURES = [
|
|
5
|
-
"image",
|
|
6
|
-
"blockquote",
|
|
7
|
-
"code",
|
|
8
|
-
"deepHeading",
|
|
9
|
-
"relativeLink",
|
|
10
|
-
] as const;
|
|
3
|
+
export const LOSSY_MARKDOWN_FEATURES = ["image", "blockquote", "code", "deepHeading"] as const;
|
|
11
4
|
export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
|
|
12
5
|
|
|
13
|
-
// The document editor has no image, quote or code nodes
|
|
14
|
-
// levels, and links must pass the document link policy (absolute http(s),
|
|
15
|
-
// mailto, tel, placeholders).
|
|
6
|
+
// The document editor has no image, quote or code nodes and only three heading levels.
|
|
16
7
|
const MAX_DOCUMENT_HEADING_DEPTH = 3;
|
|
17
8
|
|
|
18
9
|
function tokenList(value: unknown): readonly Token[] {
|
|
@@ -31,12 +22,6 @@ function isDeepHeading(token: Token): boolean {
|
|
|
31
22
|
);
|
|
32
23
|
}
|
|
33
24
|
|
|
34
|
-
function isRelativeLink(token: Token): boolean {
|
|
35
|
-
return (
|
|
36
|
-
"href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href)
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
25
|
function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
|
|
41
26
|
switch (token.type) {
|
|
42
27
|
case "image":
|
|
@@ -48,8 +33,6 @@ function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
|
|
|
48
33
|
return ["code"];
|
|
49
34
|
case "heading":
|
|
50
35
|
return isDeepHeading(token) ? ["deepHeading"] : [];
|
|
51
|
-
case "link":
|
|
52
|
-
return isRelativeLink(token) ? ["relativeLink"] : [];
|
|
53
36
|
default:
|
|
54
37
|
return [];
|
|
55
38
|
}
|
|
@@ -3,8 +3,8 @@ import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
|
3
3
|
import { Marked } from "marked";
|
|
4
4
|
|
|
5
5
|
// The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
|
|
6
|
-
// lossy banner stays true: a link outside the document link policy keeps its text, an
|
|
7
|
-
// (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
6
|
+
// lossy banner stays true: a link outside the document link policy keeps its text, an external
|
|
7
|
+
// image (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
8
8
|
const converter = new Marked({ gfm: false, breaks: false });
|
|
9
9
|
converter.use({
|
|
10
10
|
renderer: {
|
|
@@ -14,7 +14,6 @@ const LOSSY_LABEL_KEYS: Readonly<Record<LossyMarkdownFeature, string>> = {
|
|
|
14
14
|
blockquote: "managed-pages:body.lossy.blockquote",
|
|
15
15
|
code: "managed-pages:body.lossy.code",
|
|
16
16
|
deepHeading: "managed-pages:body.lossy.deepHeading",
|
|
17
|
-
relativeLink: "managed-pages:body.lossy.relativeLink",
|
|
18
17
|
};
|
|
19
18
|
|
|
20
19
|
const BODY_EDITOR_ID = "managed-pages-page-body";
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "System-check history keeps runs of the same second in order",
|
|
6
|
+
"detail": "`probe-run:list` returned `requestedAt` as `Instant.toString()`, which drops trailing zeros in the fraction, and `pageRows` sorts that field as a string. Two runs in the same second could then come out reversed (`…:20Z` sorted after `…:20.001Z`), so the history showed the older run on top and a click opened it. `requestedAt` now always carries six fractional digits, so string order matches time order."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.392.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -29,7 +29,8 @@ export const runListQuery = defineQueryHandler({
|
|
|
29
29
|
const { counts, outcome } = viewRun(run, query.user);
|
|
30
30
|
return {
|
|
31
31
|
id: run.id,
|
|
32
|
-
|
|
32
|
+
// Fixed width: pageRows sorts this as a string, and a trimmed fraction ("…20Z" vs "…20.001Z") sorts wrong.
|
|
33
|
+
requestedAt: run.requestedAt.toString({ fractionalSecondDigits: 6 }),
|
|
33
34
|
triggeredByUserId: run.triggeredByUserId,
|
|
34
35
|
triggeredByName: starterNames.get(run.triggeredByUserId) ?? run.triggeredByUserId,
|
|
35
36
|
status: run.status,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
4
|
import { PROBE_RUN_STATUS, probeRunsTable } from "./schema/probe-run.ts";
|
|
4
5
|
import {
|
|
5
6
|
countResults,
|
|
@@ -10,14 +11,12 @@ import {
|
|
|
10
11
|
visibleResults,
|
|
11
12
|
} from "./visible-results.ts";
|
|
12
13
|
|
|
13
|
-
type InstantLike = { readonly toString: () => string };
|
|
14
|
-
|
|
15
14
|
export type RunRow = {
|
|
16
15
|
readonly id: string;
|
|
17
16
|
readonly status: string;
|
|
18
|
-
readonly requestedAt:
|
|
19
|
-
readonly startedAt:
|
|
20
|
-
readonly finishedAt:
|
|
17
|
+
readonly requestedAt: Temporal.Instant;
|
|
18
|
+
readonly startedAt: Temporal.Instant | null;
|
|
19
|
+
readonly finishedAt: Temporal.Instant | null;
|
|
21
20
|
readonly triggeredByUserId: string;
|
|
22
21
|
readonly durationMs: number | null;
|
|
23
22
|
readonly results: unknown;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Document anchors accept relative links",
|
|
6
|
+
"detail": "An anchor href in a document body may be relative (/path, ./, ../, #, ?); never // or a backslash. Other URL attributes (image src and similar) and mail links still accept only explicit schemes or placeholders. The sanitizer, the editor schema, compileDocumentBody and the confirmation mail use the same split."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.402.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Collection nav takes entryLabel for the create title",
|
|
12
|
+
"detail": "A collection's nav takes an optional entryLabel (singular i18n key, new in the nav type and in the required surface keys of all locale packages). The create screen title reads \"New {label}\", or \"New entry\" without it."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.395.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -7,7 +7,7 @@ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.
|
|
|
7
7
|
import {
|
|
8
8
|
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
9
9
|
DOCUMENT_TEXT_ALIGNMENTS,
|
|
10
|
-
|
|
10
|
+
isAllowedDocumentUrlHref,
|
|
11
11
|
isDocumentPlaceholderHref,
|
|
12
12
|
isDocumentReferenceName,
|
|
13
13
|
isDocumentResourceKey,
|
|
@@ -170,7 +170,7 @@ function rewriteLink(element: ScanElement, state: CompileState): void {
|
|
|
170
170
|
} else {
|
|
171
171
|
element.setAttribute("href", "#");
|
|
172
172
|
}
|
|
173
|
-
} else if (!
|
|
173
|
+
} else if (!isAllowedDocumentUrlHref(href)) {
|
|
174
174
|
element.setAttribute("href", "#");
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
12
12
|
documentExtensions,
|
|
13
13
|
isAllowedDocumentLinkHref,
|
|
14
|
+
isAllowedDocumentUrlHref,
|
|
14
15
|
isDocumentPlaceholderHref,
|
|
15
16
|
isDocumentReferenceName,
|
|
16
17
|
isDocumentResourceKey,
|
|
@@ -101,7 +102,7 @@ function scanAnchor(element: ScanElement, out: DocumentViolation[]): void {
|
|
|
101
102
|
violation(
|
|
102
103
|
"disallowed_link_href",
|
|
103
104
|
href,
|
|
104
|
-
`Link target "${href}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`,
|
|
105
|
+
`Link target "${href}" is not allowed; use https, http, mailto, tel, a relative path or a {{placeholder}}`,
|
|
105
106
|
),
|
|
106
107
|
);
|
|
107
108
|
}
|
|
@@ -197,7 +198,7 @@ function scanUrlAttributes(tag: string, element: ScanElement, out: DocumentViola
|
|
|
197
198
|
`Images must reference an uploaded resource via data-resource; image source "${value}" is not allowed`,
|
|
198
199
|
),
|
|
199
200
|
);
|
|
200
|
-
} else if (tag === "base" || !
|
|
201
|
+
} else if (tag === "base" || !isAllowedDocumentUrlHref(value)) {
|
|
201
202
|
out.push(
|
|
202
203
|
violation(
|
|
203
204
|
"disallowed_link_href",
|
|
@@ -45,15 +45,32 @@ function hasAllowedLinkScheme(href: string): boolean {
|
|
|
45
45
|
return ALLOWED_LINK_SCHEMES.some((scheme) => href.startsWith(scheme));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Relative references are matched by their literal leading character so a
|
|
49
|
+
// scheme can never hide in them: a value starting with a letter ("x:y",
|
|
50
|
+
// "javascript:") is not relative. Protocol-relative "//host" and any backslash
|
|
51
|
+
// (browsers read "/\\host" as "//host") would leave the site.
|
|
52
|
+
const RELATIVE_HREF_PREFIXES = ["/", "./", "../", "#", "?"] as const;
|
|
53
|
+
|
|
54
|
+
function isRelativeHref(href: string): boolean {
|
|
55
|
+
if (href.includes("\\") || href.startsWith("//")) return false;
|
|
56
|
+
return RELATIVE_HREF_PREFIXES.some((prefix) => href.startsWith(prefix));
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
// Exact lowercase prefix on the unchanged value: no trim, no case folding, so
|
|
49
60
|
// "JaVaScRiPt:", "HTTPS://" or a leading tab can never slip through as a
|
|
50
61
|
// scheme variant. Markup and control characters are refused outright because
|
|
51
62
|
// downstream parsers (MJML, PDF renderer) re-read the serialized attribute.
|
|
52
|
-
export function
|
|
63
|
+
export function isAllowedDocumentUrlHref(href: string): boolean {
|
|
53
64
|
if (hasUnsafeUrlCharacter(href)) return false;
|
|
54
65
|
return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
// Anchors only: other URL attributes (form action, svg href) resolve against the
|
|
69
|
+
// page and must stay on explicit schemes or placeholders.
|
|
70
|
+
export function isAllowedDocumentLinkHref(href: string): boolean {
|
|
71
|
+
return isAllowedDocumentUrlHref(href) || (!hasUnsafeUrlCharacter(href) && isRelativeHref(href));
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
export function isDocumentResourceKey(value: string): boolean {
|
|
58
75
|
return RESOURCE_KEY.test(value);
|
|
59
76
|
}
|
|
@@ -22,7 +22,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
22
22
|
"template-resolver.documentEditor.insertPlaceholder": "Placeholder",
|
|
23
23
|
"template-resolver.documentEditor.invalidImageAlt": "The alt text must not contain < or >.",
|
|
24
24
|
"template-resolver.documentEditor.invalidLink":
|
|
25
|
-
"This link target is not allowed. Use https, http, mailto, tel or a placeholder.",
|
|
25
|
+
"This link target is not allowed. Use https, http, mailto, tel, a relative path or a placeholder.",
|
|
26
26
|
"template-resolver.documentEditor.invalidResourceKey": "This resource key is not valid.",
|
|
27
27
|
"template-resolver.documentEditor.link": "Link",
|
|
28
28
|
"template-resolver.documentEditor.linkHref": "Link target",
|
|
@@ -64,7 +64,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
64
64
|
"templateResolver.errors.document.buttonHrefNotPlaceholder":
|
|
65
65
|
"The button target “{detail}” must be a placeholder.",
|
|
66
66
|
"templateResolver.errors.document.disallowedLinkHref":
|
|
67
|
-
"Link target “{detail}” is not allowed; use https, http, mailto, tel or a placeholder.",
|
|
67
|
+
"Link target “{detail}” is not allowed; use https, http, mailto, tel, a relative path or a placeholder.",
|
|
68
68
|
"templateResolver.errors.document.documentRequiresHtmlFormat":
|
|
69
69
|
"This template kind requires the html format, got “{detail}”.",
|
|
70
70
|
"templateResolver.errors.document.layoutRequiresMjmlFormat":
|
|
@@ -36,9 +36,11 @@ export function useCollectionEntryCreate(target: TargetRef, tenantId: string | u
|
|
|
36
36
|
|
|
37
37
|
const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
|
|
38
38
|
const disabled = submitting || !canWrite || collectionId === undefined;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const entryLabel = collection?.nav.entryLabel;
|
|
40
|
+
const headerTitle =
|
|
41
|
+
entryLabel !== undefined
|
|
42
|
+
? t("template-resolver.editor.newEntryTitle", { label: t(entryLabel) })
|
|
43
|
+
: t("template-resolver.editor.newEntry");
|
|
42
44
|
|
|
43
45
|
const create = async (): Promise<void> => {
|
|
44
46
|
// skip: nothing to write to, or the slug is invalid (the field already shows the issue)
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
bootRuleError,
|
|
3
|
+
collectBootRuleViolations,
|
|
2
4
|
EXT_USER_DATA,
|
|
3
5
|
type FeatureDefinition,
|
|
4
6
|
type ResolvedPiiFlags,
|
|
5
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
8
|
import { entitiesOf } from "../shared/index.ts";
|
|
9
|
+
import { USER_DATA_RIGHTS_BOOT_RULES } from "./boot-rules.ts";
|
|
7
10
|
|
|
8
11
|
// V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
|
|
9
12
|
// export hook without a matching delete hook exports data under Art.20 but
|
|
@@ -19,25 +22,27 @@ import { entitiesOf } from "../shared/index.ts";
|
|
|
19
22
|
// fails otherwise); running this from user-data-rights' own bootCheck is
|
|
20
23
|
// equivalent to the framework-internal call it replaces.
|
|
21
24
|
export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
|
|
37
|
-
);
|
|
38
|
-
}
|
|
25
|
+
const usages = features.flatMap((feature) =>
|
|
26
|
+
feature.extensionUsages
|
|
27
|
+
.filter((usage) => usage.extensionName === EXT_USER_DATA)
|
|
28
|
+
.map((usage) => ({ feature, usage })),
|
|
29
|
+
);
|
|
30
|
+
collectBootRuleViolations(usages, ({ feature, usage }) => {
|
|
31
|
+
const hasExport = typeof usage.options?.["export"] === "function";
|
|
32
|
+
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
33
|
+
// The compile-time "at least one hook" type is bypassed by string-typed
|
|
34
|
+
// extension names and casts; without this an empty bag registers nothing.
|
|
35
|
+
if (!hasExport && !hasDelete) {
|
|
36
|
+
throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.emptyHook, {
|
|
37
|
+
message: `[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`,
|
|
38
|
+
});
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
if (hasExport && !hasDelete) {
|
|
41
|
+
throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.exportWithoutDelete, {
|
|
42
|
+
message: `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// V3: PII-entity-without-hook gate. V2 checks registered hooks for
|
|
@@ -60,19 +65,22 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
|
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
const entities = features.flatMap((feature) =>
|
|
69
|
+
[...entitiesOf(feature)].map(([entityName, entity]) => ({ feature, entityName, entity })),
|
|
70
|
+
);
|
|
71
|
+
collectBootRuleViolations(entities, ({ feature, entityName, entity }) => {
|
|
72
|
+
// skip: an entity with a registered hook is covered
|
|
73
|
+
if (hookedEntities.has(entityName)) return;
|
|
74
|
+
const subjectFields = Object.entries(entity.fields)
|
|
75
|
+
.filter(([, field]) => {
|
|
76
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
77
|
+
return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
|
|
78
|
+
})
|
|
79
|
+
.map(([name]) => name);
|
|
80
|
+
// skip: an entity without user-subject fields stays out of the pipeline by design
|
|
81
|
+
if (subjectFields.length === 0) return;
|
|
82
|
+
throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.missingHook, {
|
|
83
|
+
message: `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
78
86
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BootRuleTable } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export const USER_DATA_RIGHTS_BOOT_RULES = {
|
|
4
|
+
emptyHook: {
|
|
5
|
+
id: "user-data-rights/empty-hook",
|
|
6
|
+
summary: "An EXT_USER_DATA registration declares at least one of an export or a delete hook.",
|
|
7
|
+
fix: "Register at least one hook.",
|
|
8
|
+
},
|
|
9
|
+
exportWithoutDelete: {
|
|
10
|
+
id: "user-data-rights/export-without-delete",
|
|
11
|
+
summary: "An entity exported via EXT_USER_DATA also registers a delete hook.",
|
|
12
|
+
fix: "Add a delete hook, or a documented no-op delete if erasure is handled elsewhere.",
|
|
13
|
+
},
|
|
14
|
+
missingHook: {
|
|
15
|
+
id: "user-data-rights/missing-hook",
|
|
16
|
+
summary: "An entity with user-subject fields registers an EXT_USER_DATA hook.",
|
|
17
|
+
fix: "Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere.",
|
|
18
|
+
},
|
|
19
|
+
} as const satisfies BootRuleTable;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "user-data-rights boot checks report rule ids",
|
|
6
|
+
"detail": "The bootChecks of user-data-rights report `user-data-rights/empty-hook`, `user-data-rights/export-without-delete` and `user-data-rights/missing-hook` with a fix, and list every missing or empty hook of a run instead of stopping at the first. Message texts are unchanged."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.402.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "request-deletion-screen shows one deletion card",
|
|
12
|
+
"detail": "The request form no longer draws its own card inside the screen's card, so the screen shows one deletion card."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.390.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -77,24 +77,26 @@ export function RequestAccountDeletionScreen({
|
|
|
77
77
|
<p className="text-sm text-muted-foreground">
|
|
78
78
|
{t("userDataRights.deletion.request.intro")}
|
|
79
79
|
</p>
|
|
80
|
-
<Form onSubmit={onSubmit}>
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{submitting
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
80
|
+
<Form chromeless onSubmit={onSubmit}>
|
|
81
|
+
<div className="flex flex-col gap-4">
|
|
82
|
+
<Field id="delete-email" label={t("userDataRights.deletion.request.email")} required>
|
|
83
|
+
<Input
|
|
84
|
+
kind="text"
|
|
85
|
+
id="delete-email"
|
|
86
|
+
name="delete-email"
|
|
87
|
+
value={email}
|
|
88
|
+
onChange={setEmail}
|
|
89
|
+
disabled={submitting}
|
|
90
|
+
required
|
|
91
|
+
/>
|
|
92
|
+
</Field>
|
|
93
|
+
{error !== null && <Banner variant="error">{error}</Banner>}
|
|
94
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
95
|
+
{submitting
|
|
96
|
+
? t("userDataRights.deletion.request.submitting")
|
|
97
|
+
: t("userDataRights.deletion.request.submit")}
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
98
100
|
</Form>
|
|
99
101
|
</div>
|
|
100
102
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
|
|
3
|
-
// Nav labels for the two content collections this app mounts. Collection ids
|
|
3
|
+
// Nav and entry labels for the two content collections this app mounts. Collection ids
|
|
4
4
|
// are the app's choice, so their labels are too — a bundled feature cannot ship
|
|
5
5
|
// a translation key for a collection it never named. Every app that declares a
|
|
6
6
|
// collection carries its labels the same way.
|
|
@@ -9,7 +9,9 @@ export const collectionLabelsFeature = defineFeature("collection-labels", (r) =>
|
|
|
9
9
|
r.translations({
|
|
10
10
|
keys: {
|
|
11
11
|
"templateResolver:nav.snippets": { de: "Bausteine", en: "Snippets" },
|
|
12
|
+
"templateResolver:nav.snippet": { de: "Baustein", en: "Snippet" },
|
|
12
13
|
"templateResolver:nav.signatures": { de: "Signaturen", en: "Signatures" },
|
|
14
|
+
"templateResolver:nav.signature": { de: "Signatur", en: "Signature" },
|
|
13
15
|
},
|
|
14
16
|
});
|
|
15
17
|
return {};
|