@cosmicdrift/kumiko-bundled-features 0.2.2 → 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 (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # @cosmicdrift/kumiko-bundled-features
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0.3.0 bringt zwei neue Subsysteme (Step-Engine Tier-3 + Visual-Tree) plus
8
+ eine AST-Codemod-Pipeline als Vorarbeit für den L2-AI-Layer.
9
+
10
+ ### Breaking Changes
11
+
12
+ - `skipTransitionGuard` → `unsafeSkipTransitionGuard` (Rename in
13
+ feature-ast + engine). Der `unsafe`-Prefix macht die Tragweite des
14
+ Casts sichtbar und ist konsistent zur `unsafeProjectionUpsert`- und
15
+ `r.rawTable`-Konvention. Migration: 1:1-Ersetzung, keine Verhaltens-Änderung.
16
+
17
+ ### Features
18
+
19
+ - **Step-Engine M.4 — Tier-3 Workflow-Engine.** Neue Step-Vocabulary
20
+ `wait`, `waitForEvent`, `retry` ermöglicht persistierte Long-Running-Flows
21
+ über Job-Boundaries hinweg. Q7 Snapshot-at-Start hängt jedem Step-Run
22
+ einen SHA-256-Fingerprint des Aggregat-Zustands an, sodass Replays
23
+ deterministisch gegen den ursprünglichen Eingangszustand laufen.
24
+ - **Visual-Tree V.1.x — Tree-API + Editor-Panel.** Neue `VisualTree`-
25
+ Component plus TreeProvider-Pattern; erste TreeProviders für
26
+ `text-content` und `legal-pages` (CMS-light + Impressum/Privacy).
27
+ Fundament für den späteren No-Code-Designer (~3000 LOC, 98 Tests).
28
+ - **Codemod-Pipeline.** AST-basierte Patcher-Module für strukturelle
29
+ Feature-Edits — wird vom kommenden L2-AI-Layer als Tool-Surface
30
+ verwendet, ist aber eigenständig nutzbar für ts-morph-style Migrationen.
31
+ - **user-data-rights Sample-Recipe.** DSGVO Art. 15/17/18/20 vollständig
32
+ als Sample-Recipe (`samples/recipes/`) inklusive README — zeigt die
33
+ Export- und Forget-Pipeline gegen den `compliance-profiles`-Default
34
+ (`eu-dsgvo`).
35
+
36
+ ### Fixes
37
+
38
+ - `tier-engine`: auto-default-tier-Hook benutzt jetzt `ctx.db.raw` für
39
+ Event-Store-Operationen (#37, vorher: stiller Bug, 22 Tage live).
40
+ - `engine`: unsafe-projection-upsert nutzt `as never` statt `as any` —
41
+ schmaler Cast-Surface, weniger Compiler-Knebel.
42
+ - `visual-tree`: runtime-isolation marker für client-konsumierte Files,
43
+ damit der Multi-Entry-Build den richtigen Bundle-Split bekommt.
44
+ - `feature-ast`: vollständiger `unsafeSkipTransitionGuard`-Rename (war
45
+ in zwei Modulen noch der alte Name).
46
+ - `framework`: Error-Reasons + `noConsole`-Lint + No-Date-API-Guard
47
+ wieder push-ready.
48
+
49
+ ### Library-Updates
50
+
51
+ hono 4.12, jose 6.2, stripe 22.1, meilisearch 0.58, marked 18,
52
+ bun-types 1.3.13, lucide-react 1.14, bullmq 5.76, ioredis 5.10,
53
+ i18next 26.0, react + radix-ui-primitives auf aktuelle Minors.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies
58
+ - @cosmicdrift/kumiko-framework@0.3.0
59
+ - @cosmicdrift/kumiko-dispatcher-live@0.3.0
60
+ - @cosmicdrift/kumiko-renderer@0.3.0
61
+ - @cosmicdrift/kumiko-renderer-web@0.3.0
62
+
63
+ ## 0.2.3
64
+
65
+ ### Patch Changes
66
+
67
+ - 1dbd038: Fix `db.execute is not a function` crash in `createTierEngineFeature`'s
68
+ auto-default-tier postSave-hook when called via the dispatcher path
69
+ (`tenant:write:create`). The hook used `ctx.db as DbConnection` — a
70
+ type-lie. AppContext.db in the inTransaction-phase is a TenantDb, which
71
+ exposes select/insert/update/delete but not execute(). The event-store-
72
+ append (event-store.ts:102) calls `db.execute(sql\`SELECT pg_notify(...)\`)`,
73
+ which crashed at runtime.
74
+
75
+ Fix: typeguard via `if (!("raw" in ctx.db)) return` then use `ctx.db.raw
76
+ as DbConnection` (pattern matched signup-confirm.write.ts:107).
77
+
78
+ Plus: regression integration-test in `tier-engine/__tests__/auto-default-
79
+ tier.integration.ts` covering the dispatcher path (sysadmin →
80
+ tenant:write:create → tier_assignments-row + idempotency on tenant-update).
81
+
82
+ **Known production gap (separate from this fix):** Self-Signup goes through
83
+ `provisionSignupAccount → seedTenant` (event-store-direct), which bypasses
84
+ the dispatcher → postSave-hooks never fire in production self-signup. This
85
+ fix makes the dispatcher path coherent. Real-signup auto-default needs
86
+ follow-up work (either seedTenant fires hooks or signup-confirm calls
87
+ explicit seed-helpers).
88
+
89
+ - @cosmicdrift/kumiko-framework@0.2.3
90
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.3
91
+ - @cosmicdrift/kumiko-renderer@0.2.3
92
+ - @cosmicdrift/kumiko-renderer-web@0.2.3
93
+
3
94
  ## 0.2.2
4
95
 
5
96
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -19,7 +19,9 @@
19
19
  },
20
20
  "exports": {
21
21
  "./audit": "./src/audit/index.ts",
22
+ "./compliance-profiles": "./src/compliance-profiles/index.ts",
22
23
  "./config": "./src/config/index.ts",
24
+ "./data-retention": "./src/data-retention/index.ts",
23
25
  "./jobs": "./src/jobs/index.ts",
24
26
  "./tier-engine": "./src/tier-engine/index.ts",
25
27
  "./cap-counter": "./src/cap-counter/index.ts",
@@ -33,6 +35,9 @@
33
35
  "./file-foundation": "./src/file-foundation/index.ts",
34
36
  "./file-provider-s3": "./src/file-provider-s3/index.ts",
35
37
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
38
+ "./files": "./src/files/index.ts",
39
+ "./user-data-rights": "./src/user-data-rights/index.ts",
40
+ "./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
36
41
  "./tenant": "./src/tenant/index.ts",
37
42
  "./tenant/constants": "./src/tenant/constants.ts",
38
43
  "./tenant/seeding": "./src/tenant/seeding.ts",
@@ -58,25 +63,29 @@
58
63
  "./feature-toggles": "./src/feature-toggles/index.ts",
59
64
  "./text-content": "./src/text-content/index.ts",
60
65
  "./text-content/seeding": "./src/text-content/seeding.ts",
61
- "./legal-pages": "./src/legal-pages/index.ts"
66
+ "./text-content/web": "./src/text-content/web/index.ts",
67
+ "./legal-pages": "./src/legal-pages/index.ts",
68
+ "./legal-pages/web": "./src/legal-pages/web/index.ts",
69
+ "./step-dispatcher": "./src/step-dispatcher/index.ts"
62
70
  },
63
71
  "dependencies": {
64
- "@aws-sdk/client-s3": "^3.700.0",
65
- "@aws-sdk/s3-request-presigner": "^3.700.0",
66
- "@cosmicdrift/kumiko-dispatcher-live": "0.2.2",
67
- "@cosmicdrift/kumiko-framework": "0.2.2",
68
- "@cosmicdrift/kumiko-renderer": "0.2.2",
69
- "@cosmicdrift/kumiko-renderer-web": "0.2.2",
72
+ "@aws-sdk/client-s3": "^3.1045.0",
73
+ "@aws-sdk/lib-storage": "^3.1045.0",
74
+ "@aws-sdk/s3-request-presigner": "^3.1045.0",
75
+ "@cosmicdrift/kumiko-dispatcher-live": "0.3.0",
76
+ "@cosmicdrift/kumiko-framework": "0.3.0",
77
+ "@cosmicdrift/kumiko-renderer": "0.3.0",
78
+ "@cosmicdrift/kumiko-renderer-web": "0.3.0",
70
79
  "@mollie/api-client": "^4.5.0",
71
80
  "@node-rs/argon2": "^2.0.2",
72
81
  "@types/nodemailer": "^8.0.0",
73
82
  "clsx": "^2.1.1",
74
- "lucide-react": "^1.11.0",
75
- "marked": "^14.1.3",
83
+ "lucide-react": "^1.14.0",
84
+ "marked": "^18.0.3",
76
85
  "nodemailer": "^8.0.7",
77
- "react": "^19.2.0",
78
- "stripe": "^22.1.0",
79
- "tailwind-merge": "^3.0.2"
86
+ "react": "^19.2.6",
87
+ "stripe": "^22.1.1",
88
+ "tailwind-merge": "^3.6.0"
80
89
  },
81
90
  "publishConfig": {
82
91
  "registry": "https://registry.npmjs.org",
@@ -21,6 +21,12 @@ export type AuthUserRow = {
21
21
  // roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
22
22
  // parseRoles() vor dem Merge in die Session.
23
23
  readonly roles?: string | null;
24
+ // user.status (S2.U1) — "active" | "restricted" | "deletion_requested" |
25
+ // "deleted". Login.write.ts blockt Restricted (DSGVO Art. 18) sowie
26
+ // DeletionRequested + Deleted. Untyped string hier weil die Quelle
27
+ // user-feature-internes Enum ist; auth importiert den Constants-Wert
28
+ // an der Verwendungsstelle.
29
+ readonly status?: string | null;
24
30
  };
25
31
 
26
32
  // Returns the narrowed row or null — mirrors findForAuth's contract where
@@ -73,6 +73,17 @@ export const AuthErrors = {
73
73
  // deliberate enumeration trade-off: the lockout event itself is already
74
74
  // observable to the attacker, and legit users benefit from a clear signal.
75
75
  accountLocked: "account_locked",
76
+ // S2.U6 (DSGVO Art. 18) — Account ist im Restricted-Status. Login wird
77
+ // explicit verweigert mit eigenem Code (nicht zu invalid_credentials
78
+ // collapsen) damit UI sagen kann "Account ist aktuell pausiert, hier
79
+ // klicken zum Aufheben". Enumeration-leak akzeptiert: Restriction ist
80
+ // user-initiiert, der User weiss dass sein Konto restricted ist.
81
+ accountRestricted: "account_restricted",
82
+ // Account ist im DeletionRequested- oder Deleted-Status. Anders als
83
+ // Restricted ist das nicht reversibel via Login → wir collapsen auf
84
+ // invalid_credentials damit Forget-Pfad nicht via Login enumerierbar
85
+ // wird (User der "Konto loeschen" geklickt hat soll nicht erneut sehen
86
+ // dass die Email-Adresse noch in der DB existiert).
76
87
  } as const;
77
88
 
78
89
  // Account-lockout defaults — overridable via
@@ -30,7 +30,7 @@ export const changePasswordWrite = defineWriteHandler({
30
30
  // Load self with passwordHash — only visible to the privileged caller.
31
31
  const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
32
32
  id: event.user.id,
33
- })) as { id: number; passwordHash: string | null; version: number } | null;
33
+ })) as { id: number; passwordHash: string | null; version: number } | null; // @cast-boundary db-runner
34
34
 
