@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
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
decodeKeysetCursor,
|
|
4
|
+
encodeKeysetCursor,
|
|
5
|
+
type TenantDb,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
3
7
|
import {
|
|
4
8
|
access,
|
|
9
|
+
type DeliveryErrorCode,
|
|
5
10
|
definePagedQueryHandler,
|
|
11
|
+
isDeliveryErrorCode,
|
|
12
|
+
isUuid,
|
|
6
13
|
MAX_LIST_LIMIT,
|
|
7
14
|
type NotifyPriority,
|
|
15
|
+
type Registry,
|
|
16
|
+
SYSTEM_USER_ID,
|
|
8
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
18
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
19
|
import { Temporal } from "temporal-polyfill";
|
|
11
20
|
import * as z from "zod";
|
|
12
21
|
import { decryptStoredPii } from "../../shared/index.js";
|
|
22
|
+
import { resolveUserDisplayNames, USER_FEATURE } from "../../user/index.js";
|
|
13
23
|
import type { DeliveryStatusValue } from "../constants.js";
|
|
14
24
|
import { deliveryAttemptsTable } from "../tables.js";
|
|
15
25
|
|
|
@@ -21,17 +31,25 @@ type DeliveryLogRow = {
|
|
|
21
31
|
recipientId: string | null;
|
|
22
32
|
recipientAddress: string | null;
|
|
23
33
|
status: DeliveryStatusValue;
|
|
34
|
+
// Legacy rows may hold free text; the handler masks those on the way out.
|
|
24
35
|
error: string | null;
|
|
25
36
|
priority: NotifyPriority;
|
|
37
|
+
confirmed: boolean | null;
|
|
26
38
|
createdAt: Temporal.Instant;
|
|
27
39
|
};
|
|
28
40
|
|
|
41
|
+
function toClientError(stored: string | null): DeliveryErrorCode | null {
|
|
42
|
+
if (stored === null) return null;
|
|
43
|
+
return isDeliveryErrorCode(stored) ? stored : "channel_error";
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
// Sort whitelist keyed by the DISPLAY field the client actually sends: the
|
|
30
47
|
// declarative projectionList renderer marks every listed column uniformly
|
|
31
48
|
// sortable and a header click sends `sort=<screen.columns[].field>`, not the
|
|
32
49
|
// DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
|
|
33
|
-
// "recipient" is deliberately absent — it's PII decrypted
|
|
34
|
-
// application code, so there is no column to ORDER BY in SQL for it.
|
|
50
|
+
// "recipient" is deliberately absent — it's PII (name or decrypted address)
|
|
51
|
+
// resolved in application code, so there is no column to ORDER BY in SQL for it.
|
|
52
|
+
// A click on such a header sends an unlisted sort value and falls back to createdAt.
|
|
35
53
|
const DELIVERY_LOG_SORT_COLUMNS = {
|
|
36
54
|
createdAt: "createdAt",
|
|
37
55
|
tenantId: "tenantId",
|
|
@@ -67,6 +85,48 @@ function decodeSortCursor(column: DeliveryLogSortColumn, cursor: string): Decode
|
|
|
67
85
|
return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
// One batched name lookup for the whole page. Only when the user feature is
|
|
89
|
+
// mounted: delivery does not require it, and a direct-address send has no user.
|
|
90
|
+
async function loadDisplayNames(
|
|
91
|
+
db: TenantDb,
|
|
92
|
+
registry: Registry,
|
|
93
|
+
rows: readonly DeliveryLogRow[],
|
|
94
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
95
|
+
const recipientIds = [
|
|
96
|
+
...new Set(
|
|
97
|
+
rows
|
|
98
|
+
.map((row) => row.recipientId)
|
|
99
|
+
.filter((id): id is string => id !== null && id !== SYSTEM_USER_ID && isUuid(id)),
|
|
100
|
+
),
|
|
101
|
+
];
|
|
102
|
+
if (recipientIds.length === 0 || registry.getFeature(USER_FEATURE) === undefined)
|
|
103
|
+
return new Map();
|
|
104
|
+
return resolveUserDisplayNames(db, recipientIds);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// recipientLabel: display name, else the decrypted address, else the id.
|
|
108
|
+
async function toClientRow(row: DeliveryLogRow, displayNames: ReadonlyMap<string, string>) {
|
|
109
|
+
const address =
|
|
110
|
+
row.recipientAddress !== null
|
|
111
|
+
? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
|
|
112
|
+
: null;
|
|
113
|
+
const name = row.recipientId !== null ? displayNames.get(row.recipientId) : undefined;
|
|
114
|
+
return {
|
|
115
|
+
id: row.id,
|
|
116
|
+
tenantId: row.tenantId,
|
|
117
|
+
recipientId: row.recipientId,
|
|
118
|
+
type: row.notificationType,
|
|
119
|
+
channel: row.channel,
|
|
120
|
+
recipient: address,
|
|
121
|
+
recipientLabel: name ?? address ?? row.recipientId,
|
|
122
|
+
status: row.status,
|
|
123
|
+
error: toClientError(row.error),
|
|
124
|
+
priority: row.priority,
|
|
125
|
+
confirmed: row.confirmed,
|
|
126
|
+
createdAt: row.createdAt,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
70
130
|
export const logQuery = definePagedQueryHandler({
|
|
71
131
|
name: "log",
|
|
72
132
|
schema: z.object({
|
|
@@ -153,24 +213,9 @@ export const logQuery = definePagedQueryHandler({
|
|
|
153
213
|
// here, not in the client: a projectionList screen has no entity to
|
|
154
214
|
// derive a field-mapping from, so the row shape the query returns is the
|
|
155
215
|
// shape the declarative columns read directly.
|
|
216
|
+
const displayNames = await loadDisplayNames(db, ctx.registry, rows);
|
|
156
217
|
return {
|
|
157
|
-
rows: await Promise.all(
|
|
158
|
-
rows.map(async (row) => ({
|
|
159
|
-
id: row.id,
|
|
160
|
-
tenantId: row.tenantId,
|
|
161
|
-
recipientId: row.recipientId,
|
|
162
|
-
type: row.notificationType,
|
|
163
|
-
channel: row.channel,
|
|
164
|
-
recipient:
|
|
165
|
-
row.recipientAddress !== null
|
|
166
|
-
? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
|
|
167
|
-
: row.recipientAddress,
|
|
168
|
-
status: row.status,
|
|
169
|
-
error: row.error,
|
|
170
|
-
priority: row.priority,
|
|
171
|
-
createdAt: row.createdAt,
|
|
172
|
-
})),
|
|
173
|
-
),
|
|
218
|
+
rows: await Promise.all(rows.map((row) => toClientRow(row, displayNames))),
|
|
174
219
|
nextCursor,
|
|
175
220
|
};
|
|
176
221
|
},
|
|
@@ -1,11 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DELIVERY_FAILURE_CODES,
|
|
3
|
+
DELIVERY_SKIP_REASONS,
|
|
4
|
+
type DeliveryFailureCode,
|
|
5
|
+
type DeliverySkipReason,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { DeliveryStatus, type DeliveryStatusValue } from "./public-names.js";
|
|
8
|
+
|
|
1
9
|
type LocalizedString = { readonly en: string };
|
|
2
10
|
|
|
11
|
+
// Typed over the code vocabularies, so a new code without a label fails to compile.
|
|
12
|
+
const ERROR_LABELS = {
|
|
13
|
+
timeout: "Timed out",
|
|
14
|
+
network_error: "Network error",
|
|
15
|
+
redirect_blocked: "Redirect blocked",
|
|
16
|
+
host_not_allowed: "Host not allowed",
|
|
17
|
+
missing_credentials: "Missing credentials",
|
|
18
|
+
invalid_address: "Invalid address",
|
|
19
|
+
unexpected_response: "Unexpected provider response",
|
|
20
|
+
render_failed: "Rendering failed",
|
|
21
|
+
send_failed: "Sending failed",
|
|
22
|
+
channel_error: "Channel error",
|
|
23
|
+
channel_disabled: "Channel disabled",
|
|
24
|
+
preference_disabled: "Disabled in preferences",
|
|
25
|
+
rate_limited: "Rate limited",
|
|
26
|
+
no_address: "No address",
|
|
27
|
+
unsubscribed: "Unsubscribed",
|
|
28
|
+
duplicate_idempotency_key: "Duplicate request",
|
|
29
|
+
} as const satisfies Record<DeliveryFailureCode | DeliverySkipReason, string>;
|
|
30
|
+
|
|
31
|
+
const STATUS_LABELS = {
|
|
32
|
+
queued: "Queued",
|
|
33
|
+
sent: "Sent",
|
|
34
|
+
failed: "Failed",
|
|
35
|
+
skipped: "Skipped",
|
|
36
|
+
} as const satisfies Record<DeliveryStatusValue, string>;
|
|
37
|
+
|
|
38
|
+
function labelKeys(
|
|
39
|
+
prefix: string,
|
|
40
|
+
codes: readonly string[],
|
|
41
|
+
labels: Readonly<Record<string, string>>,
|
|
42
|
+
): Record<string, LocalizedString> {
|
|
43
|
+
return Object.fromEntries(
|
|
44
|
+
codes.map((code) => [`${prefix}.${code}`, { en: labels[code] ?? code }]),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
3
48
|
export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
49
|
"screen:delivery-log.title": { en: "Delivery log" },
|
|
5
50
|
"delivery:nav.deliveryLog": { en: "Delivery" },
|
|
51
|
+
"delivery.log.col.createdAt": { en: "Time" },
|
|
6
52
|
"delivery.log.col.tenantId": { en: "Tenant" },
|
|
7
53
|
"delivery.log.col.type": { en: "Type" },
|
|
8
54
|
"delivery.log.col.channel": { en: "Channel" },
|
|
9
55
|
"delivery.log.col.recipient": { en: "Recipient" },
|
|
10
56
|
"delivery.log.col.status": { en: "Status" },
|
|
57
|
+
"delivery.log.col.error": { en: "Error" },
|
|
58
|
+
...labelKeys("delivery.status", Object.values(DeliveryStatus), STATUS_LABELS),
|
|
59
|
+
...labelKeys(
|
|
60
|
+
"delivery.error",
|
|
61
|
+
[...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS],
|
|
62
|
+
ERROR_LABELS,
|
|
63
|
+
),
|
|
64
|
+
"delivery.status.sentUnconfirmed": { en: "Sent (unconfirmed)" },
|
|
65
|
+
"delivery.error.http": { en: "HTTP {status}" },
|
|
11
66
|
};
|
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
type ChatSendFailureCode,
|
|
11
11
|
type ChatSendResult,
|
|
12
12
|
type ChatWebhookRequest,
|
|
13
|
+
type ChatWebhookResponse,
|
|
13
14
|
type ChatWebhookTarget,
|
|
14
15
|
chatConnectionNameSchema,
|
|
15
16
|
chatWebhookUrlSchema,
|
|
@@ -38,6 +39,7 @@ export {
|
|
|
38
39
|
type RateLimitConfig,
|
|
39
40
|
} from "./delivery-service.js";
|
|
40
41
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
|
|
42
|
+
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
41
43
|
export {
|
|
42
44
|
deliveryAttemptsTable,
|
|
43
45
|
notificationAddressOptOutEntity,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
9
|
import type {
|
|
10
|
+
DeliveryErrorCode,
|
|
10
11
|
JobContext,
|
|
11
12
|
JobHandlerFn,
|
|
12
13
|
Registry,
|
|
@@ -18,7 +19,7 @@ import * as z from "zod";
|
|
|
18
19
|
import { appendAttemptEvent } from "./attempt-log.js";
|
|
19
20
|
import { buildChannelContext } from "./channel-context.js";
|
|
20
21
|
import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
21
|
-
import { collectChannels } from "./delivery-service.js";
|
|
22
|
+
import { collectChannels, redactedMessageOf } from "./delivery-service.js";
|
|
22
23
|
import type {
|
|
23
24
|
ChannelMessage,
|
|
24
25
|
DeliveryChannel,
|
|
@@ -90,8 +91,9 @@ function toMessage(p: RenderJobPayload): ChannelMessage {
|
|
|
90
91
|
function entryFor(
|
|
91
92
|
p: RenderJobPayload,
|
|
92
93
|
status: DeliveryLogEntry["status"],
|
|
93
|
-
error:
|
|
94
|
+
error: DeliveryErrorCode | null,
|
|
94
95
|
address: string | null,
|
|
96
|
+
confirmed?: false,
|
|
95
97
|
): DeliveryLogEntry {
|
|
96
98
|
return {
|
|
97
99
|
tenantId: p.tenantId as TenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
|
|
@@ -102,11 +104,33 @@ function entryFor(
|
|
|
102
104
|
status,
|
|
103
105
|
error,
|
|
104
106
|
priority: p.priority,
|
|
107
|
+
...(confirmed === false && { confirmed }),
|
|
105
108
|
};
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
112
|
+
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
113
|
+
// The redacted message goes to the log.
|
|
114
|
+
async function failAttempt(
|
|
115
|
+
ctx: JobContext,
|
|
116
|
+
db: DbConnection,
|
|
117
|
+
registry: Registry,
|
|
118
|
+
p: RenderJobPayload,
|
|
119
|
+
code: DeliveryErrorCode,
|
|
120
|
+
err: unknown,
|
|
121
|
+
): Promise<never> {
|
|
122
|
+
const message = redactedMessageOf(err);
|
|
123
|
+
ctx.log.error(`delivery.${p.channelName} ${code}: ${message}`, {
|
|
124
|
+
notificationType: p.notificationType,
|
|
125
|
+
channel: p.channelName,
|
|
126
|
+
});
|
|
127
|
+
await appendAttemptEvent(
|
|
128
|
+
db,
|
|
129
|
+
registry,
|
|
130
|
+
p.deliveryAttemptId,
|
|
131
|
+
entryFor(p, "failed", code, p.address),
|
|
132
|
+
);
|
|
133
|
+
throw new Error(code);
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
// Render the message and hand off to delivery.send. On failure: record the
|
|
@@ -119,11 +143,16 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
119
143
|
const channel = resolveChannel(registry, p.channelName);
|
|
120
144
|
const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
|
|
121
145
|
|
|
146
|
+
let rendered: RenderedMessage;
|
|
122
147
|
try {
|
|
123
148
|
if (!channel.render) {
|
|
124
149
|
throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);
|
|
125
150
|
}
|
|
126
|
-
|
|
151
|
+
rendered = await channel.render(toMessage(p), channelCtx);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return failAttempt(ctx, db, registry, p, "render_failed", err);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
127
156
|
const jobRunner = ctx["jobRunner"] as JobRunner; // @cast-boundary dynamic-key — dispatch lives on the concrete runner
|
|
128
157
|
await jobRunner.dispatch(
|
|
129
158
|
DeliveryJobs.send,
|
|
@@ -131,13 +160,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
131
160
|
{ priority: deliveryPriorityRank[p.priority] },
|
|
132
161
|
);
|
|
133
162
|
} catch (err) {
|
|
134
|
-
|
|
135
|
-
db,
|
|
136
|
-
registry,
|
|
137
|
-
p.deliveryAttemptId,
|
|
138
|
-
entryFor(p, "failed", `render: ${messageOf(err)}`, p.address),
|
|
139
|
-
);
|
|
140
|
-
throw err;
|
|
163
|
+
return failAttempt(ctx, db, registry, p, "channel_error", err);
|
|
141
164
|
}
|
|
142
165
|
};
|
|
143
166
|
|
|
@@ -157,15 +180,15 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
157
180
|
db,
|
|
158
181
|
registry,
|
|
159
182
|
p.deliveryAttemptId,
|
|
160
|
-
entryFor(
|
|
183
|
+
entryFor(
|
|
184
|
+
p,
|
|
185
|
+
result.status,
|
|
186
|
+
result.error ?? null,
|
|
187
|
+
result.address ?? p.address,
|
|
188
|
+
result.confirmed,
|
|
189
|
+
),
|
|
161
190
|
);
|
|
162
191
|
} catch (err) {
|
|
163
|
-
|
|
164
|
-
db,
|
|
165
|
-
registry,
|
|
166
|
-
p.deliveryAttemptId,
|
|
167
|
-
entryFor(p, "failed", `send: ${messageOf(err)}`, p.address),
|
|
168
|
-
);
|
|
169
|
-
throw err;
|
|
192
|
+
return failAttempt(ctx, db, registry, p, "send_failed", err);
|
|
170
193
|
}
|
|
171
194
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NotifyFactory } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { DeliveryService } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// The same binding runProdApp wires into the app context: the calling context's
|
|
5
|
+
// job dispatcher is handed to notify() per call, so queued channels go through
|
|
6
|
+
// the delivery jobs. deliverQueuedInline drops the dispatcher and sends inline.
|
|
7
|
+
export function createDeliveryNotifyFactory(
|
|
8
|
+
deliveryService: Pick<DeliveryService, "notify">,
|
|
9
|
+
options: { readonly deliverQueuedInline?: boolean } = {},
|
|
10
|
+
): NotifyFactory {
|
|
11
|
+
return (user, tenantId, jobDispatcher) => (notificationType, notifyOptions) =>
|
|
12
|
+
deliveryService.notify(
|
|
13
|
+
notificationType,
|
|
14
|
+
notifyOptions,
|
|
15
|
+
user,
|
|
16
|
+
tenantId,
|
|
17
|
+
options.deliverQueuedInline === true ? undefined : jobDispatcher,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -33,6 +33,10 @@ export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
|
|
|
33
33
|
// column — see `screen.columns[].renderer.react.__component` in feature.ts
|
|
34
34
|
// and `deliveryClient()`'s `columnRenderers` map.
|
|
35
35
|
export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
|
|
36
|
+
export const DELIVERY_TYPE_CELL_COMPONENT = "DeliveryTypeCell" as const;
|
|
37
|
+
export const DELIVERY_CHANNEL_CELL_COMPONENT = "DeliveryChannelCell" as const;
|
|
38
|
+
export const DELIVERY_TIME_CELL_COMPONENT = "DeliveryTimeCell" as const;
|
|
39
|
+
export const DELIVERY_ERROR_CELL_COMPONENT = "DeliveryErrorCell" as const;
|
|
36
40
|
|
|
37
41
|
export const DeliveryErrors = {
|
|
38
42
|
noRecipient: "delivery_no_recipient",
|
|
@@ -43,6 +43,8 @@ export const deliveryAttemptsTable = pgTable(
|
|
|
43
43
|
recipientAddress: text("recipient_address"),
|
|
44
44
|
status: text("status").notNull().$type<"queued" | "sent" | "failed" | "skipped">(),
|
|
45
45
|
error: text("error"),
|
|
46
|
+
// false = provider accepted but did not confirm; null = confirmed or not applicable.
|
|
47
|
+
confirmed: boolean("confirmed"),
|
|
46
48
|
// Default covers rows that predate the column; new rows always carry the
|
|
47
49
|
// notify() priority from the event payload.
|
|
48
50
|
priority: text("priority").notNull().default("normal").$type<"critical" | "normal" | "low">(),
|
|
@@ -71,6 +73,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
|
71
73
|
{ name: "recipient_address", pgType: "text", notNull: false },
|
|
72
74
|
{ name: "status", pgType: "text", notNull: true },
|
|
73
75
|
{ name: "error", pgType: "text", notNull: false },
|
|
76
|
+
{ name: "confirmed", pgType: "boolean", notNull: false },
|
|
74
77
|
{ name: "priority", pgType: "text", notNull: true, defaultSql: "'normal'" },
|
|
75
78
|
{ name: "created_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
|
|
76
79
|
],
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type {
|
|
4
|
-
EscapeHatchAuditSink,
|
|
5
|
-
Registry,
|
|
6
|
-
TenantId,
|
|
7
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
4
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
9
5
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
6
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
10
7
|
import type { Redis } from "ioredis";
|
|
11
8
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
|
|
12
9
|
import { collectChannels, createDeliveryService } from "./delivery-service.js";
|
|
10
|
+
import { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
13
11
|
import type { DeliveryService } from "./types.js";
|
|
14
12
|
|
|
15
13
|
export type CreateDeliveryTestContextOptions = {
|
|
@@ -19,6 +17,7 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
19
17
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
20
18
|
readonly log?: Logger;
|
|
21
19
|
readonly jobRunner?: JobRunner;
|
|
20
|
+
readonly secrets?: SecretsContext;
|
|
22
21
|
};
|
|
23
22
|
|
|
24
23
|
/**
|
|
@@ -47,9 +46,6 @@ export function createDeliveryTestContext(
|
|
|
47
46
|
|
|
48
47
|
return {
|
|
49
48
|
deliveryService, // exposed so tests can inspect/call directly if needed
|
|
50
|
-
_notifyFactory:
|
|
51
|
-
(user: { id: number; tenantId: TenantId }, tenantId: TenantId) =>
|
|
52
|
-
(notificationType: string, notifyOptions: Record<string, unknown>) =>
|
|
53
|
-
deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId), // @cast-boundary engine-bridge
|
|
49
|
+
_notifyFactory: createDeliveryNotifyFactory(deliveryService),
|
|
54
50
|
};
|
|
55
51
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type {
|
|
4
|
+
DeliveryErrorCode,
|
|
4
5
|
NotifyDeliveryStatus,
|
|
5
6
|
NotifyJobDispatcher,
|
|
6
7
|
NotifyOptions,
|
|
@@ -35,7 +36,9 @@ export type ChannelMessage = {
|
|
|
35
36
|
|
|
36
37
|
export type ChannelResult = {
|
|
37
38
|
readonly status: "sent" | "failed" | "skipped";
|
|
38
|
-
readonly error?:
|
|
39
|
+
readonly error?: DeliveryErrorCode;
|
|
40
|
+
// Only ever set to false: the provider accepted the message but did not confirm delivery.
|
|
41
|
+
readonly confirmed?: false;
|
|
39
42
|
readonly address?: string;
|
|
40
43
|
};
|
|
41
44
|
|
|
@@ -91,8 +94,9 @@ export type DeliveryLogEntry = {
|
|
|
91
94
|
readonly recipientId: string | null;
|
|
92
95
|
readonly recipientAddress: string | null;
|
|
93
96
|
readonly status: NotifyDeliveryStatus;
|
|
94
|
-
readonly error:
|
|
97
|
+
readonly error: DeliveryErrorCode | null;
|
|
95
98
|
readonly priority: NotifyPriority;
|
|
99
|
+
readonly confirmed?: false;
|
|
96
100
|
};
|
|
97
101
|
|
|
98
102
|
// --- Delivery Service ---
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
6
|
+
DELIVERY_ERROR_CELL_COMPONENT,
|
|
7
|
+
DELIVERY_FEATURE,
|
|
8
|
+
DELIVERY_STATUS_CELL_COMPONENT,
|
|
9
|
+
DELIVERY_TIME_CELL_COMPONENT,
|
|
10
|
+
DELIVERY_TYPE_CELL_COMPONENT,
|
|
11
|
+
} from "../public-names.js";
|
|
12
|
+
import { DeliveryChannelCell } from "./delivery-channel-cell.js";
|
|
13
|
+
import { DeliveryErrorCell } from "./delivery-error-cell.js";
|
|
5
14
|
import { DeliveryStatusCell } from "./delivery-status-cell.js";
|
|
15
|
+
import { DeliveryTimeCell } from "./delivery-time-cell.js";
|
|
16
|
+
import { DeliveryTypeCell } from "./delivery-type-cell.js";
|
|
6
17
|
|
|
7
18
|
export type DeliveryClientOptions = {
|
|
8
19
|
readonly translations?: TranslationsByLocale;
|
|
@@ -13,6 +24,10 @@ export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDe
|
|
|
13
24
|
name: DELIVERY_FEATURE,
|
|
14
25
|
columnRenderers: {
|
|
15
26
|
[DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
|
|
27
|
+
[DELIVERY_TYPE_CELL_COMPONENT]: DeliveryTypeCell,
|
|
28
|
+
[DELIVERY_CHANNEL_CELL_COMPONENT]: DeliveryChannelCell,
|
|
29
|
+
[DELIVERY_TIME_CELL_COMPONENT]: DeliveryTimeCell,
|
|
30
|
+
[DELIVERY_ERROR_CELL_COMPONENT]: DeliveryErrorCell,
|
|
16
31
|
},
|
|
17
32
|
...(options?.translations !== undefined && { translations: options.translations }),
|
|
18
33
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Channel column: the label comes from `delivery.channel.<name>`, registered by each
|
|
3
|
+
// channel feature; unknown channels show their raw name.
|
|
4
|
+
|
|
5
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
8
|
+
|
|
9
|
+
export function DeliveryChannelCell({ row }: ColumnRendererProps): ReactNode {
|
|
10
|
+
const t = useTranslation();
|
|
11
|
+
const channel = typeof row["channel"] === "string" ? row["channel"] : "";
|
|
12
|
+
return translateOrRaw(t, `delivery.channel.${channel}`, channel);
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Error column. The log only carries fixed codes (`DeliveryErrorCode`); `http_<status>`
|
|
3
|
+
// takes the status as a parameter, anything unknown shows as the raw value.
|
|
4
|
+
|
|
5
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import { HTTP_ERROR_CODE_PATTERN } from "@cosmicdrift/kumiko-types/handlers";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
9
|
+
|
|
10
|
+
export function DeliveryErrorCell({ row }: ColumnRendererProps): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
const error = row["error"];
|
|
13
|
+
if (typeof error !== "string" || error === "") return null;
|
|
14
|
+
const http = HTTP_ERROR_CODE_PATTERN.exec(error);
|
|
15
|
+
if (http) return t("delivery.error.http", { status: http[1] });
|
|
16
|
+
return translateOrRaw(t, `delivery.error.${error}`, error);
|
|
17
|
+
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// in feature.ts). The list frame itself is the generic declarative
|
|
5
5
|
// projectionList renderer — only this one cell stays TSX.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
10
11
|
import { DeliveryStatus } from "../public-names.js";
|
|
11
12
|
|
|
12
13
|
const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
@@ -17,10 +18,16 @@ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode {
|
|
21
|
+
const t = useTranslation();
|
|
20
22
|
const status = typeof row["status"] === "string" ? row["status"] : "";
|
|
23
|
+
const unconfirmed = status === DeliveryStatus.sent && row["confirmed"] === false;
|
|
24
|
+
const labelKey = unconfirmed ? "delivery.status.sentUnconfirmed" : `delivery.status.${status}`;
|
|
21
25
|
return (
|
|
22
|
-
<StatusBadge
|
|
23
|
-
{status}
|
|
26
|
+
<StatusBadge
|
|
27
|
+
tone={unconfirmed ? "muted" : (STATUS_TONE[status] ?? "muted")}
|
|
28
|
+
testId="delivery-status-cell"
|
|
29
|
+
>
|
|
30
|
+
{translateOrRaw(t, labelKey, status)}
|
|
24
31
|
</StatusBadge>
|
|
25
32
|
);
|
|
26
33
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { type ColumnRendererProps, formatWhen } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export function DeliveryTimeCell({ row }: ColumnRendererProps): ReactNode {
|
|
6
|
+
const createdAt = row["createdAt"];
|
|
7
|
+
return typeof createdAt === "string" ? formatWhen(createdAt) : "";
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Notification type column. Types are QNs like `<scope>:notify:<name>`; the
|
|
3
|
+
// translation key is `<scope>.notification.<name>`, registered by the app that
|
|
4
|
+
// owns the notification (bundled features register none). Falls back to the short
|
|
5
|
+
// name, or to the raw value for any other format.
|
|
6
|
+
|
|
7
|
+
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
10
|
+
|
|
11
|
+
const NOTIFICATION_QN_PATTERN = /^([^:]+):notify:(.+)$/;
|
|
12
|
+
|
|
13
|
+
export function DeliveryTypeCell({ row }: ColumnRendererProps): ReactNode {
|
|
14
|
+
const t = useTranslation();
|
|
15
|
+
const type = typeof row["type"] === "string" ? row["type"] : "";
|
|
16
|
+
const match = NOTIFICATION_QN_PATTERN.exec(type);
|
|
17
|
+
if (!match) return type;
|
|
18
|
+
const [, scope, name] = match;
|
|
19
|
+
return translateOrRaw(t, `${scope}.notification.${name}`, name ?? type);
|
|
20
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "ctx.crossTenantReads for read-only cross-tenant job reads",
|
|
6
|
+
"detail": "Jobs whose `escapeHatch` grants `unsafeRaw` get `ctx.crossTenantReads` with `selectMany`, `fetchOne` and `count` across all tenants, read-only. Each call is reported as the new escape-hatch kind `cross-tenant-read`; system crons audit it once per process and the metric `kumiko_escape_hatch_uses_total` counts every call. `EscapeHatchKind` and the `escape-hatch-used` audit schema gain `cross-tenant-read`.\n`tenant-lifecycle:job:run-tenant-destruction` checks through `ctx.crossTenantReads` whether a tenant is due and only then calls `ctx.db.unsafeRaw()`, so idle minutes no longer write an `unsafe-raw` audit event."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.336.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.344.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Optional title heading on legal pages",
|
|
6
|
+
"detail": "`LegalPageRoute` has an optional `titleHeading` flag. When `true`, the page body starts with an `<h1>` holding the escaped page title. The default (`false`) leaves the body unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -10,6 +10,8 @@ export type LegalPageRoute = {
|
|
|
10
10
|
readonly slug: string;
|
|
11
11
|
readonly lang: string;
|
|
12
12
|
readonly titleFallback: string;
|
|
13
|
+
/** Prepend the page title as `<h1>` to the rendered body. Default false. */
|
|
14
|
+
readonly titleHeading?: boolean;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
// Shape of one boot-check-required block — the type behind
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
7
8
|
import {
|
|
8
9
|
cachedSecurePageResponse,
|
|
9
10
|
type PublicPageWrapLayout,
|
|
@@ -87,7 +88,7 @@ export type LegalPagesOptions = {
|
|
|
87
88
|
|
|
88
89
|
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
89
90
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
90
|
-
const routes = opts.routes ?? LEGAL_ROUTES;
|
|
91
|
+
const routes: readonly LegalPageRoute[] = opts.routes ?? LEGAL_ROUTES;
|
|
91
92
|
const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
|
|
92
93
|
validateRoutes(routes);
|
|
93
94
|
if (opts.routes !== undefined && opts.routes.length > 0 && opts.requiredBlocks === undefined) {
|
|
@@ -172,9 +173,13 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
172
173
|
});
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
const title = data.title || route.titleFallback;
|
|
177
|
+
const renderedBody = renderMarkdownToHtml(data.content);
|
|
175
178
|
const html = wrapLayout({
|
|
176
|
-
title
|
|
177
|
-
bodyHtml:
|
|
179
|
+
title,
|
|
180
|
+
bodyHtml: route.titleHeading
|
|
181
|
+
? `<h1>${escapeHtml(title)}</h1>${renderedBody}`
|
|
182
|
+
: renderedBody,
|
|
178
183
|
lang: route.lang,
|
|
179
184
|
slug: route.slug,
|
|
180
185
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.344.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Heading and Markdown sections in the simple mail renderer",
|
|
6
|
+
"detail": "`createSimpleRenderer` accepts two more section kinds: `{ heading: string }` (rendered as an escaped `<h2>`) and `{ markdown: string }` (rendered with the hardened Markdown renderer; raw HTML is escaped and unsafe link targets are replaced by `#`). Existing section kinds render as before."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.341.0",
|
|
4
10
|
"type": "improvement",
|