@cosmicdrift/kumiko-samples 0.365.0 → 0.366.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/admin-shell/feature.ts +9 -9
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
- package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
- package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
- package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
- package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-push/feature.ts +1 -1
- package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
- package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
- package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
- package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
- package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
- package/packages/bundled-features/src/config/feature.ts +1 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +13 -0
- package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
- package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
- package/packages/bundled-features/src/data-retention/feature.ts +1 -1
- package/packages/bundled-features/src/data-retention/presets.ts +4 -4
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
- package/packages/bundled-features/src/delivery/delivery-service.ts +17 -2
- package/packages/bundled-features/src/delivery/feature.ts +9 -5
- package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
- package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
- package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
- package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
- package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
- package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
- package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/folders/feature.ts +2 -2
- package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +2 -2
- package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
- package/packages/bundled-features/src/ledger/feature.ts +5 -5
- package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
- package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
- package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
- package/packages/bundled-features/src/metrics/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
- package/packages/bundled-features/src/readiness/feature.ts +2 -2
- package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
- package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/seo/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +17 -13
- package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
- package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +66 -119
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
- package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
- package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
- package/packages/bundled-features/src/tags/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
- package/packages/bundled-features/src/tenant/feature.ts +4 -4
- package/packages/bundled-features/src/tenant/seeding.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +6 -1
- package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
- package/packages/bundled-features/src/user/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/README.md +1 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
- package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
- package/packages/bundled-features/src/user-profile/feature.ts +4 -4
- package/packages/bundled-features/src/waitlist/feature.ts +2 -2
- package/samples/apps/cap-billing-demo/README.md +1 -1
- package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
- package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
- package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
- package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
- package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
- package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
- package/samples/recipes/access-control/src/feature.ts +21 -25
- package/samples/recipes/anonymous-access/src/feature.ts +36 -43
- package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
- package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
- package/samples/recipes/basic-entity/README.md +1 -1
- package/samples/recipes/boot-check/README.md +1 -1
- package/samples/recipes/boot-check/src/feature.ts +2 -2
- package/samples/recipes/chat-channels/src/feature.ts +1 -1
- package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
- package/samples/recipes/currencies-global/src/feature.ts +3 -3
- package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
- package/samples/recipes/custom-handlers/README.md +3 -3
- package/samples/recipes/custom-handlers/src/feature.ts +37 -41
- package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
- package/samples/recipes/designer-demo/src/feature.ts +2 -1
- package/samples/recipes/embedded/src/feature.ts +1 -1
- package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
- package/samples/recipes/error-contract/src/feature.ts +25 -25
- package/samples/recipes/event-sourcing/src/feature.ts +70 -76
- package/samples/recipes/feature-toggles/src/feature.ts +28 -20
- package/samples/recipes/files-post-processing/src/feature.ts +22 -26
- package/samples/recipes/idempotency/src/feature.ts +16 -18
- package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
- package/samples/recipes/lane-routing/src/feature.ts +10 -12
- package/samples/recipes/legal-pages/src/feature.ts +1 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -4
- package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
- package/samples/recipes/managed-config/src/feature.ts +8 -8
- package/samples/recipes/managed-pages/src/feature.ts +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
- package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
- package/samples/recipes/pipeline-basics/README.md +1 -1
- package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/rate-limiting/src/feature.ts +7 -9
- package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
- package/samples/recipes/reference-data/src/feature.ts +1 -1
- package/samples/recipes/relations/src/feature.ts +2 -2
- package/samples/recipes/screens-nav/src/feature.ts +1 -1
- package/samples/recipes/search/src/feature.ts +17 -19
- package/samples/recipes/security-baseline/README.md +1 -1
- package/samples/recipes/state-machine/src/feature.ts +1 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/tags-basic/src/feature.ts +2 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/samples/recipes/tenant-settings/src/feature.ts +1 -1
- package/samples/recipes/timezones/src/feature.ts +12 -12
- package/samples/recipes/user-data-rights/README.md +2 -2
- package/samples/recipes/user-data-rights/src/feature.ts +1 -1
- package/samples/recipes/user-profile/src/feature.ts +1 -1
- package/samples/recipes/webhook-step/src/feature.ts +1 -1
- package/samples/recipes/writeform-section/src/feature.ts +12 -12
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import {
|
|
4
|
+
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
5
|
+
STEP_DISPATCH_FAILED_TYPE,
|
|
6
|
+
STEP_DISPATCH_REQUESTED_TYPE,
|
|
7
|
+
STEP_DISPATCHED_TYPE,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
10
|
+
import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
11
|
+
import { SYSTEM_USER_ID, type TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
12
|
+
import { redactEmailAddresses } from "../shared/index.js";
|
|
13
|
+
import {
|
|
14
|
+
buildDispatchSpec,
|
|
15
|
+
dispatchRequestedPayloadSchema,
|
|
16
|
+
rawStepKindOf,
|
|
17
|
+
readPayloadFields,
|
|
18
|
+
} from "./dispatch-payload.js";
|
|
19
|
+
import { type MailDispatchResult, performMailDispatch } from "./mail-runner.js";
|
|
20
|
+
import { performWebhookDispatch } from "./webhook-runner.js";
|
|
21
|
+
|
|
22
|
+
const log = createFallbackLogger("step-dispatcher");
|
|
23
|
+
|
|
24
|
+
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
25
|
+
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
26
|
+
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
27
|
+
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
28
|
+
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
29
|
+
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
30
|
+
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
31
|
+
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
32
|
+
// Thrown messages land in BullMQ's failedReason and the run log, so this never
|
|
33
|
+
// carries the receiver's error text or the target url.
|
|
34
|
+
const DELIVERY_RETRY_ERROR = "step delivery failed, retrying";
|
|
35
|
+
const DISPATCH_THREW_ERROR = "delivery attempt threw unexpectedly";
|
|
36
|
+
const FINAL_ATTEMPT_ABORTED_ERROR = "delivery aborted on the final attempt";
|
|
37
|
+
const FINAL_ATTEMPT_UNRECORDED_ERROR = "step delivery failed and its outcome could not be recorded";
|
|
38
|
+
|
|
39
|
+
export function isDispatchOutcome(e: { readonly type: string }): boolean {
|
|
40
|
+
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function eraseDispatchKey(aggregateId: string): Promise<void> {
|
|
44
|
+
await configuredPiiSubjectKms()?.eraseKey(
|
|
45
|
+
{ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: aggregateId },
|
|
46
|
+
{
|
|
47
|
+
requestId: requestContext.get()?.requestId ?? "step-dispatcher",
|
|
48
|
+
eraseReason: "step-dispatch-outcome-recorded",
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type DeliveryResult =
|
|
54
|
+
| { readonly ok: true; readonly status: number }
|
|
55
|
+
| { readonly ok: false; readonly error: string; readonly retryable: boolean };
|
|
56
|
+
|
|
57
|
+
// Mail adapter failures carry no status to classify, so every one is retried.
|
|
58
|
+
function mailResultAsDelivery(result: MailDispatchResult): DeliveryResult {
|
|
59
|
+
return result.ok ? result : { ...result, retryable: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DeliverDispatchRequestArgs = {
|
|
63
|
+
readonly aggregateId: string;
|
|
64
|
+
readonly tenantId: TenantId;
|
|
65
|
+
// 1-based run number of the job; finalAttempt = no retry follows a throw.
|
|
66
|
+
readonly attempt: number;
|
|
67
|
+
readonly finalAttempt: boolean;
|
|
68
|
+
// The outcome events are stamped with the requesting user, which is only
|
|
69
|
+
// known after the stream was loaded — hence a factory instead of a ready ctx.
|
|
70
|
+
readonly applyContextFor: (userId: string) => MultiStreamApplyContext;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Outcome events are plaintext and generic; the request payload's per-dispatch
|
|
74
|
+
// key is erased right after, so the PII dies with the dispatch instead of
|
|
75
|
+
// living in the event log. The outcome is appended before the erase on
|
|
76
|
+
// purpose: if the erase throws, the outcome survives and a redelivery only
|
|
77
|
+
// repeats the erase. A crash between the send and the outcome append still
|
|
78
|
+
// re-sends under the same Idempotency-Key — the one remaining double-send case.
|
|
79
|
+
// A transient failure before the final attempt throws without an outcome or
|
|
80
|
+
// erase: the key must survive until the outcome is final.
|
|
81
|
+
export async function deliverDispatchRequest(args: DeliverDispatchRequestArgs): Promise<void> {
|
|
82
|
+
const { aggregateId, finalAttempt } = args;
|
|
83
|
+
const stream = await args.applyContextFor(SYSTEM_USER_ID).loadAggregate(aggregateId);
|
|
84
|
+
if (stream.some(isDispatchOutcome)) {
|
|
85
|
+
await eraseDispatchKey(aggregateId);
|
|
86
|
+
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const requested = stream.find((e) => e.type === STEP_DISPATCH_REQUESTED_TYPE);
|
|
91
|
+
if (!requested) {
|
|
92
|
+
// The key ref carries no tenant, so a job triggered with a foreign or
|
|
93
|
+
// unknown aggregateId must not erase anything or append to that stream.
|
|
94
|
+
log.warn("step dispatch request not found, nothing delivered", { aggregateId });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const ctx = args.applyContextFor(requested.metadata.userId || SYSTEM_USER_ID);
|
|
98
|
+
const recorder = createOutcomeRecorder(ctx, aggregateId, args.attempt);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await deliverRequested(args, requested, ctx, recorder);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (!finalAttempt) throw err;
|
|
104
|
+
await abortOnFinalAttempt(err, ctx, recorder, aggregateId, requested.payload);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type OutcomeRecorder = {
|
|
109
|
+
readonly record: (
|
|
110
|
+
type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
|
|
111
|
+
payload: Record<string, unknown>,
|
|
112
|
+
) => Promise<void>;
|
|
113
|
+
readonly recordFailure: (stepKind: string, error: string) => Promise<void>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function createOutcomeRecorder(
|
|
117
|
+
ctx: MultiStreamApplyContext,
|
|
118
|
+
aggregateId: string,
|
|
119
|
+
attempt: number,
|
|
120
|
+
): OutcomeRecorder {
|
|
121
|
+
const record: OutcomeRecorder["record"] = async (type, payload) => {
|
|
122
|
+
await ctx.unsafeAppendEvent({
|
|
123
|
+
aggregateId,
|
|
124
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
125
|
+
type,
|
|
126
|
+
payload,
|
|
127
|
+
});
|
|
128
|
+
await eraseDispatchKey(aggregateId);
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
record,
|
|
132
|
+
recordFailure: (stepKind, error) =>
|
|
133
|
+
record(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type RequestedEvent = Awaited<ReturnType<MultiStreamApplyContext["loadAggregate"]>>[number];
|
|
138
|
+
|
|
139
|
+
async function deliverRequested(
|
|
140
|
+
args: DeliverDispatchRequestArgs,
|
|
141
|
+
requested: RequestedEvent,
|
|
142
|
+
ctx: MultiStreamApplyContext,
|
|
143
|
+
recorder: OutcomeRecorder,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const { aggregateId, tenantId, attempt, finalAttempt } = args;
|
|
146
|
+
const { record, recordFailure } = recorder;
|
|
147
|
+
|
|
148
|
+
const parsed = dispatchRequestedPayloadSchema.safeParse(requested.payload);
|
|
149
|
+
if (!parsed.success) {
|
|
150
|
+
return recordFailure(rawStepKindOf(requested.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
151
|
+
}
|
|
152
|
+
const payload = parsed.data;
|
|
153
|
+
|
|
154
|
+
const read = await readPayloadFields(payload);
|
|
155
|
+
if (read.kind === "unreadable") return recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
156
|
+
if (read.kind === "erased") return recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
157
|
+
|
|
158
|
+
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
159
|
+
if (!dispatchSpec) return recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
160
|
+
|
|
161
|
+
const result = await sendDispatch(dispatchSpec, {
|
|
162
|
+
aggregateId,
|
|
163
|
+
tenantId,
|
|
164
|
+
userId: requested.metadata.userId || SYSTEM_USER_ID,
|
|
165
|
+
secrets: ctx.secrets,
|
|
166
|
+
});
|
|
167
|
+
if (result.ok) {
|
|
168
|
+
return record(STEP_DISPATCHED_TYPE, { stepKind: payload.stepKind, status: result.status });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const loggedReason =
|
|
172
|
+
payload.stepKind === "mail.send" ? redactEmailAddresses(result.error) : result.error;
|
|
173
|
+
if (result.retryable && !finalAttempt) {
|
|
174
|
+
log.warn("step delivery failed, will retry", { aggregateId, attempt, reason: loggedReason });
|
|
175
|
+
throw new Error(DELIVERY_RETRY_ERROR);
|
|
176
|
+
}
|
|
177
|
+
if (payload.stepKind === "mail.send") {
|
|
178
|
+
log.warn("mail dispatch failed", { aggregateId, reason: loggedReason });
|
|
179
|
+
return recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
180
|
+
}
|
|
181
|
+
return recordFailure(payload.stepKind, result.error);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function sendDispatch(
|
|
185
|
+
dispatchSpec: NonNullable<ReturnType<typeof buildDispatchSpec>>,
|
|
186
|
+
target: {
|
|
187
|
+
readonly aggregateId: string;
|
|
188
|
+
readonly tenantId: TenantId;
|
|
189
|
+
readonly userId: string;
|
|
190
|
+
readonly secrets: MultiStreamApplyContext["secrets"];
|
|
191
|
+
},
|
|
192
|
+
): Promise<DeliveryResult> {
|
|
193
|
+
try {
|
|
194
|
+
return dispatchSpec.stepKind === "webhook.send"
|
|
195
|
+
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
196
|
+
tenantId: target.tenantId,
|
|
197
|
+
userId: target.userId,
|
|
198
|
+
secrets: target.secrets,
|
|
199
|
+
idempotencyKey: target.aggregateId,
|
|
200
|
+
})
|
|
201
|
+
: await performMailDispatch(dispatchSpec.spec).then(mailResultAsDelivery);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// err.message can echo the url or an address; only the class name is logged.
|
|
204
|
+
log.warn("step delivery threw", {
|
|
205
|
+
aggregateId: target.aggregateId,
|
|
206
|
+
errorName: err instanceof Error ? err.name : "unknown",
|
|
207
|
+
});
|
|
208
|
+
return { ok: false, error: DISPATCH_THREW_ERROR, retryable: true };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function abortOnFinalAttempt(
|
|
213
|
+
err: unknown,
|
|
214
|
+
ctx: MultiStreamApplyContext,
|
|
215
|
+
recorder: OutcomeRecorder,
|
|
216
|
+
aggregateId: string,
|
|
217
|
+
requestedPayload: unknown,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
// err.message can echo the url or an address; only the class name is logged.
|
|
220
|
+
log.warn("step delivery aborted on the final attempt", {
|
|
221
|
+
aggregateId,
|
|
222
|
+
errorName: err instanceof Error ? err.name : "unknown",
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
// An outcome may already be appended when only the erase threw: repeat the
|
|
226
|
+
// erase, never a second outcome.
|
|
227
|
+
const current = await ctx.loadAggregate(aggregateId);
|
|
228
|
+
if (current.some(isDispatchOutcome)) {
|
|
229
|
+
await eraseDispatchKey(aggregateId);
|
|
230
|
+
} else {
|
|
231
|
+
await recorder.recordFailure(rawStepKindOf(requestedPayload), FINAL_ATTEMPT_ABORTED_ERROR);
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error(FINAL_ATTEMPT_UNRECORDED_ERROR);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -1,41 +1,40 @@
|
|
|
1
1
|
// step-dispatcher — bundled-feature that drains deferred Tier-2 step
|
|
2
2
|
// requests (webhook.send, mail.send, ...) after their TX commits.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// or `kumiko:system:step.dispatch-failed` back
|
|
8
|
-
// the audit trail lives in the event log only — no
|
|
4
|
+
// The `kumiko:system:step.dispatch-requested` system event (registry-bypassed,
|
|
5
|
+
// see SYSTEM_EVENT_PREFIX) is picked up by an MSP that only enqueues a
|
|
6
|
+
// per-dispatch job. The job performs the side-effect and emits
|
|
7
|
+
// `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back
|
|
8
|
+
// onto the same stream so the audit trail lives in the event log only — no
|
|
9
|
+
// separate status table. The job's Redis data carries only the stream id and
|
|
10
|
+
// tenant, never the (encrypted) request payload.
|
|
9
11
|
|
|
10
|
-
import {
|
|
11
|
-
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
12
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
13
|
import {
|
|
13
14
|
defineFeature,
|
|
14
15
|
type FeatureDefinition,
|
|
15
16
|
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
16
|
-
STEP_DISPATCH_FAILED_TYPE,
|
|
17
17
|
STEP_DISPATCH_REQUESTED_TYPE,
|
|
18
|
-
STEP_DISPATCHED_TYPE,
|
|
19
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import {
|
|
21
|
-
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
19
|
+
import { createMultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
22
20
|
import * as z from "zod";
|
|
23
|
-
import { redactEmailAddresses } from "../shared/index.js";
|
|
24
21
|
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
DELIVER_MAX_PER_TENANT,
|
|
23
|
+
DELIVER_RETRIES,
|
|
24
|
+
deliverJobId,
|
|
25
|
+
STEP_DISPATCHER_FEATURE,
|
|
26
|
+
StepDispatcherJobs,
|
|
27
|
+
} from "./constants.js";
|
|
28
|
+
import {
|
|
29
|
+
deliverDispatchRequest,
|
|
30
|
+
eraseDispatchKey,
|
|
31
|
+
isDispatchOutcome,
|
|
32
|
+
} from "./deliver-dispatch-request.js";
|
|
31
33
|
import {
|
|
32
|
-
performWebhookDispatch,
|
|
33
34
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
34
35
|
WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS,
|
|
35
36
|
} from "./webhook-runner.js";
|
|
36
37
|
|
|
37
|
-
const log = createFallbackLogger("step-dispatcher");
|
|
38
|
-
|
|
39
38
|
export const stepDispatcherEnvSchema = z.object({
|
|
40
39
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
41
40
|
.string()
|
|
@@ -47,23 +46,16 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
47
46
|
|
|
48
47
|
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
54
|
-
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
55
|
-
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
56
|
-
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
57
|
-
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
49
|
+
const NEEDS_JOB_RUNNER_ERROR = "step-dispatcher needs a job runner (Redis) to deliver";
|
|
50
|
+
const DELIVER_ESCAPE_HATCH_REASON =
|
|
51
|
+
"reads the dispatch stream and appends its outcome event, which needs a raw connection";
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
61
|
-
}
|
|
53
|
+
const deliverJobPayloadSchema = z.object({ aggregateId: z.string() });
|
|
62
54
|
|
|
63
55
|
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
64
56
|
return defineFeature("step-dispatcher", (r) => {
|
|
65
57
|
r.describe(
|
|
66
|
-
"Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits.
|
|
58
|
+
"Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: \"defer\"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
|
|
67
59
|
);
|
|
68
60
|
r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
|
|
69
61
|
r.uiHints({
|
|
@@ -72,102 +64,57 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
72
64
|
recommended: false,
|
|
73
65
|
});
|
|
74
66
|
r.envSchema(stepDispatcherEnvSchema);
|
|
75
|
-
r.requires("secrets");
|
|
67
|
+
r.requires({ features: ["secrets"] });
|
|
68
|
+
|
|
69
|
+
// Literal name: the few-shot corpus parser only reads string-literal job names.
|
|
70
|
+
// StepDispatcherJobNames.deliver mirrors it; feature.boot.test.ts pins the two together.
|
|
71
|
+
r.job(
|
|
72
|
+
"deliver",
|
|
73
|
+
{
|
|
74
|
+
trigger: { manual: true },
|
|
75
|
+
retries: DELIVER_RETRIES,
|
|
76
|
+
backoff: "exponential",
|
|
77
|
+
maxPerTenant: { limit: DELIVER_MAX_PER_TENANT, onLimit: "defer" },
|
|
78
|
+
escapeHatch: { reason: DELIVER_ESCAPE_HATCH_REASON },
|
|
79
|
+
},
|
|
80
|
+
async (payload, ctx) => {
|
|
81
|
+
const { aggregateId } = deliverJobPayloadSchema.parse(payload);
|
|
82
|
+
const db = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
83
|
+
const tenantId = ctx.systemUser.tenantId;
|
|
84
|
+
await deliverDispatchRequest({
|
|
85
|
+
aggregateId,
|
|
86
|
+
tenantId,
|
|
87
|
+
attempt: ctx.attempt,
|
|
88
|
+
finalAttempt: ctx.finalAttempt,
|
|
89
|
+
applyContextFor: (userId) =>
|
|
90
|
+
createMultiStreamApplyContext({
|
|
91
|
+
registry: ctx.registry,
|
|
92
|
+
db,
|
|
93
|
+
tenantId,
|
|
94
|
+
userId,
|
|
95
|
+
callerFeature: STEP_DISPATCHER_FEATURE,
|
|
96
|
+
...(ctx.secrets && { secrets: ctx.secrets }),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
);
|
|
76
101
|
|
|
77
102
|
r.multiStreamProjection({
|
|
78
103
|
name: "step-dispatcher",
|
|
79
104
|
apply: {
|
|
80
105
|
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
81
|
-
const kms = configuredPiiSubjectKms();
|
|
82
|
-
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
83
|
-
|
|
84
|
-
const eraseDispatchKey = async (): Promise<void> => {
|
|
85
|
-
await kms?.eraseKey(
|
|
86
|
-
{ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
|
|
87
|
-
{ requestId, eraseReason: "step-dispatch-outcome-recorded" },
|
|
88
|
-
);
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// Outcome events are plaintext and generic; the request payload's
|
|
92
|
-
// per-dispatch key is erased right after, so the PII dies with the
|
|
93
|
-
// dispatch instead of living in the event log. The outcome is
|
|
94
|
-
// appended before the erase on purpose: if the erase throws, the
|
|
95
|
-
// outcome survives and a redelivery only repeats the erase. A crash
|
|
96
|
-
// between the send and the outcome append still re-sends under the
|
|
97
|
-
// same Idempotency-Key — the one remaining double-send case.
|
|
98
|
-
const recordOutcome = async (
|
|
99
|
-
type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
|
|
100
|
-
payload: Record<string, unknown>,
|
|
101
|
-
): Promise<void> => {
|
|
102
|
-
await ctx.unsafeAppendEvent({
|
|
103
|
-
aggregateId: event.aggregateId,
|
|
104
|
-
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
105
|
-
type,
|
|
106
|
-
payload,
|
|
107
|
-
});
|
|
108
|
-
await eraseDispatchKey();
|
|
109
|
-
};
|
|
110
|
-
const recordFailure = (stepKind: string, error: string) =>
|
|
111
|
-
recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
112
|
-
|
|
113
106
|
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
114
107
|
if (stream.some(isDispatchOutcome)) {
|
|
115
|
-
await eraseDispatchKey();
|
|
108
|
+
await eraseDispatchKey(event.aggregateId);
|
|
116
109
|
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
117
110
|
return;
|
|
118
111
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
const payload = parsed.data;
|
|
127
|
-
|
|
128
|
-
const read = await readPayloadFields(payload);
|
|
129
|
-
if (read.kind === "unreadable") {
|
|
130
|
-
await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
131
|
-
// skip: unreadable payload already recorded via step.dispatch-failed above
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (read.kind === "erased") {
|
|
135
|
-
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
136
|
-
// skip: erased payload recorded via step.dispatch-failed above
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
141
|
-
if (!dispatchSpec) {
|
|
142
|
-
await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
143
|
-
// skip: unparseable payload already recorded via step.dispatch-failed above
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
const result =
|
|
147
|
-
dispatchSpec.stepKind === "webhook.send"
|
|
148
|
-
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
149
|
-
tenantId: event.tenantId,
|
|
150
|
-
userId: event.metadata.userId || SYSTEM_USER_ID,
|
|
151
|
-
secrets: ctx.secrets,
|
|
152
|
-
idempotencyKey: event.aggregateId,
|
|
153
|
-
})
|
|
154
|
-
: await performMailDispatch(dispatchSpec.spec);
|
|
155
|
-
if (result.ok) {
|
|
156
|
-
await recordOutcome(STEP_DISPATCHED_TYPE, {
|
|
157
|
-
stepKind: payload.stepKind,
|
|
158
|
-
status: result.status,
|
|
159
|
-
});
|
|
160
|
-
} else {
|
|
161
|
-
if (payload.stepKind === "mail.send") {
|
|
162
|
-
log.warn("mail dispatch failed", {
|
|
163
|
-
aggregateId: event.aggregateId,
|
|
164
|
-
reason: redactEmailAddresses(result.error),
|
|
165
|
-
});
|
|
166
|
-
await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
167
|
-
} else {
|
|
168
|
-
await recordFailure(payload.stepKind, result.error);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
112
|
+
if (!ctx.jobs) throw new Error(NEEDS_JOB_RUNNER_ERROR);
|
|
113
|
+
await ctx.jobs.dispatch(
|
|
114
|
+
StepDispatcherJobs.deliver,
|
|
115
|
+
{ aggregateId: event.aggregateId, _tenantId: event.tenantId },
|
|
116
|
+
{ jobId: deliverJobId(event.aggregateId) },
|
|
117
|
+
);
|
|
171
118
|
},
|
|
172
119
|
},
|
|
173
120
|
});
|
|
@@ -100,9 +100,21 @@ export const webhookSpecSchema = z.object({
|
|
|
100
100
|
|
|
101
101
|
export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
|
|
102
102
|
|
|
103
|
+
// `retryable`: a repeat can succeed (network error, timeout, 5xx, 408, 429).
|
|
104
|
+
// Everything else is final and must not be retried.
|
|
105
|
+
type WebhookDispatchFailure = {
|
|
106
|
+
readonly ok: false;
|
|
107
|
+
readonly error: string;
|
|
108
|
+
readonly retryable: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
103
111
|
export type WebhookDispatchResult =
|
|
104
112
|
| { readonly ok: true; readonly status: number }
|
|
105
|
-
|
|
|
113
|
+
| WebhookDispatchFailure;
|
|
114
|
+
|
|
115
|
+
function isRetryableHttpStatus(status: number): boolean {
|
|
116
|
+
return status >= 500 || status === 408 || status === 429;
|
|
117
|
+
}
|
|
106
118
|
|
|
107
119
|
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
108
120
|
|
|
@@ -118,8 +130,7 @@ export type WebhookDispatchDeps = {
|
|
|
118
130
|
readonly requestTimeoutMs?: number;
|
|
119
131
|
};
|
|
120
132
|
|
|
121
|
-
// Bounds a single hanging tenant-controlled receiver to this long
|
|
122
|
-
// is sequential and shared, so N hanging requests still delay other tenants by N times this.
|
|
133
|
+
// Bounds a single hanging tenant-controlled receiver to this long per attempt.
|
|
123
134
|
export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
|
|
124
135
|
|
|
125
136
|
// Never includes the secret name or value — spec.auth.secret is a
|
|
@@ -146,28 +157,30 @@ function setHeader(headers: Record<string, string>, name: string, value: string)
|
|
|
146
157
|
async function buildWebhookHeaders(
|
|
147
158
|
spec: WebhookSpec,
|
|
148
159
|
deps: WebhookDispatchDeps,
|
|
149
|
-
): Promise<{ ok: true; headers: Record<string, string> } |
|
|
160
|
+
): Promise<{ ok: true; headers: Record<string, string> } | WebhookDispatchFailure> {
|
|
150
161
|
const headers: Record<string, string> = { ...spec.headers };
|
|
151
162
|
if (!hasHeader(headers, "content-type")) headers["content-type"] = "application/json";
|
|
152
163
|
if (!hasHeader(headers, WEBHOOK_IDEMPOTENCY_KEY_HEADER)) {
|
|
153
164
|
headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
|
|
154
165
|
}
|
|
155
166
|
if (!spec.auth) return { ok: true, headers };
|
|
156
|
-
if (!deps.secrets)
|
|
167
|
+
if (!deps.secrets)
|
|
168
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
157
169
|
// A throwing get (corrupt envelope, rotated key, KMS error) must become a
|
|
158
|
-
// dispatch-failed event; rethrowing would
|
|
159
|
-
//
|
|
170
|
+
// dispatch-failed event; rethrowing would burn the job's retries on a
|
|
171
|
+
// failure a retry cannot fix.
|
|
160
172
|
let secret: string;
|
|
161
173
|
try {
|
|
162
174
|
const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
|
|
163
175
|
userId: deps.userId,
|
|
164
176
|
handlerName: "step-dispatcher:webhook.send",
|
|
165
177
|
});
|
|
166
|
-
if (!revealed)
|
|
178
|
+
if (!revealed)
|
|
179
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
167
180
|
secret = revealed.reveal();
|
|
168
181
|
} catch {
|
|
169
182
|
log.warn("webhook auth secret lookup failed", { tenantId: deps.tenantId });
|
|
170
|
-
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
183
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
171
184
|
}
|
|
172
185
|
if (spec.auth.kind === "bearer") {
|
|
173
186
|
setHeader(headers, "authorization", `Bearer ${secret}`);
|
|
@@ -182,7 +195,7 @@ async function resolveWebhookFetchTarget(
|
|
|
182
195
|
url: URL,
|
|
183
196
|
headers: Record<string, string>,
|
|
184
197
|
): Promise<
|
|
185
|
-
{ ok: true; fetchUrl: string | URL; requestInit: RequestInit } |
|
|
198
|
+
{ ok: true; fetchUrl: string | URL; requestInit: RequestInit } | WebhookDispatchFailure
|
|
186
199
|
> {
|
|
187
200
|
if (isHostAllowlisted(url.hostname, readAllowedPrivateWebhookHostsFromEnv()))
|
|
188
201
|
return { ok: true, fetchUrl: rawUrl, requestInit: { headers } };
|
|
@@ -194,7 +207,11 @@ async function resolveWebhookFetchTarget(
|
|
|
194
207
|
const reason = err instanceof Error ? err.message : String(err);
|
|
195
208
|
if (err instanceof BlockedHostError || err instanceof HostResolutionError) {
|
|
196
209
|
log.warn("webhook host unreachable", { host: url.hostname, reason });
|
|
197
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: "webhook host is not reachable or not allowed",
|
|
213
|
+
retryable: false,
|
|
214
|
+
};
|
|
198
215
|
}
|
|
199
216
|
throw err;
|
|
200
217
|
}
|
|
@@ -214,10 +231,14 @@ export async function performWebhookDispatch(
|
|
|
214
231
|
try {
|
|
215
232
|
url = new URL(spec.url);
|
|
216
233
|
} catch {
|
|
217
|
-
return { ok: false, error: "invalid url" };
|
|
234
|
+
return { ok: false, error: "invalid url", retryable: false };
|
|
218
235
|
}
|
|
219
236
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
220
|
-
return {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
error: `unsupported url scheme "${url.protocol}"`,
|
|
240
|
+
retryable: false,
|
|
241
|
+
};
|
|
221
242
|
}
|
|
222
243
|
|
|
223
244
|
const headers = await buildWebhookHeaders(spec, deps);
|
|
@@ -237,7 +258,11 @@ export async function performWebhookDispatch(
|
|
|
237
258
|
// Only the status is used; an unread body would pin the socket until the timeout fires.
|
|
238
259
|
await res.body?.cancel().catch(() => {});
|
|
239
260
|
if (!res.ok) {
|
|
240
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: `HTTP ${res.status}: ${res.statusText}`,
|
|
264
|
+
retryable: isRetryableHttpStatus(res.status),
|
|
265
|
+
};
|
|
241
266
|
}
|
|
242
267
|
return { ok: true, status: res.status };
|
|
243
268
|
} catch (err) {
|
|
@@ -245,6 +270,7 @@ export async function performWebhookDispatch(
|
|
|
245
270
|
return {
|
|
246
271
|
ok: false,
|
|
247
272
|
error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
|
|
273
|
+
retryable: true,
|
|
248
274
|
};
|
|
249
275
|
}
|
|
250
276
|
}
|
|
@@ -154,7 +154,7 @@ export function createSubscriptionMollieFeature(
|
|
|
154
154
|
category: "billing",
|
|
155
155
|
recommended: false,
|
|
156
156
|
});
|
|
157
|
-
r.requires("billing-foundation");
|
|
157
|
+
r.requires({ features: ["billing-foundation"] });
|
|
158
158
|
r.envSchema(subscriptionMollieEnvSchema);
|
|
159
159
|
|
|
160
160
|
const plugin: SubscriptionProviderPlugin = {
|
|
@@ -155,9 +155,9 @@ export function createSubscriptionStripeFeature(
|
|
|
155
155
|
// Hard-deps: billing-foundation (plugin-host) + config (billing-live +
|
|
156
156
|
// backing:"secrets" credentials) + secrets (the store the backing:secrets
|
|
157
157
|
// dispatch reads/writes + its tenant_secrets table).
|
|
158
|
-
r.requires("billing-foundation");
|
|
159
|
-
r.requires("config");
|
|
160
|
-
r.requires("secrets");
|
|
158
|
+
r.requires({ features: ["billing-foundation"] });
|
|
159
|
+
r.requires({ features: ["config"] });
|
|
160
|
+
r.requires({ features: ["secrets"] });
|
|
161
161
|
r.envSchema(subscriptionStripeEnvSchema);
|
|
162
162
|
|
|
163
163
|
// Runtime config. api-key + webhook-secret declare backing:"secrets" —
|
|
@@ -68,8 +68,8 @@ function registerTags(
|
|
|
68
68
|
if (toggleable !== undefined) r.toggleable(toggleable);
|
|
69
69
|
|
|
70
70
|
const tagAssignmentEntity = createTagAssignmentEntity(ownership, parents);
|
|
71
|
-
r.entity("tag", tagEntity);
|
|
72
|
-
r.entity("tag-assignment", tagAssignmentEntity);
|
|
71
|
+
r.entity({ name: "tag", ...tagEntity });
|
|
72
|
+
r.entity({ name: "tag-assignment", ...tagAssignmentEntity });
|
|
73
73
|
|
|
74
74
|
r.writeHandler(createCreateTagHandler(access));
|
|
75
75
|
r.writeHandler(createUpdateTagHandler(access));
|
|
@@ -99,12 +99,12 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
99
99
|
resourceMaxBytes: createSystemConfig("number", { default: 2_097_152, bounds: { min: 1 } }),
|
|
100
100
|
},
|
|
101
101
|
});
|
|
102
|
-
r.entity("template-resource", templateResourceEntity);
|
|
102
|
+
r.entity({ name: "template-resource", ...templateResourceEntity });
|
|
103
103
|
// Only when the app actually mounts a user-owned collection: the entity's
|
|
104
104
|
// `content` is `userOwned`, which makes it subject data — the boot guard
|
|
105
105
|
// then requires an EXT_USER_DATA hook (mount `template-resolver-user-data`).
|
|
106
106
|
// Registering it unconditionally would impose that on every app.
|
|
107
|
-
if (hasUserOwned) r.entity("user-content-entry", userContentEntryEntity);
|
|
107
|
+
if (hasUserOwned) r.entity({ name: "user-content-entry", ...userContentEntryEntity });
|
|
108
108
|
|
|
109
109
|
const handlers = {
|
|
110
110
|
upsertSystem: r.writeHandler(upsertSystemWrite),
|
|
@@ -16,7 +16,7 @@ export const templateResolverUserDataFeature = defineFeature("template-resolver-
|
|
|
16
16
|
// template-resolver is optional for the same reason as in
|
|
17
17
|
// notes-history-user-data: a toggleable mount would otherwise trip the
|
|
18
18
|
// "effectively disabled" boot warning even though the entity is there.
|
|
19
|
-
r.requires("user-data-rights");
|
|
19
|
+
r.requires({ features: ["user-data-rights"] });
|
|
20
20
|
r.optionalRequires("template-resolver");
|
|
21
21
|
r.useExtension(EXT_USER_DATA, "user-content-entry", {
|
|
22
22
|
export: userContentExportHook,
|