35
35
  if (!me?.passwordHash) {
36
36
  return invalidCredentials();
@@ -152,7 +152,7 @@ async function resolveStreamTenants(
152
152
  ): Promise<readonly TenantId[]> {
153
153
  const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
154
154
  userId: me.id,
155
- })) as Array<{ tenantId: TenantId }>;
155
+ })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
156
156
  return orderTenantsByPreference(memberships, me.lastActiveTenantId);
157
157
  }
158
158
 
@@ -114,10 +114,10 @@ export function createInviteAcceptWithLoginHandler() {
114
114
  if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
115
115
  return invalidInviteToken();
116
116
 
117
- const invitationTenantId = invitation["tenantId"] as TenantId;
118
- const invitationEmail = invitation["email"] as string;
119
- const invitationRole = invitation["role"] as string;
120
- const invitationVersion = invitation["version"] as number;
117
+ const invitationTenantId = invitation["tenantId"] as TenantId; // @cast-boundary db-row
118
+ const invitationEmail = invitation["email"] as string; // @cast-boundary db-row
119
+ const invitationRole = invitation["role"] as string; // @cast-boundary db-row
120
+ const invitationVersion = invitation["version"] as number; // @cast-boundary db-row
121
121
 
122
122
  // Email-Match vom User-Input (nicht aus session — User ist anon)
123
123
  if (event.payload.email.toLowerCase() !== invitationEmail) {
@@ -134,19 +134,19 @@ export function createInviteAcceptWithLoginHandler() {
134
134
  const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.email, invitationEmail));
135
135
  if (!userRow?.["passwordHash"]) return invalidInviteToken();
136
136
  const passwordValid = await verifyPassword(
137
- userRow["passwordHash"] as string,
137
+ userRow["passwordHash"] as string, // @cast-boundary db-row
138
138
  event.payload.password,
139
139
  );
140
140
  if (!passwordValid) return invalidInviteToken();
141
141
 
142
- const userId = userRow["id"] as string;
142
+ const userId = userRow["id"] as string; // @cast-boundary db-row
143
143
 
144
144
  // Already-Member-Check (idempotent)
145
145
  const memberships = (await ctx.queryAs(
146
146
  createSystemUser(invitationTenantId),
147
147
  "tenant:query:memberships",
148
148
  { userId },
149
- )) as Array<{ tenantId: string }>;
149
+ )) as Array<{ tenantId: string }>; // @cast-boundary db-row
150
150
  const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
