@cosmicdrift/kumiko-bundled-features 0.338.0 → 0.340.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 (166) hide show
  1. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  2. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
  3. package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
  5. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  6. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  7. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  8. package/dist/auth-email-password/web/auth-gate.js +25 -1
  9. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  10. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  11. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  12. package/dist/auth-email-password/web/client-plugin.js +2 -0
  13. package/dist/auth-email-password/web/index.d.ts +2 -1
  14. package/dist/auth-email-password/web/index.js +1 -0
  15. package/dist/auth-email-password/web/session.d.ts +3 -1
  16. package/dist/auth-email-password/web/session.js +8 -2
  17. package/dist/auth-mfa/feature.js +1 -1
  18. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/billing-foundation/constants.d.ts +7 -0
  24. package/dist/billing-foundation/constants.js +8 -0
  25. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
  26. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
  27. package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
  28. package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
  29. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  30. package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
  31. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
  32. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
  33. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  34. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  35. package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
  36. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
  37. package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
  38. package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
  39. package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
  40. package/dist/billing-foundation/events.d.ts +19 -0
  41. package/dist/billing-foundation/events.js +12 -0
  42. package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
  43. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
  44. package/dist/billing-foundation/i18n.js +3 -0
  45. package/dist/billing-foundation/projection.js +23 -40
  46. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  47. package/dist/billing-foundation/types.d.ts +6 -1
  48. package/dist/billing-foundation/validate-options.js +4 -0
  49. package/dist/channel-discord/discord-channel.d.ts +6 -0
  50. package/dist/channel-discord/discord-channel.js +19 -0
  51. package/dist/channel-discord/feature.d.ts +3 -0
  52. package/dist/channel-discord/feature.js +30 -0
  53. package/dist/channel-discord/index.d.ts +2 -0
  54. package/dist/channel-discord/index.js +2 -0
  55. package/dist/channel-slack/feature.d.ts +3 -0
  56. package/dist/channel-slack/feature.js +30 -0
  57. package/dist/channel-slack/index.d.ts +2 -0
  58. package/dist/channel-slack/index.js +2 -0
  59. package/dist/channel-slack/slack-channel.d.ts +5 -0
  60. package/dist/channel-slack/slack-channel.js +16 -0
  61. package/dist/channel-teams/feature.d.ts +3 -0
  62. package/dist/channel-teams/feature.js +34 -0
  63. package/dist/channel-teams/index.d.ts +2 -0
  64. package/dist/channel-teams/index.js +2 -0
  65. package/dist/channel-teams/teams-channel.d.ts +5 -0
  66. package/dist/channel-teams/teams-channel.js +38 -0
  67. package/dist/channel-telegram/feature.d.ts +3 -0
  68. package/dist/channel-telegram/feature.js +28 -0
  69. package/dist/channel-telegram/index.d.ts +2 -0
  70. package/dist/channel-telegram/index.js +2 -0
  71. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  72. package/dist/channel-telegram/telegram-channel.js +54 -0
  73. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
  74. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
  75. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
  76. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
  77. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  78. package/dist/data-retention/feature.js +1 -1
  79. package/dist/delivery/channel-context.d.ts +2 -1
  80. package/dist/delivery/channel-context.js +2 -2
  81. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  82. package/dist/delivery/chat-webhook-channel.js +48 -0
  83. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  84. package/dist/delivery/chat-webhook-sender.js +102 -0
  85. package/dist/delivery/delivery-service.js +3 -0
  86. package/dist/delivery/index.d.ts +2 -0
  87. package/dist/delivery/index.js +2 -0
  88. package/dist/delivery/jobs.js +1 -1
  89. package/dist/delivery/testing.d.ts +2 -0
  90. package/dist/delivery/types.d.ts +3 -1
  91. package/dist/delivery/types.js +1 -2
  92. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  93. package/dist/files-tenant-data/index.js +1 -1
  94. package/dist/form-draft/feature.js +1 -1
  95. package/dist/form-draft/handlers/discard.write.js +1 -1
  96. package/dist/form-draft/handlers/save.write.js +1 -1
  97. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  98. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  99. package/dist/inbound-mail-foundation/feature.js +1 -1
  100. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
  101. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  102. package/dist/inbound-mail-foundation/index.js +1 -0
  103. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  104. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  105. package/dist/inbound-mail-foundation/projection.js +37 -45
  106. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  107. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  108. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  109. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  110. package/dist/managed-pages/handlers/set.write.js +1 -1
  111. package/dist/notes-history/handlers/add-note.write.js +1 -1
  112. package/dist/renderer-simple/feature.d.ts +8 -2
  113. package/dist/renderer-simple/feature.js +8 -5
  114. package/dist/renderer-simple/index.d.ts +2 -2
  115. package/dist/renderer-simple/index.js +1 -1
  116. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  117. package/dist/renderer-simple/simple-renderer.js +74 -27
  118. package/dist/secrets/feature.js +1 -1
  119. package/dist/sessions/feature.js +1 -1
  120. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  121. package/dist/tenant-handover/handlers/claim.write.js +1 -2
  122. package/dist/tenant-lifecycle/feature.js +1 -1
  123. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
  124. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
  125. package/dist/tier-engine/feature.js +1 -1
  126. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
  127. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
  128. package/dist/user/handlers/update-roles.js +7 -18
  129. package/dist/user-data-rights/feature.js +2 -2
  130. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
  131. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  132. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  133. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  134. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  135. package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
  136. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  137. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  138. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  139. package/dist/user-data-rights/handlers/request-export.write.js +1 -1
  140. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  141. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  142. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  143. package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
  144. package/dist/workflow-runner/db/queries/due-runs.js +5 -4
  145. package/dist/workflow-runner/event-subscriber.js +6 -2
  146. package/dist/workflow-runner/event-trigger.js +5 -0
  147. package/dist/workflow-runner/feature.js +4 -6
  148. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  149. package/dist/workflow-runner/pending-projection.js +1 -0
  150. package/dist/workflow-runner/runner.d.ts +7 -1
  151. package/dist/workflow-runner/runner.js +1 -1
  152. package/dist/workflow-runner/tables.js +8 -1
  153. package/package.json +41 -9
  154. package/src/auth-email-password/changes.json +7 -0
  155. package/src/billing-foundation/changes.json +41 -0
  156. package/src/delivery/changes.json +7 -0
  157. package/src/inbound-mail-foundation/changes.json +14 -0
  158. package/src/renderer-simple/changes.json +9 -1
  159. package/src/user/changes.json +13 -6
  160. package/src/workflow-runner/changes.json +14 -0
  161. package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
  162. package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
  163. package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
  164. package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
  165. package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
  166. package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
