@cosmicdrift/kumiko-samples 0.355.0 → 0.357.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/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
- package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
- package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +18 -24
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +16 -1
- package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
- package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
- package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
- package/packages/bundled-features/src/delivery/constants.ts +5 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
- package/packages/bundled-features/src/delivery/feature.ts +7 -1
- package/packages/bundled-features/src/delivery/jobs.ts +27 -1
- package/packages/bundled-features/src/delivery/testing.ts +1 -0
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +8 -25
- package/packages/bundled-features/src/notes-history/entity.ts +7 -6
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +109 -62
- package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
- package/packages/bundled-features/src/sessions/screens.ts +2 -0
- package/packages/bundled-features/src/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -4
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -7
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -4
- package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +6 -3
- package/packages/bundled-features/src/tenant/feature.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
- package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
- package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
- package/packages/bundled-features/src/tenant/screens.ts +9 -6
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
- package/packages/bundled-features/src/user/screens.ts +8 -1
- package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
- package/packages/bundled-features/src/waitlist/screens.ts +8 -1
- package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
- package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
|
|
6
|
+
"detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
28
28
|
"mfa.enable.reveal.qr": { en: "Scan this QR code" },
|
|
29
29
|
"mfa.enable.reveal.secret": { en: "Or enter this code manually" },
|
|
30
30
|
"mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
|
|
31
|
+
"mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
|
|
31
32
|
|
|
32
33
|
"mfa.enable.confirm.submit": { en: "Enable" },
|
|
33
34
|
"mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
|
|
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
34
34
|
reveal: {
|
|
35
35
|
title: i18nKey("mfa.enable.reveal.title"),
|
|
36
36
|
warning: i18nKey("mfa.enable.reveal.warning"),
|
|
37
|
+
acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
|
|
37
38
|
fields: [
|
|
38
39
|
{
|
|
39
40
|
field: "otpauthUri",
|
|
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
58
59
|
confirm: {
|
|
59
60
|
handler: AuthMfaHandlers.enableConfirm,
|
|
60
61
|
fields: {
|
|
61
|
-
code: {
|
|
62
|
+
code: {
|
|
63
|
+
type: "text",
|
|
64
|
+
required: true,
|
|
65
|
+
minLength: 6,
|
|
66
|
+
maxLength: 6,
|
|
67
|
+
autoComplete: "one-time-code",
|
|
68
|
+
},
|
|
62
69
|
},
|
|
63
70
|
layout: { sections: [{ fields: ["code"] }] },
|
|
64
71
|
carry: ["setupToken"],
|
|
@@ -119,7 +119,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
119
119
|
id: CAP_COUNTER_LIST_SCREEN_ID,
|
|
120
120
|
type: "entityList",
|
|
121
121
|
entity: "cap-counter",
|
|
122
|
-
columns: [
|
|
122
|
+
columns: [
|
|
123
|
+
"tenantId",
|
|
124
|
+
"capName",
|
|
125
|
+
"value",
|
|
126
|
+
"periodStart",
|
|
127
|
+
{ field: "lastSoftWarnedAt", hideOnNarrow: true },
|
|
128
|
+
],
|
|
123
129
|
defaultSort: { field: "capName", dir: "asc" },
|
|
124
130
|
// Search resolves against the caller's own tenant stream, so on this
|
|
125
131
|
// cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
|
|
@@ -31,10 +31,12 @@ export function createTenantCapListScreen(
|
|
|
31
31
|
{ field: "name", label: "cap-overview.list.col.name" },
|
|
32
32
|
{ field: "tier", label: "cap-overview.list.col.tier" },
|
|
33
33
|
{ field: "billing", label: "cap-overview.list.col.billing" },
|
|
34
|
-
|
|
34
|
+
// Name is the title; tier, billing and the first cap fill the card's three meta rows.
|
|
35
|
+
...listedCaps.map((cap, index) => ({
|
|
35
36
|
field: capFieldName(cap.id),
|
|
36
37
|
label: cap.label,
|
|
37
38
|
renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
|
|
39
|
+
...(index > 0 && { hideOnNarrow: true }),
|
|
38
40
|
})),
|
|
39
41
|
],
|
|
40
42
|
searchable: true,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeFailure,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
13
13
|
import * as z from "zod";
|
|
14
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
14
15
|
import {
|
|
15
16
|
tenantComplianceProfileEntity,
|
|
16
17
|
tenantComplianceProfileTable,
|
|
@@ -26,24 +27,16 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
|
|
|
26
27
|
// SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
|
|
27
28
|
const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
|
|
28
29
|
|
|
29
|
-
//
|
|
30
|
+
// Upsert: the first call inserts, later calls update, so repeating the same values
|
|
31
|
+
// is idempotent apart from the extra audit events.
|
|
30
32
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
33
|
+
// A SystemAdmin may target another tenant via `tenantIdOverride` (operator setup,
|
|
34
|
+
// onboarding migrations); the dispatcher rejects the override for anyone else. The
|
|
35
|
+
// executor then writes into the target tenant's stream via streamTenantId while
|
|
36
|
+
// the operator stays the recorded actor.
|
|
34
37
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
|
|
38
|
-
// executorUser.tenantId muss = ziel-tenant sein damit der event-store-
|
|
39
|
-
// Stream-Lookup nicht miss → version_conflict gibt (Memory:
|
|
40
|
-
// feedback_event_store_tenant_consistency).
|
|
41
|
-
//
|
|
42
|
-
// Validation:
|
|
43
|
-
// - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
|
|
44
|
-
// - override (optional) muss valides JSON-Object sein
|
|
45
|
-
// - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
|
|
46
|
-
// — verhindert Tippfehler die deepMerge stillschweigend ignoriert
|
|
38
|
+
// Override top-level keys are restricted to ALLOWED_OVERRIDE_KEYS because deepMerge
|
|
39
|
+
// would silently ignore a typo.
|
|
47
40
|
export const setProfileWrite = defineWriteHandler({
|
|
48
41
|
name: "set-profile",
|
|
49
42
|
schema: z.object({
|
|
@@ -57,9 +50,8 @@ export const setProfileWrite = defineWriteHandler({
|
|
|
57
50
|
description:
|
|
58
51
|
"Sets or replaces a tenant's compliance profile key plus an optional JSON override, for onboarding or a later region change; a system admin may target a different tenant through tenantIdOverride.",
|
|
59
52
|
handler: async (event, ctx) => {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
|
|
53
|
+
const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
|
|
54
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
63
55
|
|
|
64
56
|
// Override-Validation: muss parseables JSON-Object sein UND dem
|
|
65
57
|
// ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
|
|
@@ -89,7 +81,7 @@ export const setProfileWrite = defineWriteHandler({
|
|
|
89
81
|
}
|
|
90
82
|
|
|
91
83
|
// Upsert: existierenden Eintrag suchen
|
|
92
|
-
const existing = (await fetchOne(
|
|
84
|
+
const existing = (await fetchOne(db, tenantComplianceProfileTable, {
|
|
93
85
|
tenantId: tenantId,
|
|
94
86
|
})) as { id: string; version: number } | null; // @cast-boundary db-runner
|
|
95
87
|
|
|
@@ -103,8 +95,9 @@ export const setProfileWrite = defineWriteHandler({
|
|
|
103
95
|
override: event.payload.override ?? null,
|
|
104
96
|
},
|
|
105
97
|
},
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
event.user,
|
|
99
|
+
db,
|
|
100
|
+
{ streamTenantId },
|
|
108
101
|
);
|
|
109
102
|
if (!result.isSuccess) return result;
|
|
110
103
|
return {
|
|
@@ -119,8 +112,9 @@ export const setProfileWrite = defineWriteHandler({
|
|
|
119
112
|
override: event.payload.override ?? null,
|
|
120
113
|
tenantId,
|
|
121
114
|
},
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
event.user,
|
|
116
|
+
db,
|
|
117
|
+
{ streamTenantId },
|
|
124
118
|
);
|
|
125
119
|
if (!result.isSuccess) return result;
|
|
126
120
|
return {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "forget-subject shreds note bodies attached to the subject and notes that mention it",
|
|
6
|
+
"detail": "`forget-subject` on a user or record subject now also erases the per-note keys of every `note-entry` that hangs on the subject as host entity (entityId, plus entityType for record subjects) or @-mentions it, unless the host entity's retention strategy is anonymize/blockDelete. The automated Art. 17 delete hook reaches notes on the user as host too, and the export lists them. Before, `forget-subject(contact)` left the note bodies readable. Consumer tests that expect readable note bodies after `forget-subject` on a contact (e.g. solon contact-notes-forget) now see the erased sentinel. Forgetting only the author still leaves the bodies readable."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -40,6 +40,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
|
|
|
40
40
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
41
41
|
import * as z from "zod";
|
|
42
42
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
43
|
+
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
43
44
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
44
45
|
import { USER_STATUS } from "../../user/index.js";
|
|
45
46
|
import {
|
|
@@ -300,6 +301,7 @@ async function sweepDerivedSubjectData(
|
|
|
300
301
|
subject: SubjectId,
|
|
301
302
|
subjectKey: string,
|
|
302
303
|
crossTenantRunner: DbRunner,
|
|
304
|
+
audit: { readonly requestId: string; readonly erasedBy: string },
|
|
303
305
|
): Promise<string | undefined> {
|
|
304
306
|
try {
|
|
305
307
|
await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
|
|
@@ -318,6 +320,17 @@ async function sweepDerivedSubjectData(
|
|
|
318
320
|
subject,
|
|
319
321
|
);
|
|
320
322
|
}
|
|
323
|
+
|
|
324
|
+
// Note bodies are keyed per note, so the subject's own key never reaches them.
|
|
325
|
+
if (ctx.registry.features.has("notes-history")) {
|
|
326
|
+
await eraseNoteBodiesReachingSubject({
|
|
327
|
+
db: crossTenantSubjectRunner,
|
|
328
|
+
registry: ctx.registry,
|
|
329
|
+
subject,
|
|
330
|
+
erasedBy: audit.erasedBy,
|
|
331
|
+
requestId: audit.requestId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
321
334
|
});
|
|
322
335
|
return undefined;
|
|
323
336
|
} catch (err) {
|
|
@@ -471,8 +484,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
471
484
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
472
485
|
// reverse order could leave an audit trail claiming a shred that never
|
|
473
486
|
// happened.
|
|
487
|
+
const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
|
|
474
488
|
await kms.eraseKey(subject, {
|
|
475
|
-
requestId
|
|
489
|
+
requestId,
|
|
476
490
|
userId: event.user.id,
|
|
477
491
|
eraseReason: event.payload.reason,
|
|
478
492
|
});
|
|
@@ -482,6 +496,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
482
496
|
subject,
|
|
483
497
|
subjectKey,
|
|
484
498
|
ctx.db.unsafeRaw(),
|
|
499
|
+
{ requestId, erasedBy: event.user.id },
|
|
485
500
|
);
|
|
486
501
|
|
|
487
502
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
2
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id.js";
|
|
3
4
|
import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect.js";
|
|
4
5
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
|
|
5
6
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas.js";
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// **Use-Case:** Vendor (cdgs) sagt "alle Hausverwaltungs-customers haben ab
|
|
13
|
-
// heute ein `internalNumber`-Field". Tenant kann den Wert pro customer
|
|
14
|
-
// setzen, aber die Definition nicht ändern oder löschen.
|
|
15
|
-
//
|
|
16
|
-
// **Same-scope-conflict** wie bei define-tenant-field via aggregate-version-
|
|
17
|
-
// conflict (deterministische ID mit tenantId=SYSTEM_TENANT_ID).
|
|
8
|
+
// A system-wide field definition applies to every tenant. tenantId is forced to
|
|
9
|
+
// SYSTEM_TENANT_ID (never taken from the caller) and addressed via streamTenantId,
|
|
10
|
+
// so the SystemAdmin stays the recorded actor. Tenants can set values for the field
|
|
11
|
+
// but cannot change or delete the definition. A same-scope duplicate surfaces as an
|
|
12
|
+
// aggregate version conflict, because the ID is derived from SYSTEM_TENANT_ID.
|
|
18
13
|
export const defineSystemFieldHandler: WriteHandlerDef = {
|
|
19
14
|
name: "define-system-field",
|
|
20
15
|
schema: defineFieldPayloadSchema,
|
|
@@ -30,16 +25,14 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
|
|
|
30
25
|
payload.fieldKey,
|
|
31
26
|
);
|
|
32
27
|
|
|
33
|
-
|
|
34
|
-
// write. The framework's CrudExecutor writes the row with this tenantId
|
|
35
|
-
// — the row lives in the system-scope-stream.
|
|
36
|
-
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
28
|
+
const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
|
|
37
29
|
|
|
38
30
|
return defineOrResurrectFieldDefinition(
|
|
39
31
|
aggregateId,
|
|
40
32
|
buildFieldDefinitionColumns(payload),
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
event.user,
|
|
34
|
+
db,
|
|
35
|
+
streamTenantId,
|
|
43
36
|
);
|
|
44
37
|
},
|
|
45
38
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
2
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id.js";
|
|
3
4
|
import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants.js";
|
|
4
5
|
import { fieldDefinitionExecutor } from "../executor.js";
|
|
@@ -26,8 +27,10 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
26
27
|
payload.fieldKey,
|
|
27
28
|
);
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
const result = await fieldDefinitionExecutor.delete({ id: aggregateId },
|
|
30
|
+
const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
|
|
31
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, db, {
|
|
32
|
+
streamTenantId,
|
|
33
|
+
});
|
|
31
34
|
|
|
32
35
|
// Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
|
|
33
36
|
// ihrer customFields jsonb. Im selben TX = atomic.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import { fieldDefinitionExecutor } from "../executor.js";
|
|
2
3
|
import type { FieldDefinitionColumns } from "./field-definition-row.js";
|
|
3
4
|
|
|
@@ -27,13 +28,18 @@ export async function defineOrResurrectFieldDefinition(
|
|
|
27
28
|
columns: FieldDefinitionColumns,
|
|
28
29
|
user: DefineUser,
|
|
29
30
|
db: DefineDb,
|
|
31
|
+
streamTenantId?: TenantId,
|
|
30
32
|
): Promise<WriteResult> {
|
|
31
33
|
const active = await fieldDefinitionExecutor.detail({ id: aggregateId }, user, db);
|
|
32
34
|
if (active) {
|
|
33
|
-
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db
|
|
35
|
+
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
|
|
36
|
+
streamTenantId,
|
|
37
|
+
});
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db
|
|
40
|
+
const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db, {
|
|
41
|
+
streamTenantId,
|
|
42
|
+
});
|
|
37
43
|
if (restored.isSuccess) {
|
|
38
44
|
// restore() just un-deleted the row in this same tx; no concurrent writer
|
|
39
45
|
// exists, so skip the optimistic-lock version match (we'd otherwise have to
|
|
@@ -43,10 +49,13 @@ export async function defineOrResurrectFieldDefinition(
|
|
|
43
49
|
// readonly FieldDefinitionColumns isn't assignable to it.
|
|
44
50
|
return fieldDefinitionExecutor.update({ id: aggregateId, changes: { ...columns } }, user, db, {
|
|
45
51
|
skipOptimisticLock: true,
|
|
52
|
+
streamTenantId,
|
|
46
53
|
});
|
|
47
54
|
}
|
|
48
55
|
if (restored.error.code === "not_found") {
|
|
49
|
-
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db
|
|
56
|
+
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
|
|
57
|
+
streamTenantId,
|
|
58
|
+
});
|
|
50
59
|
}
|
|
51
60
|
return restored;
|
|
52
61
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery idempotencyKey is released when nothing was delivered",
|
|
6
|
+
"detail": "A retry of a failed notify() with the same idempotencyKey is no longer skipped as a duplicate. The key is deleted when no recipient got a `sent` or `queued` delivery, also when notify() throws; one successful recipient keeps it, even if notify() throws afterwards (a retry would re-send to them). For queued channels the send/render job deletes the key after its final failed attempt, not per retry. That reference is only attached when a `route` call has exactly one channel address; `to` calls (user lists) and multi-channel routes keep the key after queueing. The job payload gains an optional `idempotencyKey`, backward compatible. `createDeliveryTestContext` accepts `idempotencyRedis`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -57,7 +57,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
|
57
57
|
function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
|
|
58
58
|
return request.requireHttps
|
|
59
59
|
? { kind: "external" }
|
|
60
|
-
: { kind: "internal",
|
|
60
|
+
: { kind: "internal", allowOrigins: [target.origin] };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function hostMatches(hostname: string, allowedHost: string): boolean {
|
|
@@ -26,6 +26,11 @@ export {
|
|
|
26
26
|
type DeliveryStatusValue,
|
|
27
27
|
} from "./public-names.js";
|
|
28
28
|
|
|
29
|
+
// Single source for claim (delivery-service) and terminal-failure release (jobs).
|
|
30
|
+
export function deliveryIdempotencyRedisKey(tenantId: string, key: string): string {
|
|
31
|
+
return `delivery:idem:${tenantId}:${key}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
30
35
|
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
31
36
|
// FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EscapeHatchAuditSink,
|
|
7
7
|
NotifyDelivery,
|
|
8
8
|
NotifyJobDispatcher,
|
|
9
|
+
NotifyOptions,
|
|
9
10
|
NotifyPriority,
|
|
10
11
|
Registry,
|
|
11
12
|
TenantId,
|
|
@@ -21,7 +22,12 @@ import { redactErrorText } from "../shared/index.js";
|
|
|
21
22
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
22
23
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
23
24
|
import { buildChannelContext } from "./channel-context.js";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
27
|
+
DeliveryJobs,
|
|
28
|
+
deliveryIdempotencyRedisKey,
|
|
29
|
+
deliveryPriorityRank,
|
|
30
|
+
} from "./constants.js";
|
|
25
31
|
import {
|
|
26
32
|
type AddressOptOutKey,
|
|
27
33
|
addressOptOutKey,
|
|
@@ -171,11 +177,43 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
171
177
|
"Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
|
|
172
178
|
);
|
|
173
179
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
180
|
+
const res = await idemRedis.set(
|
|
181
|
+
deliveryIdempotencyRedisKey(tenantId, key),
|
|
182
|
+
"1",
|
|
183
|
+
"EX",
|
|
184
|
+
ttlSec,
|
|
185
|
+
"NX",
|
|
186
|
+
);
|
|
176
187
|
return res === "OK";
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
function isDeliveredOrQueued(delivery: NotifyDelivery): boolean {
|
|
191
|
+
return delivery.status === "sent" || delivery.status === "queued";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
195
|
+
// Partial success keeps it: a retry would re-send to those who got it.
|
|
196
|
+
async function releaseIdempotencyIfNothingDelivered(
|
|
197
|
+
tenantId: TenantId,
|
|
198
|
+
key: string | undefined,
|
|
199
|
+
run: NotifyRun,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
// skip: no key claimed, or a recipient already got the notification
|
|
202
|
+
if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
|
|
203
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
207
|
+
// without risking a duplicate send on another channel.
|
|
208
|
+
function jobIdempotencyKeyFor(
|
|
209
|
+
idempotencyKey: string | undefined,
|
|
210
|
+
route: NotifyOptions["route"],
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (!idempotencyKey || !route) return undefined;
|
|
213
|
+
const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
|
|
214
|
+
return routedChannelCount === 1 ? idempotencyKey : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
|
|
180
218
|
if (!tenantUserIdsQuery) {
|
|
181
219
|
throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
|
|
@@ -233,6 +271,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
233
271
|
readonly deliveries: NotifyDelivery[];
|
|
234
272
|
readonly jobDispatcher: NotifyJobDispatcher | undefined;
|
|
235
273
|
readonly locale: string | undefined;
|
|
274
|
+
// Set only when exactly one channel carries the call: the queued job may then free
|
|
275
|
+
// the key on terminal failure without risking a duplicate send on another channel.
|
|
276
|
+
readonly jobIdempotencyKey: string | undefined;
|
|
236
277
|
};
|
|
237
278
|
|
|
238
279
|
function recordDelivery(
|
|
@@ -343,6 +384,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
343
384
|
deliveryAttemptId,
|
|
344
385
|
priority,
|
|
345
386
|
message,
|
|
387
|
+
...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
|
|
346
388
|
},
|
|
347
389
|
{ priority: deliveryPriorityRank[priority] },
|
|
348
390
|
);
|
|
@@ -745,14 +787,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
745
787
|
}
|
|
746
788
|
}
|
|
747
789
|
|
|
790
|
+
async function dispatchNotification(
|
|
791
|
+
notificationType: string,
|
|
792
|
+
options: NotifyOptions,
|
|
793
|
+
tenantId: TenantId,
|
|
794
|
+
priority: NotifyPriority,
|
|
795
|
+
run: NotifyRun,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
const { to, route, data } = options;
|
|
798
|
+
if (route) {
|
|
799
|
+
await deliverDirect(
|
|
800
|
+
route,
|
|
801
|
+
notificationType,
|
|
802
|
+
data,
|
|
803
|
+
tenantId,
|
|
804
|
+
priority,
|
|
805
|
+
options.recipientId ?? null,
|
|
806
|
+
run,
|
|
807
|
+
);
|
|
808
|
+
// skip: explicit route is delivered directly, no recipient resolution needed
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (to !== undefined) {
|
|
813
|
+
let userIds: readonly string[];
|
|
814
|
+
|
|
815
|
+
if (typeof to === "string") {
|
|
816
|
+
userIds = [to];
|
|
817
|
+
} else if ("tenant" in to) {
|
|
818
|
+
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
819
|
+
} else {
|
|
820
|
+
userIds = to;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const prefetchedAddresses =
|
|
824
|
+
priority === "critical"
|
|
825
|
+
? new Map<string, string | null>()
|
|
826
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
827
|
+
const suppressed = await loadSuppressedAddresses(
|
|
828
|
+
tenantId,
|
|
829
|
+
notificationType,
|
|
830
|
+
priority,
|
|
831
|
+
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
for (const userId of userIds) {
|
|
835
|
+
await deliverToUser(
|
|
836
|
+
userId,
|
|
837
|
+
notificationType,
|
|
838
|
+
data,
|
|
839
|
+
tenantId,
|
|
840
|
+
priority,
|
|
841
|
+
run,
|
|
842
|
+
prefetchedAddresses,
|
|
843
|
+
suppressed,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
748
849
|
return {
|
|
749
850
|
async notify(notificationType, options, _user, tenantId, jobDispatcher) {
|
|
750
|
-
const {
|
|
851
|
+
const { route, idempotencyKey } = options;
|
|
751
852
|
const priority: NotifyPriority = options.priority ?? "normal";
|
|
752
853
|
const run: NotifyRun = {
|
|
753
854
|
deliveries: [],
|
|
754
855
|
jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
|
|
755
856
|
locale: options.locale,
|
|
857
|
+
jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
|
|
756
858
|
};
|
|
757
859
|
|
|
758
860
|
if (idempotencyKey) {
|
|
@@ -772,54 +874,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
772
874
|
}
|
|
773
875
|
}
|
|
774
876
|
|
|
775
|
-
|
|
776
|
-
await
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return { deliveries: run.deliveries };
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (to !== undefined) {
|
|
789
|
-
let userIds: readonly string[];
|
|
790
|
-
|
|
791
|
-
if (typeof to === "string") {
|
|
792
|
-
userIds = [to];
|
|
793
|
-
} else if ("tenant" in to) {
|
|
794
|
-
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
795
|
-
} else {
|
|
796
|
-
userIds = to;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const prefetchedAddresses =
|
|
800
|
-
priority === "critical"
|
|
801
|
-
? new Map<string, string | null>()
|
|
802
|
-
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
803
|
-
const suppressed = await loadSuppressedAddresses(
|
|
804
|
-
tenantId,
|
|
805
|
-
notificationType,
|
|
806
|
-
priority,
|
|
807
|
-
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
877
|
+
try {
|
|
878
|
+
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// A failing release must not replace the dispatch error the caller retries on.
|
|
881
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
|
|
882
|
+
(releaseErr: unknown) => {
|
|
883
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
884
|
+
notificationType,
|
|
885
|
+
});
|
|
886
|
+
},
|
|
808
887
|
);
|
|
809
|
-
|
|
810
|
-
for (const userId of userIds) {
|
|
811
|
-
await deliverToUser(
|
|
812
|
-
userId,
|
|
813
|
-
notificationType,
|
|
814
|
-
data,
|
|
815
|
-
tenantId,
|
|
816
|
-
priority,
|
|
817
|
-
run,
|
|
818
|
-
prefetchedAddresses,
|
|
819
|
-
suppressed,
|
|
820
|
-
);
|
|
821
|
-
}
|
|
888
|
+
throw err;
|
|
822
889
|
}
|
|
890
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
823
891
|
return { deliveries: run.deliveries };
|
|
824
892
|
},
|
|
825
893
|
};
|
|
@@ -247,15 +247,21 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
247
247
|
label: i18nKey("delivery.log.col.channel"),
|
|
248
248
|
renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
|
|
249
249
|
},
|
|
250
|
-
{
|
|
250
|
+
{
|
|
251
|
+
field: "recipientLabel",
|
|
252
|
+
label: i18nKey("delivery.log.col.recipient"),
|
|
253
|
+
hideOnNarrow: true,
|
|
254
|
+
},
|
|
251
255
|
{
|
|
252
256
|
field: "status",
|
|
253
257
|
label: i18nKey("delivery.log.col.status"),
|
|
258
|
+
hideOnNarrow: true,
|
|
254
259
|
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
255
260
|
},
|
|
256
261
|
{
|
|
257
262
|
field: "error",
|
|
258
263
|
label: i18nKey("delivery.log.col.error"),
|
|
264
|
+
hideOnNarrow: true,
|
|
259
265
|
renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
|
|
260
266
|
},
|
|
261
267
|
],
|