151
151
 
152
152
  // @cast-boundary db-runner — TenantDb.raw is DbRunner
@@ -107,16 +107,17 @@ export function createInviteAcceptHandler() {
107
107
  if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
108
108
  return invalidInviteToken();
109
109
 
110
- const invitationTenantId = invitation["tenantId"] as TenantId;
111
- const invitationEmail = invitation["email"] as string;
112
- const invitationRole = invitation["role"] as string;
113
- const invitationVersion = invitation["version"] as number;
110
+ const invitationTenantId = invitation["tenantId"] as TenantId; // @cast-boundary db-row
111
+ const invitationEmail = invitation["email"] as string; // @cast-boundary db-row
112
+ const invitationRole = invitation["role"] as string; // @cast-boundary db-row
113
+ const invitationVersion = invitation["version"] as number; // @cast-boundary db-row
114
114
 
115
115
  // Email-Match: User muss mit der eingeladenen Email matchen.
116
116
  // Sonst kann ein Angreifer mit Zugriff zur invitee-Mail seinen
117
117
  // eigenen Account dem Tenant zuschlagen.
118
118
  const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.id, event.user.id));
119
- if (!userRow || (userRow["email"] as string).toLowerCase() !== invitationEmail) {
119
+ const userEmail = userRow?.["email"] as string | undefined; // @cast-boundary db-row
120
+ if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
120
121
  return writeFailure(
121
122
  new UnprocessableError(AuthErrors.inviteEmailMismatch, {
122
123
  i18nKey: "auth.errors.inviteEmailMismatch",
@@ -131,7 +132,7 @@ export function createInviteAcceptHandler() {
131
132
  createSystemUser(invitationTenantId),
132
133
  "tenant:query:memberships",
133
134
  { userId: event.user.id },
134
- )) as Array<{ tenantId: string }>;
135
+ )) as Array<{ tenantId: string }>; // @cast-boundary db-row
135
136
  const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
136
137
 
137
138
  // @cast-boundary db-runner — TenantDb.raw is DbRunner
@@ -87,8 +87,8 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
87
87
  let invitationId: string;
88
88
  let token: string;
89
89
  if (existing) {
90
- invitationId = existing["id"] as string;
91
- const existingVersion = existing["version"] as number;
90
+ invitationId = existing["id"] as string; // @cast-boundary db-row
91
+ const existingVersion = existing["version"] as number; // @cast-boundary db-row
92
92
  // Resend-Idempotenz: Token aus Redis re-use wenn noch lebend.
93
93
  // Sonst neuen mintinen (alter ist abgelaufen).
94
94
  const existingToken = await getTokenForInvitation(ctx.redis, invitationId);
@@ -122,7 +122,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
122
122
  ctx.db,
123
123
  );
124
124
  if (!createResult.isSuccess) return createResult;
125
- invitationId = (createResult.data as { id: string }).id;
125
+ invitationId = (createResult.data as { id: string }).id; // @cast-boundary engine-payload
126
126
  token = generateToken();
127
127
  }
