@cosmicdrift/kumiko-samples 0.400.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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +177 -42
  4. package/packages/bundled-features/src/agent-tools/changes.json +19 -0
  5. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +8 -15
  6. package/packages/bundled-features/src/agent-tools/types.ts +11 -10
  7. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +2 -2
  8. package/packages/bundled-features/src/documents/entity.ts +7 -1
  9. package/packages/bundled-features/src/documents/feature.ts +34 -5
  10. package/packages/bundled-features/src/documents/handlers/import.write.ts +1 -1
  11. package/packages/bundled-features/src/documents/handlers/link.write.ts +2 -0
  12. package/packages/bundled-features/src/documents/i18n.ts +14 -0
  13. package/packages/bundled-features/src/documents/index.ts +1 -0
  14. package/packages/bundled-features/src/documents/tab.ts +2 -2
  15. package/packages/bundled-features/src/documents/target-write-check.ts +9 -3
  16. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  17. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +1 -0
  18. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +3 -3
  19. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +5 -5
  20. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +5 -7
  21. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  22. package/packages/bundled-features/src/managed-pages/feature.ts +9 -1
  23. package/packages/bundled-features/src/managed-pages/render-page-body.ts +66 -12
  24. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +1 -1
  25. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -2
  26. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +2 -19
  27. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +2 -2
  28. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +0 -1
  29. package/packages/bundled-features/src/readiness/changes.json +6 -0
  30. package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +2 -1
  31. package/packages/bundled-features/src/readiness/run-view.ts +4 -5
  32. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  33. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +2 -2
  34. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  35. package/packages/bundled-features/src/template-resolver/index.ts +1 -0
  36. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +18 -1
  37. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -2
  38. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +5 -3
  39. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +41 -33
  40. package/packages/bundled-features/src/user-data-rights/boot-rules.ts +19 -0
  41. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  42. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -0
  43. package/packages/bundled-features/src/user-data-rights/web/request-deletion-screen.tsx +20 -18
  44. package/samples/apps/use-all-bundled/src/app/collection-labels-feature.ts +3 -1
  45. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  46. package/samples/recipes/relations/src/feature.ts +13 -3
@@ -160,6 +160,7 @@ export {
160
160
  DocumentVariable,
161
161
  documentExtensions,
162
162
  isAllowedDocumentLinkHref,
163
+ isAllowedDocumentUrlHref,
163
164
  isDocumentPlaceholderHref,
164
165
  isDocumentReferenceName,
165
166
  isDocumentResourceKey,
@@ -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 isAllowedDocumentLinkHref(href: string): boolean {
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 headerTitle = t("template-resolver.editor.newEntryTitle", {
40
- label: collection !== undefined ? t(collection.nav.label) : "",
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
- for (const feature of features) {
23
- for (const usage of feature.extensionUsages) {
24
- if (usage.extensionName !== EXT_USER_DATA) continue;
25
- const hasExport = typeof usage.options?.["export"] === "function";
26
- const hasDelete = typeof usage.options?.["delete"] === "function";
27
- // The compile-time "at least one hook" type is bypassed by string-typed
28
- // extension names and casts; without this an empty bag registers nothing.
29
- if (!hasExport && !hasDelete) {
30
- throw new Error(
31
- `[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.`,
32
- );
33
- }
34
- if (hasExport && !hasDelete) {
35
- throw new Error(
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
- for (const feature of features) {
64
- for (const [entityName, entity] of entitiesOf(feature)) {
65
- if (hookedEntities.has(entityName)) continue;
66
- const subjectFields = Object.entries(entity.fields)
67
- .filter(([, field]) => {
68
- const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
69
- return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
70
- })
71
- .map(([name]) => name);
72
- if (subjectFields.length === 0) continue;
73
- throw new Error(
74
- `[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.`,
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",
@@ -21,6 +21,10 @@ export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> =
21
21
  },
22
22
  "user-data-rights:entity:export-job:field:userId": { en: "User" },
23
23
  "user-data-rights:entity:export-job:field:status": { en: "Status" },
24
+ "user-data-rights:entity:export-job:field:status:option:pending": { en: "Pending" },
25
+ "user-data-rights:entity:export-job:field:status:option:running": { en: "Running" },
26
+ "user-data-rights:entity:export-job:field:status:option:done": { en: "Done" },
27
+ "user-data-rights:entity:export-job:field:status:option:failed": { en: "Failed" },
24
28
  "user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
25
29
  "user-data-rights:entity:export-job:field:completedAt": { en: "Completed" },
26
30
  "user-data-rights:entity:export-job:field:expiresAt": { en: "Expires" },
@@ -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
- <Field id="delete-email" label={t("userDataRights.deletion.request.email")} required>
82
- <Input
83
- kind="text"
84
- id="delete-email"
85
- name="delete-email"
86
- value={email}
87
- onChange={setEmail}
88
- disabled={submitting}
89
- required
90
- />
91
- </Field>
92
- {error !== null && <Banner variant="error">{error}</Banner>}
93
- <Button type="submit" loading={submitting} disabled={submitting}>
94
- {submitting
95
- ? t("userDataRights.deletion.request.submitting")
96
- : t("userDataRights.deletion.request.submit")}
97
- </Button>
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 {};
@@ -315,6 +315,7 @@ export const APP_FEATURES = [
315
315
  // shells and the collection is unreachable in the UI.
316
316
  nav: {
317
317
  label: "templateResolver:nav.snippets",
318
+ entryLabel: "templateResolver:nav.snippet",
318
319
  workspaces: ["admin-shell:workspace:platform"],
319
320
  },
320
321
  // document + variableSchema so the smoke app renders the WYSIWYG with
@@ -334,6 +335,7 @@ export const APP_FEATURES = [
334
335
  access: { roles: ["TenantAdmin", "TenantMember", "SystemAdmin"] },
335
336
  nav: {
336
337
  label: "templateResolver:nav.signatures",
338
+ entryLabel: "templateResolver:nav.signature",
337
339
  workspaces: ["admin-shell:workspace:platform"],
338
340
  },
339
341
  contentFormat: "document",
@@ -1,5 +1,10 @@
1
1
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ createEntity,
4
+ createReferenceField,
5
+ createTextField,
6
+ defineFeature,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
3
8
 
4
9
  export const teamEntity = createEntity({
5
10
  table: "read_sample_teams",
@@ -12,7 +17,12 @@ export const memberEntity = createEntity({
12
17
  table: "read_sample_members",
13
18
  fields: {
14
19
  name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
15
- teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
20
+ teamId: createReferenceField({
21
+ entity: "team",
22
+ personal: false,
23
+ reason: "is_system_identifier",
24
+ required: true,
25
+ }),
16
26
  role: createTextField({ personal: false, reason: "is_business_data" }),
17
27
  },
18
28
  });
@@ -21,7 +31,7 @@ export const taskEntity = createEntity({
21
31
  table: "read_sample_member_tasks",
22
32
  fields: {
23
33
  title: createTextField({ personal: false, reason: "is_business_data", required: true }),
24
- memberId: createTextField({ personal: "ref", required: true }),
34
+ memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
25
35
  },
26
36
  });
27
37