@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +21 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
- package/packages/bundled-features/src/billing-foundation/changes.json +34 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
- package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
- package/packages/bundled-features/src/channel-discord/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
- package/packages/bundled-features/src/channel-slack/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
- package/packages/bundled-features/src/channel-teams/index.ts +2 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
- package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
- package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
- package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
- package/packages/bundled-features/src/data-retention/feature.ts +2 -3
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
- package/packages/bundled-features/src/delivery/index.ts +15 -0
- package/packages/bundled-features/src/delivery/jobs.ts +1 -1
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +8 -3
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
- package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
- package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
- package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
- package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
- package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
- package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
- package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
- package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
- package/samples/apps/use-all-bundled/tsconfig.json +1 -0
- package/samples/recipes/chat-channels/README.md +19 -0
- package/samples/recipes/chat-channels/package.json +9 -0
- package/samples/recipes/chat-channels/src/feature.ts +40 -0
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
- package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
- 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(
|
|
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: {
|
|
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: ${
|
|
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: ${
|
|
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 (${
|
|
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
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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: ${
|
|
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: ${
|
|
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,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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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";
|