@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
@@ -64,7 +64,7 @@ export function createDataRetentionFeature() {
64
64
  return;
65
65
  }
66
66
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
67
- const cleanupDb = ctx.db.unsafeRaw("retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId"); // @cast-boundary db-operator — jobs never run inside a DbTx
67
+ const cleanupDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
68
68
  const tenantPreset = await resolveTenantRetentionPreset({
69
69
  db: cleanupDb,
70
70
  registry: ctx.registry,
@@ -1,5 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { 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
- export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId): ChannelContext;
6
+ export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId, secrets?: SecretsContext): ChannelContext;
@@ -3,6 +3,6 @@ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  // Shared by the synchronous delivery-service path and the async job handlers
4
4
  // (which pass sseBroker=undefined — only the inline inApp channel uses SSE,
5
5
  // and it never runs through a job).
6
- export function buildChannelContext(db, registry, sseBroker, tenantId) {
7
- return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
6
+ export function buildChannelContext(db, registry, sseBroker, tenantId, secrets) {
7
+ return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId, secrets };
8
8
  }
@@ -0,0 +1,19 @@
1
+ import { type ChatSendResult } from "./chat-webhook-sender.js";
2
+ import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
3
+ export type ChatWebhookChannelOptions = {
4
+ readonly allowedHosts?: readonly string[];
5
+ readonly requireHttps?: boolean;
6
+ readonly timeoutMs?: number;
7
+ };
8
+ export declare function toChannelResult(address: string, result: ChatSendResult): ChannelResult;
9
+ export declare function chatMessageText(message: ChannelMessage): string;
10
+ export type ChatWebhookChannelSpec = {
11
+ readonly name: string;
12
+ readonly featureName: string;
13
+ readonly defaultAllowedHosts: readonly string[];
14
+ readonly requiredPathPrefix?: string;
15
+ readonly keyFor: (connection: string) => string;
16
+ readonly buildBody: (message: ChannelMessage) => unknown;
17
+ readonly options: ChatWebhookChannelOptions;
18
+ };
19
+ export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
@@ -0,0 +1,48 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
3
+ export function toChannelResult(address, result) {
4
+ return result.ok
5
+ ? { status: "sent", address }
6
+ : { status: "failed", error: result.code, address };
7
+ }
8
+ export function chatMessageText(message) {
9
+ return message.body ? `${message.title}\n${message.body}` : message.title;
10
+ }
11
+ // Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
12
+ // webhook URL lives in the secrets store under that name. Route-only (no resolve).
13
+ export function createChatWebhookChannel(spec) {
14
+ const { options } = spec;
15
+ return {
16
+ name: spec.name,
17
+ mode: "queued",
18
+ async send(address, message, ctx) {
19
+ if (!chatConnectionNameSchema.safeParse(address).success) {
20
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
21
+ }
22
+ let url;
23
+ try {
24
+ const revealed = await ctx.secrets?.get(ctx.tenantId, spec.keyFor(address), {
25
+ userId: SYSTEM_USER_ID,
26
+ handlerName: `${spec.featureName}:send`,
27
+ });
28
+ url = revealed?.reveal();
29
+ }
30
+ catch {
31
+ url = undefined;
32
+ }
33
+ if (!url)
34
+ return toChannelResult(address, { ok: false, code: "missing_credentials" });
35
+ const result = await postChatWebhook({
36
+ url,
37
+ allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
38
+ requireHttps: options.requireHttps ?? true,
39
+ ...(spec.requiredPathPrefix !== undefined && {
40
+ requiredPathPrefix: spec.requiredPathPrefix,
41
+ }),
42
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
43
+ body: spec.buildBody(message),
44
+ });
45
+ return toChannelResult(address, result);
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,21 @@
1
+ import * as z from "zod";
2
+ export type ChatSendFailureCode = `http_${number}` | "timeout" | "network_error" | "redirect_blocked" | "host_not_allowed" | "missing_credentials" | "invalid_address";
3
+ export type ChatSendResult = {
4
+ readonly ok: true;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly code: ChatSendFailureCode;
8
+ };
9
+ export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
10
+ export declare const chatConnectionNameSchema: z.ZodString;
11
+ export type ChatWebhookRequest = {
12
+ readonly url: string;
13
+ readonly allowedHosts: readonly string[];
14
+ readonly requireHttps: boolean;
15
+ readonly requiredPathPrefix?: string;
16
+ readonly timeoutMs: number;
17
+ readonly body: unknown;
18
+ readonly send?: (url: string, init: RequestInit) => Promise<Response>;
19
+ };
20
+ export declare function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult>;
21
+ export declare function truncateChars(text: string, maxChars: number): string;
@@ -0,0 +1,102 @@
1
+ import { BlockedHostError, egress } from "@cosmicdrift/kumiko-framework/http";
2
+ import * as z from "zod";
3
+ export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
4
+ // Tenant-chosen connection name (the route address for webhook channels). Also
5
+ // the suffix the secrets write gate accepts under the channel's namespace, so
6
+ // the two cannot drift. Validated before it is used to build a secret key.
7
+ export const chatConnectionNameSchema = z
8
+ .string()
9
+ .min(1)
10
+ .max(64)
11
+ .regex(/^[a-z0-9][a-z0-9-]*$/);
12
+ // https targets are provider endpoints on public hosts: egress "external" adds
13
+ // the private/reserved-range denial and DNS pinning on top of our allowlist. The
14
+ // cleartext opt-out (requireHttps: false) exists for operator-trusted local
15
+ // endpoints, which "external" would refuse; the allowlist already pins the host.
16
+ function policyFor(request, target) {
17
+ return request.requireHttps
18
+ ? { kind: "external" }
19
+ : { kind: "internal", allowHosts: [target.hostname] };
20
+ }
21
+ function hostMatches(hostname, allowedHost) {
22
+ const allowed = allowedHost.toLowerCase();
23
+ if (allowed.startsWith(".")) {
24
+ return hostname.length > allowed.length && hostname.endsWith(allowed);
25
+ }
26
+ return hostname === allowed;
27
+ }
28
+ // Allowlist runs on the parsed URL, never on the raw string: userinfo
29
+ // (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
30
+ function checkTarget(request) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(request.url);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ const protocolAllowed = parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
39
+ if (!protocolAllowed)
40
+ return undefined;
41
+ // egress() has no port rule; a provider webhook over https never needs a custom port.
42
+ if (request.requireHttps && parsed.port !== "")
43
+ return undefined;
44
+ if (parsed.username !== "" || parsed.password !== "")
45
+ return undefined;
46
+ const hostname = parsed.hostname.toLowerCase();
47
+ if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed)))
48
+ return undefined;
49
+ if (request.requiredPathPrefix !== undefined &&
50
+ !parsed.pathname.startsWith(request.requiredPathPrefix)) {
51
+ return undefined;
52
+ }
53
+ return parsed;
54
+ }
55
+ function isTimeout(err) {
56
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
57
+ }
58
+ // egress(internal) follows same-host redirects itself and throws on cross-host
59
+ // ones; both are the "redirect" outcome here. Matched on the stable message
60
+ // prefix only — the message is discarded, it can carry the Location header.
61
+ function isEgressRedirectRefusal(err) {
62
+ return err instanceof Error && err.message.startsWith("egress(internal): redirect");
63
+ }
64
+ function classifyFailure(err) {
65
+ if (err instanceof BlockedHostError)
66
+ return "host_not_allowed";
67
+ if (isEgressRedirectRefusal(err))
68
+ return "redirect_blocked";
69
+ return isTimeout(err) ? "timeout" : "network_error";
70
+ }
71
+ // Never throws and never surfaces the URL: egress/Bun errors can carry it, so
72
+ // every failure is reduced to a code here.
73
+ export async function postChatWebhook(request) {
74
+ const target = checkTarget(request);
75
+ if (!target)
76
+ return { ok: false, code: "host_not_allowed" };
77
+ let response;
78
+ try {
79
+ const send = request.send ?? egress(policyFor(request, target));
80
+ response = await send(target.toString(), {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify(request.body),
84
+ signal: AbortSignal.timeout(request.timeoutMs),
85
+ });
86
+ }
87
+ catch (err) {
88
+ return { ok: false, code: classifyFailure(err) };
89
+ }
90
+ // Free the connection; the body is never read or stored.
91
+ await response.body?.cancel().catch(() => undefined);
92
+ const { status } = response;
93
+ if (status >= 300 && status < 400)
94
+ return { ok: false, code: "redirect_blocked" };
95
+ if (status >= 200 && status < 300)
96
+ return { ok: true };
97
+ return { ok: false, code: `http_${status}` };
98
+ }
99
+ export function truncateChars(text, maxChars) {
100
+ const chars = Array.from(text);
101
+ return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
102
+ }
@@ -229,6 +229,9 @@ export function createDeliveryService(options) {
229
229
  async function deliverToUser(userId, notificationType, data, tenantId, priority) {
230
230
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
231
231
  for (const channel of channels) {
232
+ // Route-only channel (no per-user address): not a user-notification target.
233
+ if (!channel.resolve)
234
+ continue;
232
235
  const message = buildMessage(notificationType, data, channel.name);
233
236
  // Kill switch: tenant admin disabled this channel entirely
234
237
  if (isChannelKilled) {
@@ -1,4 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
4
  export type { DeliveryStatusValue } from "./constants.js";
3
5
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
4
6
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
@@ -1,4 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
4
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
3
5
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
4
6
  export { createDeliveryFeature } from "./feature.js";
@@ -103,7 +103,7 @@ export const deliverySendJob = async (payload, ctx) => {
103
103
  const tenantId = p.tenantId; // @cast-boundary engine-payload — stream tenant
104
104
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
105
105
  const channel = resolveChannel(registry, p.channelName);
106
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
106
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
107
107
  try {
108
108
  const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
109
109
  await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address));
@@ -1,6 +1,7 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
4
5
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
5
6
  import type { Redis } from "ioredis";
6
7
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
@@ -11,6 +12,7 @@ export type CreateDeliveryTestContextOptions = {
11
12
  readonly isChannelKilled?: KillSwitchResolver;
12
13
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
13
14
  readonly log?: Logger;
15
+ readonly jobRunner?: JobRunner;
14
16
  };
15
17
  /**
16
18
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
@@ -1,12 +1,14 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
5
6
  export type ChannelContext = {
6
7
  readonly db: TenantDb;
7
8
  readonly registry: Registry;
8
9
  readonly sseBroker: SseBroker | undefined;
9
10
  readonly tenantId: TenantId;
11
+ readonly secrets?: SecretsContext | undefined;
10
12
  };
11
13
  export type ChannelMessage = {
12
14
  readonly notificationType: string;
@@ -28,7 +30,7 @@ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
28
30
  export type DeliveryChannel = {
29
31
  readonly name: string;
30
32
  readonly mode: DeliveryChannelMode;
31
- resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
33
+ resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
32
34
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
33
35
  send(address: string, message: ChannelMessage, ctx: ChannelContext, rendered?: RenderedMessage): Promise<ChannelResult>;
34
36
  };
@@ -10,8 +10,7 @@ export function isDeliveryChannelPlugin(o) {
10
10
  "mode" in o &&
11
11
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
12
12
  (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
13
- "resolve" in o &&
14
- typeof o.resolve === "function" &&
13
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
15
14
  "send" in o &&
16
15
  typeof o.send === "function");
17
16
  }
@@ -16,7 +16,7 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
16
16
  reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
17
17
  },
18
18
  handler: async (query, ctx) => {
19
- const db = ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON);
19
+ const db = ctx.db.unsafeRaw();
20
20
  const row = await fetchOne(db, fileRefsTable, {
21
21
  id: query.payload.fileRefId,
22
22
  isDeleted: false,
@@ -34,7 +34,7 @@ export function createFilesTenantDataFeature() {
34
34
  escapeHatch: {
35
35
  reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
36
36
  },
37
- handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON)),
37
+ handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw()),
38
38
  });
39
39
  });
40
40
  }
@@ -38,7 +38,7 @@ function registerFormDraft(r) {
38
38
  trigger: { cron: "0 3 * * *" },
39
39
  concurrency: "skip",
40
40
  escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
41
- handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
41
+ handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw()),
42
42
  });
43
43
  }
44
44
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -59,7 +59,7 @@ export const discardDraftWrite = defineWriteHandler({
59
59
  // lazily on the first step-change — a file uploaded before that exists
60
60
  // predates the draft row, so the insertedAt filter must not apply.
61
61
  const isCreateMode = event.payload.draftKey.includes(":new:");
62
- const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(DISCARD_DRAFT_FILE_REFS_REASON), event.user.tenantId, ownerId, candidateKeys, existing.insertedAt, isCreateMode);
62
+ const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(), event.user.tenantId, ownerId, candidateKeys, existing.insertedAt, isCreateMode);
63
63
  // Fail-closed erase of file_refs rows FIRST (outside the best-effort
64
64
  // storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
65
65
  // by design — routing forget through it would report discarded:true
@@ -42,7 +42,7 @@ export const saveDraftWrite = defineWriteHandler({
42
42
  // Cap check only on the create path — an update never grows the number
43
43
  // of drafts this owner has, so a user already at the limit can still
44
44
  // keep saving their existing drafts.
45
- const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(SAVE_DRAFT_COUNT_REASON), event.user.tenantId, ownerId);
45
+ const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(), event.user.tenantId, ownerId);
46
46
  if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
47
47
  return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
48
48
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
+ import { type OAuthAccessTokenManager } from "./oauth-access-token.js";
2
3
  export type InboundMailConnectRoutesOptions = {
3
4
  /** HMAC-secret for the state param (deploy-time env — `scope:"system"`
4
5
  * secrets don't exist in secrets-v1). */
@@ -14,6 +15,12 @@ export type InboundMailConnectRoutesOptions = {
14
15
  readonly connectPath?: string;
15
16
  /** Default "/inbound-mail/oauth/callback". */
16
17
  readonly callbackPath?: string;
18
+ /** Apps wire the supervisor and these routes separately, so build ONE
19
+ * manager (`createOAuthAccessTokenManager`) and share it with `createInboundMailSupervisor({ oauthTokens })`:
20
+ * the callback then writes the refresh token under the account's refresh
21
+ * lock and primes the access-token cache from the code exchange. Omitted
22
+ * → plain secret write, no cache priming. */
23
+ readonly oauthTokens?: OAuthAccessTokenManager;
17
24
  };
18
25
  /** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
19
26
  * `extraRoutes: createInboundMailConnectRoutes(options)`. */
@@ -11,6 +11,7 @@
11
11
  // HMAC-verified state (CSRF-/foreign-claiming lock, see oauth-state.ts).
12
12
  import { ExtraRouteRejection, signatureRoute } from "@cosmicdrift/kumiko-framework/api";
13
13
  import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAuthMethods, InboundMailFoundationHandlers, inboundCredentialSecretKey, } from "./constants.js";
14
+ import { inboundRefreshTokenSecretOptions, } from "./oauth-access-token.js";
14
15
  import { signOAuthState, verifyOAuthState } from "./oauth-state.js";
15
16
  import { resolveInboundProviderForKey } from "./provider-factory.js";
16
17
  const DEFAULT_STATE_TTL_MINUTES = 15;
@@ -97,8 +98,9 @@ export function createInboundMailConnectRoutes(options) {
97
98
  redirectUri: options.callbackUrl,
98
99
  });
99
100
  }
100
- catch (e) {
101
- return errorJson(c, 502, "token_exchange_failed", e instanceof Error ? e.message : String(e));
101
+ catch {
102
+ // Provider error messages can carry token/response data — code only.
103
+ return errorJson(c, 502, "token_exchange_failed", "token exchange with the provider failed");
102
104
  }
103
105
  if (!tokens.refreshToken) {
104
106
  // No refresh token → no server-side sync possible. Consent must
@@ -128,12 +130,15 @@ export function createInboundMailConnectRoutes(options) {
128
130
  return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
129
131
  }
130
132
  // Refresh-token into the per-account secret slot (Slot = accountId).
131
- // Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
132
- // sync path.
133
- await deps.secrets.set(state.tenantId, inboundCredentialSecretKey(accountId), tokens.refreshToken, {
134
- redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
135
- hint: `OAuth refresh token for inbound mail account ${accountId}`,
136
- });
133
+ // Access tokens (~1h) are never persisted — the supervisor re-mints
134
+ // them from the refresh token (oauth-access-token.ts).
135
+ if (options.oauthTokens) {
136
+ await options.oauthTokens.storeRefreshToken({ id: accountId, tenantId: state.tenantId }, tokens.refreshToken);
137
+ options.oauthTokens.primeFromExchange({ id: accountId }, tokens);
138
+ }
139
+ else {
140
+ await deps.secrets.set(state.tenantId, inboundCredentialSecretKey(accountId), tokens.refreshToken, inboundRefreshTokenSecretOptions(accountId));
141
+ }
137
142
  if (options.successRedirectUrl) {
138
143
  const target = new URL(options.successRedirectUrl);
139
144
  target.searchParams.set("accountId", accountId);
@@ -176,7 +176,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
176
176
  return;
177
177
  }
178
178
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
179
- const retentionDb = ctx.db.unsafeRaw(INBOUND_MAIL_RETENTION_REASON); // @cast-boundary db-operator — jobs never run inside a DbTx
179
+ const retentionDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
180
180
  // Body-Objekte liegen in file-foundation. Provider lazy + memoized —
181
181
  // heute schreibt der Ingest kein bodyRef (storeBody-Hook ungebunden),
182
182
  // der Provider wird also nie gebaut. Sobald storeBody wired ist, raeumt
@@ -230,7 +230,7 @@ export const ingestMessageHandler = {
230
230
  tenantId,
231
231
  }, { tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE })
232
232
  : threadPlainPii;
233
- const threadRollupRunner = ctx.db.unsafeRaw(THREAD_ROLLUP_ADVISORY_LOCK_REASON);
233
+ const threadRollupRunner = ctx.db.unsafeRaw();
234
234
  await acquireNamespacedAdvisoryLock(threadRollupRunner, THREAD_ROLLUP_LOCK_NAMESPACE, threadAggId);
235
235
  let threadAppendOk = false;
236
236
  for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
@@ -4,6 +4,7 @@ export { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION, type
4
4
  export { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity, MAIL_ACCOUNT_PII_FIELDS, MAIL_THREAD_PII_FIELDS, mailAccountEntity, mailThreadEntity, seenMessageEntity, seenMessageTable, syncCursorEntity, syncCursorTable, } from "./entities.js";
5
5
  export { INBOUND_MESSAGE_AGGREGATE_TYPE, INBOUND_MESSAGE_RECEIVED_EVENT_QN, INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, type InboundMessageEventHeaders, type InboundMessageEventPayload, inboundMessageEventPayloadSchema, MAIL_ACCOUNT_AGGREGATE_TYPE, MAIL_ACCOUNT_CONNECTED_EVENT_QN, MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, MAIL_ACCOUNT_DISCONNECTED_EVENT_QN, MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, MAIL_ACCOUNT_UPDATED_EVENT_QN, MAIL_ACCOUNT_UPDATED_EVENT_SHORT, MAIL_THREAD_AGGREGATE_TYPE, MAIL_THREAD_UPDATED_EVENT_QN, MAIL_THREAD_UPDATED_EVENT_SHORT, type MailAccountEventHeaders, type MailAccountEventPayload, type MailThreadEventPayload, mailAccountEventPayloadSchema, mailThreadEventPayloadSchema, } from "./events.js";
6
6
  export { inboundMailFoundationFeature } from "./feature.js";
7
+ export { createOAuthAccessTokenManager, inboundRefreshTokenSecretOptions, type OAuthAccessTokenManager, type OAuthAccessTokenManagerDeps, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
7
8
  export { type OAuthStatePayload, signOAuthState, type VerifyOAuthStateResult, verifyOAuthState, } from "./oauth-state.js";
8
9
  export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.js";
9
10
  export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.js";
@@ -5,6 +5,7 @@ export { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION, Inbou
5
5
  export { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity, MAIL_ACCOUNT_PII_FIELDS, MAIL_THREAD_PII_FIELDS, mailAccountEntity, mailThreadEntity, seenMessageEntity, seenMessageTable, syncCursorEntity, syncCursorTable, } from "./entities.js";
6
6
  export { INBOUND_MESSAGE_AGGREGATE_TYPE, INBOUND_MESSAGE_RECEIVED_EVENT_QN, INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema, MAIL_ACCOUNT_AGGREGATE_TYPE, MAIL_ACCOUNT_CONNECTED_EVENT_QN, MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, MAIL_ACCOUNT_DISCONNECTED_EVENT_QN, MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, MAIL_ACCOUNT_UPDATED_EVENT_QN, MAIL_ACCOUNT_UPDATED_EVENT_SHORT, MAIL_THREAD_AGGREGATE_TYPE, MAIL_THREAD_UPDATED_EVENT_QN, MAIL_THREAD_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema, mailThreadEventPayloadSchema, } from "./events.js";
7
7
  export { inboundMailFoundationFeature } from "./feature.js";
8
+ export { createOAuthAccessTokenManager, inboundRefreshTokenSecretOptions, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
8
9
  export { signOAuthState, verifyOAuthState, } from "./oauth-state.js";
9
10
  export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.js";
10
11
  export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.js";
@@ -0,0 +1,28 @@
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { type InboundMailContext, type InboundMailProviderPlugin, type MailAccountRecord, type OAuthTokenSet } from "./types.js";
5
+ /** Shared by the OAuth callback and the refresh path so both write the slot identically. */
6
+ export declare function inboundRefreshTokenSecretOptions(accountId: string): {
7
+ redact: (plaintext: string) => string;
8
+ hint: string;
9
+ };
10
+ /** True for accounts whose access token the foundation must supply. */
11
+ export declare function usesFoundationManagedOAuth(account: MailAccountRecord, plugin: InboundMailProviderPlugin): boolean;
12
+ export type OAuthAccessTokenManagerDeps = {
13
+ readonly db: DbConnection;
14
+ readonly secrets: SecretsContext;
15
+ readonly providerCtx?: InboundMailContext;
16
+ readonly now?: () => Temporal.Instant;
17
+ readonly refreshTimeoutMs?: number;
18
+ };
19
+ export type OAuthAccessTokenManager = {
20
+ /** Valid access token for the account; refreshes when missing or near expiry. */
21
+ readonly getAccessToken: (account: MailAccountRecord, plugin: InboundMailProviderPlugin) => Promise<string>;
22
+ /** Writes the refresh token under the account lock and drops any cached access token. */
23
+ readonly storeRefreshToken: (account: Pick<MailAccountRecord, "id" | "tenantId">, refreshToken: string) => Promise<void>;
24
+ /** Seeds the cache from a code-exchange result (access token only). */
25
+ readonly primeFromExchange: (account: Pick<MailAccountRecord, "id">, tokens: OAuthTokenSet) => void;
26
+ readonly invalidate: (accountId: string) => void;
27
+ };
28
+ export declare function createOAuthAccessTokenManager(deps: OAuthAccessTokenManagerDeps): OAuthAccessTokenManager;
@@ -0,0 +1,131 @@
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
+ import { transaction } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
20
+ import { Temporal } from "temporal-polyfill";
21
+ import { InboundMailAuthMethods, inboundCredentialSecretKey } from "./constants.js";
22
+ import { InboundAuthError, } from "./types.js";
23
+ // 'inoa' as ASCII — disjoint from the other fixed advisory-lock namespaces.
24
+ const OAUTH_REFRESH_LOCK_NAMESPACE = 0x696e6f61;
25
+ const ACCESS_TOKEN_EXPIRY_MARGIN = Temporal.Duration.from({ minutes: 2 });
26
+ // The provider call runs while the lock transaction holds a pool connection,
27
+ // so it gets an abort signal with this upper bound.
28
+ const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
29
+ /** Shared by the OAuth callback and the refresh path so both write the slot identically. */
30
+ export function inboundRefreshTokenSecretOptions(accountId) {
31
+ return {
32
+ redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
33
+ hint: `OAuth refresh token for inbound mail account ${accountId}`,
34
+ };
35
+ }
36
+ /** True for accounts whose access token the foundation must supply. */
37
+ export function usesFoundationManagedOAuth(account, plugin) {
38
+ return (plugin.oauth !== undefined &&
39
+ (account.authMethod === InboundMailAuthMethods.oauth ||
40
+ account.authMethod === InboundMailAuthMethods.xoauth2));
41
+ }
42
+ function lockKey(account) {
43
+ return `${account.tenantId}:${account.id}`;
44
+ }
45
+ export function createOAuthAccessTokenManager(deps) {
46
+ const now = deps.now ?? (() => Temporal.Now.instant());
47
+ const refreshTimeoutMs = deps.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
48
+ const providerCtx = { ...deps.providerCtx, secrets: deps.secrets };
49
+ const cache = new Map();
50
+ const inFlight = new Map();
51
+ function parseExpiry(iso) {
52
+ try {
53
+ return Temporal.Instant.from(iso);
54
+ }
55
+ catch {
56
+ // Unparseable expiry: treat as already expired so it is never cached.
57
+ return now();
58
+ }
59
+ }
60
+ function remember(accountId, tokens) {
61
+ cache.set(accountId, {
62
+ accessToken: tokens.accessToken,
63
+ expiresAt: parseExpiry(tokens.expiresAt),
64
+ });
65
+ }
66
+ function cachedValid(accountId) {
67
+ const hit = cache.get(accountId);
68
+ if (!hit)
69
+ return undefined;
70
+ const usableUntil = hit.expiresAt.subtract(ACCESS_TOKEN_EXPIRY_MARGIN);
71
+ return Temporal.Instant.compare(now(), usableUntil) < 0 ? hit.accessToken : undefined;
72
+ }
73
+ async function refreshWithTimeout(plugin, account, refreshToken) {
74
+ const oauth = plugin.oauth;
75
+ if (!oauth)
76
+ throw new InboundAuthError("provider has no oauth flow");
77
+ return oauth.refreshAccessToken(providerCtx, account, refreshToken, {
78
+ signal: AbortSignal.timeout(refreshTimeoutMs),
79
+ });
80
+ }
81
+ async function refresh(account, plugin) {
82
+ const secretKey = inboundCredentialSecretKey(account.id);
83
+ return transaction(deps.db, async (tx) => {
84
+ await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
85
+ const stored = await deps.secrets.get(account.tenantId, secretKey);
86
+ if (!stored) {
87
+ throw new InboundAuthError("no refresh token stored for account");
88
+ }
89
+ const currentRefreshToken = stored.reveal();
90
+ const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
91
+ if (tokens.refreshToken && tokens.refreshToken !== currentRefreshToken) {
92
+ await deps.secrets.set(account.tenantId, secretKey, tokens.refreshToken, inboundRefreshTokenSecretOptions(account.id));
93
+ }
94
+ remember(account.id, tokens);
95
+ return tokens.accessToken;
96
+ });
97
+ }
98
+ return {
99
+ async getAccessToken(account, plugin) {
100
+ const hit = cachedValid(account.id);
101
+ if (hit !== undefined)
102
+ return hit;
103
+ const running = inFlight.get(account.id);
104
+ if (running)
105
+ return running;
106
+ const started = refresh(account, plugin)
107
+ .catch((err) => {
108
+ cache.delete(account.id);
109
+ throw err;
110
+ })
111
+ .finally(() => {
112
+ inFlight.delete(account.id);
113
+ });
114
+ inFlight.set(account.id, started);
115
+ return started;
116
+ },
117
+ async storeRefreshToken(account, refreshToken) {
118
+ await transaction(deps.db, async (tx) => {
119
+ await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
120
+ await deps.secrets.set(account.tenantId, inboundCredentialSecretKey(account.id), refreshToken, inboundRefreshTokenSecretOptions(account.id));
121
+ });
122
+ cache.delete(account.id);
123
+ },
124
+ primeFromExchange(account, tokens) {
125
+ remember(account.id, tokens);
126
+ },
127
+ invalidate(accountId) {
128
+ cache.delete(accountId);
129
+ },
130
+ };
131
+ }