128
128
 
@@ -114,10 +114,10 @@ export function createInviteSignupCompleteHandler() {
114
114
  if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
115
115
  return invalidInviteToken();
116
116
 
117
- const invitationTenantId = invitation["tenantId"] as TenantId;
118
- const invitationEmail = invitation["email"] as string;
119
- const invitationRole = invitation["role"] as string;
120
- const invitationVersion = invitation["version"] as number;
117
+ const invitationTenantId = invitation["tenantId"] as TenantId; // @cast-boundary db-row
118
+ const invitationEmail = invitation["email"] as string; // @cast-boundary db-row
119
+ const invitationRole = invitation["role"] as string; // @cast-boundary db-row
120
+ const invitationVersion = invitation["version"] as number; // @cast-boundary db-row
121
121
 
122
122
  // User-Not-Exists-Check: wenn die Email schon registriert ist,
123
123
  // muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
@@ -7,7 +7,7 @@ import {
7
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
9
  import { z } from "zod";
10
- import { UserQueries } from "../../user";
10
+ import { USER_STATUS, UserQueries } from "../../user";
11
11
  import { parseAuthUserRow } from "../auth-user-row";
12
12
  import {
13
13
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
@@ -53,6 +53,19 @@ function accountLocked(retryAfterSeconds: number) {
53
53
  );
54
54
  }
55
55
 
56
+ // S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
57
+ // invalid_credentials collapsen) damit UI klar sagen kann "Account ist
58
+ // pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
59
+ // restricted ist (er hat selbst die Restriction gesetzt), also kein
60
+ // Enumeration-Leak.
61
+ function accountRestricted() {
62
+ return writeFailure(
63
+ new UnprocessableError(AuthErrors.accountRestricted, {
64
+ i18nKey: "auth.errors.accountRestricted",
65
+ }),
66
+ );
67
+ }
68
+
56
69
  export type LoginHandlerOptions = {
57
70
  // When true, a valid (email + password) login fails with email_not_verified
58
71
  // if the user row's emailVerified flag is false. Enumeration-leak is
@@ -145,12 +158,29 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
145
158
  return emailNotVerified();
146
159
  }
147
160
 
161
+ // S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
162
+ // nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
163
+ // (siehe lift-restriction.write.ts Header — typisch via Magic-Link
164
+ // oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
165
+ // requirement; ohne den koennte der User mit Login-Sessions trotz
166
+ // Restriction-Flag durchschreiben.
167
+ //
168
+ // DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
169
+ // (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
170
+ // distinct error ist hier safe.
171
+ if (found.status === USER_STATUS.Restricted) {
172
+ return accountRestricted();
173
+ }
174
+ if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
175
+ return invalidCredentials();
176
+ }
177
+
148
178
  // Resolve tenant + roles via the tenant feature's memberships query.
149
179
  // Returns [] if the user has no memberships — MVP: no login without an
150
180
  // invitation, so we refuse with a dedicated error.
151
181
  const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
152
182
  userId: found.id,
153
- })) as Array<{ tenantId: TenantId; roles: readonly string[] }>;
183
+ })) as Array<{ tenantId: TenantId; roles: readonly string[] }>; // @cast-boundary db-runner
154
184
 
