@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.3.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 (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +17 -14
  3. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  4. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  7. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  8. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  9. package/src/auth-email-password/handlers/login.write.ts +1 -1
  10. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  12. package/src/auth-email-password/web/auth-client.ts +1 -1
  13. package/src/billing-foundation/events.ts +1 -1
  14. package/src/billing-foundation/feature.ts +44 -47
  15. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  16. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  17. package/src/billing-foundation/projection.ts +1 -1
  18. package/src/billing-foundation/webhook-handler.ts +1 -1
  19. package/src/cap-counter/constants.ts +1 -1
  20. package/src/cap-counter/enforce-cap.ts +1 -1
  21. package/src/cap-counter/feature.ts +3 -7
  22. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  23. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  24. package/src/cap-counter/handlers/increment.write.ts +3 -3
  25. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  26. package/src/channel-email/email-channel.ts +1 -1
  27. package/src/channel-email/types.ts +1 -1
  28. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
  29. package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
  30. package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
  31. package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
  32. package/src/compliance-profiles/seeding.ts +1 -1
  33. package/src/config/resolver.ts +1 -1
  34. package/src/data-retention/_internal/parse-override.ts +3 -2
  35. package/src/data-retention/handlers/policy-for.query.ts +1 -1
  36. package/src/data-retention/keep-for.ts +1 -1
  37. package/src/data-retention/presets.ts +1 -1
  38. package/src/data-retention/resolve-for-tenant.ts +1 -1
  39. package/src/delivery/feature.ts +1 -1
  40. package/src/delivery/testing.ts +1 -2
  41. package/src/delivery/upsert-preference.ts +1 -1
  42. package/src/feature-toggles/feature.ts +1 -1
  43. package/src/feature-toggles/handlers/list.query.ts +1 -1
  44. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  45. package/src/feature-toggles/handlers/set.write.ts +3 -3
  46. package/src/file-foundation/feature.ts +1 -1
  47. package/src/file-provider-s3/feature.ts +2 -2
  48. package/src/files-provider-s3/s3-provider.ts +2 -2
  49. package/src/jobs/handlers/list.query.ts +3 -3
  50. package/src/jobs/handlers/trigger.write.ts +1 -1
  51. package/src/legal-pages/constants.ts +1 -0
  52. package/src/legal-pages/web/client-plugin.ts +42 -0
  53. package/src/legal-pages/web/index.ts +4 -0
  54. package/src/mail-foundation/feature.ts +1 -1
  55. package/src/mail-transport-smtp/feature.ts +2 -2
  56. package/src/renderer-simple/simple-renderer.ts +1 -1
  57. package/src/secrets/handlers/rotate.job.ts +2 -2
  58. package/src/sessions/handlers/cleanup.job.ts +2 -2
  59. package/src/step-dispatcher/feature.ts +62 -0
  60. package/src/step-dispatcher/index.ts +16 -0
  61. package/src/step-dispatcher/mail-runner.ts +32 -0
  62. package/src/step-dispatcher/webhook-runner.ts +67 -0
  63. package/src/subscription-mollie/plugin-methods.ts +1 -1
  64. package/src/subscription-mollie/verify-webhook.ts +9 -5
  65. package/src/subscription-stripe/verify-webhook.ts +3 -3
  66. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  67. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  68. package/src/tenant/handlers/remove-member.write.ts +1 -1
  69. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  70. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  71. package/src/text-content/constants.ts +2 -0
  72. package/src/text-content/feature.ts +20 -4
  73. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  74. package/src/text-content/handlers/set.write.ts +1 -1
  75. package/src/text-content/web/client-plugin.ts +113 -0
  76. package/src/text-content/web/index.ts +8 -0
  77. package/src/tier-engine/feature.ts +8 -8
  78. package/src/user/handlers/find-for-auth.query.ts +1 -1
  79. package/src/user/seeding.ts +2 -2
  80. package/src/user-data-rights/feature.ts +4 -3
  81. package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  82. package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
  83. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
  84. package/src/user-data-rights/handlers/export-status.query.ts +1 -1
  85. package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  86. package/src/user-data-rights/handlers/request-export.write.ts +2 -2
  87. package/src/user-data-rights/run-export-jobs.ts +2 -2
  88. package/src/user-data-rights/run-forget-cleanup.ts +27 -28
  89. package/src/user-data-rights/run-user-export.ts +1 -1
  90. package/src/user-data-rights/token-helpers.ts +2 -2
  91. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
  92. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
@@ -0,0 +1,67 @@
1
+ // Webhook execution logic — separated from feature.ts so tests can stub
2
+ // the fetch without touching the MSP wiring.
3
+
4
+ import { z } from "zod";
5
+
6
+ export const webhookSpecSchema = z.object({
7
+ url: z.string(),
8
+ method: z.enum(["POST", "PUT", "PATCH"]),
9
+ headers: z.record(z.string(), z.string()),
10
+ body: z.unknown().optional(),
11
+ auth: z
12
+ .union([
13
+ z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
14
+ z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
15
+ ])
16
+ .optional(),
17
+ });
18
+
19
+ export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
20
+
21
+ export type WebhookDispatchResult =
22
+ | { readonly ok: true; readonly status: number }
23
+ | { readonly ok: false; readonly error: string };
24
+
25
+ // Resolves a secretRef via the test-injectable secret-store. Default
26
+ // implementation reads from process.env at the prefix WEBHOOK_SECRET_.
27
+ // Tests pass a custom resolver via setWebhookSecretResolver.
28
+ let secretResolver: (ref: string) => string | undefined = (ref) =>
29
+ process.env[`WEBHOOK_SECRET_${ref}`];
30
+
31
+ export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
32
+ secretResolver = fn;
33
+ }
34
+
35
+ let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
36
+
37
+ export function setWebhookFetch(fn: typeof fetch): void {
38
+ fetchImpl = fn;
39
+ }
40
+
41
+ export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
42
+ const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
43
+ if (spec.auth) {
44
+ const secret = secretResolver(spec.auth.secretRef);
45
+ if (!secret) {
46
+ return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
47
+ }
48
+ if (spec.auth.kind === "bearer") {
49
+ headers["authorization"] = `Bearer ${secret}`;
50
+ } else {
51
+ headers[spec.auth.name] = secret;
52
+ }
53
+ }
54
+ try {
55
+ const res = await fetchImpl(spec.url, {
56
+ method: spec.method,
57
+ headers,
58
+ body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
59
+ });
60
+ if (!res.ok) {
61
+ return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
62
+ }
63
+ return { ok: true, status: res.status };
64
+ } catch (err) {
65
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
66
+ }
67
+ }
@@ -66,7 +66,7 @@ export function createMollieCheckoutSession(
66
66
  tenantId: options.tenantId,
67
67
  priceId: options.priceId,
68
68
  },
