@cosmicdrift/kumiko-samples 0.338.0 → 0.339.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 (138) 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 +34 -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/tenant-destroy-hook.ts +4 -4
  38. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  39. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  40. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  41. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  42. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  43. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  44. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  45. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  46. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  47. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  48. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  49. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  50. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  51. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  52. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  53. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  54. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  55. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  56. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  57. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  58. package/packages/bundled-features/src/delivery/changes.json +7 -0
  59. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  60. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  61. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  62. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  63. package/packages/bundled-features/src/delivery/index.ts +15 -0
  64. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  65. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  66. package/packages/bundled-features/src/delivery/types.ts +8 -3
  67. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  68. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  69. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  72. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  74. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  75. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  79. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  80. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  81. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  82. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  83. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  84. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  85. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  86. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  87. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  88. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  89. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  90. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  91. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  92. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  93. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  94. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  95. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  97. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  98. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  99. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  100. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  101. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  102. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  104. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  108. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  109. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  113. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  114. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  115. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  116. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  117. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  118. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  119. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  120. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  121. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  122. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  123. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  124. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  125. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  126. package/samples/apps/use-all-bundled/package.json +1 -0
  127. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  128. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  129. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  130. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  131. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  132. package/samples/recipes/chat-channels/README.md +19 -0
  133. package/samples/recipes/chat-channels/package.json +9 -0
  134. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  135. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  136. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  137. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  138. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