155
185
  if (memberships.length === 0) {
156
186
  return noMembership();
@@ -1,4 +1,4 @@
1
- import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, defineWriteHandler, pipeline } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
3
 
4
4
  // Logout — JWT is stateless, so server-side we only return OK. A future
@@ -8,5 +8,5 @@ export const logoutWrite = defineWriteHandler({
8
8
  name: "logout",
9
9
  schema: z.object({}),
10
10
  access: { roles: access.authenticated },
11
- handler: async () => ({ isSuccess: true, data: { kind: "logged-out" } }),
11
+ perform: pipeline(({ r }) => [r.step.return({ isSuccess: true, data: { kind: "logged-out" } })]),
12
12
  });
@@ -117,7 +117,7 @@ export function createSignupConfirmHandler() {
117
117
  },
118
118
  });
119
119
 
120
- const tenantId = generateId() as TenantId;
120
+ const tenantId = generateId() as TenantId; // @cast-boundary engine-payload
121
121
  // Display-Name aus email-prefix als sinnvolles Default; User kann
122
122
  // den Tenant-Namen + sein eigenes displayName später ändern.
123
123
  const displayName = email.split("@")[0] ?? email;
@@ -23,6 +23,8 @@ export const defaultTranslations: TranslationsByLocale = {
23
23
  "auth.errors.accountLocked": "Konto vorübergehend gesperrt.",
24
24
  "auth.errors.accountLockedRetry": "Konto gesperrt. Neuer Versuch in {minutes} Minuten.",
25
25
  "auth.errors.emailNotVerified": "E-Mail-Adresse noch nicht bestätigt.",
26
+ "auth.errors.accountRestricted":
27
+ "Konto pausiert (Datenschutz Art. 18). Bitte Pause aufheben um wieder einzuloggen.",
26
28
  "auth.errors.rateLimited": "Zu viele Login-Versuche. Bitte kurz warten.",
27
29
  "auth.errors.invalidBody": "Ungültige Eingabe.",
28
30
  "auth.errors.loginFailed": "Login fehlgeschlagen.",
@@ -116,6 +118,8 @@ export const defaultTranslations: TranslationsByLocale = {
116
118
  "auth.errors.accountLocked": "Account temporarily locked.",
117
119
  "auth.errors.accountLockedRetry": "Account locked. Try again in {minutes} minutes.",
118
120
  "auth.errors.emailNotVerified": "Email address not yet verified.",
121
+ "auth.errors.accountRestricted":
122
+ "Account paused (GDPR Art. 18). Please lift the restriction to sign in again.",
119
123
  "auth.errors.rateLimited": "Too many login attempts. Please wait briefly.",
120
124
  "auth.errors.invalidBody": "Invalid input.",
121
125
  "auth.errors.loginFailed": "Login failed.",
@@ -244,7 +244,7 @@ export async function confirmSignup(
244
244
  body: JSON.stringify({ token, password }),
245
245
  });
246
246
  if (res.ok) {
247
- const body = (await res.json()) as SignupConfirmSuccess;
247
+ const body = (await res.json()) as SignupConfirmSuccess; // @cast-boundary engine-payload
248
248
  return { ok: true, data: body };
249
249
  }
250
250
  return { ok: false, error: await parseTokenFailure(res) };
@@ -18,7 +18,7 @@ import { BILLING_FOUNDATION_FEATURE, SubscriptionStatuses } from "./constants";
18
18
  export const SUBSCRIPTION_AGGREGATE_TYPE = "subscription" as const;
19
19
 
20
20
  // Event-name-Konstanten — short-form (für r.defineEvent) + qualifizierte
21
- // FQN (für ctx.appendEventUnsafe + projection-apply-keys).
21
+ // FQN (für ctx.unsafeAppendEvent + projection-apply-keys).
22
22
  export const SUBSCRIPTION_CREATED_EVENT_SHORT = "subscription-created" as const;
23
23
  export const SUBSCRIPTION_UPDATED_EVENT_SHORT = "subscription-updated" as const;
24
24
  export const SUBSCRIPTION_CANCELED_EVENT_SHORT = "subscription-canceled" as const;
@@ -41,7 +41,7 @@
41
41
  // (z.B. für analytics: "wie viele Wechsel im Monat?"), kommt ein
42
42
  // `subscription-provider-changed`-event-type später.
43
43
 
44
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { BILLING_FOUNDATION_FEATURE, SUBSCRIPTION_PROVIDER_EXTENSION } from "./constants";
46
46
  import {
47
47
  INVOICE_PAID_EVENT_QN,
@@ -70,53 +70,50 @@ import {
70
70
  subscriptionsProjectionTable,
71
71
  } from "./projection";
72
72
 
73
- export const billingFoundationFeature: FeatureDefinition = defineFeature(
74
- BILLING_FOUNDATION_FEATURE,
75
- (r) => {
76
- // 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
77
- // shape (= subscription-state-snapshot); der event-type taggt was
78
- // passiert ist. Future-consumer (billing-history, accounting)
79
- // listenen direkt auf den event-type ohne payload-discriminator.
80
- r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema);
81
- r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema);
82
- r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema);
83
- r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema);
84
- r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema);
73
+ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
74
+ // 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
75
+ // shape (= subscription-state-snapshot); der event-type taggt was
76
+ // passiert ist. Future-consumer (billing-history, accounting)
77
+ // listenen direkt auf den event-type ohne payload-discriminator.
78
+ r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema);
79
+ r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema);
80
+ r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema);
81
+ r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema);
82
+ r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema);
85
83
 
