@cosmicdrift/kumiko-samples 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 (147) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +21 -1
  3. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  4. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
  8. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
  11. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
  12. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
  15. package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
  18. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
  19. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
  21. package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
  22. package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
  33. package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
  34. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
  35. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
  36. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  37. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  38. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  39. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  40. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  41. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  42. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  43. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  44. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  45. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  46. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  47. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  48. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  49. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  50. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  51. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  52. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  53. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  54. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  55. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  56. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  58. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  59. package/packages/bundled-features/src/delivery/changes.json +7 -0
  60. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  61. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  62. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  64. package/packages/bundled-features/src/delivery/index.ts +15 -0
  65. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  66. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  67. package/packages/bundled-features/src/delivery/types.ts +8 -3
  68. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  69. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  72. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  73. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  75. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  80. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  82. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  83. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  84. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  85. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  86. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  87. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  88. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  89. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  90. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  91. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  92. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  93. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  94. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  95. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  97. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  99. package/packages/bundled-features/src/user/changes.json +13 -6
  100. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  101. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  102. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  104. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  108. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  109. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  113. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  114. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  115. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  116. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  117. package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
  118. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  119. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  120. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  121. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  122. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  123. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  124. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  125. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  126. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  127. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  128. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  129. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  130. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  131. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  132. package/samples/apps/use-all-bundled/package.json +1 -0
  133. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  134. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  135. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  136. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  137. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  138. package/samples/recipes/chat-channels/README.md +19 -0
  139. package/samples/recipes/chat-channels/package.json +9 -0
  140. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  141. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  142. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  143. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  144. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
  145. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  146. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  147. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -334,9 +334,7 @@ export const forgetSubjectWrite = defineWriteHandler({
334
334
  const subjectKey = subjectIdToKey(subject);
335
335
 
336
336
  const tenantScopeDenial = await resolveTenantScopeDenial(
337
- ctx.db.unsafeRaw(
338
- "tenant-scope check runs against the subject's tenant, not necessarily the caller's",
339
- ),
337
+ ctx.db.unsafeRaw(),
340
338
  ctx.registry.features,
341
339
  event.user,
342
340
  raw,
@@ -348,9 +346,7 @@ export const forgetSubjectWrite = defineWriteHandler({
348
346
  subjectKey,
349
347
  raw.kind,
350
348
  denialReasonOf(tenantScopeDenial),
351
- ctx.dbOutsideTransaction?.unsafeRaw(
352
- "denial audit append: names the prober's own tenant stream on the outside-transaction db",
353
- ),
349
+ ctx.dbOutsideTransaction?.unsafeRaw(),
354
350
  );
355
351
  return auditFailure ?? tenantScopeDenial;
356
352
  }
@@ -358,9 +354,7 @@ export const forgetSubjectWrite = defineWriteHandler({
358
354
  // Runs after the tenant gate but before any key is touched: a retention
359
355
  // check ahead of the tenant gate would leak a foreign entity's retention
360
356
  // posture to a cross-tenant prober.
361
- const retentionDenial = await resolveRetentionDenial(ctx, raw, () =>
362
- ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"),
363
- );
357
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
364
358
  if (retentionDenial) {
365
359
  const auditFailure = await appendDenialAuditEvent(
366
360
  ctx,
@@ -368,9 +362,7 @@ export const forgetSubjectWrite = defineWriteHandler({
368
362
  subjectKey,
369
363
  raw.kind,
370
364
  TARGET_RECORD_RETENTION_BLOCK_DELETE,
371
- ctx.dbOutsideTransaction?.unsafeRaw(
372
- "denial audit append: names the prober's own tenant stream on the outside-transaction db",
373
- ),
365
+ ctx.dbOutsideTransaction?.unsafeRaw(),
374
366
  );
375
367
  return auditFailure ?? retentionDenial;
376
368
  }
@@ -387,9 +379,7 @@ export const forgetSubjectWrite = defineWriteHandler({
387
379
 
388
380
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
389
381
  // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
390
- const crossTenantSubjectRunner = ctx.db.unsafeRaw(
391
- "blind-index sweep and search purge address the subject across tenants",
392
- );
382
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw();
393
383
  await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
394
384
 
395
385
  // Derived search index still holds plaintext (#1610) — purge next to the
@@ -425,9 +415,7 @@ export const forgetSubjectWrite = defineWriteHandler({
425
415
  // update is best-effort for real users.
426
416
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
427
417
  try {
428
- const userLifecycleRunner = ctx.db.unsafeRaw(
429
- "user lifecycle update and PAT revoke run on the SYSTEM user stream",
430
- );
418
+ const userLifecycleRunner = ctx.db.unsafeRaw();
431
419
  await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
432
420
  if (ctx.registry.features.has("personal-access-tokens")) {
433
421
  await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
@@ -40,7 +40,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
40
40
  const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
41
41
 
42
42
  const accessCheck = await checkFieldAccessForWrite(
43
- ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
43
+ ctx.db.unsafeRaw(),
44
44
  event.user.tenantId,
45
45
  payload.entityName,
46
46
  payload.fieldKey,
@@ -74,10 +74,7 @@ export function createDefineTenantFieldHandler(
74
74
  }
75
75
 
76
76
  if (limit !== undefined) {
77
- const current = await countTenantFieldDefinitions(
78
- ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
79
- tenantId,
80
- );
77
+ const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
81
78
  if (current >= limit) {
82
79
  return failUnprocessable("cap_exceeded", {
83
80
  capName: "customFields.fieldDefinition.count",
@@ -63,7 +63,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
63
63
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
64
64
 
65
65
  const loaded = await loadFieldDefinition(
66
- ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
66
+ ctx.db.unsafeRaw(),
67
67
  event.user.tenantId,
68
68
  payload.entityName,
69
69
  payload.fieldKey,
@@ -73,7 +73,7 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
73
73
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
74
74
  r.useExtension(EXT_USER_DATA, opts.entityName, {
75
75
  export: async (ctx: UserDataHookCtx) =>
76
- exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
76
+ exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
77
77
  escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
78
78
  });
79
79
  }
@@ -95,9 +95,8 @@ export function createDataRetentionFeature(): FeatureDefinition {
95
95
  return;
96
96
  }
97
97
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
98
- const cleanupDb = ctx.db.unsafeRaw(
99
- "retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId",
100
- ) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
98
+ const cleanupDb =
99
+ ctx.db.unsafeRaw() as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
101
100
  const tenantPreset = await resolveTenantRetentionPreset({
102
101
  db: cleanupDb,
103
102
  registry: ctx.registry,
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "Slack, Discord, Microsoft Teams and Telegram delivery channels",
6
+ "detail": "`channel-slack`, `channel-discord`, `channel-teams` and `channel-telegram` register route-only delivery channels. Webhook URLs and the Telegram bot token live in the tenant's secrets; the send job reads them with an audit entry `<feature>:send`. `DeliveryChannel.resolve` is optional and `ChannelContext.secrets` is available on the send job path.",
7
+ "migration": "keine"
8
+ },
2
9
  {
3
10
  "version": "0.338.0",
4
11
  "type": "improvement",
@@ -1,6 +1,7 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import type { ChannelContext } from "./types.js";
5
6
 
6
7
  // Build the per-tenant context a channel's resolve/render/send receives.
@@ -12,6 +13,7 @@ export function buildChannelContext(
12
13
  registry: Registry,
13
14
  sseBroker: SseBroker | undefined,
14
15
  tenantId: TenantId,
16
+ secrets?: SecretsContext,
15
17
  ): ChannelContext {
16
- return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
18
+ return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId, secrets };
17
19
  }
@@ -0,0 +1,76 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import {
3
+ type ChatSendResult,
4
+ chatConnectionNameSchema,
5
+ DEFAULT_CHAT_TIMEOUT_MS,
6
+ postChatWebhook,
7
+ } from "./chat-webhook-sender.js";
8
+ import type { ChannelContext, ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
9
+
10
+ export type ChatWebhookChannelOptions = {
11
+ // Test/self-hosting override. Replaces the provider default allowlist; never
12
+ // reachable from tenant config or secrets.
13
+ readonly allowedHosts?: readonly string[];
14
+ readonly requireHttps?: boolean;
15
+ readonly timeoutMs?: number;
16
+ };
17
+
18
+ export function toChannelResult(address: string, result: ChatSendResult): ChannelResult {
19
+ return result.ok
20
+ ? { status: "sent", address }
21
+ : { status: "failed", error: result.code, address };
22
+ }
23
+
24
+ export function chatMessageText(message: ChannelMessage): string {
25
+ return message.body ? `${message.title}\n${message.body}` : message.title;
26
+ }
27
+
28
+ export type ChatWebhookChannelSpec = {
29
+ readonly name: string;
30
+ readonly featureName: string;
31
+ readonly defaultAllowedHosts: readonly string[];
32
+ readonly requiredPathPrefix?: string;
33
+ // Resolves the secret key for a validated connection name.
34
+ readonly keyFor: (connection: string) => string;
35
+ readonly buildBody: (message: ChannelMessage) => unknown;
36
+ readonly options: ChatWebhookChannelOptions;
37
+ };
38
+
39
+ // Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
40
+ // webhook URL lives in the secrets store under that name. Route-only (no resolve).
41
+ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel {
42
+ const { options } = spec;
43
+ return {
44
+ name: spec.name,
45
+ mode: "queued",
46
+
47
+ async send(address, message, ctx: ChannelContext) {
48
+ if (!chatConnectionNameSchema.safeParse(address).success) {
49
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
50
+ }
51
+ let url: string | undefined;
52
+ try {
53
+ const revealed = await ctx.secrets?.get(ctx.tenantId, spec.keyFor(address), {
54
+ userId: SYSTEM_USER_ID,
55
+ handlerName: `${spec.featureName}:send`,
56
+ });
57
+ url = revealed?.reveal();
58
+ } catch {
59
+ url = undefined;
60
+ }
61
+ if (!url) return toChannelResult(address, { ok: false, code: "missing_credentials" });
62
+
63
+ const result = await postChatWebhook({
64
+ url,
65
+ allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
66
+ requireHttps: options.requireHttps ?? true,
67
+ ...(spec.requiredPathPrefix !== undefined && {
68
+ requiredPathPrefix: spec.requiredPathPrefix,
69
+ }),
70
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
71
+ body: spec.buildBody(message),
72
+ });
73
+ return toChannelResult(address, result);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,135 @@
1
+ import { BlockedHostError, type EgressPolicy, egress } from "@cosmicdrift/kumiko-framework/http";
2
+ import * as z from "zod";
3
+
4
+ // Closed vocabulary: these codes (and nothing else) land in delivery_attempts.error.
5
+ // Never err.message and never a provider response body — a fetch error can carry
6
+ // the request URL, and the Telegram URL embeds the bot token.
7
+ export type ChatSendFailureCode =
8
+ | `http_${number}`
9
+ | "timeout"
10
+ | "network_error"
11
+ | "redirect_blocked"
12
+ | "host_not_allowed"
13
+ | "missing_credentials"
14
+ | "invalid_address";
15
+
16
+ export type ChatSendResult =
17
+ | { readonly ok: true }
18
+ | { readonly ok: false; readonly code: ChatSendFailureCode };
19
+
20
+ export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
21
+
22
+ // Tenant-chosen connection name (the route address for webhook channels). Also
23
+ // the suffix the secrets write gate accepts under the channel's namespace, so
24
+ // the two cannot drift. Validated before it is used to build a secret key.
25
+ export const chatConnectionNameSchema = z
26
+ .string()
27
+ .min(1)
28
+ .max(64)
29
+ .regex(/^[a-z0-9][a-z0-9-]*$/);
30
+
31
+ export type ChatWebhookRequest = {
32
+ readonly url: string;
33
+ // Exact hostnames, or suffixes with a leading dot (".webhook.office.com").
34
+ readonly allowedHosts: readonly string[];
35
+ readonly requireHttps: boolean;
36
+ readonly requiredPathPrefix?: string;
37
+ readonly timeoutMs: number;
38
+ readonly body: unknown;
39
+ // Test seam; production always uses the policy-bound egress().
40
+ readonly send?: (url: string, init: RequestInit) => Promise<Response>;
41
+ };
42
+
43
+ // https targets are provider endpoints on public hosts: egress "external" adds
44
+ // the private/reserved-range denial and DNS pinning on top of our allowlist. The
45
+ // cleartext opt-out (requireHttps: false) exists for operator-trusted local
46
+ // endpoints, which "external" would refuse; the allowlist already pins the host.
47
+ function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
48
+ return request.requireHttps
49
+ ? { kind: "external" }
50
+ : { kind: "internal", allowHosts: [target.hostname] };
51
+ }
52
+
53
+ function hostMatches(hostname: string, allowedHost: string): boolean {
54
+ const allowed = allowedHost.toLowerCase();
55
+ if (allowed.startsWith(".")) {
56
+ return hostname.length > allowed.length && hostname.endsWith(allowed);
57
+ }
58
+ return hostname === allowed;
59
+ }
60
+
61
+ // Allowlist runs on the parsed URL, never on the raw string: userinfo
62
+ // (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
63
+ function checkTarget(request: ChatWebhookRequest): URL | undefined {
64
+ let parsed: URL;
65
+ try {
66
+ parsed = new URL(request.url);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ const protocolAllowed =
71
+ parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
72
+ if (!protocolAllowed) return undefined;
73
+ // egress() has no port rule; a provider webhook over https never needs a custom port.
74
+ if (request.requireHttps && parsed.port !== "") return undefined;
75
+ if (parsed.username !== "" || parsed.password !== "") return undefined;
76
+ const hostname = parsed.hostname.toLowerCase();
77
+ if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed))) return undefined;
78
+ if (
79
+ request.requiredPathPrefix !== undefined &&
80
+ !parsed.pathname.startsWith(request.requiredPathPrefix)
81
+ ) {
82
+ return undefined;
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ function isTimeout(err: unknown): boolean {
88
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
89
+ }
90
+
91
+ // egress(internal) follows same-host redirects itself and throws on cross-host
92
+ // ones; both are the "redirect" outcome here. Matched on the stable message
93
+ // prefix only — the message is discarded, it can carry the Location header.
94
+ function isEgressRedirectRefusal(err: unknown): boolean {
95
+ return err instanceof Error && err.message.startsWith("egress(internal): redirect");
96
+ }
97
+
98
+ function classifyFailure(err: unknown): ChatSendFailureCode {
99
+ if (err instanceof BlockedHostError) return "host_not_allowed";
100
+ if (isEgressRedirectRefusal(err)) return "redirect_blocked";
101
+ return isTimeout(err) ? "timeout" : "network_error";
102
+ }
103
+
104
+ // Never throws and never surfaces the URL: egress/Bun errors can carry it, so
105
+ // every failure is reduced to a code here.
106
+ export async function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult> {
107
+ const target = checkTarget(request);
108
+ if (!target) return { ok: false, code: "host_not_allowed" };
109
+
110
+ let response: Response;
111
+ try {
112
+ const send = request.send ?? egress(policyFor(request, target));
113
+ response = await send(target.toString(), {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify(request.body),
117
+ signal: AbortSignal.timeout(request.timeoutMs),
118
+ });
119
+ } catch (err) {
120
+ return { ok: false, code: classifyFailure(err) };
121
+ }
122
+
123
+ // Free the connection; the body is never read or stored.
124
+ await response.body?.cancel().catch(() => undefined);
125
+
126
+ const { status } = response;
127
+ if (status >= 300 && status < 400) return { ok: false, code: "redirect_blocked" };
128
+ if (status >= 200 && status < 300) return { ok: true };
129
+ return { ok: false, code: `http_${status}` };
130
+ }
131
+
132
+ export function truncateChars(text: string, maxChars: number): string {
133
+ const chars = Array.from(text);
134
+ return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
135
+ }
@@ -375,6 +375,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
375
375
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
376
376
 
377
377
  for (const channel of channels) {
378
+ // Route-only channel (no per-user address): not a user-notification target.
379
+ if (!channel.resolve) continue;
380
+
378
381
  const message = buildMessage(notificationType, data, channel.name);
379
382
 
380
383
  // Kill switch: tenant admin disabled this channel entirely
@@ -1,4 +1,19 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export {
3
+ type ChatWebhookChannelOptions,
4
+ chatMessageText,
5
+ createChatWebhookChannel,
6
+ toChannelResult,
7
+ } from "./chat-webhook-channel.js";
8
+ export {
9
+ type ChatSendFailureCode,
10
+ type ChatSendResult,
11
+ type ChatWebhookRequest,
12
+ chatConnectionNameSchema,
13
+ DEFAULT_CHAT_TIMEOUT_MS,
14
+ postChatWebhook,
15
+ truncateChars,
16
+ } from "./chat-webhook-sender.js";
2
17
  export type { DeliveryStatusValue } from "./constants.js";
3
18
  export {
4
19
  DELIVERY_CHANNEL_EXTENSION,
@@ -147,7 +147,7 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
147
147
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
148
148
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
149
149
  const channel = resolveChannel(registry, p.channelName);
150
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
150
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
151
151
 
152
152
  try {
153
153
  const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
@@ -5,6 +5,7 @@ import type {
5
5
  Registry,
6
6
  TenantId,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
8
9
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
9
10
  import type { Redis } from "ioredis";
10
11
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
@@ -17,6 +18,7 @@ export type CreateDeliveryTestContextOptions = {
17
18
  readonly isChannelKilled?: KillSwitchResolver;
18
19
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
19
20
  readonly log?: Logger;
21
+ readonly jobRunner?: JobRunner;
20
22
  };
21
23
 
22
24
  /**
@@ -7,6 +7,7 @@ import type {
7
7
  SessionUser,
8
8
  TenantId,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
10
11
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
11
12
 
12
13
  // --- Channel Interface ---
@@ -16,6 +17,9 @@ export type ChannelContext = {
16
17
  readonly registry: Registry;
17
18
  readonly sseBroker: SseBroker | undefined;
18
19
  readonly tenantId: TenantId;
20
+ // Only wired on the delivery.send job path. Chat channels read their
21
+ // per-tenant webhook URL / bot token from it; inline sends have none.
22
+ readonly secrets?: SecretsContext | undefined;
19
23
  };
20
24
 
21
25
  export type ChannelMessage = {
@@ -49,7 +53,9 @@ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
49
53
  export type DeliveryChannel = {
50
54
  readonly name: string;
51
55
  readonly mode: DeliveryChannelMode;
52
- resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
56
+ // Absent for channels addressed only through `route` (tenant-owned chat
57
+ // targets): deliverToUser skips them without writing a no_address row.
58
+ resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
53
59
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
54
60
  send(
55
61
  address: string,
@@ -106,8 +112,7 @@ export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin
106
112
  "mode" in o &&
107
113
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
108
114
  (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
109
- "resolve" in o &&
110
- typeof o.resolve === "function" &&
115
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
111
116
  "send" in o &&
112
117
  typeof o.send === "function"
113
118
  );
@@ -38,7 +38,7 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
38
38
  reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
39
39
  },
40
40
  handler: async (query, ctx) => {
41
- const db = ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON);
41
+ const db = ctx.db.unsafeRaw();
42
42
  const row = await fetchOne<FileRefTenantRow>(db, fileRefsTable, {
43
43
  id: query.payload.fileRefId,
44
44
  isDeleted: false,
@@ -55,7 +55,7 @@ export function createFilesTenantDataFeature(): FeatureDefinition {
55
55
  sweepOrphanedDerivativesJob(
56
56
  payload,
57
57
  ctx,
58
- ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
58
+ ctx.db.unsafeRaw() as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
59
59
  ),
60
60
  });
61
61
  });
@@ -51,7 +51,7 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
51
51
  cleanupDraftsJob(
52
52
  payload,
53
53
  ctx,
54
- ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
54
+ ctx.db.unsafeRaw() as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
55
55
  ),
56
56
  });
57
57
  }
@@ -69,7 +69,7 @@ export const discardDraftWrite = defineWriteHandler({
69
69
  // predates the draft row, so the insertedAt filter must not apply.
70
70
  const isCreateMode = event.payload.draftKey.includes(":new:");
71
71
  const ownedRefs = await filterOwnedFileRefs(
72
- ctx.db.unsafeRaw(DISCARD_DRAFT_FILE_REFS_REASON),
72
+ ctx.db.unsafeRaw(),
73
73
  event.user.tenantId,
74
74
  ownerId,
75
75
  candidateKeys,
@@ -62,11 +62,7 @@ export const saveDraftWrite = defineWriteHandler({
62
62
  // Cap check only on the create path — an update never grows the number
63
63
  // of drafts this owner has, so a user already at the limit can still
64
64
  // keep saving their existing drafts.
65
- const draftCount = await countDraftsByOwner(
66
- ctx.db.unsafeRaw(SAVE_DRAFT_COUNT_REASON),
67
- event.user.tenantId,
68
- ownerId,
69
- );
65
+ const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(), event.user.tenantId, ownerId);
70
66
  if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
71
67
  return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
72
68
  }
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "Inbound mail projections use typed bun-db helpers",
6
+ "detail": "Mail-account and mail-thread events upsert through `upsertOnConflict` (`connectedAt` is only written on insert), inbound messages insert through `insertOnConflictDoNothing`. The raw `db/queries/inbound-projections.ts` module is gone.",
7
+ "migration": "keine"
8
+ },
9
+ {
10
+ "version": "0.339.0",
11
+ "type": "improvement",
12
+ "title": "Inbound mail OAuth accounts refresh their access token",
13
+ "detail": "`createInboundMailSupervisor` obtains a valid access token via the new `createOAuthAccessTokenManager` before `plugin.fetch`/`plugin.watch` for accounts whose provider declares `oauth` and whose authMethod is `oauth`/`xoauth2`, and passes it as `opts.accessToken` (`fetch`) or `{ accessToken }` (`watch`, 4th argument). `refreshAccessToken` receives an optional 4th argument `{ signal }` (aborted after 30 s); providers must pass it to their token-endpoint fetch, since the foundation awaits the provider and never drops a late result. The foundation has no single construction point for routes and supervisor, so apps that want connect to prime the cache and write under the refresh lock create one manager with `createOAuthAccessTokenManager({ db, secrets })` and pass it to both `createInboundMailSupervisor({ oauthTokens })` and `createInboundMailConnectRoutes({ oauthTokens })`; without it the supervisor builds its own manager and the callback writes the secret without the lock. `InboundAuthError` from `refreshAccessToken` moves the account to `auth_error`; the generic sync-error log and the `token_exchange_failed` response body no longer contain provider error messages.",
14
+ "migration": "Apps with OAuth inbound accounts create one `createOAuthAccessTokenManager({ db, secrets })` and pass it as `oauthTokens` to both `createInboundMailSupervisor` and `createInboundMailConnectRoutes`. Provider plugins with a `refreshAccessToken` pass the `signal` from its fourth argument to their token-endpoint fetch."
15
+ },
2
16
  {
3
17
  "version": "0.335.0",
4
18
  "type": "fix",
@@ -24,6 +24,10 @@ import {
24
24
  InboundMailFoundationHandlers,
25
25
  inboundCredentialSecretKey,
26
26
  } from "./constants.js";
27
+ import {
28
+ inboundRefreshTokenSecretOptions,
29
+ type OAuthAccessTokenManager,
30
+ } from "./oauth-access-token.js";
27
31
  import { type OAuthStatePayload, signOAuthState, verifyOAuthState } from "./oauth-state.js";
28
32
  import { resolveInboundProviderForKey } from "./provider-factory.js";
29
33
 
@@ -46,6 +50,12 @@ export type InboundMailConnectRoutesOptions = {
46
50
  readonly connectPath?: string;
47
51
  /** Default "/inbound-mail/oauth/callback". */
48
52
  readonly callbackPath?: string;
53
+ /** Apps wire the supervisor and these routes separately, so build ONE
54
+ * manager (`createOAuthAccessTokenManager`) and share it with `createInboundMailSupervisor({ oauthTokens })`:
55
+ * the callback then writes the refresh token under the account's refresh
56
+ * lock and primes the access-token cache from the code exchange. Omitted
57
+ * → plain secret write, no cache priming. */
58
+ readonly oauthTokens?: OAuthAccessTokenManager;
49
59
  };
50
60
 
51
61
  function errorJson(c: Context, status: 400 | 401 | 404 | 500 | 502, code: string, message: string) {
@@ -185,12 +195,13 @@ export function createInboundMailConnectRoutes(
185
195
  code,
186
196
  redirectUri: options.callbackUrl,
187
197
  });
188
- } catch (e) {
198
+ } catch {
199
+ // Provider error messages can carry token/response data — code only.
189
200
  return errorJson(
190
201
  c,
191
202
  502,
192
203
  "token_exchange_failed",
193
- e instanceof Error ? e.message : String(e),
204
+ "token exchange with the provider failed",
194
205
  );
195
206
  }
196
207
  if (!tokens.refreshToken) {
@@ -228,17 +239,22 @@ export function createInboundMailConnectRoutes(
228
239
  }
229
240
 
230
241
  // Refresh-token into the per-account secret slot (Slot = accountId).
231
- // Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
232
- // sync path.
233
- await deps.secrets.set(
234
- state.tenantId,
235
- inboundCredentialSecretKey(accountId),
236
- tokens.refreshToken,
237
- {
238
- redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
239
- hint: `OAuth refresh token for inbound mail account ${accountId}`,
240
- },
241
- );
242
+ // Access tokens (~1h) are never persisted — the supervisor re-mints
243
+ // them from the refresh token (oauth-access-token.ts).
244
+ if (options.oauthTokens) {
245
+ await options.oauthTokens.storeRefreshToken(
246
+ { id: accountId, tenantId: state.tenantId },
247
+ tokens.refreshToken,
248
+ );
249
+ options.oauthTokens.primeFromExchange({ id: accountId }, tokens);
250
+ } else {
251
+ await deps.secrets.set(
252
+ state.tenantId,
253
+ inboundCredentialSecretKey(accountId),
254
+ tokens.refreshToken,
255
+ inboundRefreshTokenSecretOptions(accountId),
256
+ );
257
+ }
242
258
 
243
259
  if (options.successRedirectUrl) {
244
260
  const target = new URL(options.successRedirectUrl);
@@ -230,7 +230,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
230
230
  return;
231
231
  }
232
232
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
233
- const retentionDb = ctx.db.unsafeRaw(INBOUND_MAIL_RETENTION_REASON) as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
233
+ const retentionDb = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
234
234
 
235
235
  // Body-Objekte liegen in file-foundation. Provider lazy + memoized —
236
236
  // heute schreibt der Ingest kein bodyRef (storeBody-Hook ungebunden),
@@ -273,7 +273,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
273
273
  )
274
274
  : threadPlainPii;
275
275
 
276
- const threadRollupRunner = ctx.db.unsafeRaw(THREAD_ROLLUP_ADVISORY_LOCK_REASON);
276
+ const threadRollupRunner = ctx.db.unsafeRaw();
277
277
  await acquireNamespacedAdvisoryLock(
278
278
  threadRollupRunner,
279
279
  THREAD_ROLLUP_LOCK_NAMESPACE,
@@ -55,6 +55,13 @@ export {
55
55
  mailThreadEventPayloadSchema,
56
56
  } from "./events.js";
57
57
  export { inboundMailFoundationFeature } from "./feature.js";
58
+ export {
59
+ createOAuthAccessTokenManager,
60
+ inboundRefreshTokenSecretOptions,
61
+ type OAuthAccessTokenManager,
62
+ type OAuthAccessTokenManagerDeps,
63
+ usesFoundationManagedOAuth,
64
+ } from "./oauth-access-token.js";
58
65
  export {
59
66
  type OAuthStatePayload,
60
67
  signOAuthState,