@cosmicdrift/kumiko-samples 0.342.0 → 0.344.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 +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -1
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
- package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
- package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -0
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
- package/packages/bundled-features/src/channel-push/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/changes.json +8 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/changes.json +32 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
- package/packages/bundled-features/src/delivery/constants.ts +4 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
- package/packages/bundled-features/src/delivery/events.ts +15 -1
- package/packages/bundled-features/src/delivery/feature.ts +32 -4
- package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
- package/packages/bundled-features/src/delivery/i18n.ts +55 -0
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +43 -20
- package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
- package/packages/bundled-features/src/delivery/public-names.ts +4 -0
- package/packages/bundled-features/src/delivery/tables.ts +3 -0
- package/packages/bundled-features/src/delivery/testing.ts +5 -9
- package/packages/bundled-features/src/delivery/types.ts +6 -2
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
- package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
- package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
- package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
- package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/constants.ts +10 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -0
- package/packages/bundled-features/src/secrets/i18n.ts +5 -3
- package/packages/bundled-features/src/secrets/index.ts +8 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/redact.ts +27 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
- package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
- package/packages/bundled-features/src/user/index.ts +1 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
|
@@ -13,11 +13,23 @@ export {
|
|
|
13
13
|
type SubscriptionStatus,
|
|
14
14
|
SubscriptionStatuses,
|
|
15
15
|
} from "../constants.js";
|
|
16
|
+
export {
|
|
17
|
+
CONSENT_LOCALES,
|
|
18
|
+
type ConsentLocale,
|
|
19
|
+
resolveConsentLocale,
|
|
20
|
+
} from "../consumer-protection/consent-locale.js";
|
|
16
21
|
export type {
|
|
17
22
|
BillingPlanBenefit,
|
|
18
23
|
BillingPlanCatalog,
|
|
19
24
|
BillingPlansResult,
|
|
20
25
|
BillingPlanView,
|
|
26
|
+
LegalLinkSet,
|
|
21
27
|
} from "../types.js";
|
|
22
28
|
export { BillingPlansPanel } from "./billing-plans-panel.js";
|
|
29
|
+
export {
|
|
30
|
+
CheckoutConsentDialog,
|
|
31
|
+
type CheckoutConsentDialogProps,
|
|
32
|
+
type CheckoutConsentPayload,
|
|
33
|
+
CONSENT_TEXT_OUTDATED_CODE,
|
|
34
|
+
} from "./checkout-consent-dialog.js";
|
|
23
35
|
export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
|
|
@@ -47,6 +47,7 @@ export function createChannelDiscordFeature(
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
const channel = createDiscordChannel(options, webhooks.keyFor);
|
|
50
|
+
r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
|
|
50
51
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
|
|
51
52
|
mode: channel.mode,
|
|
52
53
|
send: channel.send,
|
|
@@ -16,6 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
16
16
|
});
|
|
17
17
|
r.requires("delivery");
|
|
18
18
|
|
|
19
|
+
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
19
20
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
|
|
20
21
|
mode: channel.mode,
|
|
21
22
|
resolve: channel.resolve,
|
|
@@ -24,6 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
// Register as delivery channel via extension system
|
|
27
|
+
r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
|
|
27
28
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
|
|
28
29
|
mode: inAppChannel.mode,
|
|
29
30
|
resolve: inAppChannel.resolve,
|
|
@@ -16,6 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
|
|
|
16
16
|
});
|
|
17
17
|
r.requires("delivery");
|
|
18
18
|
|
|
19
|
+
r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
|
|
19
20
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
20
21
|
mode: channel.mode,
|
|
21
22
|
resolve: channel.resolve,
|
|
@@ -41,6 +41,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const channel = createSlackChannel(options, webhooks.keyFor);
|
|
44
|
+
r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
|
|
44
45
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
|
|
45
46
|
mode: channel.mode,
|
|
46
47
|
send: channel.send,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Teams no longer reports any 2xx as delivered",
|
|
6
|
+
"detail": "`channel-teams` no longer reports every 2xx answer as sent. A 200 with the body `1` (classic Office connector) is a confirmed send. A 202 (Power Automate / Workflows endpoints) means the message was accepted but not confirmed: the attempt is `sent` and `NotifyResult.deliveries[].confirmed` is `false`. Any other 2xx, including an empty 200 as a made-up URL returns, fails with `unexpected_response`.\n`NotifyDelivery.confirmed` and `ChannelResult.confirmed` are new and only ever `false`. `createChatWebhookChannel` takes an optional `classifyResponse`, which `postChatWebhook` calls for 2xx answers with a status and a lazy `readBodyPrefix()` (at most 64 bytes; the body is always cancelled afterwards). `ChatSendResult` success now carries `confirmed`. `confirmed` is returned for inline delivery only; jobs do not carry it yet."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -45,6 +45,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
const channel = createTeamsChannel(options, webhooks.keyFor);
|
|
48
|
+
r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
|
|
48
49
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
|
|
49
50
|
mode: channel.mode,
|
|
50
51
|
send: channel.send,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChatSendResult,
|
|
2
3
|
type ChatWebhookChannelOptions,
|
|
4
|
+
type ChatWebhookResponse,
|
|
3
5
|
createChatWebhookChannel,
|
|
4
6
|
type DeliveryChannel,
|
|
5
7
|
} from "../delivery/index.js";
|
|
@@ -40,6 +42,19 @@ export function buildTeamsAdaptiveCard(title: string, body: string | undefined):
|
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Classic Office connectors answer 200 with the body "1". Workflow endpoints answer
|
|
46
|
+
// 202 and never say whether the card reached the channel. Any other 2xx (an empty
|
|
47
|
+
// 200 is what a made-up URL returns) is not a Teams success.
|
|
48
|
+
export async function classifyTeamsResponse(
|
|
49
|
+
response: ChatWebhookResponse,
|
|
50
|
+
): Promise<ChatSendResult> {
|
|
51
|
+
if (response.status === 202) return { ok: true, confirmed: false };
|
|
52
|
+
if (response.status === 200 && (await response.readBodyPrefix()).trim() === "1") {
|
|
53
|
+
return { ok: true, confirmed: true };
|
|
54
|
+
}
|
|
55
|
+
return { ok: false, code: "unexpected_response" };
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
export function createTeamsChannel(
|
|
44
59
|
options: TeamsChannelOptions,
|
|
45
60
|
keyFor: (connection: string) => string,
|
|
@@ -49,6 +64,7 @@ export function createTeamsChannel(
|
|
|
49
64
|
featureName: "channel-teams",
|
|
50
65
|
defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
|
|
51
66
|
keyFor,
|
|
67
|
+
classifyResponse: classifyTeamsResponse,
|
|
52
68
|
buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
|
|
53
69
|
options,
|
|
54
70
|
});
|
|
@@ -33,6 +33,7 @@ export function createChannelTelegramFeature(
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
const channel = createTelegramChannel(options, botToken.name);
|
|
36
|
+
r.translations({ keys: { "delivery.channel.telegram": { en: "Telegram" } } });
|
|
36
37
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
|
|
37
38
|
mode: channel.mode,
|
|
38
39
|
send: channel.send,
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery log marks sends the provider did not confirm",
|
|
6
|
+
"detail": "The delivery log now records when a provider accepted a send without confirming delivery (for example a Teams webhook answering 202). `store_delivery_attempts` gets a nullable boolean column `confirmed`: `false` means accepted but unconfirmed, `null` means confirmed or not applicable. The `deliveryAttempt` event carries `confirmed: false` on both the inline and the job path, `delivery:query:log` returns `confirmed` per row, and the status cell shows \"Sent (unconfirmed)\" for such rows.",
|
|
7
|
+
"migration": "New nullable column store_delivery_attempts.confirmed. Consumers generate the migration with the generator (kumiko-schema generate) and apply it; existing rows stay null."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.343.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Delivery errors are fixed codes instead of raw messages",
|
|
13
|
+
"detail": "Delivery stores and returns only fixed error codes, never raw error messages. `delivery_attempts.error`, the `deliveryAttempt` event, `NotifyResult.deliveries[].error` and the job failure now hold one of `DELIVERY_FAILURE_CODES` (`timeout`, `network_error`, `redirect_blocked`, `host_not_allowed`, `missing_credentials`, `invalid_address`, `unexpected_response`, `render_failed`, `send_failed`, `channel_error`), one of `DELIVERY_SKIP_REASONS`, or `http_<status>`. A throwing channel ends as `send_failed` or `render_failed`; a failure around resolve or dispatch ends as `channel_error`. The full error goes to the log with URLs and email addresses redacted. `redactUrls` and `redactErrorText` are new next to `redactEmailAddresses`, which moved out of the step dispatcher.\n`@cosmicdrift/kumiko-framework/engine` exports `DELIVERY_FAILURE_CODES`, `DELIVERY_SKIP_REASONS`, `isDeliveryErrorCode` and the types `DeliveryErrorCode`, `DeliveryFailureCode`, `DeliverySkipReason` and `ChatSendFailureCode`. `NotifyDelivery.error`, `ChannelResult.error` and `DeliveryLogEntry.error` are now `DeliveryErrorCode`. `delivery:query:log` returns `channel_error` for stored rows that still hold free text.",
|
|
14
|
+
"migration": "NotifyDelivery.error is now a DeliveryErrorCode. Code that matches on error text must match the code instead (for example send_failed). Custom channels must return a DeliveryErrorCode in ChannelResult.error. Existing attempt events keep their free text; reading them through delivery:query:log masks it as channel_error."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.343.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Delivery log shows time and error and translates its values",
|
|
20
|
+
"detail": "The delivery log screen shows the time and the error of each attempt and translates type, channel, status and error. Error codes map to `delivery.error.<code>` (`http_<status>` to `delivery.error.http` with a `status` parameter), statuses to `delivery.status.<status>`, channels to `delivery.channel.<name>`. Every bundled channel feature registers its own channel label. Notification types are shown through `<scope>.notification.<name>` for a type `<scope>:notify:<name>`; apps register those labels, and the short name is shown when none exists. English, German and Spanish texts are included for the bundled keys.\n`translateOrRaw` moved from the tenant web folder to `shared/web`; it is internal."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.343.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "Delivery log shows the recipient name",
|
|
26
|
+
"detail": "`delivery:query:log` returns a `recipientLabel` per row and the delivery log screen shows it in the recipient column: the user's display name, else the decrypted address, else the recipient id. Names are resolved with one batched lookup per page, and only when the user feature is mounted. The new `resolveUserDisplayNames(db, userIds)` is exported from the user feature. The recipient column is not sortable."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.343.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "createDeliveryTestContext matches production notify",
|
|
32
|
+
"detail": "`createDeliveryTestContext` now behaves like the production `ctx.notify`. It takes a `secrets` option for inline-delivered chat channels and passes the calling context's job dispatcher to `notify()` on every call. Queued channels now go through the delivery jobs when the stack has a job consumer (`jobs: { consumerLane: \"worker\" }`); call `stack.drainJobs()` before asserting on the result. With `jobs: {}` and no `consumerLane` the attempts stay queued and `drainJobs()` never finishes. Without `jobs` they still deliver inline.\nNew export `createDeliveryNotifyFactory(deliveryService, { deliverQueuedInline? })` builds this `NotifyFactory` for custom setups."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.341.0",
|
|
4
36
|
"type": "breaking",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import {
|
|
3
3
|
type ChatSendResult,
|
|
4
|
+
type ChatWebhookRequest,
|
|
4
5
|
type ChatWebhookTarget,
|
|
5
6
|
chatConnectionNameSchema,
|
|
6
7
|
DEFAULT_CHAT_TIMEOUT_MS,
|
|
@@ -17,9 +18,10 @@ export type ChatWebhookChannelOptions = {
|
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function toChannelResult(address: string, result: ChatSendResult): ChannelResult {
|
|
20
|
-
return result.
|
|
21
|
+
if (!result.ok) return { status: "failed", error: result.code, address };
|
|
22
|
+
return result.confirmed
|
|
21
23
|
? { status: "sent", address }
|
|
22
|
-
: { status: "
|
|
24
|
+
: { status: "sent", confirmed: false, address };
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function chatMessageText(message: ChannelMessage): string {
|
|
@@ -50,6 +52,8 @@ export type ChatWebhookChannelSpec = {
|
|
|
50
52
|
// Resolves the secret key for a validated connection name.
|
|
51
53
|
readonly keyFor: (connection: string) => string;
|
|
52
54
|
readonly buildBody: (message: ChannelMessage) => unknown;
|
|
55
|
+
// Providers whose 2xx does not by itself mean "delivered" check the answer here.
|
|
56
|
+
readonly classifyResponse?: ChatWebhookRequest["classifyResponse"];
|
|
53
57
|
readonly options: ChatWebhookChannelOptions;
|
|
54
58
|
};
|
|
55
59
|
|
|
@@ -82,6 +86,7 @@ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): Delivery
|
|
|
82
86
|
...resolveChatWebhookTarget(spec, options),
|
|
83
87
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
84
88
|
body: spec.buildBody(message),
|
|
89
|
+
...(spec.classifyResponse && { classifyResponse: spec.classifyResponse }),
|
|
85
90
|
});
|
|
86
91
|
return toChannelResult(address, result);
|
|
87
92
|
},
|
|
@@ -1,22 +1,27 @@
|
|
|
1
|
+
import type { ChatSendFailureCode } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import { BlockedHostError, type EgressPolicy, egress } from "@cosmicdrift/kumiko-framework/http";
|
|
2
3
|
import * as z from "zod";
|
|
3
4
|
|
|
4
5
|
// Closed vocabulary: these codes (and nothing else) land in delivery_attempts.error.
|
|
5
6
|
// Never err.message and never a provider response body — a fetch error can carry
|
|
6
7
|
// 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";
|
|
8
|
+
export type { ChatSendFailureCode };
|
|
15
9
|
|
|
10
|
+
// confirmed=false: the provider accepted the request but does not say whether the
|
|
11
|
+
// message arrived (e.g. a 202 from a workflow endpoint).
|
|
16
12
|
export type ChatSendResult =
|
|
17
|
-
| { readonly ok: true }
|
|
13
|
+
| { readonly ok: true; readonly confirmed: boolean }
|
|
18
14
|
| { readonly ok: false; readonly code: ChatSendFailureCode };
|
|
19
15
|
|
|
16
|
+
// What a classifier may look at on a 2xx answer. The body is never stored or logged.
|
|
17
|
+
export type ChatWebhookResponse = {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
// At most 64 bytes, UTF-8 decoded.
|
|
20
|
+
readBodyPrefix(): Promise<string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const MAX_BODY_PREFIX_BYTES = 64;
|
|
24
|
+
|
|
20
25
|
export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
|
|
21
26
|
|
|
22
27
|
// Tenant-chosen connection name (the route address for webhook channels). Also
|
|
@@ -41,6 +46,8 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
|
41
46
|
readonly body: unknown;
|
|
42
47
|
// Test seam; production always uses the policy-bound egress().
|
|
43
48
|
readonly send?: (url: string, init: RequestInit) => Promise<Response>;
|
|
49
|
+
// Called for 2xx answers only. Default: every 2xx is a confirmed send.
|
|
50
|
+
readonly classifyResponse?: (response: ChatWebhookResponse) => Promise<ChatSendResult>;
|
|
44
51
|
};
|
|
45
52
|
|
|
46
53
|
// https targets are provider endpoints on public hosts: egress "external" adds
|
|
@@ -129,15 +136,54 @@ export async function postChatWebhook(request: ChatWebhookRequest): Promise<Chat
|
|
|
129
136
|
return { ok: false, code: classifyFailure(err) };
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
// Free the connection; the body is never read or stored.
|
|
133
|
-
await response.body?.cancel().catch(() => undefined);
|
|
134
|
-
|
|
135
139
|
const { status } = response;
|
|
136
|
-
if (status >= 300 && status < 400)
|
|
137
|
-
|
|
140
|
+
if (status >= 300 && status < 400) {
|
|
141
|
+
await response.body?.cancel().catch(() => undefined);
|
|
142
|
+
return { ok: false, code: "redirect_blocked" };
|
|
143
|
+
}
|
|
144
|
+
if (status >= 200 && status < 300) return classifySuccess(request, response);
|
|
145
|
+
await response.body?.cancel().catch(() => undefined);
|
|
138
146
|
return { ok: false, code: `http_${status}` };
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
// The body is cancelled afterwards in every case, even when the classifier throws,
|
|
150
|
+
// so the connection is freed and a large body is never read to the end.
|
|
151
|
+
async function classifySuccess(
|
|
152
|
+
request: ChatWebhookRequest,
|
|
153
|
+
response: Response,
|
|
154
|
+
): Promise<ChatSendResult> {
|
|
155
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
156
|
+
try {
|
|
157
|
+
if (!request.classifyResponse) return { ok: true, confirmed: true };
|
|
158
|
+
return await request.classifyResponse({
|
|
159
|
+
status: response.status,
|
|
160
|
+
async readBodyPrefix() {
|
|
161
|
+
reader ??= response.body?.getReader();
|
|
162
|
+
if (!reader) return "";
|
|
163
|
+
const chunks: Uint8Array[] = [];
|
|
164
|
+
let total = 0;
|
|
165
|
+
while (total < MAX_BODY_PREFIX_BYTES) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
chunks.push(value);
|
|
169
|
+
total += value.length;
|
|
170
|
+
}
|
|
171
|
+
const joined = new Uint8Array(total);
|
|
172
|
+
let offset = 0;
|
|
173
|
+
for (const chunk of chunks) {
|
|
174
|
+
joined.set(chunk, offset);
|
|
175
|
+
offset += chunk.length;
|
|
176
|
+
}
|
|
177
|
+
return new TextDecoder().decode(joined.subarray(0, MAX_BODY_PREFIX_BYTES));
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return { ok: false, code: classifyFailure(err) };
|
|
182
|
+
} finally {
|
|
183
|
+
await (reader ? reader.cancel() : response.body?.cancel())?.catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
141
187
|
export function truncateChars(text: string, maxChars: number): string {
|
|
142
188
|
const chars = Array.from(text);
|
|
143
189
|
return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
|
|
@@ -8,10 +8,14 @@ export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
|
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
10
|
DELIVERY_ATTEMPT_EVENT,
|
|
11
|
+
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
12
|
+
DELIVERY_ERROR_CELL_COMPONENT,
|
|
11
13
|
DELIVERY_FEATURE,
|
|
12
14
|
DELIVERY_LOG_SCREEN_ID,
|
|
13
15
|
DELIVERY_RESUBSCRIBE_PATH,
|
|
14
16
|
DELIVERY_STATUS_CELL_COMPONENT,
|
|
17
|
+
DELIVERY_TIME_CELL_COMPONENT,
|
|
18
|
+
DELIVERY_TYPE_CELL_COMPONENT,
|
|
15
19
|
DELIVERY_UNSUBSCRIBE_PATH,
|
|
16
20
|
DeliveryErrors,
|
|
17
21
|
DeliveryHandlers,
|
|
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
|
2
2
|
import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type {
|
|
5
|
+
DeliveryErrorCode,
|
|
5
6
|
EscapeHatchAuditSink,
|
|
6
7
|
NotifyDelivery,
|
|
7
8
|
NotifyJobDispatcher,
|
|
@@ -10,12 +11,13 @@ import type {
|
|
|
10
11
|
TenantId,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
13
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
-
import type
|
|
14
|
+
import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
14
15
|
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
15
16
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
16
17
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
17
18
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
18
19
|
import type { Redis } from "ioredis";
|
|
20
|
+
import { redactErrorText } from "../shared/index.js";
|
|
19
21
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
20
22
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
21
23
|
import { buildChannelContext } from "./channel-context.js";
|
|
@@ -25,12 +27,18 @@ import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
|
25
27
|
import {
|
|
26
28
|
type ChannelContext,
|
|
27
29
|
type ChannelMessage,
|
|
30
|
+
type ChannelResult,
|
|
28
31
|
type DeliveryChannel,
|
|
29
32
|
type DeliveryLogEntry,
|
|
30
33
|
type DeliveryService,
|
|
31
34
|
isDeliveryChannelPlugin,
|
|
35
|
+
type RenderedMessage,
|
|
32
36
|
} from "./types.js";
|
|
33
37
|
|
|
38
|
+
export function redactedMessageOf(err: unknown): string {
|
|
39
|
+
return redactErrorText(err instanceof Error ? err.message : String(err));
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export type RateLimitConfig = {
|
|
35
43
|
readonly redis: Redis;
|
|
36
44
|
readonly maxPerHour: number; // per channel per tenant
|
|
@@ -99,6 +107,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
99
107
|
log,
|
|
100
108
|
} = options;
|
|
101
109
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
110
|
+
const logError = createFallbackLogger("delivery", log);
|
|
102
111
|
|
|
103
112
|
// Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
|
|
104
113
|
// Lua. Runs single-threaded in Redis, so two parallel clients can't both
|
|
@@ -232,6 +241,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
232
241
|
recipientId: entry.recipientId,
|
|
233
242
|
status: entry.status,
|
|
234
243
|
error: entry.error,
|
|
244
|
+
...(entry.confirmed === false && { confirmed: entry.confirmed }),
|
|
235
245
|
deliveryAttemptId,
|
|
236
246
|
});
|
|
237
247
|
}
|
|
@@ -244,6 +254,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
244
254
|
recordDelivery(run, entry, attemptId);
|
|
245
255
|
}
|
|
246
256
|
|
|
257
|
+
// The stored/returned error is only the code; the redacted message goes to the log.
|
|
258
|
+
async function logInlineFailure(
|
|
259
|
+
run: NotifyRun,
|
|
260
|
+
args: {
|
|
261
|
+
readonly channel: DeliveryChannel;
|
|
262
|
+
readonly address: string;
|
|
263
|
+
readonly tenantId: TenantId;
|
|
264
|
+
readonly recipientId: string | null;
|
|
265
|
+
readonly notificationType: string;
|
|
266
|
+
readonly priority: NotifyPriority;
|
|
267
|
+
},
|
|
268
|
+
code: DeliveryErrorCode,
|
|
269
|
+
err: unknown,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
logError.error(`${args.channel.name} ${code}: ${redactedMessageOf(err)}`, {
|
|
272
|
+
notificationType: args.notificationType,
|
|
273
|
+
channel: args.channel.name,
|
|
274
|
+
});
|
|
275
|
+
await logDelivery(run, {
|
|
276
|
+
tenantId: args.tenantId,
|
|
277
|
+
notificationType: args.notificationType,
|
|
278
|
+
channel: args.channel.name,
|
|
279
|
+
recipientId: args.recipientId,
|
|
280
|
+
recipientAddress: args.address,
|
|
281
|
+
status: "failed",
|
|
282
|
+
error: code,
|
|
283
|
+
priority: args.priority,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
247
287
|
// Deliver one resolved (channel, address) pair. Inline channels (inApp) and
|
|
248
288
|
// the no-job-runner fallback render + send synchronously and log the terminal
|
|
249
289
|
// status. Queued channels (email/push) record a `queued` attempt up front and
|
|
@@ -305,8 +345,24 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
305
345
|
recordDelivery(run, queuedEntry, deliveryAttemptId);
|
|
306
346
|
} else {
|
|
307
347
|
// Inline (inApp) or no-job-runner fallback: render + send synchronously.
|
|
308
|
-
|
|
309
|
-
|
|
348
|
+
let rendered: RenderedMessage | undefined;
|
|
349
|
+
if (channel.render) {
|
|
350
|
+
try {
|
|
351
|
+
rendered = await channel.render(message, channelCtx);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
await logInlineFailure(run, args, "render_failed", err);
|
|
354
|
+
// skip: logInlineFailure recorded and logged the failure
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let result: ChannelResult;
|
|
359
|
+
try {
|
|
360
|
+
result = await channel.send(address, message, channelCtx, rendered);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
await logInlineFailure(run, args, "send_failed", err);
|
|
363
|
+
// skip: logInlineFailure recorded and logged the failure
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
310
366
|
await logDelivery(run, {
|
|
311
367
|
tenantId,
|
|
312
368
|
notificationType,
|
|
@@ -316,6 +372,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
316
372
|
status: result.status,
|
|
317
373
|
error: result.error ?? null,
|
|
318
374
|
priority,
|
|
375
|
+
...(result.confirmed === false && { confirmed: result.confirmed }),
|
|
319
376
|
});
|
|
320
377
|
}
|
|
321
378
|
}
|
|
@@ -517,6 +574,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
517
574
|
priority,
|
|
518
575
|
});
|
|
519
576
|
} catch (err) {
|
|
577
|
+
logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
|
|
578
|
+
notificationType,
|
|
579
|
+
channel: channel.name,
|
|
580
|
+
});
|
|
520
581
|
await logDelivery(run, {
|
|
521
582
|
tenantId,
|
|
522
583
|
notificationType,
|
|
@@ -524,7 +585,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
524
585
|
recipientId: userId,
|
|
525
586
|
recipientAddress: null,
|
|
526
587
|
status: "failed",
|
|
527
|
-
error:
|
|
588
|
+
error: "channel_error",
|
|
528
589
|
priority,
|
|
529
590
|
});
|
|
530
591
|
}
|
|
@@ -599,6 +660,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
599
660
|
priority,
|
|
600
661
|
});
|
|
601
662
|
} catch (err) {
|
|
663
|
+
logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
|
|
664
|
+
notificationType,
|
|
665
|
+
channel: channel.name,
|
|
666
|
+
});
|
|
602
667
|
await logDelivery(run, {
|
|
603
668
|
tenantId,
|
|
604
669
|
notificationType,
|
|
@@ -606,7 +671,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
606
671
|
recipientId,
|
|
607
672
|
recipientAddress: address,
|
|
608
673
|
status: "failed",
|
|
609
|
-
error:
|
|
674
|
+
error: "channel_error",
|
|
610
675
|
priority,
|
|
611
676
|
});
|
|
612
677
|
}
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// delivery-service.ts (validates payloads before the low-level append()
|
|
4
4
|
// — out-of-dispatcher writes otherwise skip schema enforcement).
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
DELIVERY_FAILURE_CODES,
|
|
8
|
+
DELIVERY_SKIP_REASONS,
|
|
9
|
+
HTTP_ERROR_CODE_PATTERN,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
11
|
import * as z from "zod";
|
|
7
12
|
import { DeliveryStatus } from "./constants.js";
|
|
8
13
|
|
|
@@ -17,8 +22,17 @@ export const deliveryAttemptSchema = z.object({
|
|
|
17
22
|
DeliveryStatus.failed,
|
|
18
23
|
DeliveryStatus.skipped,
|
|
19
24
|
]),
|
|
20
|
-
|
|
25
|
+
// Closed code set; z.custom would not serialize to JSON-Schema. Events written
|
|
26
|
+
// before the code vocabulary may hold free text, they are not re-validated on replay.
|
|
27
|
+
error: z
|
|
28
|
+
.union([
|
|
29
|
+
z.enum([...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS]),
|
|
30
|
+
z.string().regex(HTTP_ERROR_CODE_PATTERN),
|
|
31
|
+
])
|
|
32
|
+
.nullable(),
|
|
21
33
|
priority: z.enum(["critical", "normal", "low"]),
|
|
34
|
+
// Provider accepted the request but does not confirm delivery (e.g. Teams 202).
|
|
35
|
+
confirmed: z.literal(false).optional(),
|
|
22
36
|
});
|
|
23
37
|
|
|
24
38
|
export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
|
|
@@ -8,9 +8,13 @@ import {
|
|
|
8
8
|
import type * as z from "zod";
|
|
9
9
|
import {
|
|
10
10
|
DELIVERY_ATTEMPT_EVENT,
|
|
11
|
+
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
11
12
|
DELIVERY_CHANNEL_EXTENSION,
|
|
13
|
+
DELIVERY_ERROR_CELL_COMPONENT,
|
|
12
14
|
DELIVERY_LOG_SCREEN_ID,
|
|
13
15
|
DELIVERY_STATUS_CELL_COMPONENT,
|
|
16
|
+
DELIVERY_TIME_CELL_COMPONENT,
|
|
17
|
+
DELIVERY_TYPE_CELL_COMPONENT,
|
|
14
18
|
DeliveryJobNames,
|
|
15
19
|
DeliveryQueries,
|
|
16
20
|
} from "./constants.js";
|
|
@@ -118,8 +122,14 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
118
122
|
status: p.status,
|
|
119
123
|
error: p.error,
|
|
120
124
|
priority: p.priority,
|
|
125
|
+
confirmed: p.confirmed ?? null,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
status: p.status,
|
|
129
|
+
error: p.error,
|
|
130
|
+
recipientAddress: p.recipientAddress,
|
|
131
|
+
confirmed: p.confirmed ?? null,
|
|
121
132
|
},
|
|
122
|
-
{ status: p.status, error: p.error, recipientAddress: p.recipientAddress },
|
|
123
133
|
);
|
|
124
134
|
},
|
|
125
135
|
},
|
|
@@ -168,20 +178,38 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
168
178
|
type: "projectionList",
|
|
169
179
|
query: DeliveryQueries.log,
|
|
170
180
|
columns: [
|
|
181
|
+
{
|
|
182
|
+
field: "createdAt",
|
|
183
|
+
label: i18nKey("delivery.log.col.createdAt"),
|
|
184
|
+
renderer: { react: { __component: DELIVERY_TIME_CELL_COMPONENT } },
|
|
185
|
+
},
|
|
171
186
|
{
|
|
172
187
|
field: "tenantId",
|
|
173
188
|
label: i18nKey("delivery.log.col.tenantId"),
|
|
174
189
|
refEntity: "tenant:tenant",
|
|
175
190
|
refLabelField: "name",
|
|
176
191
|
},
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
{
|
|
193
|
+
field: "type",
|
|
194
|
+
label: i18nKey("delivery.log.col.type"),
|
|
195
|
+
renderer: { react: { __component: DELIVERY_TYPE_CELL_COMPONENT } },
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
field: "channel",
|
|
199
|
+
label: i18nKey("delivery.log.col.channel"),
|
|
200
|
+
renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
|
|
201
|
+
},
|
|
202
|
+
{ field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
|
|
180
203
|
{
|
|
181
204
|
field: "status",
|
|
182
205
|
label: i18nKey("delivery.log.col.status"),
|
|
183
206
|
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
184
207
|
},
|
|
208
|
+
{
|
|
209
|
+
field: "error",
|
|
210
|
+
label: i18nKey("delivery.log.col.error"),
|
|
211
|
+
renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
|
|
212
|
+
},
|
|
185
213
|
],
|
|
186
214
|
// Cursor-based paging: deliveryAttemptsTable's id is an event-stream
|
|
187
215
|
// aggregate id (not a sequential PK), so the "pages" default (offset +
|