86
- // Inline projection: materialized current state in `read_subscriptions`.
87
- // Apply läuft in derselben TX wie ctx.appendEventUnsafe — read-your-
88
- // own-write ohne dispatcher-tick.
89
- r.projection({
90
- name: "subscription",
91
- source: SUBSCRIPTION_AGGREGATE_TYPE,
92
- table: subscriptionsProjectionTable,
93
- apply: {
94
- [SUBSCRIPTION_CREATED_EVENT_QN]: applySubscriptionCreated,
95
- [SUBSCRIPTION_UPDATED_EVENT_QN]: applySubscriptionUpdated,
96
- [SUBSCRIPTION_CANCELED_EVENT_QN]: applySubscriptionCanceled,
97
- [INVOICE_PAID_EVENT_QN]: applyInvoicePaid,
98
- [INVOICE_PAYMENT_FAILED_EVENT_QN]: applyInvoicePaymentFailed,
99
- },
100
- });
84
+ // Inline projection: materialized current state in `read_subscriptions`.
85
+ // Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
86
+ // own-write ohne dispatcher-tick.
87
+ r.projection({
88
+ name: "subscription",
89
+ source: SUBSCRIPTION_AGGREGATE_TYPE,
90
+ table: subscriptionsProjectionTable,
91
+ apply: {
92
+ [SUBSCRIPTION_CREATED_EVENT_QN]: applySubscriptionCreated,
93
+ [SUBSCRIPTION_UPDATED_EVENT_QN]: applySubscriptionUpdated,
94
+ [SUBSCRIPTION_CANCELED_EVENT_QN]: applySubscriptionCanceled,
95
+ [INVOICE_PAID_EVENT_QN]: applyInvoicePaid,
96
+ [INVOICE_PAYMENT_FAILED_EVENT_QN]: applyInvoicePaymentFailed,
97
+ },
98
+ });
101
99
 
