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