@cosmicdrift/kumiko-bundled-features 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/dist/admin-shell/feature.js +9 -9
- package/dist/agent-tools/tool-dispatch.d.ts +9 -1
- package/dist/agent-tools/tool-dispatch.js +55 -5
- package/dist/audit/feature.js +1 -1
- package/dist/auth-email-password/constants.d.ts +0 -1
- package/dist/auth-email-password/constants.js +0 -2
- package/dist/auth-email-password/feature.js +3 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
- package/dist/auth-mfa/feature.js +4 -4
- package/dist/auth-mfa-user-data/index.js +1 -1
- package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
- package/dist/billing-foundation/feature.js +1 -1
- package/dist/cap-counter/cap-reservation.js +73 -25
- package/dist/cap-counter/constants.d.ts +1 -0
- package/dist/cap-counter/constants.js +2 -0
- package/dist/cap-counter/feature.js +2 -2
- package/dist/cap-counter/tables.js +5 -2
- package/dist/cap-overview/feature.js +3 -3
- package/dist/channel-discord/feature.js +2 -2
- package/dist/channel-email/feature.js +1 -1
- package/dist/channel-in-app/feature.js +1 -1
- package/dist/channel-push/feature.js +1 -1
- package/dist/channel-slack/feature.js +2 -2
- package/dist/channel-teams/feature.js +2 -2
- package/dist/channel-telegram/feature.js +2 -2
- package/dist/compliance-profiles/feature.js +1 -1
- package/dist/compliance-profiles-ops/index.js +3 -3
- package/dist/config/feature.js +1 -1
- package/dist/custom-fields/feature.js +2 -2
- package/dist/custom-fields/wire-for-entity.js +17 -13
- package/dist/data-retention/feature.js +1 -1
- package/dist/data-retention/presets.d.ts +4 -4
- package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
- package/dist/delivery/db/queries/address-opt-outs.js +6 -2
- package/dist/delivery/db/queries/preferences.d.ts +1 -1
- package/dist/delivery/db/queries/preferences.js +4 -4
- package/dist/delivery/delivery-service.js +9 -2
- package/dist/delivery/feature.js +9 -5
- package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
- package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
- package/dist/delivery/handlers/set-preference.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-address.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
- package/dist/delivery/notification-type-aliases.d.ts +3 -0
- package/dist/delivery/notification-type-aliases.js +11 -0
- package/dist/derivatives-sharp/feature.js +1 -1
- package/dist/document-ingest-foundation/feature.js +2 -2
- package/dist/file-derivatives/feature.js +3 -3
- package/dist/file-foundation/feature.js +1 -1
- package/dist/file-provider-inmemory/feature.js +2 -2
- package/dist/file-provider-s3/feature.js +3 -3
- package/dist/file-provider-s3-env/feature.js +2 -2
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/folders/feature.js +2 -2
- package/dist/folders-user-data/index.js +1 -1
- package/dist/form-draft/feature.js +2 -2
- package/dist/form-draft-user-data/index.js +1 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
- package/dist/inbound-mail-foundation/connect-routes.js +6 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +17 -7
- package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
- package/dist/inbound-provider-imap/feature.js +2 -2
- package/dist/inbound-provider-inmemory/feature.js +1 -1
- package/dist/ledger/feature.js +5 -5
- package/dist/legal-pages/feature.js +1 -1
- package/dist/mail-foundation/feature.js +1 -1
- package/dist/mail-transport-inmemory/feature.js +2 -2
- package/dist/mail-transport-smtp/feature.js +3 -3
- package/dist/managed-pages/css-gate.js +1 -1
- package/dist/managed-pages/feature.js +2 -2
- package/dist/metrics/feature.js +2 -2
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history-user-data/index.js +1 -1
- package/dist/personal-access-tokens/feature.js +17 -13
- package/dist/readiness/feature.js +2 -2
- package/dist/renderer-foundation/feature.js +1 -1
- package/dist/renderer-pdf/feature.js +1 -1
- package/dist/secrets/feature.js +1 -1
- package/dist/seo/feature.js +1 -1
- package/dist/sessions/feature.js +20 -16
- package/dist/step-dispatcher/constants.d.ts +10 -0
- package/dist/step-dispatcher/constants.js +17 -0
- package/dist/step-dispatcher/deliver-dispatch-request.d.ts +14 -0
- package/dist/step-dispatcher/deliver-dispatch-request.js +163 -0
- package/dist/step-dispatcher/feature.js +48 -101
- package/dist/step-dispatcher/webhook-runner.d.ts +7 -4
- package/dist/step-dispatcher/webhook-runner.js +26 -11
- package/dist/subscription-mollie/feature.js +1 -1
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/tags/feature.js +2 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver-user-data/index.js +1 -1
- package/dist/tenant/feature.js +4 -4
- package/dist/tenant/seeding.js +1 -1
- package/dist/tenant-lifecycle/feature.js +2 -3
- package/dist/tenant-lifecycle/stages.js +4 -1
- package/dist/tenant-settings/feature.js +1 -1
- package/dist/tier-engine/feature.js +86 -72
- package/dist/user/feature.js +1 -1
- package/dist/user-data-rights/feature.js +4 -4
- package/dist/user-data-rights-defaults/feature.js +2 -2
- package/dist/user-profile/feature.js +4 -4
- package/dist/waitlist/feature.js +2 -2
- package/package.json +10 -10
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +13 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/delivery/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
- package/src/step-dispatcher/changes.json +8 -1
|
@@ -1,22 +1,19 @@
|
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE,
|
|
12
|
-
import {
|
|
13
|
-
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
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.
|
|
11
|
+
import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_REQUESTED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { createMultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
14
13
|
import * as z from "zod";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
|
|
19
|
-
const log = createFallbackLogger("step-dispatcher");
|
|
14
|
+
import { DELIVER_MAX_PER_TENANT, DELIVER_RETRIES, deliverJobId, STEP_DISPATCHER_FEATURE, StepDispatcherJobs, } from "./constants.js";
|
|
15
|
+
import { deliverDispatchRequest, eraseDispatchKey, isDispatchOutcome, } from "./deliver-dispatch-request.js";
|
|
16
|
+
import { WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
|
|
20
17
|
export const stepDispatcherEnvSchema = z.object({
|
|
21
18
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
22
19
|
.string()
|
|
@@ -24,20 +21,12 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
24
21
|
.describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value."),
|
|
25
22
|
});
|
|
26
23
|
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
31
|
-
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
32
|
-
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
33
|
-
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
34
|
-
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
35
|
-
function isDispatchOutcome(e) {
|
|
36
|
-
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
37
|
-
}
|
|
24
|
+
const NEEDS_JOB_RUNNER_ERROR = "step-dispatcher needs a job runner (Redis) to deliver";
|
|
25
|
+
const DELIVER_ESCAPE_HATCH_REASON = "reads the dispatch stream and appends its outcome event, which needs a raw connection";
|
|
26
|
+
const deliverJobPayloadSchema = z.object({ aggregateId: z.string() });
|
|
38
27
|
export function createStepDispatcherFeature() {
|
|
39
28
|
return defineFeature("step-dispatcher", (r) => {
|
|
40
|
-
r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits.
|
|
29
|
+
r.describe("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.");
|
|
41
30
|
r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
|
|
42
31
|
r.uiHints({
|
|
43
32
|
displayLabel: "Step Dispatcher · Deferred Side-Effects",
|
|
@@ -45,89 +34,47 @@ export function createStepDispatcherFeature() {
|
|
|
45
34
|
recommended: false,
|
|
46
35
|
});
|
|
47
36
|
r.envSchema(stepDispatcherEnvSchema);
|
|
48
|
-
r.requires("secrets");
|
|
37
|
+
r.requires({ features: ["secrets"] });
|
|
38
|
+
// Literal name: the few-shot corpus parser only reads string-literal job names.
|
|
39
|
+
// StepDispatcherJobNames.deliver mirrors it; feature.boot.test.ts pins the two together.
|
|
40
|
+
r.job("deliver", {
|
|
41
|
+
trigger: { manual: true },
|
|
42
|
+
retries: DELIVER_RETRIES,
|
|
43
|
+
backoff: "exponential",
|
|
44
|
+
maxPerTenant: { limit: DELIVER_MAX_PER_TENANT, onLimit: "defer" },
|
|
45
|
+
escapeHatch: { reason: DELIVER_ESCAPE_HATCH_REASON },
|
|
46
|
+
}, async (payload, ctx) => {
|
|
47
|
+
const { aggregateId } = deliverJobPayloadSchema.parse(payload);
|
|
48
|
+
const db = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
49
|
+
const tenantId = ctx.systemUser.tenantId;
|
|
50
|
+
await deliverDispatchRequest({
|
|
51
|
+
aggregateId,
|
|
52
|
+
tenantId,
|
|
53
|
+
attempt: ctx.attempt,
|
|
54
|
+
finalAttempt: ctx.finalAttempt,
|
|
55
|
+
applyContextFor: (userId) => createMultiStreamApplyContext({
|
|
56
|
+
registry: ctx.registry,
|
|
57
|
+
db,
|
|
58
|
+
tenantId,
|
|
59
|
+
userId,
|
|
60
|
+
callerFeature: STEP_DISPATCHER_FEATURE,
|
|
61
|
+
...(ctx.secrets && { secrets: ctx.secrets }),
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
49
65
|
r.multiStreamProjection({
|
|
50
66
|
name: "step-dispatcher",
|
|
51
67
|
apply: {
|
|
52
68
|
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
53
|
-
const kms = configuredPiiSubjectKms();
|
|
54
|
-
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
55
|
-
const eraseDispatchKey = async () => {
|
|
56
|
-
await kms?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId }, { requestId, eraseReason: "step-dispatch-outcome-recorded" });
|
|
57
|
-
};
|
|
58
|
-
// Outcome events are plaintext and generic; the request payload's
|
|
59
|
-
// per-dispatch key is erased right after, so the PII dies with the
|
|
60
|
-
// dispatch instead of living in the event log. The outcome is
|
|
61
|
-
// appended before the erase on purpose: if the erase throws, the
|
|
62
|
-
// outcome survives and a redelivery only repeats the erase. A crash
|
|
63
|
-
// between the send and the outcome append still re-sends under the
|
|
64
|
-
// same Idempotency-Key — the one remaining double-send case.
|
|
65
|
-
const recordOutcome = async (type, payload) => {
|
|
66
|
-
await ctx.unsafeAppendEvent({
|
|
67
|
-
aggregateId: event.aggregateId,
|
|
68
|
-
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
69
|
-
type,
|
|
70
|
-
payload,
|
|
71
|
-
});
|
|
72
|
-
await eraseDispatchKey();
|
|
73
|
-
};
|
|
74
|
-
const recordFailure = (stepKind, error) => recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
75
69
|
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
76
70
|
if (stream.some(isDispatchOutcome)) {
|
|
77
|
-
await eraseDispatchKey();
|
|
71
|
+
await eraseDispatchKey(event.aggregateId);
|
|
78
72
|
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
79
73
|
return;
|
|
80
74
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const payload = parsed.data;
|
|
88
|
-
const read = await readPayloadFields(payload);
|
|
89
|
-
if (read.kind === "unreadable") {
|
|
90
|
-
await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
91
|
-
// skip: unreadable payload already recorded via step.dispatch-failed above
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (read.kind === "erased") {
|
|
95
|
-
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
96
|
-
// skip: erased payload recorded via step.dispatch-failed above
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
100
|
-
if (!dispatchSpec) {
|
|
101
|
-
await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
102
|
-
// skip: unparseable payload already recorded via step.dispatch-failed above
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const result = dispatchSpec.stepKind === "webhook.send"
|
|
106
|
-
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
107
|
-
tenantId: event.tenantId,
|
|
108
|
-
userId: event.metadata.userId || SYSTEM_USER_ID,
|
|
109
|
-
secrets: ctx.secrets,
|
|
110
|
-
idempotencyKey: event.aggregateId,
|
|
111
|
-
})
|
|
112
|
-
: await performMailDispatch(dispatchSpec.spec);
|
|
113
|
-
if (result.ok) {
|
|
114
|
-
await recordOutcome(STEP_DISPATCHED_TYPE, {
|
|
115
|
-
stepKind: payload.stepKind,
|
|
116
|
-
status: result.status,
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
if (payload.stepKind === "mail.send") {
|
|
121
|
-
log.warn("mail dispatch failed", {
|
|
122
|
-
aggregateId: event.aggregateId,
|
|
123
|
-
reason: redactEmailAddresses(result.error),
|
|
124
|
-
});
|
|
125
|
-
await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
await recordFailure(payload.stepKind, result.error);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
75
|
+
if (!ctx.jobs)
|
|
76
|
+
throw new Error(NEEDS_JOB_RUNNER_ERROR);
|
|
77
|
+
await ctx.jobs.dispatch(StepDispatcherJobs.deliver, { aggregateId: event.aggregateId, _tenantId: event.tenantId }, { jobId: deliverJobId(event.aggregateId) });
|
|
131
78
|
},
|
|
132
79
|
},
|
|
133
80
|
});
|
|
@@ -33,13 +33,15 @@ export declare const webhookSpecSchema: z.ZodObject<{
|
|
|
33
33
|
}, z.core.$strip>]>>;
|
|
34
34
|
}, z.core.$strip>;
|
|
35
35
|
export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
|
|
36
|
-
|
|
37
|
-
readonly ok: true;
|
|
38
|
-
readonly status: number;
|
|
39
|
-
} | {
|
|
36
|
+
type WebhookDispatchFailure = {
|
|
40
37
|
readonly ok: false;
|
|
41
38
|
readonly error: string;
|
|
39
|
+
readonly retryable: boolean;
|
|
42
40
|
};
|
|
41
|
+
export type WebhookDispatchResult = {
|
|
42
|
+
readonly ok: true;
|
|
43
|
+
readonly status: number;
|
|
44
|
+
} | WebhookDispatchFailure;
|
|
43
45
|
export declare function setWebhookFetch(fn: typeof fetch): void;
|
|
44
46
|
export type WebhookDispatchDeps = {
|
|
45
47
|
readonly tenantId: TenantId;
|
|
@@ -51,3 +53,4 @@ export type WebhookDispatchDeps = {
|
|
|
51
53
|
export declare const WEBHOOK_REQUEST_TIMEOUT_MS = 10000;
|
|
52
54
|
export declare const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
53
55
|
export declare function performWebhookDispatch(spec: WebhookSpec, deps: WebhookDispatchDeps): Promise<WebhookDispatchResult>;
|
|
56
|
+
export {};
|
|
@@ -75,12 +75,14 @@ export const webhookSpecSchema = z.object({
|
|
|
75
75
|
])
|
|
76
76
|
.optional(),
|
|
77
77
|
});
|
|
78
|
+
function isRetryableHttpStatus(status) {
|
|
79
|
+
return status >= 500 || status === 408 || status === 429;
|
|
80
|
+
}
|
|
78
81
|
let fetchImpl = globalThis.fetch.bind(globalThis);
|
|
79
82
|
export function setWebhookFetch(fn) {
|
|
80
83
|
fetchImpl = fn;
|
|
81
84
|
}
|
|
82
|
-
// Bounds a single hanging tenant-controlled receiver to this long
|
|
83
|
-
// is sequential and shared, so N hanging requests still delay other tenants by N times this.
|
|
85
|
+
// Bounds a single hanging tenant-controlled receiver to this long per attempt.
|
|
84
86
|
export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
|
|
85
87
|
// Never includes the secret name or value — spec.auth.secret is a
|
|
86
88
|
// tenant-chosen name, but the error still reaches the tenant via the
|
|
@@ -110,10 +112,10 @@ async function buildWebhookHeaders(spec, deps) {
|
|
|
110
112
|
if (!spec.auth)
|
|
111
113
|
return { ok: true, headers };
|
|
112
114
|
if (!deps.secrets)
|
|
113
|
-
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
115
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
114
116
|
// A throwing get (corrupt envelope, rotated key, KMS error) must become a
|
|
115
|
-
// dispatch-failed event; rethrowing would
|
|
116
|
-
//
|
|
117
|
+
// dispatch-failed event; rethrowing would burn the job's retries on a
|
|
118
|
+
// failure a retry cannot fix.
|
|
117
119
|
let secret;
|
|
118
120
|
try {
|
|
119
121
|
const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
|
|
@@ -121,12 +123,12 @@ async function buildWebhookHeaders(spec, deps) {
|
|
|
121
123
|
handlerName: "step-dispatcher:webhook.send",
|
|
122
124
|
});
|
|
123
125
|
if (!revealed)
|
|
124
|
-
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
126
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
125
127
|
secret = revealed.reveal();
|
|
126
128
|
}
|
|
127
129
|
catch {
|
|
128
130
|
log.warn("webhook auth secret lookup failed", { tenantId: deps.tenantId });
|
|
129
|
-
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
131
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
|
|
130
132
|
}
|
|
131
133
|
if (spec.auth.kind === "bearer") {
|
|
132
134
|
setHeader(headers, "authorization", `Bearer ${secret}`);
|
|
@@ -148,7 +150,11 @@ async function resolveWebhookFetchTarget(rawUrl, url, headers) {
|
|
|
148
150
|
const reason = err instanceof Error ? err.message : String(err);
|
|
149
151
|
if (err instanceof BlockedHostError || err instanceof HostResolutionError) {
|
|
150
152
|
log.warn("webhook host unreachable", { host: url.hostname, reason });
|
|
151
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: "webhook host is not reachable or not allowed",
|
|
156
|
+
retryable: false,
|
|
157
|
+
};
|
|
152
158
|
}
|
|
153
159
|
throw err;
|
|
154
160
|
}
|
|
@@ -165,10 +171,14 @@ export async function performWebhookDispatch(spec, deps) {
|
|
|
165
171
|
url = new URL(spec.url);
|
|
166
172
|
}
|
|
167
173
|
catch {
|
|
168
|
-
return { ok: false, error: "invalid url" };
|
|
174
|
+
return { ok: false, error: "invalid url", retryable: false };
|
|
169
175
|
}
|
|
170
176
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
171
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: `unsupported url scheme "${url.protocol}"`,
|
|
180
|
+
retryable: false,
|
|
181
|
+
};
|
|
172
182
|
}
|
|
173
183
|
const headers = await buildWebhookHeaders(spec, deps);
|
|
174
184
|
if (!headers.ok)
|
|
@@ -187,7 +197,11 @@ export async function performWebhookDispatch(spec, deps) {
|
|
|
187
197
|
// Only the status is used; an unread body would pin the socket until the timeout fires.
|
|
188
198
|
await res.body?.cancel().catch(() => { });
|
|
189
199
|
if (!res.ok) {
|
|
190
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: `HTTP ${res.status}: ${res.statusText}`,
|
|
203
|
+
retryable: isRetryableHttpStatus(res.status),
|
|
204
|
+
};
|
|
191
205
|
}
|
|
192
206
|
return { ok: true, status: res.status };
|
|
193
207
|
}
|
|
@@ -196,6 +210,7 @@ export async function performWebhookDispatch(spec, deps) {
|
|
|
196
210
|
return {
|
|
197
211
|
ok: false,
|
|
198
212
|
error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
|
|
213
|
+
retryable: true,
|
|
199
214
|
};
|
|
200
215
|
}
|
|
201
216
|
}
|
|
@@ -102,7 +102,7 @@ export function createSubscriptionMollieFeature(options) {
|
|
|
102
102
|
category: "billing",
|
|
103
103
|
recommended: false,
|
|
104
104
|
});
|
|
105
|
-
r.requires("billing-foundation");
|
|
105
|
+
r.requires({ features: ["billing-foundation"] });
|
|
106
106
|
r.envSchema(subscriptionMollieEnvSchema);
|
|
107
107
|
const plugin = {
|
|
108
108
|
verifyAndParseWebhook: verifyAndParse,
|
|
@@ -93,9 +93,9 @@ export function createSubscriptionStripeFeature(options = {}) {
|
|
|
93
93
|
// Hard-deps: billing-foundation (plugin-host) + config (billing-live +
|
|
94
94
|
// backing:"secrets" credentials) + secrets (the store the backing:secrets
|
|
95
95
|
// dispatch reads/writes + its tenant_secrets table).
|
|
96
|
-
r.requires("billing-foundation");
|
|
97
|
-
r.requires("config");
|
|
98
|
-
r.requires("secrets");
|
|
96
|
+
r.requires({ features: ["billing-foundation"] });
|
|
97
|
+
r.requires({ features: ["config"] });
|
|
98
|
+
r.requires({ features: ["secrets"] });
|
|
99
99
|
r.envSchema(subscriptionStripeEnvSchema);
|
|
100
100
|
// Runtime config. api-key + webhook-secret declare backing:"secrets" —
|
|
101
101
|
// the value lives envelope-encrypted in the secrets store under
|
package/dist/tags/feature.js
CHANGED
|
@@ -41,8 +41,8 @@ function registerTags(r, access, toggleable, ownership, parents) {
|
|
|
41
41
|
if (toggleable !== undefined)
|
|
42
42
|
r.toggleable(toggleable);
|
|
43
43
|
const tagAssignmentEntity = createTagAssignmentEntity(ownership, parents);
|
|
44
|
-
r.entity("tag", tagEntity);
|
|
45
|
-
r.entity("tag-assignment", tagAssignmentEntity);
|
|
44
|
+
r.entity({ name: "tag", ...tagEntity });
|
|
45
|
+
r.entity({ name: "tag-assignment", ...tagAssignmentEntity });
|
|
46
46
|
r.writeHandler(createCreateTagHandler(access));
|
|
47
47
|
r.writeHandler(createUpdateTagHandler(access));
|
|
48
48
|
r.writeHandler(createDeleteTagHandler(access));
|
|
@@ -63,13 +63,13 @@ export function createTemplateResolverFeature(opts = {}) {
|
|
|
63
63
|
resourceMaxBytes: createSystemConfig("number", { default: 2_097_152, bounds: { min: 1 } }),
|
|
64
64
|
},
|
|
65
65
|
});
|
|
66
|
-
r.entity("template-resource", templateResourceEntity);
|
|
66
|
+
r.entity({ name: "template-resource", ...templateResourceEntity });
|
|
67
67
|
// Only when the app actually mounts a user-owned collection: the entity's
|
|
68
68
|
// `content` is `userOwned`, which makes it subject data — the boot guard
|
|
69
69
|
// then requires an EXT_USER_DATA hook (mount `template-resolver-user-data`).
|
|
70
70
|
// Registering it unconditionally would impose that on every app.
|
|
71
71
|
if (hasUserOwned)
|
|
72
|
-
r.entity("user-content-entry", userContentEntryEntity);
|
|
72
|
+
r.entity({ name: "user-content-entry", ...userContentEntryEntity });
|
|
73
73
|
const handlers = {
|
|
74
74
|
upsertSystem: r.writeHandler(upsertSystemWrite),
|
|
75
75
|
upsertTenant: r.writeHandler(upsertTenantWrite),
|
|
@@ -12,7 +12,7 @@ export const templateResolverUserDataFeature = defineFeature("template-resolver-
|
|
|
12
12
|
// template-resolver is optional for the same reason as in
|
|
13
13
|
// notes-history-user-data: a toggleable mount would otherwise trip the
|
|
14
14
|
// "effectively disabled" boot warning even though the entity is there.
|
|
15
|
-
r.requires("user-data-rights");
|
|
15
|
+
r.requires({ features: ["user-data-rights"] });
|
|
16
16
|
r.optionalRequires("template-resolver");
|
|
17
17
|
r.useExtension(EXT_USER_DATA, "user-content-entry", {
|
|
18
18
|
export: userContentExportHook,
|
package/dist/tenant/feature.js
CHANGED
|
@@ -38,7 +38,7 @@ export function createTenantFeature(options) {
|
|
|
38
38
|
recommended: true,
|
|
39
39
|
});
|
|
40
40
|
r.systemScope();
|
|
41
|
-
r.requires("config");
|
|
41
|
+
r.requires({ features: ["config"] });
|
|
42
42
|
r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
|
|
43
43
|
// The elevation guards read the registry, the screens only these options: a role the
|
|
44
44
|
// server accepts but the screen omits is silently stripped on the next member-roles save.
|
|
@@ -49,9 +49,9 @@ export function createTenantFeature(options) {
|
|
|
49
49
|
throw new Error(`[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`);
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
|
-
r.entity("tenant", tenantEntity);
|
|
53
|
-
r.entity("tenant-membership", tenantMembershipEntity);
|
|
54
|
-
r.entity("tenant-invitation", tenantInvitationEntity);
|
|
52
|
+
r.entity({ name: "tenant", ...tenantEntity });
|
|
53
|
+
r.entity({ name: "tenant-membership", ...tenantMembershipEntity });
|
|
54
|
+
r.entity({ name: "tenant-invitation", ...tenantInvitationEntity });
|
|
55
55
|
r.config({
|
|
56
56
|
keys: {
|
|
57
57
|
// Stammdaten-Settings — Tenant-Admin darf ändern, alle dürfen lesen.
|
package/dist/tenant/seeding.js
CHANGED
|
@@ -185,7 +185,7 @@ export async function seedTenant(db, options, hooks) {
|
|
|
185
185
|
}
|
|
186
186
|
// "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
|
|
187
187
|
// (keyed by result.entityName === "tenant") — that's exactly what
|
|
188
|
-
// tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
|
|
188
|
+
// tier-engine's `r.hook({ type: "postSave", target: { allOf: "tenant" }, ... })` needs.
|
|
189
189
|
await fireEntityPostSave(hooks, "tenant:seed", result.data, { tenantId: options.id, db });
|
|
190
190
|
return { id: options.id };
|
|
191
191
|
}
|
|
@@ -19,7 +19,7 @@ export function createTenantLifecycleFeature() {
|
|
|
19
19
|
category: "compliance",
|
|
20
20
|
recommended: false,
|
|
21
21
|
});
|
|
22
|
-
r.requires("tenant", "compliance-profiles");
|
|
22
|
+
r.requires({ features: ["tenant", "compliance-profiles"] });
|
|
23
23
|
r.optionalRequires("sessions");
|
|
24
24
|
r.usesApi("compliance.forTenant");
|
|
25
25
|
r.extendsRegistrar(EXT_TENANT_DATA, {});
|
|
@@ -74,8 +74,7 @@ export function createTenantLifecycleFeature() {
|
|
|
74
74
|
if (!ctx.registry) {
|
|
75
75
|
throw new Error("run-tenant-destruction: ctx.registry required (JobContext incomplete)");
|
|
76
76
|
}
|
|
77
|
-
const
|
|
78
|
-
const now = T.Now.instant();
|
|
77
|
+
const now = ctx.tz.now();
|
|
79
78
|
// Without crossTenantReads (undeclared hatch) fall through to the sweep.
|
|
80
79
|
if (ctx.crossTenantReads && !(await hasTenantDestructionWork(ctx.crossTenantReads, now))) {
|
|
81
80
|
// skip: no tenant is due, so the sweep would not touch anything
|
|
@@ -126,7 +126,7 @@ async function tombstoneTenantRow(ctx) {
|
|
|
126
126
|
throw new Error(`tenant-lifecycle: failed to forget membership ${membership.id} for tenant ${ctx.tenantId}: ${result.error.message}`);
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
-
await tenantCrud.update({
|
|
129
|
+
const tombstone = await tenantCrud.update({
|
|
130
130
|
id: ctx.tenantId,
|
|
131
131
|
changes: {
|
|
132
132
|
status: "destroyed",
|
|
@@ -134,6 +134,9 @@ async function tombstoneTenantRow(ctx) {
|
|
|
134
134
|
isEnabled: false,
|
|
135
135
|
},
|
|
136
136
|
}, user, db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
137
|
+
if (!tombstone.isSuccess) {
|
|
138
|
+
throw new Error(`tenant-lifecycle: failed to mark tenant ${ctx.tenantId} destroyed: ${tombstone.error.message}`);
|
|
139
|
+
}
|
|
137
140
|
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
138
141
|
return STAGE_DONE;
|
|
139
142
|
}
|
|
@@ -16,7 +16,7 @@ import { buildTenantSettingsKeys } from "./config.js";
|
|
|
16
16
|
import { TENANT_SETTINGS_FEATURE_NAME } from "./constants.js";
|
|
17
17
|
export function createTenantSettingsFeature(opts = {}) {
|
|
18
18
|
return defineFeature(TENANT_SETTINGS_FEATURE_NAME, (r) => {
|
|
19
|
-
r.requires("config");
|
|
19
|
+
r.requires({ features: ["config"] });
|
|
20
20
|
r.describe("Per-tenant Currency + Locale defaults, exposed as two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that surface in the self-populating Settings-Hub via `mask`. Pair with defineCreateWithTenantDefaults to auto-fill a money field's currency or a locale field on create instead of hard-coding a literal per entity.");
|
|
21
21
|
r.config({ keys: buildTenantSettingsKeys(opts) });
|
|
22
22
|
r.translations({
|