102
- // Plugin extension-point. Provider-Plugins registrieren sich hier.
103
- r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {
104
- onRegister: () => {
105
- // No side-effects at register-time.
106
- },
107
- });
100
+ // Plugin extension-point. Provider-Plugins registrieren sich hier.
101
+ r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {
102
+ onRegister: () => {
103
+ // No side-effects at register-time.
104
+ },
105
+ });
108
106
 
109
- // Custom write-handlers:
110
- // - process-event: programmatic entry-point vom webhook-handler;
111
- // dispatcht zu type-passendem appendEvent
112
- // - create-checkout-session: Tenant-Admin "Upgrade to Pro"-flow
113
- // - create-portal-session: Tenant-Admin "Manage Subscription"-flow
114
- r.writeHandler(processEventHandler);
115
- r.writeHandler(createCheckoutSessionHandler);
116
- r.writeHandler(createPortalSessionHandler);
107
+ // Custom write-handlers:
108
+ // - process-event: programmatic entry-point vom webhook-handler;
109
+ // dispatcht zu type-passendem appendEvent
110
+ // - create-checkout-session: Tenant-Admin "Upgrade to Pro"-flow
111
+ // - create-portal-session: Tenant-Admin "Manage Subscription"-flow
112
+ r.writeHandler(processEventHandler);
113
+ r.writeHandler(createCheckoutSessionHandler);
114
+ r.writeHandler(createPortalSessionHandler);
117
115
 
