@cosmicdrift/kumiko-bundled-features 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.
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
- package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
- package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
- package/dist/auth-email-password/web/auth-gate.js +25 -1
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
- package/dist/auth-email-password/web/auth-redirect.js +58 -0
- package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
- package/dist/auth-email-password/web/client-plugin.js +2 -0
- package/dist/auth-email-password/web/index.d.ts +2 -1
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/session.d.ts +3 -1
- package/dist/auth-email-password/web/session.js +8 -2
- package/dist/auth-mfa/feature.js +1 -1
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/verify.write.js +1 -1
- package/dist/auth-mfa/mfa-code-verifier.js +1 -1
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/constants.d.ts +7 -0
- package/dist/billing-foundation/constants.js +8 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
- package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
- package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
- package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
- package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
- package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
- package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
- package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
- package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
- package/dist/billing-foundation/events.d.ts +19 -0
- package/dist/billing-foundation/events.js +12 -0
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
- package/dist/billing-foundation/i18n.js +3 -0
- package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
- package/dist/billing-foundation/types.d.ts +6 -1
- package/dist/billing-foundation/validate-options.js +4 -0
- package/dist/channel-discord/discord-channel.d.ts +6 -0
- package/dist/channel-discord/discord-channel.js +19 -0
- package/dist/channel-discord/feature.d.ts +3 -0
- package/dist/channel-discord/feature.js +30 -0
- package/dist/channel-discord/index.d.ts +2 -0
- package/dist/channel-discord/index.js +2 -0
- package/dist/channel-slack/feature.d.ts +3 -0
- package/dist/channel-slack/feature.js +30 -0
- package/dist/channel-slack/index.d.ts +2 -0
- package/dist/channel-slack/index.js +2 -0
- package/dist/channel-slack/slack-channel.d.ts +5 -0
- package/dist/channel-slack/slack-channel.js +16 -0
- package/dist/channel-teams/feature.d.ts +3 -0
- package/dist/channel-teams/feature.js +34 -0
- package/dist/channel-teams/index.d.ts +2 -0
- package/dist/channel-teams/index.js +2 -0
- package/dist/channel-teams/teams-channel.d.ts +5 -0
- package/dist/channel-teams/teams-channel.js +38 -0
- package/dist/channel-telegram/feature.d.ts +3 -0
- package/dist/channel-telegram/feature.js +28 -0
- package/dist/channel-telegram/index.d.ts +2 -0
- package/dist/channel-telegram/index.js +2 -0
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +54 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
- package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
- package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
- package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
- package/dist/custom-fields/wire-user-data-rights.js +1 -1
- package/dist/data-retention/feature.js +1 -1
- package/dist/delivery/channel-context.d.ts +2 -1
- package/dist/delivery/channel-context.js +2 -2
- package/dist/delivery/chat-webhook-channel.d.ts +19 -0
- package/dist/delivery/chat-webhook-channel.js +48 -0
- package/dist/delivery/chat-webhook-sender.d.ts +21 -0
- package/dist/delivery/chat-webhook-sender.js +102 -0
- package/dist/delivery/delivery-service.js +3 -0
- package/dist/delivery/index.d.ts +2 -0
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/jobs.js +1 -1
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/types.d.ts +3 -1
- package/dist/delivery/types.js +1 -2
- package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/form-draft/feature.js +1 -1
- package/dist/form-draft/handlers/discard.write.js +1 -1
- package/dist/form-draft/handlers/save.write.js +1 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
- package/dist/inbound-mail-foundation/connect-routes.js +13 -8
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
- package/dist/inbound-mail-foundation/index.d.ts +1 -0
- package/dist/inbound-mail-foundation/index.js +1 -0
- package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
- package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
- package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +18 -1
- package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
- package/dist/managed-pages/handlers/set.write.js +1 -1
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/renderer-simple/feature.d.ts +8 -2
- package/dist/renderer-simple/feature.js +8 -5
- package/dist/renderer-simple/index.d.ts +2 -2
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +15 -0
- package/dist/renderer-simple/simple-renderer.js +74 -27
- package/dist/secrets/feature.js +1 -1
- package/dist/sessions/feature.js +1 -1
- package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
- package/dist/tenant-handover/handlers/claim.write.js +1 -2
- package/dist/tenant-lifecycle/feature.js +1 -1
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
- package/dist/tier-engine/feature.js +1 -1
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
- package/dist/user-data-rights/feature.js +2 -2
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
- package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/export-status.query.js +1 -1
- package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
- package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
- package/dist/user-data-rights/handlers/request-export.write.js +1 -1
- package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
- package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
- package/dist/workflow-runner/event-subscriber.js +6 -2
- package/dist/workflow-runner/event-trigger.js +5 -0
- package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
- package/dist/workflow-runner/pending-projection.js +1 -0
- package/dist/workflow-runner/runner.d.ts +7 -1
- package/dist/workflow-runner/runner.js +1 -1
- package/dist/workflow-runner/tables.js +8 -1
- package/package.json +41 -9
- package/src/auth-email-password/changes.json +7 -0
- package/src/billing-foundation/changes.json +34 -0
- package/src/delivery/changes.json +7 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/renderer-simple/changes.json +9 -1
- package/src/workflow-runner/changes.json +7 -0
|
@@ -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) {
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/delivery/index.js
CHANGED
|
@@ -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";
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -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.
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/delivery/types.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
101
|
-
|
|
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
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
|
@@ -29,7 +29,7 @@ async function archiveAndDeleteRows(ctx, table, aggregateType) {
|
|
|
29
29
|
declareEscapeHatch({ reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON });
|
|
30
30
|
const rows = await ctx.db.selectMany(table, { tenantId: ctx.tenantId });
|
|
31
31
|
for (const row of rows) {
|
|
32
|
-
await archiveStream(ctx.db.unsafeRaw(
|
|
32
|
+
await archiveStream(ctx.db.unsafeRaw(), {
|
|
33
33
|
tenantId: ctx.tenantId,
|
|
34
34
|
aggregateId: row.id,
|
|
35
35
|
aggregateType,
|
|
@@ -112,7 +112,15 @@ export type InboundOAuthFlow = {
|
|
|
112
112
|
readonly code: string;
|
|
113
113
|
readonly redirectUri: string;
|
|
114
114
|
}) => Promise<OAuthTokenSet>;
|
|
115
|
-
readonly refreshAccessToken: (ctx: InboundMailContext, account: MailAccountRecord, refreshToken: string
|
|
115
|
+
readonly refreshAccessToken: (ctx: InboundMailContext, account: MailAccountRecord, refreshToken: string,
|
|
116
|
+
/** The foundation aborts `signal` after a hard timeout while holding a
|
|
117
|
+
* per-account lock (a pool connection is pinned meanwhile). Providers
|
|
118
|
+
* MUST pass it to their token-endpoint fetch; the foundation awaits the
|
|
119
|
+
* provider's result and never drops a late one, because that could lose
|
|
120
|
+
* a rotated refresh token. */
|
|
121
|
+
opts?: {
|
|
122
|
+
readonly signal?: AbortSignal;
|
|
123
|
+
}) => Promise<OAuthTokenSet>;
|
|
116
124
|
};
|
|
117
125
|
/**
|
|
118
126
|
* Inbound-Mail-Provider-Plugin contract. Jedes Provider-Feature
|
|
@@ -129,6 +137,10 @@ export type InboundMailProviderPlugin = {
|
|
|
129
137
|
readonly fetch: (ctx: InboundMailContext, account: MailAccountRecord, cursor: SyncCursorPayload | null, opts: {
|
|
130
138
|
readonly backfillWindowDays: number;
|
|
131
139
|
readonly maxMessages: number;
|
|
140
|
+
/** Valid OAuth access token, minted by the foundation for accounts
|
|
141
|
+
* whose provider declares `oauth` (authMethod oauth/xoauth2). Absent
|
|
142
|
+
* for password accounts and providers without an oauth flow. */
|
|
143
|
+
readonly accessToken?: string;
|
|
132
144
|
}) => Promise<InboundFetchResult>;
|
|
133
145
|
/** Nur OAuth-Provider. */
|
|
134
146
|
readonly oauth?: InboundOAuthFlow;
|
|
@@ -140,6 +152,11 @@ export type InboundMailProviderPlugin = {
|
|
|
140
152
|
readonly watch?: (ctx: InboundMailContext, account: MailAccountRecord, handlers: {
|
|
141
153
|
readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
|
|
142
154
|
readonly onError: (err: unknown) => void;
|
|
155
|
+
},
|
|
156
|
+
/** Same `accessToken` semantics as `fetch` opts; re-minted on every
|
|
157
|
+
* supervisor (re)start of the watch. */
|
|
158
|
+
opts?: {
|
|
159
|
+
readonly accessToken?: string;
|
|
143
160
|
}) => Promise<() => Promise<void>>;
|
|
144
161
|
};
|
|
145
162
|
export declare function isInboundMailProviderPlugin(o: unknown): o is InboundMailProviderPlugin;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
3
|
+
import { type OAuthAccessTokenManager } from "./oauth-access-token.js";
|
|
3
4
|
import { type InboundMailContext, type MailAccountRecord, type RawInboundMessage } from "./types.js";
|
|
4
5
|
export type InboundMailSupervisorDeps = {
|
|
5
6
|
/** Slim-Context für Provider-Calls (registry Pflicht, secrets für
|
|
@@ -34,6 +35,10 @@ export type InboundMailSupervisorDeps = {
|
|
|
34
35
|
* watcher lives. Omit for single-process deployments — every active
|
|
35
36
|
* account is watched locally, same as before #1719. */
|
|
36
37
|
readonly lock?: DistributedLock;
|
|
38
|
+
/** Access-token lifecycle for OAuth accounts. Defaults to a manager built
|
|
39
|
+
* from `db` + `providerCtx.secrets`; pass the instance shared with
|
|
40
|
+
* `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
|
|
41
|
+
readonly oauthTokens?: OAuthAccessTokenManager;
|
|
37
42
|
/** TTL (seconds) for the per-account watch lease. Default 90s, renewed
|
|
38
43
|
* every ttl/3. Only used when `lock` is set. */
|
|
39
44
|
readonly watchLeaseTtlSeconds?: number;
|