69
- }) as Promise<Payment>)) satisfies Payment;
69
+ }) as Promise<Payment>)) satisfies Payment; // @cast-boundary engine-bridge
70
70
 
71
71
  const checkoutHref = payment.getCheckoutUrl();
72
72
  if (!checkoutHref) {
@@ -102,7 +102,7 @@ export function verifyAndParseMollieWebhook(
102
102
  return null;
103
103
  }
104
104
 
105
- const metadata = (subscription.metadata as Record<string, string> | null) ?? {};
105
+ const metadata = (subscription.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
106
106
  const tenantId = metadata["tenantId"];
107
107
  if (!tenantId || tenantId.length === 0) return null;
108
108
  const priceId = metadata["priceId"];
@@ -153,7 +153,7 @@ async function ensureSubscriptionForMandate(
153
153
  ): Promise<MollieSubscription | null> {
154
154
  const customerId = payment.customerId;
155
155
  if (!customerId) return null;
156
- const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {};
156
+ const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
157
157
  const tenantId = paymentMetadata["tenantId"];
158
158
  const priceId = paymentMetadata["priceId"];
159
159
  if (!tenantId || !priceId) return null;
@@ -163,7 +163,7 @@ async function ensureSubscriptionForMandate(
163
163
  const existing = await client.customerSubscriptions.list(customerId);
164
164
  const matchingExisting = existing.find(
165
165
  (sub) =>
166
- (sub.metadata as Record<string, string> | null)?.["priceId"] === priceId &&
166
+ (sub.metadata as Record<string, string> | null)?.["priceId"] === priceId && // @cast-boundary engine-bridge
167
167
  (sub.status === "active" || sub.status === "pending"),
168
168
  );
169
169
  if (matchingExisting) return matchingExisting;
@@ -185,8 +185,12 @@ export function extractMollieId(rawBody: string, headers: Record<string, string>
185
185
  const contentType = headers["content-type"] ?? "";
186
186
  if (contentType.includes("application/json")) {
187
187
  try {
188
- const parsed = JSON.parse(rawBody) as { id?: unknown };
189
- return typeof parsed.id === "string" ? parsed.id : null;
188
+ const parsed: unknown = JSON.parse(rawBody);
189
+ const id =
190
+ typeof parsed === "object" && parsed !== null && "id" in parsed
191
+ ? (parsed as Record<string, unknown>)["id"] // @cast-boundary engine-payload
192
+ : undefined;
193
+ return typeof id === "string" ? id : null;
190
194
  } catch {
191
195
  return null;
192
196
  }
@@ -203,15 +203,15 @@ async function extractSubscriptionFromEvent(
203
203
  case StripeEventTypes.customerSubscriptionCreated:
204
204
  case StripeEventTypes.customerSubscriptionUpdated:
205
205
  case StripeEventTypes.customerSubscriptionDeleted:
206
- return event.data.object as Stripe.Subscription;
206
+ return event.data.object as Stripe.Subscription; // @cast-boundary engine-bridge
207
207
  case StripeEventTypes.invoicePaid:
208
208
  case StripeEventTypes.invoicePaymentFailed: {
209
209
  // Lazy-fetch der subscription. invoice.subscription ist eine
210
210
  // string-id (Stripe-Webhooks expanden nicht auto). Wir holen das
211
211
  // full subscription-Object damit der downstream-mapping
212
212
  // (status, tier via priceId, period-end) konsistent funktioniert.
213
- const invoice = event.data.object as Stripe.Invoice;
214
- const subRef = (invoice as { subscription?: string | Stripe.Subscription | null })
213
+ const invoice = event.data.object as Stripe.Invoice; // @cast-boundary engine-bridge
214
+ const subRef = (invoice as { subscription?: string | Stripe.Subscription | null }) // @cast-boundary engine-payload
215
215
  .subscription;
216
216
  if (!subRef) {
217
217
  // Invoice ohne subscription-reference (= one-shot-invoice, nicht
@@ -14,6 +14,6 @@ export const activeTenantIdsQuery = defineQueryHandler({
14
14
  .from(tenantTable)
15
15
  .where(eq(tenantTable["isEnabled"], true));
16
16
 
17
- return rows.map((row) => (row as DbRow)["id"] as number);
17
+ return rows.map((row) => (row as DbRow)["id"] as number); // @cast-boundary db-row
18
18
  },
19
19
  });
@@ -61,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
61
61
  const updateResult = await executor.update(
62
62
  {
63
63
  id: event.payload.invitationId,
64
- version: invitation["version"] as number,
64
+ version: invitation["version"] as number, // @cast-boundary db-row
65
65
  changes: { status: INVITATION_STATUS.cancelled },
66
66
  },
67
67
  event.user,
@@ -31,7 +31,7 @@ export const removeMemberWrite = defineWriteHandler({
31
31
  }
32
32
 
33
33
  const result = await executor.delete(
34
- { id: (existing as DbRow)["id"] as string },
34
+ { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
35
35
  event.user,
36
36
  db,
37
37
  );
@@ -27,7 +27,7 @@ export const resolveUserIdsQuery = defineQueryHandler({
27
27
  .select({ userId: tenantMembershipsTable.userId })
28
28
  .from(tenantMembershipsTable)
29
29
  .where(eq(tenantMembershipsTable.tenantId, tenantId));
30
- return rows.map((r) => r["userId"] as number);
30
+ return rows.map((r) => r["userId"] as number); // @cast-boundary db-row
31
31
  }
32
32
 
33
33
  if (userId !== undefined) {
@@ -39,11 +39,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
39
39
  // between this read and append) surfaces as version_conflict rather than
40
40
  // silent overwrite. Per-membership parallelism is rare; if it happens,
41
41
  // the client retries on the error.
42
- const row = existing as DbRow;
42
+ const row = existing as DbRow; // @cast-boundary generic-record
43
43
  const result = await executor.update(
44
44
  {
45
- id: row["id"] as string,
46
- version: row["version"] as number,
45
+ id: row["id"] as string, // @cast-boundary db-row
46
+ version: row["version"] as number, // @cast-boundary db-row
47
47
  changes: { roles: JSON.stringify(event.payload.roles) },
48
48
  },
49
49
  event.user,
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const TEXT_CONTENT_FEATURE = "text-content" as const;
3
4
 
@@ -9,6 +10,7 @@ export const TextContentHandlers = {
9
10
  // Qualified query handler names (QN format: scope:type:name)
10
11
  export const TextContentQueries = {
11
12
  bySlug: "text-content:query:by-slug",
13
+ byTenant: "text-content:query:by-tenant",
12
14
  } as const;
13
15
 
14
16
  // Error codes
@@ -1,5 +1,6 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { bySlugQuery } from "./handlers/by-slug.query";
3
+ import { byTenantQuery } from "./handlers/by-tenant.query";
3
4
  import { setWrite } from "./handlers/set.write";
4
5
  import { textBlockEntity } from "./table";
5
6
 
@@ -11,8 +12,16 @@ import { textBlockEntity } from "./table";
11
12
  //
12
13
  // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
13
14
  // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
14
- // query — Routes/Render kommen pro Use-Case (legal-pages, etc.).
15
- export function createTextContentFeature(): FeatureDefinition {
15
+ // query + by-tenant-list-query — Routes/Render kommen pro Use-Case
16
+ // (legal-pages, Visual-Tree, etc.).
17
+ //
18
+ // **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
19
+ // Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
20
+ // wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
21
+ // sodass andere Features compile-time-typed Cross-Feature-Edits triggern
22
+ // können — siehe legal-pages's TreeProvider der text-content:edit als
23
+ // Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
24
+ export function createTextContentFeature() {
16
25
  return defineFeature("text-content", (r) => {
17
26
  r.entity("text-block", textBlockEntity);
18
27
 
@@ -22,8 +31,15 @@ export function createTextContentFeature(): FeatureDefinition {
22
31
 
23
32
  const queries = {
24
33
  bySlug: r.queryHandler(bySlugQuery),
34
+ byTenant: r.queryHandler(byTenantQuery),
25
35
  };
26
36
 
27
- return { handlers, queries };
37
+ const treeHandle = r.treeActions({
38
+ edit: { args: { slug: "" as string, lang: "" as string } },
39
+ list: {},
40
+ create: { args: { folder: "" as string } },
41
+ });
42
+
43
+ return { handlers, queries, treeHandle };
28
44
  });
29
45
  }
@@ -0,0 +1,56 @@
1
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { type TextBlockRow, textBlocksTable } from "../table";
7
+
8
+ // Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
9
+ // Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
10
+ // in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
11
+ // auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
12
+ // `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
13
+ //
14
+ // **Listing statt single-row**: anders als by-slug returnt das hier
15
+ // `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
16
+ // Summary-Felder (kein full body — den lädt der Editor on-demand via
17
+ // by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
18
+ export type TextBlockSummary = {
19
+ readonly slug: string;
20
+ readonly lang: string;
21
+ readonly title: string;
22
+ readonly body: string | null;
23
+ readonly updatedAt: Date;
24
+ };
25
+
26
+ export const byTenantQuery = defineQueryHandler({
27
+ name: "by-tenant",
28
+ schema: z.object({
29
+ /** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch
30
+ * zur by-slug.query und set.write Override-Logik. */
31
+ tenantIdOverride: z.string().min(1).optional(),
32
+ }),
33
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
34
+ handler: async (query, ctx) => {
35
+ const override = query.payload.tenantIdOverride;
36
+ if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
37
+ throw new AccessDeniedError({
38
+ i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
39
+ details: { reason: "tenant_override_requires_system_admin" },
40
+ });
41
+ }
42
+ const tenantId = override ?? query.user.tenantId;
43
+ const rows = castTenantRows<TextBlockRow>(
44
+ await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
45
+ );
46
+ return {
47
+ blocks: rows.map((row) => ({
48
+ slug: row.slug,
49
+ lang: row.lang,
50
+ title: row.title,
51
+ body: row.body,
52
+ updatedAt: row.updatedAt,
53
+ })),
54
+ };
55
+ },
56
+ });
@@ -68,7 +68,7 @@ export const setWrite = defineWriteHandler({
68
68
  // Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
69
69
  // SYSTEM_TENANT) als by verwendet.
70
70
  const executorUser =
71
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
71
+ override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
72
72
 
73
73
  const existing = await fetchOne<TextBlockRow>(
74
74
  db,
@@ -0,0 +1,113 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
3
+ // in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
4
+ // und liefert den treeProvider der Text-Blocks aus der by-tenant Query
5
+ // lädt, nach Slug-Prefix gruppiert und als TreeNode[] emitted.
6
+ //
7
+ // **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
8
+ // werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
9
+ // Trenner landen als Top-Level-Knoten. Beispiele:
10
+ // - "page:index:hero.title" → folder "page", label "index:hero.title"
11
+ // - "imprint" → root-node, label "imprint"
12
+ // V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
13
+ //
14
+ // **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
15
+ // "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
16
+ //
17
+ // **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
18
+ // ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
19
+ // updated-Events propagiert werden.
20
+
21
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
22
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
23
+ import { TextContentQueries } from "../constants";
24
+
25
+ type BlockSummary = {
26
+ readonly slug: string;
27
+ readonly lang: string;
28
+ readonly title: string;
29
+ readonly body: string | null;
30
+ readonly updatedAt: string;
31
+ };
32
+
33
+ type ByTenantResponse = {
34
+ readonly data: { readonly blocks: readonly BlockSummary[] };
35
+ };
36
+
37
+ // Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
38
+ // der Slug keinen Trenner enthält (dann landet er als Root-Node).
39
+ function getFolderName(slug: string): string | undefined {
40
+ const sepIdx = slug.search(/[:/]/);
41
+ if (sepIdx === -1) return undefined;
42
+ return slug.slice(0, sepIdx);
43
+ }
44
+
45
+ function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
46
+ const rootNodes: TreeNode[] = [];
47
+ const folders = new Map<string, TreeNode[]>();
48
+
49
+ for (const block of blocks) {
50
+ const node: TreeNode = {
51
+ label: block.title || block.slug,
52
+ target: {
53
+ featureId: "text-content",
54
+ action: "edit",
55
+ args: { slug: block.slug, lang: block.lang },
56
+ },
57
+ state: block.body ? "filled" : "stub",
58
+ };
59
+
60
+ const folderName = getFolderName(block.slug);
61
+ if (folderName === undefined) {
62
+ rootNodes.push(node);
63
+ } else {
64
+ const existing = folders.get(folderName) ?? [];
65
+ existing.push(node);
66
+ folders.set(folderName, existing);
67
+ }
68
+ }
69
+
70
+ for (const [name, children] of folders) {
71
+ rootNodes.push({
72
+ label: name,
73
+ icon: "folder",
74
+ state: "filled",
75
+ children,
76
+ });
77
+ }
78
+
79
+ return rootNodes;
80
+ }
81
+
82
+ const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
83
+ fetch("/api/query", {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({
87
+ type: TextContentQueries.byTenant,
88
+ payload: {},
89
+ }),
90
+ })
91
+ .then((r) => r.json())
92
+ .then((data: ByTenantResponse) => {
93
+ const nodes = groupBlocksBySlugPrefix(data.data.blocks);
94
+ emit(nodes);
95
+ })
96
+ .catch(() => {
97
+ // V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
98
+ emit([]);
99
+ });
100
+ return () => {};
101
+ };
102
+
103
+ export function textContentClient(): ClientFeatureDefinition {
104
+ return {
105
+ name: "text-content",
106
+ treeProvider,
107
+ treeActions: {
108
+ edit: { args: { slug: "" as string, lang: "" as string } },
109
+ list: {},
110
+ create: { args: { folder: "" as string } },
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des text-content Features.
3
+ // Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
4
+ // konsumiert — die Server-Seite (defineFeature) lebt in
5
+ // `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
6
+ // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
+
8
+ export { textContentClient } from "./client-plugin";
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
197
197
  // Invalidation: tier-assignment events update the cache.
198
198
  r.entityHook("postSave", "tier-assignment", async (result) => {
199
199
  // result.data has tenantId + tier (after entity-update merge)
200
- const data = result.data as { tenantId?: unknown; tier?: unknown };
200
+ const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
201
201
  // skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
202
202
  // entity-events sind beide fields immer strings; ein malformed-payload
203
203
  // (custom-handler-bug) würde hier silent zum cache-skip führen statt
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
210
210
  );
211
211
  });
212
212
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
213
- const data = payload.data as { tenantId?: unknown };
213
+ const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
214
214
  // skip: gleiche type-guard semantik wie postSave-hook oben.
215
215
  if (typeof data.tenantId !== "string") return;
216
216
  cache.delete(data.tenantId as TenantId);
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
235
235
  "tenant",
236
236
  async (result, ctx) => {
237
237
  // result-shape: kumiko-framework's SaveContext mit isNew + data
238
- const saveResult = result as { isNew?: unknown; data?: unknown };
238
+ const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
239
239
  // skip: nur bei tenant-create (initial) — tenant-updates feuern
240
240
  // auch postSave aber wir wollen kein neues tier-assignment bei
241
241
  // re-keying oder name-update.
242
242
  if (saveResult.isNew !== true) return;
243
- const data = saveResult.data as { id?: unknown };
243
+ const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
244
244
  // skip: defensive type-guard. Tenant-entity hat id zwingend, aber
245
245
  // CrudExecutor's payload-shape ist runtime-unknown.
246
246
  if (typeof data.id !== "string") return;
247
- const newTenantId = data.id as TenantId;
247
+ const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
248
248
  const aggregateId = tierAssignmentAggregateId(newTenantId);
249
249
 
250
250
  // skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
@@ -268,7 +268,7 @@ export function createTierEngineFeature<
268
268
  // `as TenantDb` gegen future refactor.
269
269
  // skip: defensive — sollte im inTransaction nie greifen.
270
270
  if (!("raw" in ctx.db)) return;
271
- const rawDb = ctx.db.raw as DbConnection;
271
+ const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
272
272
 
273
273
  // Idempotency: stream-existence-check vor create. Pattern aus
274
274
  // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
@@ -276,7 +276,7 @@ export function createTierEngineFeature<
276
276
  const [streamRow] = (await rawDb
277
277
  .select({ v: maxFn(eventsTable.version) })
278
278
  .from(eventsTable)
279
- .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
279
+ .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
280
280
  // skip: idempotency — aggregate-stream existiert schon (re-replay
281
281
  // nach projection-rebuild oder hook-retry). create() würde
282
282
  // version_conflict werfen + tenant-create rollback'n. Pattern aus
@@ -336,7 +336,7 @@ export function createTierEngineFeature<
336
336
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
337
337
  // Bedürfnis entsteht.
338
338
  type AssignmentRow = { tenantId: string; tier: string };
339
- const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
339
+ const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
340
340
  for (const row of rows) {
341
341
  cache.set(
342
342
  row.tenantId as TenantId,
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
30
30
  const condition =
31
31
  query.payload.email !== undefined
32
32
  ? eq(userTable["email"], query.payload.email)
33
- : eq(userTable["id"], query.payload.id as string);
33
+ : eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
34
34
 
35
35
  const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
36
36
  return (row as DbRow) ?? null;
@@ -57,7 +57,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
57
57
  const tdb = createTenantDb(db, by.tenantId, "system");
58
58
 
59
59
  const existing = await fetchOne(db, userTable, eq(userTable["email"], options.email));
60
- if (existing) return existing["id"] as string;
60
+ if (existing) return existing["id"] as string; // @cast-boundary db-row
61
61
 
62
62
  const result = await userExecutor.create(
63
63
  {
@@ -86,7 +86,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
86
86
  // als ehrlicher Throw rauskommt statt downstream als undefined-Bug.
87
87
  function extractId(data: unknown, who: string): string {
88
88
  if (typeof data === "object" && data !== null && "id" in data) {
89
- const id = (data as { id: unknown }).id;
89
+ const id = (data as { id: unknown }).id; // @cast-boundary engine-bridge
90
90
  if (typeof id === "string") return id;
91
91
  }
92
92
  throw new Error(`${who}: executor.create result has no string id (got ${JSON.stringify(data)})`);
@@ -238,7 +238,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
238
238
  _userId: ctx._userId ?? SYSTEM_USER_ID,
239
239
  };
240
240
  await runExportJobs({
241
- db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
241
+ db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
242
242
  registry: ctx.registry,
243
243
  buildStorageProvider: async (tenantId) =>
244
244
  createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"),
@@ -268,11 +268,12 @@ async function mapQueryResponseToRedirect(
268
268
  ): Promise<Response> {
269
269
  if (!queryRes.ok) {
270
270
  const errorBody = await queryRes.text();
271
- return c.body(errorBody, queryRes.status as 400 | 401 | 404 | 410 | 500, {
271
+ const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
272
+ return c.body(errorBody, statusCode, {
272
273
  "content-type": queryRes.headers.get("content-type") ?? "application/json",
273
274
  });
274
275
  }
275
- const body = (await queryRes.json()) as { data?: { url?: string } };
276
+ const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
276
277
  if (!body.data?.url) {
277
278
  return c.json({ error: "download_resolution_failed" }, 500);
278
279
  }
@@ -77,7 +77,7 @@ export const cancelDeletionWrite = defineWriteHandler({
77
77
  data: {
78
78
  userId: event.user.id,
79
79
  status: USER_STATUS.Active,
80
- gracePeriodEnd: null as string | null,
80
+ gracePeriodEnd: null as string | null, // @cast-boundary generic-record
81
81
  },
82
82
  };
83
83
  },
@@ -80,11 +80,11 @@ export const downloadByJobQuery = defineQueryHandler({
80
80
  ctx.db.raw,
81
81
  exportJobsTable,
82
82
  eq(exportJobsTable["id"], jobId),
83
- )) as JobRow | null;
83
+ )) as JobRow | null; // @cast-boundary db-row
84
84
 
85
85
  if (!jobRow || jobRow.userId !== userId) {
86
86
  await recordInvalidAttempt({
87
- db: ctx.db.raw as DbConnection,
87
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
88
88
  tenantId,
89
89
  now,
90
90
  result: "notFound",
@@ -102,7 +102,7 @@ export const downloadByJobQuery = defineQueryHandler({
102
102
 
103
103
  if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
104
104
  await recordInvalidAttempt({
105
- db: ctx.db.raw as DbConnection,
105
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
106
106
  tenantId,
107
107
  now,
108
108
  result: "failed",
@@ -119,7 +119,7 @@ export const downloadByJobQuery = defineQueryHandler({
119
119
  }
120
120
  if (!jobRow.downloadStorageKey) {
121
121
  await recordInvalidAttempt({
122
- db: ctx.db.raw as DbConnection,
122
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
123
123
  tenantId,
124
124
  now,
125
125
  result: "expired",
@@ -142,7 +142,7 @@ export const downloadByJobQuery = defineQueryHandler({
142
142
  );
143
143
  if (!provider.getSignedUrl) {
144
144
  await recordInvalidAttempt({
145
- db: ctx.db.raw as DbConnection,
145
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
146
146
  tenantId,
147
147
  now,
148
148
  result: "signedUrlNotSupported",
@@ -177,20 +177,17 @@ export const downloadByJobQuery = defineQueryHandler({
177
177
  ctx.db.raw,
178
178
  exportDownloadTokensTable,
179
179
  eq(exportDownloadTokensTable["jobId"], jobId),
180
- )) as TokenRow | null;
180
+ )) as TokenRow | null; // @cast-boundary db-row
181
181
 
182
182
  if (tokenRow) {
183
183
  await recordDownloadUse({
184
- // @cast-boundary db: ctx.db.raw ist DbRunner, im query-pfad immer
185
- // Connection. Cast legit weil recordDownloadUse intern createTenantDb
186
- // braucht.
187
- db: ctx.db.raw as DbConnection,
184
+ db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
188
185
  tokenId: tokenRow.id,
189
186
  tokenVersion: tokenRow.version,
190
187
  tokenUseCount: tokenRow.useCount ?? 0,
191
188
  tenantId: jobRow.requestedFromTenantId as Parameters<
192
189
  typeof recordDownloadUse
193
- >[0]["tenantId"],
190
+ >[0]["tenantId"], // @cast-boundary engine-bridge
194
191
  now,
195
192
  ip: query.payload.auditMeta?.ip ?? null,
196
193
  userAgent: query.payload.auditMeta?.userAgent ?? null,