@cosmicdrift/kumiko-bundled-features 0.356.0 → 0.358.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-mfa/i18n.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +8 -1
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/book-cap-usage.d.ts +11 -5
- package/dist/cap-counter/book-cap-usage.js +54 -13
- package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +17 -0
- package/dist/cap-counter/calendar-cap-usage-reservation.js +53 -0
- package/dist/cap-counter/cap-reservation.d.ts +15 -6
- package/dist/cap-counter/cap-reservation.js +80 -31
- package/dist/cap-counter/enforce-cap.d.ts +5 -5
- package/dist/cap-counter/enforce-cap.js +7 -20
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-counter/feature.js +7 -1
- package/dist/cap-counter/index.d.ts +1 -0
- package/dist/cap-counter/index.js +1 -0
- package/dist/cap-counter/with-cap-enforcement.js +4 -19
- package/dist/cap-overview/screens.js +3 -1
- package/dist/config/table.d.ts +6 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -3
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/chat-webhook-sender.js +1 -1
- package/dist/delivery/constants.d.ts +1 -0
- package/dist/delivery/constants.js +4 -0
- package/dist/delivery/delivery-service.js +62 -25
- package/dist/delivery/feature.js +7 -1
- package/dist/delivery/jobs.js +25 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/testing.d.ts +1 -0
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/folders/entity.d.ts +8 -0
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/jobs/job-run-logger.js +21 -17
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/job-run-table.js +10 -3
- package/dist/ledger/entity.d.ts +13 -0
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/entity.js +9 -7
- package/dist/notes-history-user-data/hooks.d.ts +8 -0
- package/dist/notes-history-user-data/hooks.js +56 -72
- package/dist/notes-history-user-data/index.d.ts +1 -0
- package/dist/notes-history-user-data/index.js +2 -1
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +2 -0
- package/dist/secrets/table.d.ts +1 -0
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/screens.js +2 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/template-resolver/table.d.ts +10 -0
- package/dist/template-resolver/user-content-table.d.ts +10 -0
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +6 -3
- package/dist/tenant/feature.js +2 -0
- package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
- package/dist/tenant/handlers/member-role-availability.query.js +18 -0
- package/dist/tenant/invitation-table.d.ts +6 -0
- package/dist/tenant/member-role-options.d.ts +2 -0
- package/dist/tenant/member-role-options.js +4 -0
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +6 -0
- package/dist/tenant/screens.d.ts +4 -2
- package/dist/tenant/screens.js +10 -6
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/schema/user.d.ts +12 -0
- package/dist/user/screens.js +8 -1
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +6 -0
- package/dist/user-data-rights/screens.js +8 -1
- package/dist/waitlist/entity.d.ts +14 -0
- package/dist/waitlist/screens.js +8 -1
- package/package.json +9 -9
- package/src/auth-mfa/changes.json +6 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +6 -0
- package/src/notes-history/changes.json +6 -0
- package/src/tenant/changes.json +6 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
+
import { type CapCounterContext } from "./book-cap-usage.js";
|
|
4
4
|
/**
|
|
5
5
|
* Cap-Toleranz-Profile — pro Cap-Typ asymmetrisch. Quelle: Memory
|
|
6
6
|
* `project_pricing_byok_caps` §3 + `docs/plans/marketing/produkt/
|
|
@@ -79,7 +79,7 @@ export declare function assertBelowHardCap(valueSeenByLastUnit: number, cap: {
|
|
|
79
79
|
* (apps-count) need stricter serialization at the create-handler
|
|
80
80
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
81
81
|
*/
|
|
82
|
-
export declare function enforceCap(ctx:
|
|
82
|
+
export declare function enforceCap(ctx: CapCounterContext, options: {
|
|
83
83
|
readonly capName: string;
|
|
84
84
|
readonly periodStartIso: string;
|
|
85
85
|
readonly limit: number;
|
|
@@ -110,7 +110,7 @@ export declare function enforceCap(ctx: HandlerContext, options: {
|
|
|
110
110
|
* Projection mit pre-aggregierten daily-buckets. Heute nicht
|
|
111
111
|
* vorgezogen.
|
|
112
112
|
*/
|
|
113
|
-
export declare function enforceRollingCap(ctx:
|
|
113
|
+
export declare function enforceRollingCap(ctx: CapCounterContext, options: {
|
|
114
114
|
readonly capName: string;
|
|
115
115
|
readonly windowDays: number;
|
|
116
116
|
readonly limit: number;
|
|
@@ -162,7 +162,7 @@ export type SoftHitNotifier = (info: {
|
|
|
162
162
|
* verzweigen (z.B. UI-Toast bei soft-hit zusätzlich zur
|
|
163
163
|
* Email-Notification).
|
|
164
164
|
*/
|
|
165
|
-
export declare function enforceCapAndMaybeNotify(ctx:
|
|
165
|
+
export declare function enforceCapAndMaybeNotify(ctx: CapCounterContext, options: {
|
|
166
166
|
readonly capName: string;
|
|
167
167
|
readonly periodStartIso: string;
|
|
168
168
|
readonly limit: number;
|
|
@@ -181,7 +181,7 @@ export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
|
|
|
181
181
|
* oder er ruft `enforceRollingCapAndMaybeNotify` nur einmal pro
|
|
182
182
|
* Tag/Stunde auf (Hourly-Cron statt pro Request).
|
|
183
183
|
*/
|
|
184
|
-
export declare function enforceRollingCapAndMaybeNotify(ctx:
|
|
184
|
+
export declare function enforceRollingCapAndMaybeNotify(ctx: CapCounterContext, options: {
|
|
185
185
|
readonly capName: string;
|
|
186
186
|
readonly windowDays: number;
|
|
187
187
|
readonly limit: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
|
-
import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
|
|
4
|
+
import { markCapSoftWarned, readRollingCapUsage, resolveCapCounterScope, } from "./book-cap-usage.js";
|
|
5
5
|
import { capCounterEntity } from "./entity.js";
|
|
6
6
|
const { table } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
7
7
|
export const CAP_TOLERANCES = {
|
|
@@ -45,16 +45,14 @@ export function assertBelowHardCap(valueSeenByLastUnit, cap) {
|
|
|
45
45
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
46
46
|
*/
|
|
47
47
|
export async function enforceCap(ctx, options) {
|
|
48
|
-
|
|
49
|
-
throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
|
|
50
|
-
}
|
|
48
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
51
49
|
const amount = options.amount ?? 1;
|
|
52
50
|
if (!Number.isInteger(amount) || amount < 1) {
|
|
53
51
|
throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
|
|
54
52
|
}
|
|
55
53
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
56
54
|
const softThreshold = options.limit * tolerance.soft;
|
|
57
|
-
const rows = await
|
|
55
|
+
const rows = await readDb.selectMany(table, { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
|
|
58
56
|
const row = rows[0];
|
|
59
57
|
const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
|
|
60
58
|
// The last of `amount` units sees this value before its own increment.
|
|
@@ -94,16 +92,11 @@ export async function enforceCap(ctx, options) {
|
|
|
94
92
|
* vorgezogen.
|
|
95
93
|
*/
|
|
96
94
|
export async function enforceRollingCap(ctx, options) {
|
|
97
|
-
|
|
98
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
|
|
99
|
-
}
|
|
100
|
-
if (!ctx.user?.tenantId) {
|
|
101
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id");
|
|
102
|
-
}
|
|
95
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
103
96
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
104
97
|
const softThreshold = options.limit * tolerance.soft;
|
|
105
98
|
const hardThreshold = options.limit * tolerance.hard;
|
|
106
|
-
const value = await readRollingCapUsage(
|
|
99
|
+
const value = await readRollingCapUsage(readDb, user.tenantId, {
|
|
107
100
|
capName: options.capName,
|
|
108
101
|
windowDays: options.windowDays,
|
|
109
102
|
});
|
|
@@ -184,14 +177,11 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
|
|
|
184
177
|
...(options.amount !== undefined && { amount: options.amount }),
|
|
185
178
|
});
|
|
186
179
|
if (result.state === "soft-hit" && result.crossed) {
|
|
187
|
-
if (!ctx.user?.tenantId) {
|
|
188
|
-
throw new Error("cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
|
|
189
|
-
}
|
|
190
180
|
await options.notify({
|
|
191
181
|
capName: options.capName,
|
|
192
182
|
value: result.value,
|
|
193
183
|
limit: options.limit,
|
|
194
|
-
tenantId: ctx.user.tenantId,
|
|
184
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
195
185
|
});
|
|
196
186
|
// Flip the soft-warned flag so the same period doesn't re-notify.
|
|
197
187
|
const marked = await markCapSoftWarned(ctx, {
|
|
@@ -222,14 +212,11 @@ export async function enforceRollingCapAndMaybeNotify(ctx, options) {
|
|
|
222
212
|
profile: options.profile,
|
|
223
213
|
});
|
|
224
214
|
if (result.state === "soft-hit") {
|
|
225
|
-
if (!ctx.user?.tenantId) {
|
|
226
|
-
throw new Error("cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
|
|
227
|
-
}
|
|
228
215
|
await options.notify({
|
|
229
216
|
capName: options.capName,
|
|
230
217
|
value: result.value,
|
|
231
218
|
limit: options.limit,
|
|
232
|
-
tenantId: ctx.user.tenantId,
|
|
219
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
233
220
|
});
|
|
234
221
|
}
|
|
235
222
|
return result;
|
|
@@ -4,6 +4,7 @@ export declare const capCounterEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
6
|
readonly minLength?: number;
|
|
7
|
+
readonly autoComplete?: string;
|
|
7
8
|
readonly required?: boolean;
|
|
8
9
|
readonly searchable?: boolean;
|
|
9
10
|
readonly sortable?: boolean;
|
|
@@ -94,7 +94,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
94
94
|
id: CAP_COUNTER_LIST_SCREEN_ID,
|
|
95
95
|
type: "entityList",
|
|
96
96
|
entity: "cap-counter",
|
|
97
|
-
columns: [
|
|
97
|
+
columns: [
|
|
98
|
+
"tenantId",
|
|
99
|
+
"capName",
|
|
100
|
+
"value",
|
|
101
|
+
"periodStart",
|
|
102
|
+
{ field: "lastSoftWarnedAt", hideOnNarrow: true },
|
|
103
|
+
],
|
|
98
104
|
defaultSort: { field: "capName", dir: "asc" },
|
|
99
105
|
// Search resolves against the caller's own tenant stream, so on this
|
|
100
106
|
// cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
2
2
|
export { type BookCapUsageOptions, bookCapUsage, type MarkCapSoftWarnedOptions, markCapSoftWarned, type ReadRollingCapUsageOptions, readRollingCapUsage, } from "./book-cap-usage.js";
|
|
3
|
+
export { type CalendarCapUsageReservation, type CalendarCapUsageReservationSpec, reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
|
|
3
4
|
export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
|
|
4
5
|
export { CAP_TOLERANCES, CapExceededError, type CapToleranceProfile, type CapToleranceProfileName, currentCalendarMonthStartIso, type EnforceCapResult, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, type SoftHitNotifier, type StockCapResult, } from "./enforce-cap.js";
|
|
5
6
|
export { capCounterEntity } from "./entity.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public API of the cap-counter bundled-feature.
|
|
2
2
|
export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
3
3
|
export { bookCapUsage, markCapSoftWarned, readRollingCapUsage, } from "./book-cap-usage.js";
|
|
4
|
+
export { reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
|
|
4
5
|
export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
|
|
5
6
|
export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
|
|
6
7
|
export { capCounterEntity } from "./entity.js";
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
//
|
|
24
24
|
// No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
|
|
25
25
|
// (enforce-cap.ts).
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
|
|
27
|
+
import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
|
|
28
|
+
import { assertBelowHardCap, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
|
|
28
29
|
import { chainReservations } from "./reservation-chain.js";
|
|
29
30
|
/**
|
|
30
31
|
* Wrap a write-handler with calendar-period cap-enforcement.
|
|
@@ -41,23 +42,7 @@ export function withCapEnforcement(handler, capResolver) {
|
|
|
41
42
|
...handler,
|
|
42
43
|
reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
|
|
43
44
|
const cap = await capResolver(event, ctx);
|
|
44
|
-
|
|
45
|
-
await enforceCapAndMaybeNotify(ctx, {
|
|
46
|
-
capName: cap.capName,
|
|
47
|
-
periodStartIso: cap.periodStartIso,
|
|
48
|
-
limit: cap.limit,
|
|
49
|
-
profile: cap.profile,
|
|
50
|
-
notify: cap.notify,
|
|
51
|
-
markSoftWarnedOutsideTransaction: true,
|
|
52
|
-
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
53
|
-
});
|
|
54
|
-
const amount = cap.amount ?? 1;
|
|
55
|
-
return reserveCalendarCap(ctx, {
|
|
56
|
-
capName: cap.capName,
|
|
57
|
-
amount,
|
|
58
|
-
periodStartIso: cap.periodStartIso,
|
|
59
|
-
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
|
|
60
|
-
});
|
|
45
|
+
return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
|
|
61
46
|
}),
|
|
62
47
|
};
|
|
63
48
|
}
|
|
@@ -15,10 +15,12 @@ export function createTenantCapListScreen(caps, listCaps, tiers) {
|
|
|
15
15
|
{ field: "name", label: "cap-overview.list.col.name" },
|
|
16
16
|
{ field: "tier", label: "cap-overview.list.col.tier" },
|
|
17
17
|
{ field: "billing", label: "cap-overview.list.col.billing" },
|
|
18
|
-
|
|
18
|
+
// Name is the title; tier, billing and the first cap fill the card's three meta rows.
|
|
19
|
+
...listedCaps.map((cap, index) => ({
|
|
19
20
|
field: capFieldName(cap.id),
|
|
20
21
|
label: cap.label,
|
|
21
22
|
renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
|
|
23
|
+
...(index > 0 && { hideOnNarrow: true }),
|
|
22
24
|
})),
|
|
23
25
|
],
|
|
24
26
|
searchable: true,
|
package/dist/config/table.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
6
|
readonly minLength?: number;
|
|
7
|
+
readonly autoComplete?: string;
|
|
7
8
|
readonly required?: boolean;
|
|
8
9
|
readonly searchable?: boolean;
|
|
9
10
|
readonly sortable?: boolean;
|
|
@@ -26,6 +27,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
26
27
|
readonly description?: string;
|
|
27
28
|
readonly maxLength?: number;
|
|
28
29
|
readonly minLength?: number;
|
|
30
|
+
readonly autoComplete?: string;
|
|
29
31
|
readonly required?: boolean;
|
|
30
32
|
readonly searchable?: boolean;
|
|
31
33
|
readonly sortable?: boolean;
|
|
@@ -48,6 +50,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
48
50
|
readonly description?: string;
|
|
49
51
|
readonly maxLength?: number;
|
|
50
52
|
readonly minLength?: number;
|
|
53
|
+
readonly autoComplete?: string;
|
|
51
54
|
readonly required?: boolean;
|
|
52
55
|
readonly searchable?: boolean;
|
|
53
56
|
readonly sortable?: boolean;
|
|
@@ -72,6 +75,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
|
|
|
72
75
|
readonly description?: string;
|
|
73
76
|
readonly maxLength?: number;
|
|
74
77
|
readonly minLength?: number;
|
|
78
|
+
readonly autoComplete?: string;
|
|
75
79
|
readonly required?: boolean;
|
|
76
80
|
readonly searchable?: boolean;
|
|
77
81
|
readonly sortable?: boolean;
|
|
@@ -94,6 +98,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
|
|
|
94
98
|
readonly description?: string;
|
|
95
99
|
readonly maxLength?: number;
|
|
96
100
|
readonly minLength?: number;
|
|
101
|
+
readonly autoComplete?: string;
|
|
97
102
|
readonly required?: boolean;
|
|
98
103
|
readonly searchable?: boolean;
|
|
99
104
|
readonly sortable?: boolean;
|
|
@@ -116,6 +121,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
|
|
|
116
121
|
readonly description?: string;
|
|
117
122
|
readonly maxLength?: number;
|
|
118
123
|
readonly minLength?: number;
|
|
124
|
+
readonly autoComplete?: string;
|
|
119
125
|
readonly required?: boolean;
|
|
120
126
|
readonly searchable?: boolean;
|
|
121
127
|
readonly sortable?: boolean;
|
|
@@ -10,6 +10,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
|
|
|
10
10
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
11
|
import * as z from "zod";
|
|
12
12
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
13
|
+
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
13
14
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
14
15
|
import { USER_STATUS } from "../../user/index.js";
|
|
15
16
|
import { denyIfTargetOutsideAdminTenant, isSystemAdminActor, updateUserLifecycle, } from "../../user-data-rights/index.js";
|
|
@@ -213,7 +214,7 @@ function sweepErrorLabel(err) {
|
|
|
213
214
|
// The key is already erased when this runs, so a failing sweep must not skip the
|
|
214
215
|
// audit event: the failure is returned as a label for the event instead of thrown.
|
|
215
216
|
// The savepoint keeps a database error from aborting the handler transaction.
|
|
216
|
-
async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner) {
|
|
217
|
+
async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner, audit) {
|
|
217
218
|
try {
|
|
218
219
|
await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
|
|
219
220
|
// Blind-index sweep (#818): nulls bidx columns now so the deterministic
|
|
@@ -224,6 +225,15 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
|
|
|
224
225
|
if (ctx.searchAdapter) {
|
|
225
226
|
await purgeSearchDocumentsForSubject(crossTenantSubjectRunner, ctx.registry.features, ctx.searchAdapter, subjectKey, subject);
|
|
226
227
|
}
|
|
228
|
+
// Note bodies are keyed per note, so the subject's own key never reaches them.
|
|
229
|
+
if (ctx.registry.features.has("notes-history")) {
|
|
230
|
+
await eraseNoteBodiesReachingSubject({
|
|
231
|
+
db: crossTenantSubjectRunner,
|
|
232
|
+
subject,
|
|
233
|
+
erasedBy: audit.erasedBy,
|
|
234
|
+
requestId: audit.requestId,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
227
237
|
});
|
|
228
238
|
return undefined;
|
|
229
239
|
}
|
|
@@ -335,12 +345,13 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
335
345
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
336
346
|
// reverse order could leave an audit trail claiming a shred that never
|
|
337
347
|
// happened.
|
|
348
|
+
const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
|
|
338
349
|
await kms.eraseKey(subject, {
|
|
339
|
-
requestId
|
|
350
|
+
requestId,
|
|
340
351
|
userId: event.user.id,
|
|
341
352
|
eraseReason: event.payload.reason,
|
|
342
353
|
});
|
|
343
|
-
const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw());
|
|
354
|
+
const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw(), { requestId, erasedBy: event.user.id });
|
|
344
355
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
345
356
|
// ciphertext unreadable, but status + PATs are standalone credentials —
|
|
346
357
|
// the PAT resolver only checks revokedAt/expiresAt, NOT user.status
|
|
@@ -4,6 +4,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
6
|
readonly minLength?: number;
|
|
7
|
+
readonly autoComplete?: string;
|
|
7
8
|
readonly required?: boolean;
|
|
8
9
|
readonly searchable?: boolean;
|
|
9
10
|
readonly sortable?: boolean;
|
|
@@ -26,6 +27,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
26
27
|
readonly description?: string;
|
|
27
28
|
readonly maxLength?: number;
|
|
28
29
|
readonly minLength?: number;
|
|
30
|
+
readonly autoComplete?: string;
|
|
29
31
|
readonly required?: boolean;
|
|
30
32
|
readonly searchable?: boolean;
|
|
31
33
|
readonly sortable?: boolean;
|
|
@@ -48,6 +50,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
48
50
|
readonly description?: string;
|
|
49
51
|
readonly maxLength?: number;
|
|
50
52
|
readonly minLength?: number;
|
|
53
|
+
readonly autoComplete?: string;
|
|
51
54
|
readonly required?: boolean;
|
|
52
55
|
readonly searchable?: boolean;
|
|
53
56
|
readonly sortable?: boolean;
|
|
@@ -95,6 +98,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
95
98
|
readonly description?: string;
|
|
96
99
|
readonly maxLength?: number;
|
|
97
100
|
readonly minLength?: number;
|
|
101
|
+
readonly autoComplete?: string;
|
|
98
102
|
readonly required?: boolean;
|
|
99
103
|
readonly searchable?: boolean;
|
|
100
104
|
readonly sortable?: boolean;
|
|
@@ -4,6 +4,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
|
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
6
|
readonly minLength?: number;
|
|
7
|
+
readonly autoComplete?: string;
|
|
7
8
|
readonly required?: boolean;
|
|
8
9
|
readonly searchable?: boolean;
|
|
9
10
|
readonly sortable?: boolean;
|
|
@@ -42,6 +43,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
|
|
|
42
43
|
readonly description?: string;
|
|
43
44
|
readonly maxLength?: number;
|
|
44
45
|
readonly minLength?: number;
|
|
46
|
+
readonly autoComplete?: string;
|
|
45
47
|
readonly required?: boolean;
|
|
46
48
|
readonly searchable?: boolean;
|
|
47
49
|
readonly sortable?: boolean;
|
|
@@ -66,6 +68,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
|
|
|
66
68
|
readonly description?: string;
|
|
67
69
|
readonly maxLength?: number;
|
|
68
70
|
readonly minLength?: number;
|
|
71
|
+
readonly autoComplete?: string;
|
|
69
72
|
readonly required?: boolean;
|
|
70
73
|
readonly searchable?: boolean;
|
|
71
74
|
readonly sortable?: boolean;
|
|
@@ -104,6 +107,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
|
|
|
104
107
|
readonly description?: string;
|
|
105
108
|
readonly maxLength?: number;
|
|
106
109
|
readonly minLength?: number;
|
|
110
|
+
readonly autoComplete?: string;
|
|
107
111
|
readonly required?: boolean;
|
|
108
112
|
readonly searchable?: boolean;
|
|
109
113
|
readonly sortable?: boolean;
|
|
@@ -17,7 +17,7 @@ export const chatConnectionNameSchema = z
|
|
|
17
17
|
function policyFor(request, target) {
|
|
18
18
|
return request.requireHttps
|
|
19
19
|
? { kind: "external" }
|
|
20
|
-
: { kind: "internal",
|
|
20
|
+
: { kind: "internal", allowOrigins: [target.origin] };
|
|
21
21
|
}
|
|
22
22
|
function hostMatches(hostname, allowedHost) {
|
|
23
23
|
const allowed = allowedHost.toLowerCase();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
export declare const DELIVERY_CHANNEL_EXTENSION: "deliveryChannel";
|
|
3
3
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, type DeliveryStatusValue, } from "./public-names.js";
|
|
4
|
+
export declare function deliveryIdempotencyRedisKey(tenantId: string, key: string): string;
|
|
4
5
|
export declare const deliveryPriorityRank: Record<NotifyPriority, number>;
|
|
5
6
|
export declare const DeliveryJobs: {
|
|
6
7
|
readonly render: string;
|
|
@@ -4,6 +4,10 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
|
|
|
4
4
|
// channel-in-app, channel-push, ...).
|
|
5
5
|
export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel";
|
|
6
6
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, } from "./public-names.js";
|
|
7
|
+
// Single source for claim (delivery-service) and terminal-failure release (jobs).
|
|
8
|
+
export function deliveryIdempotencyRedisKey(tenantId, key) {
|
|
9
|
+
return `delivery:idem:${tenantId}:${key}`;
|
|
10
|
+
}
|
|
7
11
|
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
8
12
|
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
9
13
|
// FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
|
|
@@ -8,7 +8,7 @@ import { redactErrorText } from "../shared/index.js";
|
|
|
8
8
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
9
9
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
10
10
|
import { buildChannelContext } from "./channel-context.js";
|
|
11
|
-
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
11
|
+
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
|
|
12
12
|
import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
|
|
13
13
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
14
14
|
import { isDeliveryChannelPlugin, } from "./types.js";
|
|
@@ -74,10 +74,28 @@ export function createDeliveryService(options) {
|
|
|
74
74
|
if (!idemRedis) {
|
|
75
75
|
throw new Error("Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured");
|
|
76
76
|
}
|
|
77
|
-
const
|
|
78
|
-
const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
|
|
77
|
+
const res = await idemRedis.set(deliveryIdempotencyRedisKey(tenantId, key), "1", "EX", ttlSec, "NX");
|
|
79
78
|
return res === "OK";
|
|
80
79
|
}
|
|
80
|
+
function isDeliveredOrQueued(delivery) {
|
|
81
|
+
return delivery.status === "sent" || delivery.status === "queued";
|
|
82
|
+
}
|
|
83
|
+
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
84
|
+
// Partial success keeps it: a retry would re-send to those who got it.
|
|
85
|
+
async function releaseIdempotencyIfNothingDelivered(tenantId, key, run) {
|
|
86
|
+
// skip: no key claimed, or a recipient already got the notification
|
|
87
|
+
if (!key || run.deliveries.some(isDeliveredOrQueued))
|
|
88
|
+
return;
|
|
89
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
90
|
+
}
|
|
91
|
+
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
92
|
+
// without risking a duplicate send on another channel.
|
|
93
|
+
function jobIdempotencyKeyFor(idempotencyKey, route) {
|
|
94
|
+
if (!idempotencyKey || !route)
|
|
95
|
+
return undefined;
|
|
96
|
+
const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
|
|
97
|
+
return routedChannelCount === 1 ? idempotencyKey : undefined;
|
|
98
|
+
}
|
|
81
99
|
async function resolveUserIdsForTenant(tenantId) {
|
|
82
100
|
if (!tenantUserIdsQuery) {
|
|
83
101
|
throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
|
|
@@ -191,6 +209,7 @@ export function createDeliveryService(options) {
|
|
|
191
209
|
deliveryAttemptId,
|
|
192
210
|
priority,
|
|
193
211
|
message,
|
|
212
|
+
...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
|
|
194
213
|
}, { priority: deliveryPriorityRank[priority] });
|
|
195
214
|
recordDelivery(run, queuedEntry, deliveryAttemptId);
|
|
196
215
|
}
|
|
@@ -517,14 +536,42 @@ export function createDeliveryService(options) {
|
|
|
517
536
|
}
|
|
518
537
|
}
|
|
519
538
|
}
|
|
539
|
+
async function dispatchNotification(notificationType, options, tenantId, priority, run) {
|
|
540
|
+
const { to, route, data } = options;
|
|
541
|
+
if (route) {
|
|
542
|
+
await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null, run);
|
|
543
|
+
// skip: explicit route is delivered directly, no recipient resolution needed
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (to !== undefined) {
|
|
547
|
+
let userIds;
|
|
548
|
+
if (typeof to === "string") {
|
|
549
|
+
userIds = [to];
|
|
550
|
+
}
|
|
551
|
+
else if ("tenant" in to) {
|
|
552
|
+
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
userIds = to;
|
|
556
|
+
}
|
|
557
|
+
const prefetchedAddresses = priority === "critical"
|
|
558
|
+
? new Map()
|
|
559
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
560
|
+
const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
|
|
561
|
+
for (const userId of userIds) {
|
|
562
|
+
await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
520
566
|
return {
|
|
521
567
|
async notify(notificationType, options, _user, tenantId, jobDispatcher) {
|
|
522
|
-
const {
|
|
568
|
+
const { route, idempotencyKey } = options;
|
|
523
569
|
const priority = options.priority ?? "normal";
|
|
524
570
|
const run = {
|
|
525
571
|
deliveries: [],
|
|
526
572
|
jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
|
|
527
573
|
locale: options.locale,
|
|
574
|
+
jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
|
|
528
575
|
};
|
|
529
576
|
if (idempotencyKey) {
|
|
530
577
|
const first = await claimIdempotency(tenantId, idempotencyKey);
|
|
@@ -542,29 +589,19 @@ export function createDeliveryService(options) {
|
|
|
542
589
|
return { deliveries: run.deliveries };
|
|
543
590
|
}
|
|
544
591
|
}
|
|
545
|
-
|
|
546
|
-
await
|
|
547
|
-
return { deliveries: run.deliveries };
|
|
592
|
+
try {
|
|
593
|
+
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
548
594
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
else {
|
|
558
|
-
userIds = to;
|
|
559
|
-
}
|
|
560
|
-
const prefetchedAddresses = priority === "critical"
|
|
561
|
-
? new Map()
|
|
562
|
-
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
563
|
-
const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
|
|
564
|
-
for (const userId of userIds) {
|
|
565
|
-
await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
|
|
566
|
-
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
// A failing release must not replace the dispatch error the caller retries on.
|
|
597
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch((releaseErr) => {
|
|
598
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
599
|
+
notificationType,
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
throw err;
|
|
567
603
|
}
|
|
604
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
568
605
|
return { deliveries: run.deliveries };
|
|
569
606
|
},
|
|
570
607
|
};
|
package/dist/delivery/feature.js
CHANGED
|
@@ -179,15 +179,21 @@ export function createDeliveryFeature(options) {
|
|
|
179
179
|
label: i18nKey("delivery.log.col.channel"),
|
|
180
180
|
renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
|
|
181
181
|
},
|
|
182
|
-
{
|
|
182
|
+
{
|
|
183
|
+
field: "recipientLabel",
|
|
184
|
+
label: i18nKey("delivery.log.col.recipient"),
|
|
185
|
+
hideOnNarrow: true,
|
|
186
|
+
},
|
|
183
187
|
{
|
|
184
188
|
field: "status",
|
|
185
189
|
label: i18nKey("delivery.log.col.status"),
|
|
190
|
+
hideOnNarrow: true,
|
|
186
191
|
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
187
192
|
},
|
|
188
193
|
{
|
|
189
194
|
field: "error",
|
|
190
195
|
label: i18nKey("delivery.log.col.error"),
|
|
196
|
+
hideOnNarrow: true,
|
|
191
197
|
renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
|
|
192
198
|
},
|
|
193
199
|
],
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -8,7 +8,7 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { appendAttemptEvent } from "./attempt-log.js";
|
|
10
10
|
import { buildChannelContext } from "./channel-context.js";
|
|
11
|
-
import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
11
|
+
import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
|
|
12
12
|
import { collectChannels, redactedMessageOf } from "./delivery-service.js";
|
|
13
13
|
const channelMessageSchema = z.object({
|
|
14
14
|
notificationType: z.string(),
|
|
@@ -26,6 +26,7 @@ const renderJobPayloadSchema = z.object({
|
|
|
26
26
|
deliveryAttemptId: z.string(),
|
|
27
27
|
priority: z.enum(["critical", "normal", "low"]),
|
|
28
28
|
message: channelMessageSchema,
|
|
29
|
+
idempotencyKey: z.string().optional(),
|
|
29
30
|
});
|
|
30
31
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
31
32
|
rendered: z
|
|
@@ -75,6 +76,28 @@ function entryFor(p, status, error, address, confirmed) {
|
|
|
75
76
|
...(confirmed === false && { confirmed }),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
79
|
+
// Only the final retry frees the key: earlier failures are retried by the runner and
|
|
80
|
+
// must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
|
|
81
|
+
// wires the same instance into both.
|
|
82
|
+
async function releaseIdempotencyOnTerminalFailure(ctx, p) {
|
|
83
|
+
// skip: nothing to release before the last attempt or without an idempotency key
|
|
84
|
+
if (!ctx.finalAttempt || !p.idempotencyKey)
|
|
85
|
+
return;
|
|
86
|
+
if (!ctx.redis) {
|
|
87
|
+
ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
|
|
88
|
+
notificationType: p.notificationType,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
|
|
97
|
+
notificationType: p.notificationType,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
78
101
|
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
79
102
|
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
80
103
|
// The redacted message goes to the log.
|
|
@@ -85,6 +108,7 @@ async function failAttempt(ctx, db, registry, p, code, err) {
|
|
|
85
108
|
channel: p.channelName,
|
|
86
109
|
});
|
|
87
110
|
await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", code, p.address));
|
|
111
|
+
await releaseIdempotencyOnTerminalFailure(ctx, p);
|
|
88
112
|
throw new Error(code);
|
|
89
113
|
}
|
|
90
114
|
// Render the message and hand off to delivery.send. On failure: record the
|