@@ -13,11 +13,10 @@
13
13
  // foundation production mounten müssen die Tabelle in ihre eigene
14
14
  // `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
15
15
  // import). setupTestStack pusht sie automatisch via r.projection.table.
16
+ import { insertOnConflictDoNothing, requireEntityTableMeta, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
16
17
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
17
18
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import { paymentRowId } from "./aggregate-id.js";
19
- import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
20
- import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
21
20
  import { paymentEntity, subscriptionEntity } from "./entities.js";
22
21
  // Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
23
22
  // projection-apply als auch von list-query / get-helper genutzt damit
@@ -27,6 +26,10 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
27
26
  // raw drizzle-pgTable, not an r.entity — apps mounting billing-foundation in
28
27
  // production must add read_payments to their own drizzle/generate.ts too.
29
28
  export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
29
+ // Unbranded metas: the apply functions below ARE the executor of these
30
+ // projections, so they may write through the typed helpers.
31
+ const subscriptionsProjectionMeta = requireEntityTableMeta(subscriptionsProjectionTable, "subscription");
32
+ const paymentsProjectionMeta = requireEntityTableMeta(paymentsProjectionTable, "payment");
30
33
  // =============================================================================
31
34
  // Shared helpers
32
35
  // =============================================================================
@@ -59,9 +62,7 @@ function cancelAtSetFromPayload(p) {
59
62
  * on every apply including the initial create — a projection rebuild at a
60
63
  * different wall-clock time must materialize the exact same row, and
61
64
  * `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
62
- * incomplete subscriptions) depends on that determinism. Raw `.unsafe()`
63
- * bypasses the typed query-builder's Temporal.Instant-to-string coercion,
64
- * hence the explicit `.toString()`. */
65
+ * incomplete subscriptions) depends on that determinism. */
65
66
  async function upsert(tx, event, set, fullPayload) {
66
67
  // INSERT-fallback braucht ALL fields (NOT NULL constraints). Wenn
67
68
  // jemand nur teil-felder updated (z.B. invoice-payment-failed nur
@@ -69,38 +70,21 @@ async function upsert(tx, event, set, fullPayload) {
69
70
  // INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
70
71
  const insertCols = {
71
72
  id: event.aggregateId,
72
- tenant_id: event.tenantId,
73
- provider_name: fullPayload.providerName,
74
- provider_customer_id: fullPayload.providerCustomerId,
75
- provider_subscription_id: fullPayload.providerSubscriptionId,
73
+ tenantId: event.tenantId,
74
+ providerName: fullPayload.providerName,
75
+ providerCustomerId: fullPayload.providerCustomerId,
76
+ providerSubscriptionId: fullPayload.providerSubscriptionId,
76
77
  status: fullPayload.status,
77
78
  tier: fullPayload.tier,
78
- current_period_end: fullPayload.currentPeriodEndIso,
79
- cancel_at: fullPayload.cancelAtIso ?? null,
80
- modified_at: event.createdAt.toString(),
79
+ currentPeriodEnd: fullPayload.currentPeriodEndIso,
80
+ cancelAt: fullPayload.cancelAtIso ?? null,
81
+ modifiedAt: event.createdAt,
81
82
  };
82
- // Map camelCase set-keys to snake_case DB columns.
83
- const setMap = {
84
- providerName: "provider_name",
85
- providerCustomerId: "provider_customer_id",
86
- providerSubscriptionId: "provider_subscription_id",
87
- status: "status",
88
- tier: "tier",
89
- currentPeriodEnd: "current_period_end",
90
- cancelAt: "cancel_at",
91
- };
92
- const insertParams = Object.values(insertCols);
93
- const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
94
- // modified_at is already bound as the last insertParams placeholder —
95
- // reused (not re-pushed) for the ON CONFLICT SET clause below.
96
- const setClauses = [`"modified_at" = $${insertParams.length}`];
97
- const allParams = [...insertParams];
98
- for (const [k, v] of setEntries) {
99
- allParams.push(v);
100
- setClauses.push(`"${setMap[k]}" = $${allParams.length}`);
101
- }
102
- const tableName = subscriptionsProjectionTable.tableName;
103
- await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
83
+ const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
84
+ await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
85
+ conflictKeys: ["id"],
86
+ update: { modifiedAt: event.createdAt, ...definedSet },
87
+ });
104
88
  }
105
89
  // =============================================================================
106
90
  // Apply-functions — eine pro event-typ
@@ -164,17 +148,16 @@ export const applyInvoicePaymentFailed = defineApply(async (event, tx) => {
164
148
  * the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
165
149
  * rebuild idempotent without mutating an existing row. */
166
150
  export const applyPaymentReceived = defineApply(async (event, tx) => {
167
- const tableName = paymentsProjectionTable.tableName;
168
151
  const providerEventId = event.metadata.headers?.["providerEventId"];
169
152
  const providerName = event.metadata.headers?.["providerName"];
170
153
  if (typeof providerEventId !== "string" || typeof providerName !== "string") {
171
154
  throw new Error("applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both");
172
155
  }
173
- await insertPaymentProjectionRow(tx, tableName, {
156
+ await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
174
157
  id: paymentRowId(event.tenantId, providerName, providerEventId),
175
- tenant_id: event.tenantId,
176
- provider_name: event.payload.providerName,
177
- provider_customer_id: event.payload.providerCustomerId,
178
- price_id: event.payload.priceId,
158
+ tenantId: event.tenantId,
159
+ providerName: event.payload.providerName,
160
+ providerCustomerId: event.payload.providerCustomerId,
161
+ priceId: event.payload.priceId,
179
162
  });
180
163
  });
@@ -20,7 +20,7 @@ export const PAYMENT_TENANT_DESTROY_ARCHIVE_REASON = "tenant-destroy looks up th
20
20
  export async function subscriptionTenantDestroyHook(ctx) {
21
21
  declareEscapeHatch({ reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON });
22
22
  const { profile } = await resolveProfileForTenant({
23
- db: ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON),
23
+ db: ctx.db.unsafeRaw(),
24
24
  tenantId: ctx.tenantId,
25
25
  });
26
26
  const aggregateId = subscriptionAggregateId(ctx.tenantId);
@@ -30,7 +30,7 @@ export async function subscriptionTenantDestroyHook(ctx) {
30
30
  else {
31
31
  await ctx.db.deleteMany(subscriptionsProjectionTable, { id: aggregateId });
32
32
  }
33
- await archiveStream(ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON), {
33
+ await archiveStream(ctx.db.unsafeRaw(), {
34
34
  tenantId: ctx.tenantId,
35
35
  aggregateId,
36
36
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
@@ -46,7 +46,7 @@ export async function subscriptionTenantDestroyHook(ctx) {
46
46
  export async function paymentTenantDestroyHook(ctx) {
47
47
  declareEscapeHatch({ reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON });
48
48
  const { profile } = await resolveProfileForTenant({
49
- db: ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON),
49
+ db: ctx.db.unsafeRaw(),
50
50
  tenantId: ctx.tenantId,
51
51
  });
52
52
  const aggregateId = paymentAggregateId(ctx.tenantId);
@@ -58,7 +58,7 @@ export async function paymentTenantDestroyHook(ctx) {
58
58
  tenantId: ctx.tenantId,
59
59
  });
60
60
  }
61
- await archiveStream(ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON), {
61
+ await archiveStream(ctx.db.unsafeRaw(), {
62
62
  tenantId: ctx.tenantId,
63
63
  aggregateId,
64
64
  aggregateType: PAYMENT_AGGREGATE_TYPE,
@@ -2,7 +2,7 @@
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
- import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus } from "./constants.js";
5
+ import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
6
6
  export type SubscriptionEvent = {
7
7
  /** Discriminator against PaymentEvent. Optional (instead of required) so
8
8
  * existing plugins like subscription-mollie, which don't set this field,
@@ -289,6 +289,11 @@ export type ConsumerProtectionOptions = {
289
289
  /** Per-locale VAT note; must carry at least `de` and `en`. */
290
290
  readonly vatNote: Readonly<Record<string, string>>;
291
291
  readonly operatorEmail: string;
292
+ /** Where the public § 312k declaration is reachable. `"tenant-host"`
293
+ * (default): on a host that resolves a tenant. `"platform"`: also on a
294
+ * host that resolves none (the platform apex) — the declaration then runs
295
+ * without a request tenant and finds the contract by the declarant's email. */
296
+ readonly terminationScope?: TerminationScope;
292
297
  /** Root-relative paths or absolute https URLs. */
293
298
  readonly legalLinks: {
294
299
  readonly terms: string;
@@ -3,6 +3,7 @@
3
3
  // concerns (app-feature-structure guard's 300-line budget). Internal — not
4
4
  // re-exported from index.ts.
5
5
  import * as z from "zod";
6
+ import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
6
7
  function isRootRelativePath(path) {
7
8
  return path.startsWith("/") && !path.startsWith("//");
8
9
  }
@@ -28,6 +29,9 @@ function validateConsumerProtection(cp) {
28
29
  if (!z.email().safeParse(cp.operatorEmail).success) {
29
30
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
30
31
  }
32
+ if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
33
+ throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
34
+ }
31
35
  for (const [key, link] of Object.entries(cp.legalLinks)) {
32
36
  if (!isRootRelativeOrHttpsUrl(link)) {
33
37
  throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
@@ -0,0 +1,6 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type DiscordChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const DISCORD_DEFAULT_ALLOWED_HOSTS: readonly ["discord.com", "discordapp.com"];
4
+ export declare const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
5
+ export declare const DISCORD_CONTENT_MAX_CHARS = 2000;
6
+ export declare function createDiscordChannel(options: DiscordChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,19 @@
1
+ import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
2
+ export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
3
+ export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
4
+ export const DISCORD_CONTENT_MAX_CHARS = 2000;
5
+ export function createDiscordChannel(options, keyFor) {
6
+ return createChatWebhookChannel({
7
+ name: "discord",
8
+ featureName: "channel-discord",
9
+ defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
10
+ requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
11
+ keyFor,
12
+ // parse: [] keeps @everyone / @here / role and user mentions in the text inert.
13
+ buildBody: (message) => ({
14
+ content: truncateChars(chatMessageText(message), DISCORD_CONTENT_MAX_CHARS),
15
+ allowed_mentions: { parse: [] },
16
+ }),
17
+ options,
18
+ });
19
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type DiscordChannelOptions } from "./discord-channel.js";
3
+ export declare function createChannelDiscordFeature(options?: DiscordChannelOptions): FeatureDefinition;
@@ -0,0 +1,30 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createDiscordChannel } from "./discord-channel.js";
4
+ export function createChannelDiscordFeature(options = {}) {
5
+ return defineFeature("channel-discord", (r) => {
6
+ r.describe('Posts delivery notifications to a Discord webhook, registered as the `discord` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { discord: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-discord:webhooks.<connection name>`; only the connection name is logged. Mentions are disabled and content is cut to 2000 characters. Requires `delivery` and `secrets`. Host allowlist (discord.com, discordapp.com, path /api/webhooks/), https-only and the request timeout are app options of `createChannelDiscordFeature(opts)`, never tenant config.');
7
+ r.uiHints({
8
+ displayLabel: "Discord Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
16
+ hint: {
17
+ en: 'One secret per connection name; the value is the Discord webhook URL. Address it with route: { discord: "<connection name>" }.',
18
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Discord-Webhook-URL. Adressierung mit route: { discord: "<Verbindungsname>" }.',
19
+ es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Discord. Se usa con route: { discord: "<nombre de conexión>" }.',
20
+ },
21
+ scope: "tenant",
22
+ nameSchema: chatConnectionNameSchema,
23
+ });
24
+ const channel = createDiscordChannel(options, webhooks.keyFor);
25
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
26
+ mode: channel.mode,
27
+ send: channel.send,
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,2 @@
1
+ export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
2
+ export { createChannelDiscordFeature } from "./feature.js";
@@ -0,0 +1,2 @@
1
+ export { createDiscordChannel } from "./discord-channel.js";
2
+ export { createChannelDiscordFeature } from "./feature.js";
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type SlackChannelOptions } from "./slack-channel.js";
3
+ export declare function createChannelSlackFeature(options?: SlackChannelOptions): FeatureDefinition;
@@ -0,0 +1,30 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createSlackChannel } from "./slack-channel.js";
4
+ export function createChannelSlackFeature(options = {}) {
5
+ return defineFeature("channel-slack", (r) => {
6
+ r.describe('Posts delivery notifications to a Slack incoming webhook, registered as the `slack` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { slack: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-slack:webhooks.<connection name>`; only the connection name is logged. Requires `delivery` and `secrets`. Host allowlist (hooks.slack.com), https-only and the request timeout are app options of `createChannelSlackFeature(opts)`, never tenant config.');
7
+ r.uiHints({
8
+ displayLabel: "Slack Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
16
+ hint: {
17
+ en: 'One secret per connection name; the value is the Slack webhook URL. Address it with route: { slack: "<connection name>" }.',
18
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Slack-Webhook-URL. Adressierung mit route: { slack: "<Verbindungsname>" }.',
19
+ es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Slack. Se usa con route: { slack: "<nombre de conexión>" }.',
20
+ },
21
+ scope: "tenant",
22
+ nameSchema: chatConnectionNameSchema,
23
+ });
24
+ const channel = createSlackChannel(options, webhooks.keyFor);
25
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
26
+ mode: channel.mode,
27
+ send: channel.send,
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelSlackFeature } from "./feature.js";
2
+ export { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelSlackFeature } from "./feature.js";
2
+ export { createSlackChannel } from "./slack-channel.js";
@@ -0,0 +1,5 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type SlackChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const SLACK_DEFAULT_ALLOWED_HOSTS: readonly ["hooks.slack.com"];
4
+ export declare function escapeSlackText(text: string): string;
5
+ export declare function createSlackChannel(options: SlackChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,16 @@
1
+ import { chatMessageText, createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"];
3
+ // Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
4
+ export function escapeSlackText(text) {
5
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
6
+ }
7
+ export function createSlackChannel(options, keyFor) {
8
+ return createChatWebhookChannel({
9
+ name: "slack",
10
+ featureName: "channel-slack",
11
+ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS,
12
+ keyFor,
13
+ buildBody: (message) => ({ text: escapeSlackText(chatMessageText(message)) }),
14
+ options,
15
+ });
16
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type TeamsChannelOptions } from "./teams-channel.js";
3
+ export declare function createChannelTeamsFeature(options?: TeamsChannelOptions): FeatureDefinition;
@@ -0,0 +1,34 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createTeamsChannel } from "./teams-channel.js";
4
+ export function createChannelTeamsFeature(options = {}) {
5
+ return defineFeature("channel-teams", (r) => {
6
+ r.describe('Posts delivery notifications to a Microsoft Teams webhook (Workflows or connector) as an Adaptive Card, registered as the `teams` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { teams: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-teams:webhooks.<connection name>`; only the connection name is logged. Requires `delivery` and `secrets`. Host allowlist (.webhook.office.com, .logic.azure.com, .powerplatform.com), https-only and the request timeout are app options of `createChannelTeamsFeature(opts)`, never tenant config.');
7
+ r.uiHints({
8
+ displayLabel: "Microsoft Teams Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: {
16
+ en: "Microsoft Teams webhooks",
17
+ de: "Microsoft Teams-Webhooks",
18
+ es: "Webhooks de Microsoft Teams",
19
+ },
20
+ hint: {
21
+ en: 'One secret per connection name; the value is the Microsoft Teams webhook URL. Address it with route: { teams: "<connection name>" }.',
22
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Microsoft Teams-Webhook-URL. Adressierung mit route: { teams: "<Verbindungsname>" }.',
23
+ es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Microsoft Teams. Se usa con route: { teams: "<nombre de conexión>" }.',
24
+ },
25
+ scope: "tenant",
26
+ nameSchema: chatConnectionNameSchema,
27
+ });
28
+ const channel = createTeamsChannel(options, webhooks.keyFor);
29
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
30
+ mode: channel.mode,
31
+ send: channel.send,
32
+ });
33
+ });
34
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelTeamsFeature } from "./feature.js";
2
+ export { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelTeamsFeature } from "./feature.js";
2
+ export { createTeamsChannel } from "./teams-channel.js";
@@ -0,0 +1,5 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type TeamsChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const TEAMS_DEFAULT_ALLOWED_HOSTS: readonly [".webhook.office.com", ".logic.azure.com", ".powerplatform.com"];
4
+ export declare function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown;
5
+ export declare function createTeamsChannel(options: TeamsChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,38 @@
1
+ import { createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
3
+ ".webhook.office.com",
4
+ ".logic.azure.com",
5
+ ".powerplatform.com",
6
+ ];
7
+ // Text only ever goes into TextBlocks (no markup interpretation of titles/bodies
8
+ // beyond Adaptive Card's own text rendering).
9
+ export function buildTeamsAdaptiveCard(title, body) {
10
+ return {
11
+ type: "message",
12
+ attachments: [
13
+ {
14
+ contentType: "application/vnd.microsoft.card.adaptive",
15
+ contentUrl: null,
16
+ content: {
17
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
18
+ type: "AdaptiveCard",
19
+ version: "1.4",
20
+ body: [
21
+ { type: "TextBlock", text: title, weight: "Bolder", wrap: true },
22
+ ...(body ? [{ type: "TextBlock", text: body, wrap: true }] : []),
23
+ ],
24
+ },
25
+ },
26
+ ],
27
+ };
28
+ }
29
+ export function createTeamsChannel(options, keyFor) {
30
+ return createChatWebhookChannel({
31
+ name: "teams",
32
+ featureName: "channel-teams",
33
+ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
34
+ keyFor,
35
+ buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
36
+ options,
37
+ });
38
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type TelegramChannelOptions } from "./telegram-channel.js";
3
+ export declare function createChannelTelegramFeature(options?: TelegramChannelOptions): FeatureDefinition;
@@ -0,0 +1,28 @@
1
+ import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createTelegramChannel } from "./telegram-channel.js";
4
+ export function createChannelTelegramFeature(options = {}) {
5
+ return defineFeature("channel-telegram", (r) => {
6
+ r.describe('Sends delivery notifications through the Telegram Bot API (`sendMessage`, plain text), registered as the `telegram` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { telegram: "<chat id or @channel>" } })`. The bot token is the tenant secret `channel-telegram:secret:bot-token`; the token never reaches logs or delivery attempts. Requires `delivery` and `secrets`. The API base URL and request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.');
7
+ r.uiHints({
8
+ displayLabel: "Telegram Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const botToken = r.secret("botToken", {
15
+ label: { en: "Telegram bot token" },
16
+ hint: {
17
+ en: 'Token from @BotFather. Used for every Telegram chat the tenant addresses via route: { telegram: "<chat id>" }.',
18
+ },
19
+ redact: () => "••••••••",
20
+ scope: "tenant",
21
+ });
22
+ const channel = createTelegramChannel(options, botToken.name);
23
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
24
+ mode: channel.mode,
25
+ send: channel.send,
26
+ });
27
+ });
28
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelTelegramFeature } from "./feature.js";
2
+ export { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelTelegramFeature } from "./feature.js";
2
+ export { createTelegramChannel } from "./telegram-channel.js";
@@ -0,0 +1,8 @@
1
+ import { type DeliveryChannel } from "../delivery/index.js";
2
+ export type TelegramChannelOptions = {
3
+ readonly apiBaseUrl?: string;
4
+ readonly timeoutMs?: number;
5
+ };
6
+ export declare const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
7
+ export declare const TELEGRAM_TEXT_MAX_CHARS = 4096;
8
+ export declare function createTelegramChannel(options: TelegramChannelOptions, botTokenKey: string): DeliveryChannel;
@@ -0,0 +1,54 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { chatMessageText, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, toChannelResult, truncateChars, } from "../delivery/index.js";
3
+ export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
4
+ export const TELEGRAM_TEXT_MAX_CHARS = 4096;
5
+ // Numeric chat/group id (groups and channels are negative) or a public @channelname.
6
+ const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
7
+ // `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
8
+ const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
9
+ export function createTelegramChannel(options, botTokenKey) {
10
+ const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
11
+ return {
12
+ name: "telegram",
13
+ mode: "queued",
14
+ async send(address, message, ctx) {
15
+ if (!TELEGRAM_CHAT_ID_PATTERN.test(address)) {
16
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
17
+ }
18
+ let token;
19
+ try {
20
+ const revealed = await ctx.secrets?.get(ctx.tenantId, botTokenKey, {
21
+ userId: SYSTEM_USER_ID,
22
+ handlerName: "channel-telegram:send",
23
+ });
24
+ token = revealed?.reveal();
25
+ }
26
+ catch {
27
+ token = undefined;
28
+ }
29
+ if (!token || !TELEGRAM_BOT_TOKEN_PATTERN.test(token)) {
30
+ return toChannelResult(address, { ok: false, code: "missing_credentials" });
31
+ }
32
+ // The base URL is the allowlist: only its own host is reachable.
33
+ let baseHost;
34
+ try {
35
+ baseHost = new URL(apiBaseUrl).hostname;
36
+ }
37
+ catch {
38
+ return toChannelResult(address, { ok: false, code: "host_not_allowed" });
39
+ }
40
+ const result = await postChatWebhook({
41
+ url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
42
+ allowedHosts: [baseHost],
43
+ requireHttps: apiBaseUrl.startsWith("https:"),
44
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
45
+ // No parse_mode: Telegram renders the text literally.
46
+ body: {
47
+ chat_id: address,
48
+ text: truncateChars(chatMessageText(message), TELEGRAM_TEXT_MAX_CHARS),
49
+ },
50
+ });
51
+ return toChannelResult(address, result);
52
+ },
53
+ };
54
+ }
@@ -248,17 +248,17 @@ export const forgetSubjectWrite = defineWriteHandler({
248
248
  ? { kind: "tenant", tenantId: raw.tenantId }
249
249
  : { kind: "record", entity: raw.entity, id: raw.id };
250
250
  const subjectKey = subjectIdToKey(subject);
251
- const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw("tenant-scope check runs against the subject's tenant, not necessarily the caller's"), ctx.registry.features, event.user, raw);
251
+ const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw(), ctx.registry.features, event.user, raw);
252
252
  if (tenantScopeDenial) {
253
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
253
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw());
254
254
  return auditFailure ?? tenantScopeDenial;
