@cosmicdrift/kumiko-samples 0.351.0 → 0.353.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 +2 -2
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +6 -1
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/ledger/entity.ts +4 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/tenant/changes.json +7 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +13 -1
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -6,19 +6,20 @@
|
|
|
6
6
|
// the wrapper keeps the pattern explicit and co-located.
|
|
7
7
|
//
|
|
8
8
|
// **Calendar reservation:** the wrapper only declares the cap; the dispatcher runs the
|
|
9
|
-
// reservation (reserveBeforeTransaction)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// COMMIT
|
|
14
|
-
//
|
|
15
|
-
// the
|
|
9
|
+
// reservation (reserveBeforeTransaction) after all pre-handler gates and before the handler
|
|
10
|
+
// transaction opens, in its own short committed write (counter increment plus a
|
|
11
|
+
// store_cap_reservations row). The handler transaction deletes the row on its own commit; a release
|
|
12
|
+
// after a rollback, failure result or failed COMMIT only gives back what is still booked, so an
|
|
13
|
+
// unknown COMMIT outcome cannot under-count. No connection is held across the handler. Rows a
|
|
14
|
+
// crashed process left behind expire after CAP_RESERVATION_TTL_MINUTES and are given back before the
|
|
15
|
+
// next reserve of the same cap. Reached through a nested ctx.write the cap is not reserved, so the
|
|
16
|
+
// dispatcher rejects that call unless the top-level batch reserved the same handler.
|
|
16
17
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// The wrapper spreads the wrapped handler and chains its reserveBeforeTransaction: inner reserves
|
|
19
|
+
// first, release runs in reverse, and an outer failure gives the inner reservation back.
|
|
20
|
+
//
|
|
21
|
+
// Rolling caps reserve the same way: a version-guarded `rolling-incremented` append plus a
|
|
22
|
+
// reservation row, undone by a `rolling-released` event. The window sum is incremented minus released.
|
|
22
23
|
//
|
|
23
24
|
// No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
|
|
24
25
|
// (enforce-cap.ts).
|
|
@@ -28,9 +29,11 @@ import type {
|
|
|
28
29
|
WriteEvent,
|
|
29
30
|
WriteHandlerDef,
|
|
30
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
import {
|
|
33
|
+
releaseExpiredCapReservations,
|
|
34
|
+
reserveCalendarCap,
|
|
35
|
+
reserveRollingCap,
|
|
36
|
+
} from "./cap-reservation.js";
|
|
34
37
|
import {
|
|
35
38
|
assertBelowHardCap,
|
|
36
39
|
type CapToleranceProfileName,
|
|
@@ -38,6 +41,7 @@ import {
|
|
|
38
41
|
enforceRollingCapAndMaybeNotify,
|
|
39
42
|
type SoftHitNotifier,
|
|
40
43
|
} from "./enforce-cap.js";
|
|
44
|
+
import { chainReservations } from "./reservation-chain.js";
|
|
41
45
|
|
|
42
46
|
// =============================================================================
|
|
43
47
|
// Calendar-Period-Wrapper
|
|
@@ -85,39 +89,31 @@ export function withCapEnforcement(
|
|
|
85
89
|
): WriteHandlerDef {
|
|
86
90
|
return {
|
|
87
91
|
...handler,
|
|
88
|
-
reserveBeforeTransaction:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
capName: cap.capName,
|
|
93
|
-
periodStartIso: cap.periodStartIso,
|
|
94
|
-
limit: cap.limit,
|
|
95
|
-
profile: cap.profile,
|
|
96
|
-
notify: cap.notify,
|
|
97
|
-
markSoftWarnedOutsideTransaction: true,
|
|
98
|
-
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
99
|
-
});
|
|
92
|
+
reserveBeforeTransaction: chainReservations(
|
|
93
|
+
handler.reserveBeforeTransaction,
|
|
94
|
+
async (event, ctx) => {
|
|
95
|
+
const cap = await capResolver(event, ctx);
|
|
100
96
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
97
|
+
await releaseExpiredCapReservations(ctx, cap.capName);
|
|
98
|
+
await enforceCapAndMaybeNotify(ctx, {
|
|
99
|
+
capName: cap.capName,
|
|
100
|
+
periodStartIso: cap.periodStartIso,
|
|
101
|
+
limit: cap.limit,
|
|
102
|
+
profile: cap.profile,
|
|
103
|
+
notify: cap.notify,
|
|
104
|
+
markSoftWarnedOutsideTransaction: true,
|
|
105
|
+
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
106
|
+
});
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const amount = cap.amount ?? 1;
|
|
109
|
+
return reserveCalendarCap(ctx, {
|
|
113
110
|
capName: cap.capName,
|
|
114
111
|
amount,
|
|
115
112
|
periodStartIso: cap.periodStartIso,
|
|
116
|
-
|
|
113
|
+
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
|
|
117
114
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
},
|
|
115
|
+
},
|
|
116
|
+
),
|
|
121
117
|
};
|
|
122
118
|
}
|
|
123
119
|
|
|
@@ -142,44 +138,41 @@ export type RollingCapResolver = (
|
|
|
142
138
|
/**
|
|
143
139
|
* Wrap a write-handler with rolling-window cap-enforcement.
|
|
144
140
|
*
|
|
145
|
-
* Same flow as `withCapEnforcement` but uses
|
|
146
|
-
* `
|
|
147
|
-
* `
|
|
141
|
+
* Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
|
|
142
|
+
* appending a `rolling-incremented` event before the handler transaction; a release appends
|
|
143
|
+
* `rolling-released`.
|
|
148
144
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
|
|
152
|
-
* (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
|
|
145
|
+
* Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
|
|
146
|
+
* sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
|
|
153
147
|
*/
|
|
154
148
|
export function withRollingCapEnforcement(
|
|
155
149
|
handler: WriteHandlerDef,
|
|
156
150
|
capResolver: RollingCapResolver,
|
|
157
151
|
): WriteHandlerDef {
|
|
158
152
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await enforceRollingCapAndMaybeNotify(ctx, {
|
|
166
|
-
capName: cap.capName,
|
|
167
|
-
windowDays: cap.windowDays,
|
|
168
|
-
limit: cap.limit,
|
|
169
|
-
profile: cap.profile,
|
|
170
|
-
notify: cap.notify,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const result = await handler.handler(event, ctx);
|
|
153
|
+
...handler,
|
|
154
|
+
reserveBeforeTransaction: chainReservations(
|
|
155
|
+
handler.reserveBeforeTransaction,
|
|
156
|
+
async (event, ctx) => {
|
|
157
|
+
const cap = await capResolver(event, ctx);
|
|
174
158
|
|
|
175
|
-
|
|
176
|
-
await ctx
|
|
159
|
+
await releaseExpiredCapReservations(ctx, cap.capName);
|
|
160
|
+
await enforceRollingCapAndMaybeNotify(ctx, {
|
|
177
161
|
capName: cap.capName,
|
|
178
|
-
|
|
162
|
+
windowDays: cap.windowDays,
|
|
163
|
+
limit: cap.limit,
|
|
164
|
+
profile: cap.profile,
|
|
165
|
+
notify: cap.notify,
|
|
179
166
|
});
|
|
180
|
-
}
|
|
181
167
|
|
|
182
|
-
|
|
183
|
-
|
|
168
|
+
const amount = cap.amount ?? 1;
|
|
169
|
+
return reserveRollingCap(ctx, {
|
|
170
|
+
capName: cap.capName,
|
|
171
|
+
windowDays: cap.windowDays,
|
|
172
|
+
amount,
|
|
173
|
+
guardCurrentValue: (usage) => assertBelowHardCap(usage + amount - 1, cap),
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
),
|
|
184
177
|
};
|
|
185
178
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { access, BUILT_IN_MEMBERSHIP_ROLES } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
|
|
3
3
|
// Usage numbers are an operator concern by default; wider visibility is an
|
|
4
4
|
// explicit `usageVisibleTo` opt-in on createCapOverviewFeature. The my-caps
|
|
@@ -9,14 +9,7 @@ export const DEFAULT_CAP_USAGE_ROLES: readonly string[] = access.roles(...access
|
|
|
9
9
|
// Roles the engine itself knows (role-assignment.ts ranks). App-declared roles
|
|
10
10
|
// (assignableRole) only exist once the registry is composed, which is after
|
|
11
11
|
// this feature is defined, so they cannot be validated here.
|
|
12
|
-
const BUILT_IN_USAGE_VISIBLE_ROLES: ReadonlySet<string> = new Set(
|
|
13
|
-
"User",
|
|
14
|
-
"Member",
|
|
15
|
-
"Editor",
|
|
16
|
-
"Admin",
|
|
17
|
-
"TenantAdmin",
|
|
18
|
-
"SystemAdmin",
|
|
19
|
-
]);
|
|
12
|
+
const BUILT_IN_USAGE_VISIBLE_ROLES: ReadonlySet<string> = new Set(BUILT_IN_MEMBERSHIP_ROLES);
|
|
20
13
|
|
|
21
14
|
export function resolveCapUsageRoles(
|
|
22
15
|
usageVisibleTo: readonly string[] | undefined,
|
|
@@ -10,7 +10,7 @@ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/er
|
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { subscriptionsProjectionTable } from "../../billing-foundation/index.js";
|
|
12
12
|
import type { CapLimitContext } from "../../cap-counter/index.js";
|
|
13
|
-
import { tenantTable } from "../../tenant/index.js";
|
|
13
|
+
import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
|
|
14
14
|
import { tierAssignmentEntity } from "../../tier-engine/index.js";
|
|
15
15
|
import { capFieldName } from "../constants.js";
|
|
16
16
|
import type { CapSpec, CapUsage } from "../types.js";
|
|
@@ -174,7 +174,10 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
174
174
|
"cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
|
|
175
175
|
);
|
|
176
176
|
|
|
177
|
-
const tenants = await
|
|
177
|
+
const tenants = await decryptTenantNames(
|
|
178
|
+
await selectMany<TenantRow>(db, tenantTable, {}),
|
|
179
|
+
"cap-overview:tenant-caps:list",
|
|
180
|
+
);
|
|
178
181
|
const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
|
|
179
182
|
const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
|
|
180
183
|
|
|
@@ -2,7 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
-
import { tenantTable } from "../../tenant/index.js";
|
|
5
|
+
import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
|
|
6
6
|
|
|
7
7
|
type TenantRow = { readonly id: string; readonly name: string };
|
|
8
8
|
|
|
@@ -22,14 +22,11 @@ export const tenantOptionsQuery = defineQueryHandler({
|
|
|
22
22
|
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
23
23
|
"cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
|
|
24
24
|
);
|
|
25
|
-
const tenants = await
|
|
26
|
-
db,
|
|
27
|
-
|
|
28
|
-
{},
|
|
29
|
-
{
|
|
30
|
-
orderBy: { col: "name", direction: "asc" },
|
|
31
|
-
},
|
|
25
|
+
const tenants = await decryptTenantNames(
|
|
26
|
+
await selectMany<TenantRow>(db, tenantTable, {}),
|
|
27
|
+
"cap-overview:tenant-options",
|
|
32
28
|
);
|
|
33
|
-
|
|
29
|
+
const sorted = [...tenants].sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
return { rows: sorted.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
|
|
34
31
|
},
|
|
35
32
|
});
|
|
@@ -50,6 +50,7 @@ export function createChannelDiscordFeature(
|
|
|
50
50
|
r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
|
|
51
51
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
|
|
52
52
|
mode: channel.mode,
|
|
53
|
+
addressKind: "connection-name",
|
|
53
54
|
send: channel.send,
|
|
54
55
|
});
|
|
55
56
|
});
|
|
@@ -44,6 +44,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
|
|
|
44
44
|
r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
|
|
45
45
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
|
|
46
46
|
mode: channel.mode,
|
|
47
|
+
addressKind: "connection-name",
|
|
47
48
|
send: channel.send,
|
|
48
49
|
});
|
|
49
50
|
});
|
|
@@ -48,6 +48,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
|
|
|
48
48
|
r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
|
|
49
49
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
|
|
50
50
|
mode: channel.mode,
|
|
51
|
+
addressKind: "connection-name",
|
|
51
52
|
send: channel.send,
|
|
52
53
|
});
|
|
53
54
|
});
|
|
@@ -3,7 +3,7 @@ import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework
|
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { tenantComplianceProfileTable } from "../../compliance-profiles/index.js";
|
|
6
|
-
import { tenantTable } from "../../tenant/index.js";
|
|
6
|
+
import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
|
|
7
7
|
|
|
8
8
|
// SystemAdmin platform-wide counterpart to needs-profile (#2089).
|
|
9
9
|
//
|
|
@@ -34,9 +34,10 @@ export const tenantsMissingProfileQuery = defineQueryHandler({
|
|
|
34
34
|
"platform operator scans every tenant for a missing compliance-profile selection",
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
const tenants = await
|
|
38
|
-
isEnabled: true,
|
|
39
|
-
|
|
37
|
+
const tenants = await decryptTenantNames(
|
|
38
|
+
await db.selectMany<{ id: TenantId; name: string }>(tenantTable, { isEnabled: true }),
|
|
39
|
+
"compliance-profiles-ops:tenants-missing-profile",
|
|
40
|
+
);
|
|
40
41
|
const profileRows = await db.selectMany<{ tenantId: TenantId }>(
|
|
41
42
|
tenantComplianceProfileTable,
|
|
42
43
|
{},
|
|
@@ -13,8 +13,10 @@ import { hasConfigAccess } from "../write-helpers.js";
|
|
|
13
13
|
export const valuesQuery = defineQueryHandler({
|
|
14
14
|
name: "values",
|
|
15
15
|
description:
|
|
16
|
-
"Returns the effective value, scope and winning source for every config key the caller may read, with encrypted and secret-backed values masked; use it to inspect the current settings.",
|
|
17
|
-
schema: z.object({
|
|
16
|
+
"Returns the effective value, scope and winning source for every config key the caller may read (optionally narrowed to `keys`), with encrypted and secret-backed values masked; use it to inspect the current settings.",
|
|
17
|
+
schema: z.object({
|
|
18
|
+
keys: z.array(z.string()).optional(),
|
|
19
|
+
}),
|
|
18
20
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
19
21
|
access: {
|
|
20
22
|
openToAll: {
|
|
@@ -37,7 +39,10 @@ export const valuesQuery = defineQueryHandler({
|
|
|
37
39
|
const allKeys = registry.getAllConfigKeys();
|
|
38
40
|
const keyDefs = new Map<string, ConfigKeyDefinition>();
|
|
39
41
|
const filteredKeys: string[] = [];
|
|
42
|
+
const requestedKeys =
|
|
43
|
+
query.payload.keys === undefined ? undefined : new Set(query.payload.keys);
|
|
40
44
|
for (const [qualifiedKey, keyDef] of allKeys) {
|
|
45
|
+
if (requestedKeys !== undefined && !requestedKeys.has(qualifiedKey)) continue;
|
|
41
46
|
if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
42
47
|
keyDefs.set(qualifiedKey, keyDef);
|
|
43
48
|
filteredKeys.push(qualifiedKey);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { type PruneEventsOptions, pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
4
|
+
import { deliveryAttemptsTable } from "./tables.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_ATTEMPT_LOG_RETENTION_DAYS = 90;
|
|
7
|
+
const ROW_DELETE_BATCH_SIZE = 1000;
|
|
8
|
+
|
|
9
|
+
export type AttemptLogRetentionOptions = Pick<PruneEventsOptions, "olderThan" | "olderThanDays">;
|
|
10
|
+
|
|
11
|
+
export function resolveAttemptLogRetentionDays(
|
|
12
|
+
option: number | false | undefined,
|
|
13
|
+
): number | undefined {
|
|
14
|
+
if (option === false) return undefined;
|
|
15
|
+
const days = option ?? DEFAULT_ATTEMPT_LOG_RETENTION_DAYS;
|
|
16
|
+
if (!Number.isInteger(days) || days <= 0) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`delivery: attemptLogRetentionDays must be a positive integer or false, got ${String(option)}`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return days;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Prunes the attempt events first and then the projection rows older than the same cutoff: a
|
|
25
|
+
// rebuild would not recreate rows whose events are gone, so they would otherwise stay forever.
|
|
26
|
+
// A lagging event consumer makes pruneEvents throw ConsumerLagError before anything is deleted;
|
|
27
|
+
// the caller retries on its next run.
|
|
28
|
+
export async function runAttemptLogRetention(
|
|
29
|
+
db: DbConnection,
|
|
30
|
+
options: AttemptLogRetentionOptions,
|
|
31
|
+
): Promise<{ readonly deletedEvents: number; readonly deletedRows: number }> {
|
|
32
|
+
const pruned = await pruneEvents(db, { ...options, aggregateTypes: ["deliveryAttempt"] });
|
|
33
|
+
const rows = await deleteManyBatched(
|
|
34
|
+
db,
|
|
35
|
+
deliveryAttemptsTable,
|
|
36
|
+
{ createdAt: { lt: pruned.cutoff } },
|
|
37
|
+
{ limit: ROW_DELETE_BATCH_SIZE },
|
|
38
|
+
);
|
|
39
|
+
return { deletedEvents: pruned.deletedCount, deletedRows: rows.deleted };
|
|
40
|
+
}
|
|
@@ -8,9 +8,28 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
8
8
|
import { append, getStreamVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
9
|
import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
10
10
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
|
-
import { DELIVERY_ATTEMPT_EVENT } from "./constants.js";
|
|
11
|
+
import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
12
12
|
import { deliveryAttemptSchema } from "./events.js";
|
|
13
|
-
import
|
|
13
|
+
import { maskRecipientAddress } from "./mask-recipient-address.js";
|
|
14
|
+
import { type DeliveryLogEntry, isDeliveryChannelPlugin } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// Unknown channels count as personal: masking is the fail-closed default.
|
|
17
|
+
function addressIsConnectionName(registry: Registry, channelName: string): boolean {
|
|
18
|
+
return registry
|
|
19
|
+
.getExtensionUsages(DELIVERY_CHANNEL_EXTENSION)
|
|
20
|
+
.some(
|
|
21
|
+
(usage) =>
|
|
22
|
+
usage.entityName === channelName &&
|
|
23
|
+
isDeliveryChannelPlugin(usage.options) &&
|
|
24
|
+
usage.options.addressKind === "connection-name",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loggedAddress(registry: Registry, entry: DeliveryLogEntry): string | null {
|
|
29
|
+
return addressIsConnectionName(registry, entry.channel)
|
|
30
|
+
? entry.recipientAddress
|
|
31
|
+
: maskRecipientAddress(entry.recipientAddress);
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
// Shared append + inline-projection write (low-level append() does not
|
|
16
35
|
// auto-fire projections — only the dispatcher/executor paths do).
|
|
@@ -22,10 +41,14 @@ async function writeAttemptEvent(
|
|
|
22
41
|
entry: DeliveryLogEntry,
|
|
23
42
|
): Promise<void> {
|
|
24
43
|
const { tenantId, ...rest } = entry;
|
|
44
|
+
// Masked here, the only place attempt events are written, so no event or projection row ever holds the full address.
|
|
25
45
|
// Schema-parse to match ctx.appendEvent's guarantee: a payload drift between
|
|
26
46
|
// service/job + feature-registration fails loudly here instead of landing on
|
|
27
47
|
// the events-table and crashing a consumer later.
|
|
28
|
-
const payload = deliveryAttemptSchema.parse(
|
|
48
|
+
const payload = deliveryAttemptSchema.parse({
|
|
49
|
+
...rest,
|
|
50
|
+
recipientAddress: loggedAddress(registry, entry),
|
|
51
|
+
});
|
|
29
52
|
const stored = await append(db, {
|
|
30
53
|
aggregateId: attemptId,
|
|
31
54
|
aggregateType: "deliveryAttempt",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Delivery attempt log stores masked recipient addresses; attempts older than 90 days are pruned",
|
|
6
|
+
"detail": "The log query, the export and the stored events show masked addresses, and the feature registers the daily attempt-log-retention job.",
|
|
7
|
+
"migration": "Read recipients from your own data, not from the attempt log, because it only shows masked addresses now. Set `attemptLogRetentionDays` on createDeliveryFeature to another number of days, or to `false` to keep the log, if 90 days does not fit. Schedule nothing extra, the feature registers the cron job itself. Existing full-address entries are removed by the retention job once they are older than the window, or erased when their user is forgotten."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.352.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Forgetting a user erases the recipient address of their delivery attempts in plaintext mode too, through an event that survives projection rebuilds"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.343.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -7,6 +7,7 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
|
|
|
7
7
|
export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
|
+
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
10
11
|
DELIVERY_ATTEMPT_EVENT,
|
|
11
12
|
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
12
13
|
DELIVERY_ERROR_CELL_COMPONENT,
|
|
@@ -36,3 +36,6 @@ export const deliveryAttemptSchema = z.object({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
|
|
39
|
+
|
|
40
|
+
// No payload: the event only marks the attempt row's address as erased.
|
|
41
|
+
export const deliveryAttemptAddressErasedSchema = z.object({});
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
4
5
|
defineFeature,
|
|
5
6
|
type FeatureDefinition,
|
|
6
7
|
i18nKey,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
8
10
|
import type * as z from "zod";
|
|
11
|
+
import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.js";
|
|
9
12
|
import {
|
|
13
|
+
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
10
14
|
DELIVERY_ATTEMPT_EVENT,
|
|
11
15
|
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
12
16
|
DELIVERY_CHANNEL_EXTENSION,
|
|
@@ -18,7 +22,7 @@ import {
|
|
|
18
22
|
DeliveryJobNames,
|
|
19
23
|
DeliveryQueries,
|
|
20
24
|
} from "./constants.js";
|
|
21
|
-
import { deliveryAttemptSchema } from "./events.js";
|
|
25
|
+
import { deliveryAttemptAddressErasedSchema, deliveryAttemptSchema } from "./events.js";
|
|
22
26
|
import { logQuery } from "./handlers/log.query.js";
|
|
23
27
|
import { preferencesQuery } from "./handlers/preferences.query.js";
|
|
24
28
|
import { resubscribeAddressWrite } from "./handlers/resubscribe-address.write.js";
|
|
@@ -44,10 +48,17 @@ export type DeliveryFeatureOptions = {
|
|
|
44
48
|
// who in a line-of-business app is the operator, not the platform — pass
|
|
45
49
|
// access.systemAdmin to keep this platform-only and out of their nav.
|
|
46
50
|
readonly access?: readonly string[];
|
|
51
|
+
// Attempt events and log rows older than this many days are pruned by a daily job. Default 90;
|
|
52
|
+
// `false` keeps the log forever.
|
|
53
|
+
readonly attemptLogRetentionDays?: number | false;
|
|
47
54
|
};
|
|
48
55
|
|
|
56
|
+
const ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON =
|
|
57
|
+
"prunes delivery attempt events and log rows of every tenant";
|
|
58
|
+
|
|
49
59
|
export function createDeliveryFeature(options?: DeliveryFeatureOptions): FeatureDefinition {
|
|
50
60
|
const resolvedAccess = options?.access ?? access.admin;
|
|
61
|
+
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
51
62
|
return defineFeature("delivery", (r) => {
|
|
52
63
|
r.describe(
|
|
53
64
|
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.",
|
|
@@ -92,6 +103,12 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
92
103
|
},
|
|
93
104
|
});
|
|
94
105
|
|
|
106
|
+
// Written when the recipient is forgotten: clears the address on the projection row and, being
|
|
107
|
+
// an event, survives a projection rebuild (a plain UPDATE would be undone by the replay).
|
|
108
|
+
r.defineEvent("attempt-address-erased", deliveryAttemptAddressErasedSchema, {
|
|
109
|
+
piiFields: "none",
|
|
110
|
+
});
|
|
111
|
+
|
|
95
112
|
// Inline projection that materialises every delivery attempt into
|
|
96
113
|
// deliveryAttemptsTable. Runs in the SAME transaction as the low-level
|
|
97
114
|
// append(), so callers see their write immediately — no dispatcher
|
|
@@ -132,6 +149,14 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
132
149
|
},
|
|
133
150
|
);
|
|
134
151
|
},
|
|
152
|
+
[DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT]: async (event, tx) => {
|
|
153
|
+
await updateMany(
|
|
154
|
+
tx,
|
|
155
|
+
deliveryAttemptsTable,
|
|
156
|
+
{ recipientAddress: null },
|
|
157
|
+
{ id: event.aggregateId },
|
|
158
|
+
);
|
|
159
|
+
},
|
|
135
160
|
},
|
|
136
161
|
});
|
|
137
162
|
|
|
@@ -160,6 +185,29 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
160
185
|
deliverySendJob,
|
|
161
186
|
);
|
|
162
187
|
|
|
188
|
+
if (attemptLogRetentionDays !== undefined) {
|
|
189
|
+
r.job({
|
|
190
|
+
name: DeliveryJobNames.attemptLogRetention,
|
|
191
|
+
trigger: { cron: "15 3 * * *" },
|
|
192
|
+
concurrency: "skip",
|
|
193
|
+
escapeHatch: { reason: ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON },
|
|
194
|
+
handler: async (_payload, ctx) => {
|
|
195
|
+
const db = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
196
|
+
try {
|
|
197
|
+
const pruned = await runAttemptLogRetention(db, {
|
|
198
|
+
olderThanDays: attemptLogRetentionDays,
|
|
199
|
+
});
|
|
200
|
+
ctx.log.info("delivery attempt log pruned", pruned);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!(error instanceof ConsumerLagError)) throw error;
|
|
203
|
+
ctx.log.warn("delivery attempt log retention postponed: an event consumer lags", {
|
|
204
|
+
consumer: error.laggingConsumer,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
const handlers = {
|
|
164
212
|
setPreference: r.writeHandler(setPreferenceWrite),
|
|
165
213
|
unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
2
|
+
export {
|
|
3
|
+
type AttemptLogRetentionOptions,
|
|
4
|
+
DEFAULT_ATTEMPT_LOG_RETENTION_DAYS,
|
|
5
|
+
runAttemptLogRetention,
|
|
6
|
+
} from "./attempt-log-retention.js";
|
|
2
7
|
export {
|
|
3
8
|
type ChatWebhookChannelOptions,
|
|
4
9
|
chatMessageText,
|
|
@@ -21,6 +26,7 @@ export {
|
|
|
21
26
|
} from "./chat-webhook-sender.js";
|
|
22
27
|
export type { DeliveryStatusValue } from "./constants.js";
|
|
23
28
|
export {
|
|
29
|
+
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
24
30
|
DELIVERY_CHANNEL_EXTENSION,
|
|
25
31
|
DELIVERY_FEATURE,
|
|
26
32
|
DELIVERY_LOG_SCREEN_ID,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
|
|
3
|
+
const MASK = "***";
|
|
4
|
+
const VISIBLE_TAIL_LENGTH = 4;
|
|
5
|
+
const MIN_LENGTH_FOR_VISIBLE_TAIL = 8;
|
|
6
|
+
|
|
7
|
+
function maskedUrl(address: string): string | undefined {
|
|
8
|
+
if (!URL.canParse(address)) return undefined;
|
|
9
|
+
const url = new URL(address);
|
|
10
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
11
|
+
return `${url.origin}/${MASK}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function maskedEmail(address: string): string | undefined {
|
|
15
|
+
const parts = address.split("@");
|
|
16
|
+
const [local, domain] = parts;
|
|
17
|
+
if (parts.length !== 2 || !local || domain === undefined) return undefined;
|
|
18
|
+
return `${local.charAt(0)}${MASK}@${domain}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The attempt log is an audit trail, not a contact list: it keeps enough of the address to
|
|
22
|
+
// recognise where a message went, never the full value. URLs go first because webhook URLs can
|
|
23
|
+
// contain an "@" and their path carries the secret.
|
|
24
|
+
export function maskRecipientAddress(address: string | null): string | null {
|
|
25
|
+
if (address === null) return null;
|
|
26
|
+
return maskPlainAddress(address);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function maskPlainAddress(address: string): string {
|
|
30
|
+
// isPiiCiphertext is a type predicate; its false branch would narrow `string` to `never`.
|
|
31
|
+
if (isPiiCiphertext(address)) return MASK;
|
|
32
|
+
return maskStructuredOrTail(address);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function maskStructuredOrTail(address: string): string {
|
|
36
|
+
const structured = maskedUrl(address) ?? maskedEmail(address);
|
|
37
|
+
if (structured !== undefined) return structured;
|
|
38
|
+
if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL) return MASK;
|
|
39
|
+
return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
|
|
40
|
+
}
|
|
@@ -11,7 +11,7 @@ export const DeliveryHandlers = {
|
|
|
11
11
|
resubscribeUser: "delivery:write:resubscribe-user",
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// Stable path: links signed against it keep working. The unsubscribe route is
|
|
15
15
|
// mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
|
|
16
16
|
export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
|
|
17
17
|
|
|
@@ -61,4 +61,9 @@ export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
|
61
61
|
export const DeliveryJobNames = {
|
|
62
62
|
render: "render",
|
|
63
63
|
send: "send",
|
|
64
|
+
attemptLogRetention: "attempt-log-retention",
|
|
64
65
|
} as const;
|
|
66
|
+
|
|
67
|
+
// Clears recipientAddress on an attempt row after its recipient was forgotten.
|
|
68
|
+
export const DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT =
|
|
69
|
+
"delivery:event:attempt-address-erased" as const;
|