@cosmicdrift/kumiko-samples 0.403.0 → 0.404.1
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 +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
- package/packages/bundled-features/src/data-retention/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/documents/changes.json +8 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +6 -2
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/feature.ts +6 -7
- package/packages/bundled-features/src/legal-pages/README.md +21 -13
- package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- package/packages/bundled-features/src/template-resolver/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/lane-routing/README.md +1 -0
- package/samples/recipes/lane-routing/src/feature.ts +8 -0
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -16,34 +16,34 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
|
|
|
16
16
|
|
|
17
17
|
## 1. Endpoint → Artikel-Mapping
|
|
18
18
|
|
|
19
|
-
| Artikel
|
|
20
|
-
|
|
21
|
-
| **Art. 15 (Auskunft, light)**
|
|
22
|
-
| **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export`
|
|
23
|
-
| **Art. 15 + 20**
|
|
24
|
-
| **Art. 15 + 20**
|
|
25
|
-
| **Art. 17 (Löschung)**
|
|
26
|
-
| **Art. 17**
|
|
27
|
-
| **Art. 17**
|
|
28
|
-
| **Art. 18 (Restriction)**
|
|
29
|
-
| **Art. 18**
|
|
30
|
-
| **Operator (DPO)**
|
|
19
|
+
| Artikel | Endpoint / Runner | Wer ruft auf | Was passiert |
|
|
20
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.events.append` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
|
|
22
|
+
| **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
|
|
23
|
+
| **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
|
|
24
|
+
| **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
|
|
25
|
+
| **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
|
|
26
|
+
| **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
|
|
27
|
+
| **Art. 17** | `run-forget-cleanup` (Cron) | System | Findet User mit `status=DeletionRequested AND gracePeriodEnd < now`. Pro User Sub-TX über alle EXT_USER_DATA-Provider mit Strategy aus `retention.policyFor`. user-Hook anonymisiert (PII raus, Sentinel-Email). |
|
|
28
|
+
| **Art. 18 (Restriction)** | `user-data-rights:write:restrict-account` | Admin / SystemAdmin | Status-Flip → Auth-Middleware-Guard blockt Logins. `sessions.revokeAllForUser` killt aktive Sessions. |
|
|
29
|
+
| **Art. 18** | `user-data-rights:write:lift-restriction` | Admin / SystemAdmin | Restriction aufheben. |
|
|
30
|
+
| **Operator (DPO)** | `user-data-rights:query:list-download-attempts` | Admin / SystemAdmin | Brute-Force-Detection: zeigt invalid Download-Versuche (notFound / expired / failed / signedUrlNotSupported) gefiltert nach Result, IP, Zeitraum. |
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
34
|
## 2. Speicherorte (Verarbeitungsverzeichnis Art. 30)
|
|
35
35
|
|
|
36
|
-
| Tabelle
|
|
37
|
-
|
|
38
|
-
| `read_users`
|
|
39
|
-
| `read_tenant_memberships`
|
|
40
|
-
| `kumiko_events`
|
|
41
|
-
| `read_export_jobs`
|
|
42
|
-
| `read_export_download_tokens`
|
|
43
|
-
| `read_download_attempts`
|
|
44
|
-
| `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override
|
|
45
|
-
| `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity
|
|
46
|
-
| Storage-Provider (Local / S3)
|
|
36
|
+
| Tabelle | Inhalt | Personenbezug | Retention | Zweck |
|
|
37
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
38
|
+
| `read_users` | User-Profil (email, displayName, passwordHash, locale, status, gracePeriodEnd, roles) | direkt | per Domain (typ. blockDelete bei Aufbewahrungspflicht, sonst hardDelete via Forget-Pipeline) | Authentifizierung, Nutzer-Profil |
|
|
39
|
+
| `read_tenant_memberships` | User↔Tenant-Verknüpfung + Rollen | direkt (via userId) | per Tenant-Lifecycle | Mehrmandant-Zuordnung |
|
|
40
|
+
| `kumiko_events` | Event-Store (alle write-Events) | direkt (`createdBy = userId`) | per Domain-Policy via `data-retention` | Audit-Trail (Art. 15-Selbstauskunft Quelle), Event-Sourcing |
|
|
41
|
+
| `read_export_jobs` | Async Export-Status (queued/running/done/failed), userId, requestedAt, doneAt, storageKey | direkt (userId) | per `compliance-profiles` Profil-Default | Idempotenz + Status-Polling |
|
|
42
|
+
| `read_export_download_tokens` | Magic-Link-Hash (SHA-256), TTL, useCount, lastUsedAt, lastUsedFromIp, lastUsedUserAgent | indirekt (Token→Job→User) | `compliance-profiles.userRights.exportDownloadTtl` (default 7d) | Magic-Link-Auth + Multi-Use-Audit |
|
|
43
|
+
| `read_download_attempts` | Invalid Download-Versuche: result, via, tokenHash, ip, userAgent, attemptedAt | indirekt (IP) | **90d hardDelete** (Entity-Default, Disk-Bomb-Schutz) | DPO-Brute-Force-Detection |
|
|
44
|
+
| `read_tenant_compliance_profiles` | Per-Tenant Profile-Wahl + Override | nein | unbounded (Konfiguration) | Region-/Branchen-Defaults |
|
|
45
|
+
| `read_tenant_retention_overrides` | Per-Tenant Retention-Override pro Entity | nein | unbounded (Konfiguration) | Aufbewahrungspflicht-Edge-Cases |
|
|
46
|
+
| Storage-Provider (Local / S3) | Export-ZIPs + File-Binaries | indirekt (Inhalt) | Local: per `exportDownloadTtl` Cleanup. S3: lifecycle-Policy (App-Author-Verantwortung) | Daten-Export-Auslieferung |
|
|
47
47
|
|
|
48
48
|
### Bekanntes Residuum: `createdBy` / `metadata.userId` in `kumiko_events`
|
|
49
49
|
|
|
@@ -69,12 +69,12 @@ Retention-Policy irgendwann das Event selbst löscht.
|
|
|
69
69
|
|
|
70
70
|
## 3. Compliance-Profile
|
|
71
71
|
|
|
72
|
-
| Profil
|
|
73
|
-
|
|
74
|
-
| `eu-dsgvo`
|
|
75
|
-
| `swiss-dsg`
|
|
76
|
-
| `de-hr-dsgvo-hgb`
|
|
77
|
-
| `minimal-no-region` | 30d
|
|
72
|
+
| Profil | gracePeriod | exportDownloadTtl | Stale-After | Sub-Processors |
|
|
73
|
+
| ------------------- | ----------- | ----------------- | ----------- | -------------------------------------------------------------- |
|
|
74
|
+
| `eu-dsgvo` | 30d | 7d | 30d | per Tenant konfigurierbar |
|
|
75
|
+
| `swiss-dsg` | 30d | 7d | 30d | + EDÖB-Meldepfad |
|
|
76
|
+
| `de-hr-dsgvo-hgb` | 30d | 7d | 30d | + HR-Aufbewahrung 10y für HR-Entities (anonymize statt delete) |
|
|
77
|
+
| `minimal-no-region` | 30d | 7d | 30d | Migration-Edge-Case ohne Region |
|
|
78
78
|
|
|
79
79
|
Tenant kann via `compliance-profiles:write:set-profile` + Override (`override.userRights.gracePeriod={ days: N }` etc.) eigene Werte setzen.
|
|
80
80
|
|
|
@@ -128,13 +128,13 @@ und Organisatorische Maßnahmen" übernommen werden:
|
|
|
128
128
|
|
|
129
129
|
## 5. Cron-Jobs / Operationale Trigger
|
|
130
130
|
|
|
131
|
-
| Job
|
|
132
|
-
|
|
133
|
-
| `run-forget-cleanup`
|
|
134
|
-
| `run-export-jobs`
|
|
135
|
-
| `data-retention-cleanup` | Cron (in `data-retention`)
|
|
136
|
-
| Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries
|
|
137
|
-
| Download-Attempt-Cleanup | Per Entity-Default 90d
|
|
131
|
+
| Job | Trigger | Was passiert | Operator-Sichtbarkeit |
|
|
132
|
+
| ------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
133
|
+
| `run-forget-cleanup` | Cron (per App-Author konfigurierbar, typisch täglich) | Findet User mit abgelaufener Grace, ruft `EXT_USER_DATA.delete` pro Provider, anonymisiert User, sendet `sendDeletionExecutedEmail`-Callback | `read_job_runs` (success/fail), Hook-Errors als `errors[]` im Result |
|
|
134
|
+
| `run-export-jobs` | Cron + Event-getriggert | Findet pending Export-Jobs, ruft `runUserExport` → ZIP-Bau → Storage-Upload → Magic-Link-Token → `sendExportReadyEmail` | `read_job_runs`, `read_export_jobs.status` |
|
|
135
|
+
| `data-retention-cleanup` | Cron (in `data-retention`) | Cleant abgelaufene Rows pro Entity per `retention.keepFor` | `read_job_runs` |
|
|
136
|
+
| Token-Cleanup (implizit) | Per `compliance-profiles.userRights.exportDownloadTtl` | Worker-Job entfernt expired Magic-Link-Tokens + Storage-Binaries | `read_export_download_tokens` |
|
|
137
|
+
| Download-Attempt-Cleanup | Per Entity-Default 90d | Cleant `read_download_attempts` | — |
|
|
138
138
|
|
|
139
139
|
App-Author registriert die Cron-Trigger im run-config; `jobs`-Feature
|
|
140
140
|
persistiert Run-Tracking + Retry-Pfad.
|
|
@@ -18,16 +18,16 @@ defineFeature("tasks", (r) => {
|
|
|
18
18
|
r.useExtension(EXT_USER_DATA, "task", {
|
|
19
19
|
export: async (ctx) => ({
|
|
20
20
|
entity: "task",
|
|
21
|
-
rows: await ctx.db.select().from(tasksTable)
|
|
22
|
-
.where(eq(tasksTable.authorId, ctx.userId)),
|
|
21
|
+
rows: await ctx.db.select().from(tasksTable).where(eq(tasksTable.authorId, ctx.userId)),
|
|
23
22
|
}),
|
|
24
23
|
delete: async (ctx, strategy) => {
|
|
25
24
|
if (strategy === "anonymize") {
|
|
26
|
-
await ctx.db
|
|
25
|
+
await ctx.db
|
|
26
|
+
.update(tasksTable)
|
|
27
|
+
.set({ authorId: null })
|
|
27
28
|
.where(eq(tasksTable.authorId, ctx.userId));
|
|
28
29
|
} else {
|
|
29
|
-
await ctx.db.delete(tasksTable)
|
|
30
|
-
.where(eq(tasksTable.authorId, ctx.userId));
|
|
30
|
+
await ctx.db.delete(tasksTable).where(eq(tasksTable.authorId, ctx.userId));
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
});
|
|
@@ -42,15 +42,15 @@ Hook-Signaturen in `framework/src/engine/extensions/user-data.ts`:
|
|
|
42
42
|
|
|
43
43
|
## Endpoints
|
|
44
44
|
|
|
45
|
-
| Article
|
|
46
|
-
|
|
47
|
-
| Art. 15
|
|
48
|
-
| Art. 15+20 | `user-data-rights:write:request-export`
|
|
49
|
-
| Art. 17
|
|
50
|
-
| Art. 17
|
|
51
|
-
| Art. 18
|
|
52
|
-
| Art. 18
|
|
53
|
-
| Operator
|
|
45
|
+
| Article | Handler | Zweck |
|
|
46
|
+
| ---------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| Art. 15 | `user-data-rights:query:my-audit-log` | User sieht eigene Framework-Events (account-weit über alle Memberships). Domain-Entities ohne `ctx.events.append` erscheinen NICHT — nur im Export-Bundle. |
|
|
48
|
+
| Art. 15+20 | `user-data-rights:write:request-export` | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten + signed Magic-Link |
|
|
49
|
+
| Art. 17 | `user-data-rights:write:request-deletion` | Soft-Delete mit Grace-Period, anschließend Cron anonymisiert User + cleant Domain-Entities |
|
|
50
|
+
| Art. 17 | `user-data-rights:write:cancel-deletion` | User widerruft seinen Forget-Request während der Grace |
|
|
51
|
+
| Art. 18 | `user-data-rights:write:restrict-account` | Auth-Middleware blockt Logins bis Lift |
|
|
52
|
+
| Art. 18 | `user-data-rights:write:lift-restriction` | Admin/SystemAdmin hebt Restriction auf |
|
|
53
|
+
| Operator | `user-data-rights:query:list-download-attempts` | DPO-Sicht auf invalid Download-Versuche (Brute-Force-Detection, Admin/SystemAdmin only) |
|
|
54
54
|
|
|
55
55
|
Plus 2 anonyme HTTP-Routes für Export-Download (Magic-Link-Pfad +
|
|
56
56
|
session-auth-Pfad), siehe `handlers/download-by-{token,job}.query.ts`.
|
|
@@ -77,30 +77,30 @@ weglassen wenn er Custom-Hooks registrieren will.
|
|
|
77
77
|
|
|
78
78
|
## Audit-Trail
|
|
79
79
|
|
|
80
|
-
| Tabelle
|
|
81
|
-
|
|
82
|
-
| `kumiko_events`
|
|
83
|
-
| `read_export_jobs`
|
|
84
|
-
| `read_export_download_tokens` | Magic-Link-Hash + TTL + lastUsed-Audit
|
|
85
|
-
| `read_download_attempts`
|
|
80
|
+
| Tabelle | Zweck | Retention |
|
|
81
|
+
| ----------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
82
|
+
| `kumiko_events` | Framework-Event-Store, Quelle für `my-audit-log` | per Domain-Policy |
|
|
83
|
+
| `read_export_jobs` | Async Export-Status (queued / done / failed) | per `compliance-profiles` |
|
|
84
|
+
| `read_export_download_tokens` | Magic-Link-Hash + TTL + lastUsed-Audit | per `compliance-profiles` (default `exportDownloadTtl`) |
|
|
85
|
+
| `read_download_attempts` | Invalid-Download-Versuche für DPO-Brute-Force-Detection | **90d hardDelete** (Entity-Default — schützt vor Disk-Bomb bei aktiven Angriffen) |
|
|
86
86
|
|
|
87
87
|
## Tests
|
|
88
88
|
|
|
89
89
|
18 Testdateien, 188 Tests, alle grün:
|
|
90
90
|
|
|
91
|
-
| Datei
|
|
92
|
-
|
|
93
|
-
| `audit-log.integration.test.ts`
|
|
94
|
-
| `cross-data-matrix.integration.test.ts`
|
|
95
|
-
| `download.integration.test.ts`
|
|
96
|
-
| `request-export.integration.test.ts`
|
|
97
|
-
| `request-deletion-callback.integration.test.ts` + `request-cancel-deletion.integration.test.ts` | Grace + Cancel-Pfad + Email-Callback best-effort
|
|
98
|
-
| `restriction-flow.integration.test.ts`
|
|
99
|
-
| `run-{export-jobs,forget-cleanup,user-export}.integration.test.ts`
|
|
100
|
-
| `policy-to-strategy.test.ts`
|
|
101
|
-
| `user-data-rights.integration.test.ts`
|
|
102
|
-
| `token-helpers.test.ts` + `zip-path.test.ts`
|
|
103
|
-
| `export-job-{idempotency,schema}.test.ts`
|
|
91
|
+
| Datei | Pinst |
|
|
92
|
+
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `audit-log.integration.test.ts` | Cross-User-Isolation, Account-weite Sicht, eventType-Filter, Admin-only operator-query, download-attempt 90d-retention |
|
|
94
|
+
| `cross-data-matrix.integration.test.ts` | 3-Provider-Pipeline (user + fileRef + custom-domain), Cross-Tenant Forget mit user-anonymize, Other-User-Isolation |
|
|
95
|
+
| `download.integration.test.ts` | HTTP-e2e via `r.httpRoute`: Magic-Link, multi-use, expired, failed-job, storage-cleared, cross-tenant-same-user, malicious-filename |
|
|
96
|
+
| `request-export.integration.test.ts` | Idempotency, active-job-constraint, cross-tenant-anyMember-userId-pattern |
|
|
97
|
+
| `request-deletion-callback.integration.test.ts` + `request-cancel-deletion.integration.test.ts` | Grace + Cancel-Pfad + Email-Callback best-effort |
|
|
98
|
+
| `restriction-flow.integration.test.ts` | Status-Flip + Auth-Middleware-Block + Lift |
|
|
99
|
+
| `run-{export-jobs,forget-cleanup,user-export}.integration.test.ts` | Worker-Logic + Idempotency + Email-Callbacks |
|
|
100
|
+
| `policy-to-strategy.test.ts` | Retention.strategy → UserDataDeleteStrategy mapping |
|
|
101
|
+
| `user-data-rights.integration.test.ts` | Boot-Smoke + Feature-Meta |
|
|
102
|
+
| `token-helpers.test.ts` + `zip-path.test.ts` | Token-Hashing + Path-Traversal-Schutz |
|
|
103
|
+
| `export-job-{idempotency,schema}.test.ts` | Active-job-uniqueness + Schema-Constraints |
|
|
104
104
|
|
|
105
105
|
## Sample
|
|
106
106
|
|
|
@@ -600,14 +600,14 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
600
600
|
type: "gauge",
|
|
601
601
|
unit: "seconds",
|
|
602
602
|
description:
|
|
603
|
-
"Unix timestamp of the last export storage-cleanup pass that completed. Alert on time() minus this value exceeding
|
|
603
|
+
"Unix timestamp of the last export storage-cleanup pass that completed. Alert on time() minus this value exceeding one day plus a buffer; the backlog-age gauge alone stays at its last value when the cron stops.",
|
|
604
604
|
});
|
|
605
605
|
|
|
606
606
|
// Worker for the asynchronous export pipeline, triggered by a cron job.
|
|
607
607
|
const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
|
|
608
608
|
r.job({
|
|
609
609
|
name: "run-export-jobs",
|
|
610
|
-
trigger: {
|
|
610
|
+
trigger: { maintenance: true },
|
|
611
611
|
concurrency: "skip",
|
|
612
612
|
escapeHatch: { reason: RUN_EXPORT_JOBS_REASON },
|
|
613
613
|
handler: async (_payload, ctx) => {
|
|
@@ -705,7 +705,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
705
705
|
const RUN_FORGET_CLEANUP_REASON = "executes overdue Art.17 forget requests across every tenant";
|
|
706
706
|
r.job({
|
|
707
707
|
name: "run-forget-cleanup",
|
|
708
|
-
trigger: {
|
|
708
|
+
trigger: { maintenance: true },
|
|
709
709
|
concurrency: "skip",
|
|
710
710
|
escapeHatch: { reason: RUN_FORGET_CLEANUP_REASON },
|
|
711
711
|
handler: async (_payload, ctx) => {
|
|
@@ -32,6 +32,7 @@ export function makePublicDeletionGate(
|
|
|
32
32
|
routes: PublicDeletionRoutes,
|
|
33
33
|
): ComponentType<{ children: ReactNode }> {
|
|
34
34
|
const shell = routes.shell ?? centeredShell;
|
|
35
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per userDataRightsClient(); gates only receive children, so routes are bound here
|
|
35
36
|
function PublicDeletionGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
36
37
|
const path = window.location.pathname;
|
|
37
38
|
if (path === routes.requestPath) return shell(<RequestAccountDeletionScreen />);
|
|
@@ -24,11 +24,11 @@ Port 4177 is hardcoded in the `dev` script. The server reads the root
|
|
|
24
24
|
All three log into the dev tenant "Admin Console Demo" (tenant key
|
|
25
25
|
`demo`). Passwords are in `src/app/auth-constants.ts`.
|
|
26
26
|
|
|
27
|
-
| Email
|
|
28
|
-
|
|
29
|
-
| `sysadmin@admin-console.local`
|
|
30
|
-
| `tenant-admin@admin-console.local` | tenant `TenantAdmin`
|
|
31
|
-
| `regular@admin-console.local`
|
|
27
|
+
| Email | Role | Sees |
|
|
28
|
+
| ---------------------------------- | ------------------------------------ | ------------------------------------ |
|
|
29
|
+
| `sysadmin@admin-console.local` | global `SystemAdmin`, tenant `Admin` | tenant-admin and platform workspaces |
|
|
30
|
+
| `tenant-admin@admin-console.local` | tenant `TenantAdmin` | tenant-admin workspace only |
|
|
31
|
+
| `regular@admin-console.local` | tenant `User` | no admin workspace |
|
|
32
32
|
|
|
33
33
|
The SystemAdmin comes from the `auth.admin` bootstrap in
|
|
34
34
|
`src/app/server.ts`; the other two are created by `seedRoleUsers` in
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"@cosmicdrift/kumiko-dev-server": "workspace:*",
|
|
12
12
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
13
13
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
14
|
-
"lucide-react": "^1.
|
|
15
|
-
"react": "^19.
|
|
16
|
-
"react-dom": "^19.
|
|
14
|
+
"lucide-react": "^1.65.0",
|
|
15
|
+
"react": "^19.3.1",
|
|
16
|
+
"react-dom": "^19.3.1",
|
|
17
17
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -9,9 +9,9 @@ The sample doubles as **living documentation** for the pattern: read the code to
|
|
|
9
9
|
A tiny newsletter app with two tiers:
|
|
10
10
|
|
|
11
11
|
| Tier | Newsletters per month | Soft warning at | Hard block at |
|
|
12
|
-
|
|
13
|
-
| free | 10
|
|
14
|
-
| pro
|
|
12
|
+
| ---- | --------------------- | --------------- | ------------- |
|
|
13
|
+
| free | 10 | 11 (110%) | 12 (120%) |
|
|
14
|
+
| pro | 100 | 110 (110%) | 120 (120%) |
|
|
15
15
|
|
|
16
16
|
Mails land in an **in-memory transport** (`mail-transport-inmemory`).
|
|
17
17
|
There's no real SMTP server — perfect for the demo, no Mailpit/Mailcrab
|
|
@@ -87,12 +87,12 @@ cd samples/apps/cap-billing-demo
|
|
|
87
87
|
bun dev # → http://localhost:4290
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
| Login
|
|
91
|
-
|
|
92
|
-
| URL
|
|
93
|
-
| Email
|
|
94
|
-
| Password | `changeme`
|
|
95
|
-
| Tenant
|
|
90
|
+
| Login | Value |
|
|
91
|
+
| -------- | ----------------------- |
|
|
92
|
+
| URL | `http://localhost:4290` |
|
|
93
|
+
| Email | `admin@cap-demo.local` |
|
|
94
|
+
| Password | `changeme` |
|
|
95
|
+
| Tenant | "Cap-Billing-Demo" |
|
|
96
96
|
|
|
97
97
|
In the browser, use the Designer/Admin UI to set the config key
|
|
98
98
|
`newsletter:config:tier` to `"free"` or `"pro"` and trigger the
|
|
@@ -110,13 +110,13 @@ omitted to keep the focus on cap+tier.
|
|
|
110
110
|
|
|
111
111
|
The sample is intentionally minimal. For a production app, swap:
|
|
112
112
|
|
|
113
|
-
| Demo component
|
|
114
|
-
|
|
115
|
-
| `mail-transport-inmemory`
|
|
116
|
-
| Hardcoded `DEMO_TIER_MAP`
|
|
113
|
+
| Demo component | Production replacement |
|
|
114
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
115
|
+
| `mail-transport-inmemory` | `mail-transport-smtp` (BYOK) or a custom plugin |
|
|
116
|
+
| Hardcoded `DEMO_TIER_MAP` | stays — tier definitions are static, the subscription row only writes the `tier` key |
|
|
117
117
|
| Tier switch via webhook (test-only) | mount a real plugin: `createSubscriptionStripeFeature(...)` and/or `createSubscriptionMollieFeature(...)` in the run config |
|
|
118
|
-
| 2 tiers (free/pro)
|
|
119
|
-
| Newsletter domain
|
|
118
|
+
| 2 tiers (free/pro) | any number, see `samples/apps/platform/src/tier-map.ts` for a 4-tier example |
|
|
119
|
+
| Newsletter domain | your own feature with `withCapEnforcement(handler, capResolver)` |
|
|
120
120
|
|
|
121
121
|
The **plugin API switch** between demo and production is a single
|
|
122
122
|
config value: `mail-foundation:config:provider` flips from
|
|
@@ -26,13 +26,13 @@ The server reads the root `.env` (`--env-file=../../../.env`).
|
|
|
26
26
|
|
|
27
27
|
`src/features/demo/feature.ts` declares five keys with `r.config()`:
|
|
28
28
|
|
|
29
|
-
| Key
|
|
30
|
-
|
|
31
|
-
| `siteName`
|
|
32
|
-
| `themeColor`
|
|
33
|
-
| `maxUploadSize`
|
|
34
|
-
| `emailNotifications` | user
|
|
35
|
-
| `autoApprove`
|
|
29
|
+
| Key | Scope | Type | Default | Write access |
|
|
30
|
+
| -------------------- | ------ | ------------------------ | --------- | -------------------- |
|
|
31
|
+
| `siteName` | tenant | text | `My Site` | all |
|
|
32
|
+
| `themeColor` | tenant | text | `#000000` | all |
|
|
33
|
+
| `maxUploadSize` | tenant | number, bounds 1 to 1000 | `10` | all |
|
|
34
|
+
| `emailNotifications` | user | boolean | `true` | all |
|
|
35
|
+
| `autoApprove` | system | boolean | `false` | `access.systemAdmin` |
|
|
36
36
|
|
|
37
37
|
### Seeds
|
|
38
38
|
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"@cosmicdrift/kumiko-dev-server": "workspace:*",
|
|
14
14
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
15
15
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
16
|
-
"react": "^19.
|
|
17
|
-
"react-dom": "^19.
|
|
16
|
+
"react": "^19.3.1",
|
|
17
|
+
"react-dom": "^19.3.1",
|
|
18
18
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sample app that produces marketing screenshots for
|
|
4
4
|
[kumiko.rocks](https://kumiko.rocks). Two small internal tools (asset tracker
|
|
5
|
-
|
|
6
|
-
same framework stack covers two tools without separate codebases.
|
|
5
|
+
- helpdesk) on a single Kumiko instance — a DACH-persona proof that the
|
|
6
|
+
same framework stack covers two tools without separate codebases.
|
|
7
7
|
|
|
8
8
|
**No auth** — like the showcase, in auto-mint-JWT mode. Straight to
|
|
9
9
|
the edit/list screen without a login. To exercise auth paths, see
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
15
15
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
16
16
|
"@cosmicdrift/kumiko-types": "workspace:*",
|
|
17
|
-
"react": "^19.
|
|
18
|
-
"react-dom": "^19.
|
|
17
|
+
"react": "^19.3.1",
|
|
18
|
+
"react-dom": "^19.3.1",
|
|
19
19
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"@cosmicdrift/kumiko-dev-server": "workspace:*",
|
|
14
14
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
15
15
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
16
|
-
"lucide-react": "^1.
|
|
17
|
-
"react": "^19.
|
|
18
|
-
"react-dom": "^19.
|
|
16
|
+
"lucide-react": "^1.65.0",
|
|
17
|
+
"react": "^19.3.1",
|
|
18
|
+
"react-dom": "^19.3.1",
|
|
19
19
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"@cosmicdrift/kumiko-dev-server": "workspace:*",
|
|
6
6
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
7
7
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
8
|
-
"react": "^19.
|
|
9
|
-
"react-dom": "^19.
|
|
8
|
+
"react": "^19.3.1",
|
|
9
|
+
"react-dom": "^19.3.1",
|
|
10
10
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
11
11
|
},
|
|
12
12
|
"description": "Styleguide-Gallery: rendert die Auto-UI-Bausteine, Screenshot-Runner schießt sie in 3 Themes für die Doku",
|
|
@@ -31,14 +31,14 @@ bun kumiko dev
|
|
|
31
31
|
cd samples/apps/ui-walkthrough && bun dev # http://localhost:4173, admin@kumiko.dev / kumiko-admin
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
| Mask
|
|
35
|
-
|
|
|
36
|
-
| Liste
|
|
37
|
-
| Detail
|
|
38
|
-
| Drawer
|
|
39
|
-
| Formular | `/vehicle-list`, click "Octavia" (`/vehicle-edit`)
|
|
40
|
-
| Wizard
|
|
41
|
-
| Mobile
|
|
34
|
+
| Mask | Route |
|
|
35
|
+
| -------- | ----------------------------------------------------------------------------------------------- |
|
|
36
|
+
| Liste | `/lease-list` (pager check: `/lease-list-short`, 3 rows) |
|
|
37
|
+
| Detail | click "Max Nachmieter" (`/lease-detail/00000000-0000-4000-8000-000000003103`), tab "Positionen" |
|
|
38
|
+
| Drawer | Positionen tab, button "Miete anpassen" |
|
|
39
|
+
| Formular | `/vehicle-list`, click "Octavia" (`/vehicle-edit`) |
|
|
40
|
+
| Wizard | `/vehicle-list`, row menu "Schritt für Schritt" (`/vehicle-wizard`), 3x "Weiter" for step 4 |
|
|
41
|
+
| Mobile | `/campaign-list` at 390 px width |
|
|
42
42
|
|
|
43
43
|
Screenshots (light 1440x900, mobile 390x844, dark for liste and detail):
|
|
44
44
|
|
|
@@ -102,4 +102,3 @@ bun kumiko test e2e samples/apps/ui-walkthrough
|
|
|
102
102
|
|
|
103
103
|
Six Playwright specs: smoke + create flow + update flow + 4 generated
|
|
104
104
|
specs (from the registry-driven E2E generator).
|
|
105
|
-
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
|
|
17
17
|
"@cosmicdrift/kumiko-renderer-puppeteer-client": "workspace:*",
|
|
18
18
|
"@cosmicdrift/kumiko-server-runtime": "workspace:*",
|
|
19
|
-
"react": "^19.
|
|
20
|
-
"react-dom": "^19.
|
|
19
|
+
"react": "^19.3.1",
|
|
20
|
+
"react-dom": "^19.3.1",
|
|
21
21
|
"zod": "^4.6.7",
|
|
22
22
|
"@cosmicdrift/kumiko-locale-de": "workspace:*",
|
|
23
23
|
"@cosmicdrift/kumiko-locale-es": "workspace:*"
|
|
@@ -12,8 +12,8 @@ from any real app. This sample is the gate.
|
|
|
12
12
|
|
|
13
13
|
The `use-all-bundled-smoke` job guards `release`. It boots the sample with `KUMIKO_DRY_RUN_ENV=boot` (no DB) so feature-wiring bugs surface before publish.
|
|
14
14
|
|
|
15
|
-
| Job
|
|
16
|
-
|
|
15
|
+
| Job | What it catches |
|
|
16
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
17
|
| `use-all-bundled-smoke` | feature-wiring bugs: `Object.entries(undefined)`, self-extension, missing-requires, schema-Validators, `composeFeatures` failures. **No DB**. Boot exits after `createRegistry`. |
|
|
18
18
|
|
|
19
19
|
DB-backed boot (schema apply for every bundled feature against real Postgres + Redis, dispatcher round-trip) is gated by `src/__tests__/full-stack-boot.integration.test.ts` in the non-blocking `integration` CI-job. The prod-only paths (`assertSchemaCurrent`, `GET /health`) run in `packages/dev-server`'s `run-prod-app.integration.test.ts` — not against this sample's feature set.
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@cosmicdrift/kumiko-renderer-puppeteer-client": "workspace:*",
|
|
26
26
|
"@cosmicdrift/kumiko-server-runtime": "workspace:*",
|
|
27
27
|
"@cosmicdrift/kumiko-types": "workspace:*",
|
|
28
|
-
"react": "^19.
|
|
29
|
-
"react-dom": "^19.
|
|
28
|
+
"react": "^19.3.1",
|
|
29
|
+
"react-dom": "^19.3.1",
|
|
30
30
|
"zod": "^4.6.7",
|
|
31
31
|
"@cosmicdrift/kumiko-locale-de": "workspace:*",
|
|
32
32
|
"@cosmicdrift/kumiko-locale-es": "workspace:*",
|
|
@@ -232,6 +232,19 @@ function scanSamples(): Map<string, MutableFeatureRow> {
|
|
|
232
232
|
return byFeature;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// The docs site builds sample pages from READMEs only; a primarySample without one
|
|
236
|
+
// makes feature-reference link to a page that does not exist.
|
|
237
|
+
export function assertPrimarySampleHasReadme(
|
|
238
|
+
feature: string,
|
|
239
|
+
slug: string,
|
|
240
|
+
readmeExists: boolean,
|
|
241
|
+
): void {
|
|
242
|
+
if (readmeExists) return;
|
|
243
|
+
throw new Error(
|
|
244
|
+
`sample-index.overrides.json: feature "${feature}" has primarySample "${slug}" but ${slug}/README.md does not exist. Add a README.md to that sample (the docs site only generates sample pages from READMEs) or point primarySample at a sample that has one.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
235
248
|
export function loadOverrides(): SampleIndexOverrides {
|
|
236
249
|
if (!existsSync(OVERRIDES_PATH)) return {};
|
|
237
250
|
return JSON.parse(readFileSync(OVERRIDES_PATH, "utf-8")) as SampleIndexOverrides;
|
|
@@ -257,7 +270,11 @@ export function buildSampleIndex(): SampleIndex {
|
|
|
257
270
|
const primarySample = override?.primarySample ?? dedicated[0];
|
|
258
271
|
let readmeSummary: string | undefined;
|
|
259
272
|
if (primarySample) {
|
|
260
|
-
|
|
273
|
+
const readmePath = join(sampleDirFromSlug(primarySample), "README.md");
|
|
274
|
+
if (override?.primarySample) {
|
|
275
|
+
assertPrimarySampleHasReadme(name, primarySample, existsSync(readmePath));
|
|
276
|
+
}
|
|
277
|
+
readmeSummary = extractReadmeSummary(readmePath);
|
|
261
278
|
}
|
|
262
279
|
|
|
263
280
|
features[name] = {
|
|
@@ -14,12 +14,12 @@ the integration test top-to-bottom and you've understood the contract.
|
|
|
14
14
|
A tiny todo app where each user has private todos. The demo proves that
|
|
15
15
|
DSGVO requests work end-to-end:
|
|
16
16
|
|
|
17
|
-
| Article
|
|
18
|
-
|
|
19
|
-
| **Art. 15**
|
|
20
|
-
| **Art. 15+20** | `user-data-rights:write:request-export`
|
|
21
|
-
| **Art. 17**
|
|
22
|
-
| **Art. 18**
|
|
17
|
+
| Article | Endpoint / Runner | What it does |
|
|
18
|
+
| -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| **Art. 15** | `user-data-rights:query:my-audit-log` | User sees own framework events (auth, deletion-request, restriction). Domain entities like todos appear in the **export bundle** (Art. 20), not the audit-log — only handlers using `ctx.events.append` show up here. |
|
|
20
|
+
| **Art. 15+20** | `user-data-rights:write:request-export` | ZIP with user-profile + fileRefs + todos + signed download magic-link |
|
|
21
|
+
| **Art. 17** | `user-data-rights:write:request-deletion` | Soft-delete with grace period; cron anonymizes user + deletes todos |
|
|
22
|
+
| **Art. 18** | `user-data-rights:write:restrict-account` | Auth-middleware blocks logins until lift-restriction |
|
|
23
23
|
|
|
24
24
|
## Architecture in 3 layers
|
|
25
25
|
|
|
@@ -80,12 +80,12 @@ cd samples/apps/user-data-rights-demo
|
|
|
80
80
|
bun dev # → http://localhost:4291
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
| Login
|
|
84
|
-
|
|
85
|
-
| URL
|
|
86
|
-
| Email
|
|
87
|
-
| Password | `changeme`
|
|
88
|
-
| Tenant
|
|
83
|
+
| Login | Value |
|
|
84
|
+
| -------- | ------------------------------ |
|
|
85
|
+
| URL | `http://localhost:4291` |
|
|
86
|
+
| Email | `admin@user-data-rights.local` |
|
|
87
|
+
| Password | `changeme` |
|
|
88
|
+
| Tenant | "User-Data-Rights Demo" |
|
|
89
89
|
|
|
90
90
|
In the browser, use the dispatcher to create a few todos, then call
|
|
91
91
|
`user-data-rights:write:request-export` — the worker queues a job and
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"@cosmicdrift/kumiko-dev-server": "workspace:*",
|
|
12
12
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
13
13
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
14
|
-
"react": "^19.
|
|
15
|
-
"react-dom": "^19.
|
|
14
|
+
"react": "^19.3.1",
|
|
15
|
+
"react-dom": "^19.3.1",
|
|
16
16
|
"@cosmicdrift/kumiko-locale-de": "workspace:*"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
@@ -10,9 +10,9 @@ spec also paints a product-board mock (`hero-app.png`), uses it in the hero
|
|
|
10
10
|
|
|
11
11
|
`buildLandingPage`'s `head.schemaJson` combines `organizationSchema` +
|
|
12
12
|
`webPageSchema` (from the `seo` feature) into one `@graph` block — Organization
|
|
13
|
-
|
|
14
|
-
`renderApexPage` already knows how to emit. This is the seam an app extends
|
|
15
|
-
with `faqPageSchema` for an FAQ section, or its own custom schema.org type.
|
|
13
|
+
- WebPage nodes in a single `<script type="application/ld+json">`, which
|
|
14
|
+
`renderApexPage` already knows how to emit. This is the seam an app extends
|
|
15
|
+
with `faqPageSchema` for an FAQ section, or its own custom schema.org type.
|
|
16
16
|
|
|
17
17
|
## Site discovery: sitemap.xml / llms.txt
|
|
18
18
|
|