118
- // Custom list-query auf der subscription-projection (raw drizzle-
119
- // table; kein r.entity weil Schreiben via projection-apply läuft).
120
- r.queryHandler(listSubscriptionsQuery);
121
- },
122
- );
116
+ // Custom list-query auf der subscription-projection (raw drizzle-
117
+ // table; kein r.entity weil Schreiben via projection-apply läuft).
118
+ r.queryHandler(listSubscriptionsQuery);
119
+ });
@@ -28,7 +28,7 @@ export const createPortalSessionHandler: WriteHandlerDef = {
28
28
  schema: createPortalSessionSchema,
29
29
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
30
30
  handler: async (event, ctx) => {
31
- const payload = event.payload as CreatePortalSessionPayload;
31
+ const payload = event.payload as CreatePortalSessionPayload; // @cast-boundary engine-payload
32
32
  const tenantId = event.user.tenantId;
33
33
 
34
34
  // 1. Hol current subscription-row für den Tenant. Aggregate-id ist
@@ -41,8 +41,8 @@ export const createPortalSessionHandler: WriteHandlerDef = {
41
41
  "subscription-foundation: no active subscription for this tenant. Create one via create-checkout-session first.",
42
42
  );
43
43
  }
44
- const providerName = row["providerName"] as string;
45
- const providerCustomerId = row["providerCustomerId"] as string;
44
+ const providerName = row["providerName"] as string; // @cast-boundary db-row
45
+ const providerCustomerId = row["providerCustomerId"] as string; // @cast-boundary db-row
46
46
 
47
47
  // 2. Plugin-Lookup
48
48
  const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
@@ -9,7 +9,7 @@
9
9
  // kein zweiter append.
10
10
  // 2. Type-mapping: SubscriptionEvent.type (= normalisiert vom Plugin)
11
11
  // → einer der 5 ES-event-typen.
12
- // 3. ctx.appendEventUnsafe — Inline-projection materialisiert die
12
+ // 3. ctx.unsafeAppendEvent — Inline-projection materialisiert die
13
13
  // `read_subscriptions`-row in derselben TX.
14
14
 
15
15
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
@@ -69,7 +69,7 @@ const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
69
69
  [SubscriptionEventTypes.canceled]: SUBSCRIPTION_CANCELED_EVENT_QN,
70
70
  [SubscriptionEventTypes.invoicePaid]: INVOICE_PAID_EVENT_QN,
71
71
  [SubscriptionEventTypes.invoicePaymentFailed]: INVOICE_PAYMENT_FAILED_EVENT_QN,
72
- };
72
+ } satisfies Readonly<Record<string, string>>;
73
73
 
74
74
  // =============================================================================
75
75
  // Handler
@@ -141,7 +141,7 @@ export const processEventHandler: WriteHandlerDef = {
141
141
  providerName: payload.providerName,
142
142
  rawPayload: payload.rawPayload,
143
143
  };
144
- await ctx.appendEventUnsafe({
144
+ await ctx.unsafeAppendEvent({
145
145
  aggregateId: aggId,
146
146
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
147
147
  type: esEventType,
@@ -1,7 +1,7 @@
1
1
  // Inline-projection für `read_subscriptions`. Materialisiert die 5
2
2
  // subscription-events in eine row pro Tenant.
3
3
  //
4
- // Apply läuft in derselben TX wie ctx.appendEventUnsafe — Caller sieht
4
+ // Apply läuft in derselben TX wie ctx.unsafeAppendEvent — Caller sieht
5
5
  // seinen Schreib-State sofort (kein dispatcher-tick nötig). PK = event.
6
6
  // aggregateId (= deterministic uuidv5 pro Tenant) → replays kollidieren
7
7
  // auf der PK statt doppelte rows zu erzeugen.
@@ -169,7 +169,7 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
169
169
  );
170
170
  }
171
171
 
172
- return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200);
172
+ return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
173
173
  };
174
174
  }
175
175