255
255
  }
256
256
  // Runs after the tenant gate but before any key is touched: a retention
257
257
  // check ahead of the tenant gate would leak a foreign entity's retention
258
258
  // posture to a cross-tenant prober.
259
- const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
259
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
260
260
  if (retentionDenial) {
261
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
261
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw());
262
262
  return auditFailure ?? retentionDenial;
263
263
  }
264
264
  // Erase BEFORE the audit append: if the append throws, the key is gone
@@ -272,7 +272,7 @@ export const forgetSubjectWrite = defineWriteHandler({
272
272
  });
273
273
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
274
274
  // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
275
- const crossTenantSubjectRunner = ctx.db.unsafeRaw("blind-index sweep and search purge address the subject across tenants");
275
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw();
276
276
  await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
277
277
  // Derived search index still holds plaintext (#1610) — purge next to the
278
278
  // blind-index sweep. No adapter → no-op (apps without search).
@@ -300,7 +300,7 @@ export const forgetSubjectWrite = defineWriteHandler({
300
300
  // update is best-effort for real users.
301
301
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
302
302
  try {
303
- const userLifecycleRunner = ctx.db.unsafeRaw("user lifecycle update and PAT revoke run on the SYSTEM user stream");
303
+ const userLifecycleRunner = ctx.db.unsafeRaw();
304
304
  await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
305
305
  if (ctx.registry.features.has("personal-access-tokens")) {
306
306
  await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
@@ -31,7 +31,7 @@ export const clearCustomFieldHandler = {
31
31
  },
32
32
  handler: async (event, ctx) => {
33
33
  const payload = event.payload; // @cast-boundary engine-payload
34
- const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
34
+ const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
35
35
  if (!accessCheck.ok) {
36
36
  if (accessCheck.reason === "field_definition_not_found") {
37
37
  return failNotFound("fieldDefinition", payload.fieldKey);
@@ -26,7 +26,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
26
26
  throw new Error("define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions");
27
27
  }
28
28
  if (limit !== undefined) {
29
- const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON), tenantId);
29
+ const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
30
30
  if (current >= limit) {
31
31
  return failUnprocessable("cap_exceeded", {
32
32
  capName: "customFields.fieldDefinition.count",
@@ -54,7 +54,7 @@ export const setCustomFieldHandler = {
54
54
  },
55
55
  handler: async (event, ctx) => {
56
56
  const payload = event.payload; // @cast-boundary engine-payload
57
- const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey);
57
+ const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey);
58
58
  if (!loaded.found) {
59
59
  return failNotFound("fieldDefinition", payload.fieldKey);
60
60
  }
@@ -46,7 +46,7 @@ export function wireCustomFieldsUserDataRightsFor(r, opts) {
46
46
  const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
47
47
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
48
48
  r.useExtension(EXT_USER_DATA, opts.entityName, {
49
- export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
49
+ export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
50
50
  escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
51
51
  });
52
52
  }