@@ -0,0 +1,201 @@
1
+ // OAuth access-token lifecycle for inbound-mail accounts. Access tokens
2
+ // (~1h) are never persisted: they live in a per-process cache and are
3
+ // re-minted from the refresh token in the account's secret slot.
4
+ //
5
+ // Serialization:
6
+ // - in-process: single-flight per account (concurrent callers share one
7
+ // refresh).
8
+ // - cross-worker: a pg advisory xact lock around "re-read refresh token →
9
+ // provider refresh → persist rotated refresh token". This serializes
10
+ // refreshes and secret writes so a rotating provider can't see two
11
+ // workers redeem the same refresh token concurrently. It does NOT make
12
+ // refresh exactly-once across workers: access tokens are per-process, so
13
+ // each worker still refreshes once itself.
14
+ //
15
+ // The refresh token is re-read inside the lock via `secrets.get` without an
16
+ // audit context. SecretsContext does not cache values (only unwrapped DEKs),
17
+ // so the read always reflects the latest committed write.
18
+
19
+ import { transaction } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
21
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
22
+ import { Temporal } from "temporal-polyfill";
23
+ import { InboundMailAuthMethods, inboundCredentialSecretKey } from "./constants.js";
24
+ import {
25
+ InboundAuthError,
26
+ type InboundMailContext,
27
+ type InboundMailProviderPlugin,
28
+ type MailAccountRecord,
29
+ type OAuthTokenSet,
30
+ } from "./types.js";
31
+
32
+ // 'inoa' as ASCII — disjoint from the other fixed advisory-lock namespaces.
33
+ const OAUTH_REFRESH_LOCK_NAMESPACE = 0x696e6f61;
34
+ const ACCESS_TOKEN_EXPIRY_MARGIN = Temporal.Duration.from({ minutes: 2 });
35
+ // The provider call runs while the lock transaction holds a pool connection,
36
+ // so it gets an abort signal with this upper bound.
37
+ const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
38
+
39
+ /** Shared by the OAuth callback and the refresh path so both write the slot identically. */
40
+ export function inboundRefreshTokenSecretOptions(accountId: string) {
41
+ return {
42
+ redact: (plaintext: string) => `${plaintext.slice(0, 4)}…`,
43
+ hint: `OAuth refresh token for inbound mail account ${accountId}`,
44
+ };
45
+ }
46
+
47
+ /** True for accounts whose access token the foundation must supply. */
48
+ export function usesFoundationManagedOAuth(
49
+ account: MailAccountRecord,
50
+ plugin: InboundMailProviderPlugin,
51
+ ): boolean {
52
+ return (
53
+ plugin.oauth !== undefined &&
54
+ (account.authMethod === InboundMailAuthMethods.oauth ||
55
+ account.authMethod === InboundMailAuthMethods.xoauth2)
56
+ );
57
+ }
58
+
59
+ export type OAuthAccessTokenManagerDeps = {
60
+ readonly db: DbConnection;
61
+ readonly secrets: SecretsContext;
62
+ readonly providerCtx?: InboundMailContext;
63
+ readonly now?: () => Temporal.Instant;
64
+ readonly refreshTimeoutMs?: number;
65
+ };
66
+
67
+ export type OAuthAccessTokenManager = {
68
+ /** Valid access token for the account; refreshes when missing or near expiry. */
69
+ readonly getAccessToken: (
70
+ account: MailAccountRecord,
71
+ plugin: InboundMailProviderPlugin,
72
+ ) => Promise<string>;
73
+ /** Writes the refresh token under the account lock and drops any cached access token. */
74
+ readonly storeRefreshToken: (
75
+ account: Pick<MailAccountRecord, "id" | "tenantId">,
76
+ refreshToken: string,
77
+ ) => Promise<void>;
78
+ /** Seeds the cache from a code-exchange result (access token only). */
79
+ readonly primeFromExchange: (
80
+ account: Pick<MailAccountRecord, "id">,
81
+ tokens: OAuthTokenSet,
82
+ ) => void;
83
+ readonly invalidate: (accountId: string) => void;
84
+ };
85
+
86
+ type CachedToken = { readonly accessToken: string; readonly expiresAt: Temporal.Instant };
87
+
88
+ function lockKey(account: Pick<MailAccountRecord, "id" | "tenantId">): string {
89
+ return `${account.tenantId}:${account.id}`;
90
+ }
91
+
92
+ export function createOAuthAccessTokenManager(
93
+ deps: OAuthAccessTokenManagerDeps,
94
+ ): OAuthAccessTokenManager {
95
+ const now = deps.now ?? (() => Temporal.Now.instant());
96
+ const refreshTimeoutMs = deps.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
97
+ const providerCtx: InboundMailContext = { ...deps.providerCtx, secrets: deps.secrets };
98
+ const cache = new Map<string, CachedToken>();
99
+ const inFlight = new Map<string, Promise<string>>();
100
+
101
+ function parseExpiry(iso: string): Temporal.Instant {
102
+ try {
103
+ return Temporal.Instant.from(iso);
104
+ } catch {
105
+ // Unparseable expiry: treat as already expired so it is never cached.
106
+ return now();
107
+ }
108
+ }
109
+
110
+ function remember(accountId: string, tokens: OAuthTokenSet): void {
111
+ cache.set(accountId, {
112
+ accessToken: tokens.accessToken,
113
+ expiresAt: parseExpiry(tokens.expiresAt),
114
+ });
115
+ }
116
+
117
+ function cachedValid(accountId: string): string | undefined {
118
+ const hit = cache.get(accountId);
119
+ if (!hit) return undefined;
120
+ const usableUntil = hit.expiresAt.subtract(ACCESS_TOKEN_EXPIRY_MARGIN);
121
+ return Temporal.Instant.compare(now(), usableUntil) < 0 ? hit.accessToken : undefined;
122
+ }
123
+
124
+ async function refreshWithTimeout(
125
+ plugin: InboundMailProviderPlugin,
126
+ account: MailAccountRecord,
127
+ refreshToken: string,
128
+ ): Promise<OAuthTokenSet> {
129
+ const oauth = plugin.oauth;
130
+ if (!oauth) throw new InboundAuthError("provider has no oauth flow");
131
+ return oauth.refreshAccessToken(providerCtx, account, refreshToken, {
132
+ signal: AbortSignal.timeout(refreshTimeoutMs),
133
+ });
134
+ }
135
+
136
+ async function refresh(
137
+ account: MailAccountRecord,
138
+ plugin: InboundMailProviderPlugin,
139
+ ): Promise<string> {
140
+ const secretKey = inboundCredentialSecretKey(account.id);
141
+ return transaction(deps.db, async (tx) => {
142
+ await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
143
+ const stored = await deps.secrets.get(account.tenantId, secretKey);
144
+ if (!stored) {
145
+ throw new InboundAuthError("no refresh token stored for account");
146
+ }
147
+ const currentRefreshToken = stored.reveal();
148
+ const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
149
+ if (tokens.refreshToken && tokens.refreshToken !== currentRefreshToken) {
150
+ await deps.secrets.set(
151
+ account.tenantId,
152
+ secretKey,
153
+ tokens.refreshToken,
154
+ inboundRefreshTokenSecretOptions(account.id),
155
+ );
156
+ }
157
+ remember(account.id, tokens);
158
+ return tokens.accessToken;
159
+ });
160
+ }
161
+
162
+ return {
163
+ async getAccessToken(account, plugin) {
164
+ const hit = cachedValid(account.id);
165
+ if (hit !== undefined) return hit;
166
+ const running = inFlight.get(account.id);
167
+ if (running) return running;
168
+ const started = refresh(account, plugin)
169
+ .catch((err: unknown) => {
170
+ cache.delete(account.id);
171
+ throw err;
172
+ })
173
+ .finally(() => {
174
+ inFlight.delete(account.id);
175
+ });
176
+ inFlight.set(account.id, started);
177
+ return started;
178
+ },
179
+
180
+ async storeRefreshToken(account, refreshToken) {
181
+ await transaction(deps.db, async (tx) => {
182
+ await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
183
+ await deps.secrets.set(
184
+ account.tenantId,
185
+ inboundCredentialSecretKey(account.id),
186
+ refreshToken,
187
+ inboundRefreshTokenSecretOptions(account.id),
188
+ );
189
+ });
190
+ cache.delete(account.id);
191
+ },
192
+
193
+ primeFromExchange(account, tokens) {
194
+ remember(account.id, tokens);
195
+ },
196
+
197
+ invalidate(accountId) {
198
+ cache.delete(accountId);
199
+ },
200
+ };
201
+ }
@@ -47,7 +47,7 @@ async function archiveAndDeleteRows(
47
47
  declareEscapeHatch({ reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON });
48
48
  const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
49
49
  for (const row of rows) {
50
- await archiveStream(ctx.db.unsafeRaw(INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON), {
50
+ await archiveStream(ctx.db.unsafeRaw(), {
51
51
  tenantId: ctx.tenantId,
52
52
  aggregateId: row.id,
53
53
  aggregateType,
@@ -135,6 +135,12 @@ export type InboundOAuthFlow = {
135
135
  ctx: InboundMailContext,
136
136
  account: MailAccountRecord,
137
137
  refreshToken: string,
138
+ /** The foundation aborts `signal` after a hard timeout while holding a
139
+ * per-account lock (a pool connection is pinned meanwhile). Providers
140
+ * MUST pass it to their token-endpoint fetch; the foundation awaits the
141
+ * provider's result and never drops a late one, because that could lose
142
+ * a rotated refresh token. */
143
+ opts?: { readonly signal?: AbortSignal },
138
144
  ) => Promise<OAuthTokenSet>;
139
145
  };
140
146
 
@@ -154,7 +160,14 @@ export type InboundMailProviderPlugin = {
154
160
  ctx: InboundMailContext,
155
161
  account: MailAccountRecord,
156
162
  cursor: SyncCursorPayload | null,
157
- opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
163
+ opts: {
164
+ readonly backfillWindowDays: number;
165
+ readonly maxMessages: number;
166
+ /** Valid OAuth access token, minted by the foundation for accounts
167
+ * whose provider declares `oauth` (authMethod oauth/xoauth2). Absent
168
+ * for password accounts and providers without an oauth flow. */
169
+ readonly accessToken?: string;
170
+ },
158
171
  ) => Promise<InboundFetchResult>;
159
172
  /** Nur OAuth-Provider. */
160
173
  readonly oauth?: InboundOAuthFlow;
@@ -170,6 +183,9 @@ export type InboundMailProviderPlugin = {
170
183
  readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
171
184
  readonly onError: (err: unknown) => void;
172
185
  },
186
+ /** Same `accessToken` semantics as `fetch` opts; re-minted on every
187
+ * supervisor (re)start of the watch. */
188
+ opts?: { readonly accessToken?: string },
173
189
  ) => Promise<() => Promise<void>>;
174
190
  };
175
191
 
@@ -54,6 +54,11 @@ import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
54
54
  import { Temporal } from "temporal-polyfill";
55
55
  import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
56
56
  import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities.js";
57
+ import {
58
+ createOAuthAccessTokenManager,
59
+ type OAuthAccessTokenManager,
60
+ usesFoundationManagedOAuth,
61
+ } from "./oauth-access-token.js";
57
62
  import { mailAccountsProjectionTable } from "./projection.js";
58
63
  import { resolveInboundProviderForAccount } from "./provider-factory.js";
59
64
  import {
@@ -67,6 +72,12 @@ import {
67
72
  type SyncCursorPayload,
68
73
  } from "./types.js";
69
74
 
75
+ // Provider errors can embed token or response data in `message`; logs carry
76
+ // only the error class.
77
+ function describeError(err: unknown): string {
78
+ return err instanceof Error ? err.name : typeof err;
79
+ }
80
+
70
81
  const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
71
82
  const DEFAULT_BACKFILL_WINDOW_DAYS = 30;
72
83
  const DEFAULT_MAX_MESSAGES_PER_POLL = 200;
@@ -110,6 +121,10 @@ export type InboundMailSupervisorDeps = {
110
121
  * watcher lives. Omit for single-process deployments — every active
111
122
  * account is watched locally, same as before #1719. */
112
123
  readonly lock?: DistributedLock;
124
+ /** Access-token lifecycle for OAuth accounts. Defaults to a manager built
125
+ * from `db` + `providerCtx.secrets`; pass the instance shared with
126
+ * `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
127
+ readonly oauthTokens?: OAuthAccessTokenManager;
113
128
  /** TTL (seconds) for the per-account watch lease. Default 90s, renewed
114
129
  * every ttl/3. Only used when `lock` is set. */
115
130
  readonly watchLeaseTtlSeconds?: number;
@@ -157,6 +172,29 @@ export function createInboundMailSupervisor(
157
172
  // configured TTL — real deployments (default 90s) never hit it.
158
173
  const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
159
174
  const log = deps.log ?? (() => {});
175
+ const oauthTokens =
176
+ deps.oauthTokens ??
177
+ (deps.providerCtx.secrets
178
+ ? createOAuthAccessTokenManager({
179
+ db: deps.db,
180
+ secrets: deps.providerCtx.secrets,
181
+ providerCtx: deps.providerCtx,
182
+ })
183
+ : undefined);
184
+
185
+ // Undefined for non-OAuth accounts. A refresh failure propagates into the
186
+ // caller's handleSyncError: InboundAuthError → auth_error, anything else
187
+ // → transient retry.
188
+ async function resolveAccessToken(
189
+ account: MailAccountRecord,
190
+ plugin: InboundMailProviderPlugin,
191
+ ): Promise<string | undefined> {
192
+ if (!usesFoundationManagedOAuth(account, plugin)) return undefined;
193
+ if (!oauthTokens) {
194
+ throw new Error("oauth account needs a secrets context for token refresh");
195
+ }
196
+ return oauthTokens.getAccessToken(account, plugin);
197
+ }
160
198
 
161
199
  let running = false;
162
200
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -336,9 +374,7 @@ export function createInboundMailSupervisor(
336
374
  return true;
337
375
  }
338
376
  // Transient/unbekannt: loggen, nächster Tick retried.
339
- log(
340
- `inbound-mail: account ${account.id} sync error: ${err instanceof Error ? err.message : String(err)}`,
341
- );
377
+ log(`inbound-mail: account ${account.id} sync error: ${describeError(err)}`);
342
378
  return true;
343
379
  }
344
380
 
@@ -354,9 +390,11 @@ export function createInboundMailSupervisor(
354
390
  try {
355
391
  // hasMore-Schleife: Pagination innerhalb eines Ticks bis Budget.
356
392
  for (;;) {
393
+ const accessToken = await resolveAccessToken(account, plugin);
357
394
  const result = await plugin.fetch(deps.providerCtx, account, cursor, {
358
395
  backfillWindowDays,
359
396
  maxMessages: budget,
397
+ ...(accessToken !== undefined && { accessToken }),
360
398
  });
361
399
  await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
362
400
  await saveCursor(account, result.nextCursor);
@@ -376,7 +414,7 @@ export function createInboundMailSupervisor(
376
414
  try {
377
415
  plugin = resolveInboundProviderForAccount(deps.providerCtx, account);
378
416
  } catch (err) {
379
- log(`inbound-mail: ${err instanceof Error ? err.message : String(err)}`);
417
+ log(`inbound-mail: ${describeError(err)}`);
380
418
  continue;
381
419
  }
382
420
  await pollAccount(account, plugin);
@@ -431,7 +469,7 @@ export function createInboundMailSupervisor(
431
469
  renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
432
470
  } catch (err) {
433
471
  log(
434
- `inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
472
+ `inbound-mail: watch lease renew for account ${account.id} failed: ${describeError(err)}`,
435
473
  );
436
474
  state.renewFailures += 1;
437
475
  // After failures spanning a full lease TTL, tear down — otherwise a
@@ -533,7 +571,7 @@ export function createInboundMailSupervisor(
533
571
  const delay = state.backoffMs;
534
572
  state.backoffMs = Math.min(state.backoffMs * 2, backoffMaxMs);
535
573
  log(
536
- `inbound-mail: watch for account ${account.id} died (${err instanceof Error ? err.message : String(err)}) — restart in ${delay}ms`,
574
+ `inbound-mail: watch for account ${account.id} died (${describeError(err)}) — restart in ${delay}ms`,
537
575
  );
538
576
  void markAccount(account, { watchState: `backoff:${delay}ms` }, "watch_supervisor");
539
577
  state.restartTimer = setTimeout(() => {
@@ -544,27 +582,33 @@ export function createInboundMailSupervisor(
544
582
 
545
583
  state.starting = true;
546
584
  try {
547
- const stop = await plugin.watch(deps.providerCtx, account, {
548
- onMessages: async (msgs) => {
549
- try {
550
- await ingestBatch(account, msgs, "watch");
551
- } catch (err) {
552
- // Ingest-Fehler killt den Watcher nicht — der Poll holt die
553
- // Messages beim nächsten Tick (Dedup macht's idempotent).
554
- log(
555
- `inbound-mail: watch-ingest for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
556
- );
557
- }
558
- },
559
- onError: (err) => {
560
- void (async () => {
561
- const keepRunning = await handleSyncError(account, err);
562
- // auth_error → stopWatcher hat die generation gebumpt,
563
- // scheduleRestart no-op't; für alle anderen: Backoff-Restart.
564
- if (keepRunning) scheduleRestart(err);
565
- })();
585
+ const accessToken = await resolveAccessToken(account, plugin);
586
+ const stop = await plugin.watch(
587
+ deps.providerCtx,
588
+ account,
589
+ {
590
+ onMessages: async (msgs) => {
591
+ try {
592
+ await ingestBatch(account, msgs, "watch");
593
+ } catch (err) {
594
+ // An ingest failure must not kill the watcher: the next poll
595
+ // tick fetches the messages again and dedup keeps it idempotent.
596
+ log(
597
+ `inbound-mail: watch-ingest for account ${account.id} failed: ${describeError(err)}`,
598
+ );
599
+ }
600
+ },
601
+ onError: (err) => {
602
+ void (async () => {
603
+ const keepRunning = await handleSyncError(account, err);
604
+ // On auth_error stopWatcher already bumped the generation, so
605
+ // scheduleRestart is a no-op; every other error restarts with backoff.
606
+ if (keepRunning) scheduleRestart(err);
607
+ })();
608
+ },
566
609
  },
567
- });
610
+ accessToken !== undefined ? { accessToken } : undefined,
611
+ );
568
612
  if (state.generation !== generation || !running) {
569
613
  await stop();
570
614
  // skip: stop() kam während des Connects — Watcher wurde sofort wieder abgebaut.
@@ -581,7 +625,7 @@ export function createInboundMailSupervisor(
581
625
  await markAccount(account, { watchState: "watching" }, "watch_supervisor");
582
626
  } catch (err) {
583
627
  log(
584
- `inbound-mail: markAccount(watching) for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
628
+ `inbound-mail: markAccount(watching) for account ${account.id} failed: ${describeError(err)}`,
585
629
  );
586
630
  }
587
631
  } catch (err) {
@@ -617,9 +661,7 @@ export function createInboundMailSupervisor(
617
661
  try {
618
662
  await stop();
619
663
  } catch (err) {
620
- log(
621
- `inbound-mail: stop watcher ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`,
622
- );
664
+ log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
623
665
  }
624
666
  }
625
667
  if (deps.lock && lockToken) {
@@ -627,7 +669,7 @@ export function createInboundMailSupervisor(
627
669
  await deps.lock.release(accountId, lockToken);
628
670
  } catch (err) {
629
671
  log(
630
- `inbound-mail: watch lease release for account ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`,
672
+ `inbound-mail: watch lease release for account ${accountId} failed: ${describeError(err)}`,
631
673
  );
632
674
  }
633
675
  }
@@ -642,11 +684,7 @@ export function createInboundMailSupervisor(
642
684
  pollTimer = setTimeout(() => {
643
685
  pollTimer = null;
644
686
  pollInFlight = pollOnce()
645
- .catch((err) =>
646
- log(
647
- `inbound-mail: poll tick failed: ${err instanceof Error ? err.message : String(err)}`,
648
- ),
649
- )
687
+ .catch((err) => log(`inbound-mail: poll tick failed: ${describeError(err)}`))
650
688
  .finally(() => {
651
689
  pollInFlight = null;
652
690
  scheduleNextPoll();
@@ -72,7 +72,7 @@ export const setWrite = defineWriteHandler({
72
72
  const scopedDb =
73
73
  override !== undefined
74
74
  ? createTenantDb(
75
- db.unsafeRaw(SET_PAGE_TENANT_OVERRIDE_REASON),
75
+ db.unsafeRaw(),
76
76
  override as TenantId, // @cast-boundary engine-bridge
77
77
  "tenant",
78
78
  )
@@ -72,16 +72,13 @@ export function createAddNoteHandler(
72
72
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
73
73
  // caught — so the lookup runs in a savepoint where available, otherwise directly
74
74
  // (pool statements are their own units).
75
- authorName = await runInSavepointIfSupported(
76
- ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON),
77
- async (sp) => {
78
- const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
79
- id: event.user.id,
80
- });
81
- if (!userRow?.displayName) return null;
82
- return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
83
- },
84
- );
75
+ authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(), async (sp) => {
76
+ const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
77
+ id: event.user.id,
78
+ });
79
+ if (!userRow?.displayName) return null;
80
+ return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
81
+ });
85
82
  } catch (e) {
86
83
  ctx.log?.warn("notes-history: authorName lookup failed", {
87
84
  error: e,
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "createRendererSimpleFeature({ mailBranding }) brands every notification mail, including the auth mails, with product name or logo, primary color and footer text and links",
6
+ "detail": "renderer-simple accepts mailBranding for all framework notification mails",
7
+ "migration": "No code change needed."
8
+ }
9
+ ]
@@ -1,4 +1,5 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { NotificationRenderer } from "../delivery/index.js";
2
3
  import {
3
4
  RENDERER_EXTENSION,
4
5
  type RendererContext,
@@ -7,7 +8,7 @@ import {
7
8
  type RenderResponse,
8
9
  } from "../renderer-foundation/index.js";
9
10
  import { resolveNotificationVariables } from "./resolve-variables.js";
10
- import { simpleRenderer } from "./simple-renderer.js";
11
+ import { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
11
12
 
12
13
  // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
13
14
  // NotificationRenderer-Contract), renderer-foundation erwartet
@@ -19,6 +20,7 @@ import { simpleRenderer } from "./simple-renderer.js";
19
20
  export async function adaptToFoundation(
20
21
  req: RenderRequest,
21
22
  ctx: RendererContext,
23
+ renderer: NotificationRenderer = simpleRenderer,
22
24
  ): Promise<RenderResponse> {
23
25
  if (req.kind !== "notification") {
24
26
  // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
@@ -29,14 +31,22 @@ export async function adaptToFoundation(
29
31
  );
30
32
  }
31
33
  const variables = await resolveNotificationVariables(req, ctx);
32
- const html = await simpleRenderer.render({
34
+ const html = await renderer.render({
33
35
  template: req.payload.template ?? "",
34
36
  variables,
35
37
  });
36
38
  return { kind: "notification", html };
37
39
  }
38
40
 
39
- export function createRendererSimpleFeature(): FeatureDefinition {
41
+ export type RendererSimpleOptions = {
42
+ /** Branding applied to every notification mail, including the framework auth mails. */
43
+ readonly mailBranding?: MailBranding;
44
+ };
45
+
46
+ export function createRendererSimpleFeature(options?: RendererSimpleOptions): FeatureDefinition {
47
+ const renderer = options?.mailBranding
48
+ ? createSimpleRenderer(options.mailBranding)
49
+ : simpleRenderer;
40
50
  return defineFeature("renderer-simple", (r) => {
41
51
  r.describe(
42
52
  'Default renderer plugin for `kind="notification"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails \u2014 swap it for `renderer-mail-html` if you need MJML/Markdown layouts.',
@@ -51,7 +61,7 @@ export function createRendererSimpleFeature(): FeatureDefinition {
51
61
 
52
62
  r.useExtension(RENDERER_EXTENSION, "simple", {
53
63
  kinds: ["notification"] as const,
54
- render: adaptToFoundation,
64
+ render: (req, ctx) => adaptToFoundation(req, ctx, renderer),
55
65
  });
56
66
  });
57
67
  }
@@ -1,2 +1,2 @@
1
- export { createRendererSimpleFeature } from "./feature.js";
2
- export { simpleRenderer } from "./simple-renderer.js";
1
+ export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";