@cosmicdrift/kumiko-samples 0.345.0 → 0.346.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/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +14 -3
- package/packages/bundled-features/src/audit/handlers/list.query.ts +11 -2
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +22 -0
- package/packages/bundled-features/src/audit/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +31 -11
- package/packages/bundled-features/src/step-dispatcher/changes.json +25 -0
- package/packages/bundled-features/src/step-dispatcher/dispatch-payload.ts +144 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +30 -151
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +23 -6
- package/packages/bundled-features/src/tier-engine/constants.ts +7 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +2 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +7 -2
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +10 -4
- package/packages/bundled-features/src/tier-engine/index.ts +6 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/recipes/webhook-step/README.md +1 -1
- package/samples/recipes/webhook-step/package.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.346.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.346.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.346.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Audit queries can read app-instance system events with scope \"system\" (SystemAdmin only)",
|
|
6
|
+
"detail": "Audit queries accept `scope: \"system\"` for SystemAdmin\n`audit:query:list` and `audit:query:details` take an optional `scope` (`\"tenant\"` or `\"system\"`). With `\"system\"` a SystemAdmin reads the app-instance system events, e.g. `kumiko:system:app.started`; other roles are denied. Without `scope` nothing changes."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.345.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -19,6 +19,11 @@ export const AuditQueries = {
|
|
|
19
19
|
details: "audit:query:details",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
22
|
+
/** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
|
|
23
|
+
export const AuditScopes = { tenant: "tenant", system: "system" } as const;
|
|
24
|
+
export type AuditScope = (typeof AuditScopes)[keyof typeof AuditScopes];
|
|
25
|
+
export const AUDIT_SCOPE_VALUES = [AuditScopes.tenant, AuditScopes.system] as const;
|
|
26
|
+
|
|
22
27
|
/** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
|
|
23
28
|
export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
|
|
24
29
|
|
|
@@ -36,7 +36,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
36
36
|
"audit",
|
|
37
37
|
(r) => {
|
|
38
38
|
r.describe(
|
|
39
|
-
"Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
39
|
+
"Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
40
40
|
);
|
|
41
41
|
r.uiHints({
|
|
42
42
|
displayLabel: "Audit Log",
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
// Single audit event by its event-store id — backs the audit-log-detail
|
|
2
2
|
// screen. Tenant-isolated at the WHERE level like list.query, so a caller
|
|
3
|
-
// can only read events in their own tenant
|
|
3
|
+
// can only read events in their own tenant; a SystemAdmin can pass scope
|
|
4
|
+
// "system" to read app-instance system events (e.g. app.started).
|
|
4
5
|
|
|
5
6
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
7
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
9
|
import * as z from "zod";
|
|
10
|
+
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
11
|
+
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
9
12
|
|
|
10
13
|
export const detailsQuery = defineQueryHandler({
|
|
11
14
|
name: "details",
|
|
12
15
|
description:
|
|
13
|
-
|
|
16
|
+
'Returns one audit-trail event of the caller\'s tenant by its event-store id, with full payload and metadata; use it to inspect the exact change behind a row of the audit log list. A SystemAdmin can set scope "system" to read an app-instance system event (e.g. app.started).',
|
|
14
17
|
schema: z.object({
|
|
15
18
|
id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
|
|
19
|
+
scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
|
|
16
20
|
}),
|
|
17
21
|
access: { roles: access.admin },
|
|
18
22
|
handler: async (query, ctx) => {
|
|
23
|
+
const scopeFilter = resolveAuditScopeFilter(query.user, query.payload.scope);
|
|
19
24
|
const rows = await selectMany<{
|
|
20
25
|
id: bigint;
|
|
21
26
|
aggregateId: string;
|
|
@@ -29,7 +34,13 @@ export const detailsQuery = defineQueryHandler({
|
|
|
29
34
|
}>(
|
|
30
35
|
ctx.db,
|
|
31
36
|
eventsTable,
|
|
32
|
-
{
|
|
37
|
+
{
|
|
38
|
+
tenantId: scopeFilter.tenantId,
|
|
39
|
+
...(scopeFilter.aggregateType !== undefined && {
|
|
40
|
+
aggregateType: scopeFilter.aggregateType,
|
|
41
|
+
}),
|
|
42
|
+
id: BigInt(query.payload.id),
|
|
43
|
+
},
|
|
33
44
|
{ limit: 1 },
|
|
34
45
|
);
|
|
35
46
|
const row = rows[0];
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// No projection, no separate audit table. Queryable with the same filter
|
|
7
7
|
// surface any audit UI needs; tenant-isolated at the WHERE level so cross-
|
|
8
8
|
// tenant peeking is structurally impossible for non-SystemAdmin callers.
|
|
9
|
+
// A SystemAdmin can pass scope "system" to read the app-instance system events
|
|
10
|
+
// (e.g. app.started); no other cross-tenant read exists.
|
|
9
11
|
//
|
|
10
12
|
// Sensitive field-values are ciphertext inside the event payload (the log
|
|
11
13
|
// carries them encrypted); stripSensitive only strips the event echo. This
|
|
@@ -17,6 +19,8 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
17
19
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
20
|
import { Temporal } from "temporal-polyfill";
|
|
19
21
|
import * as z from "zod";
|
|
22
|
+
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
23
|
+
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
20
24
|
|
|
21
25
|
const MAX_LIMIT = 100;
|
|
22
26
|
|
|
@@ -65,7 +69,7 @@ function buildAuditWhere(
|
|
|
65
69
|
export const listQuery = defineQueryHandler({
|
|
66
70
|
name: "list",
|
|
67
71
|
description:
|
|
68
|
-
|
|
72
|
+
'Lists the tenant\'s audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when. A SystemAdmin can set scope "system" to read the app-instance system events (e.g. app.started) instead of the own tenant.',
|
|
69
73
|
schema: z
|
|
70
74
|
.object({
|
|
71
75
|
cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
|
|
@@ -80,6 +84,7 @@ export const listQuery = defineQueryHandler({
|
|
|
80
84
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
81
85
|
from: z.iso.datetime().optional(),
|
|
82
86
|
to: z.iso.datetime().optional(),
|
|
87
|
+
scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
|
|
83
88
|
})
|
|
84
89
|
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
85
90
|
message: "`from` must be less than or equal to `to`",
|
|
@@ -88,7 +93,11 @@ export const listQuery = defineQueryHandler({
|
|
|
88
93
|
access: { roles: access.admin },
|
|
89
94
|
handler: async (query, ctx) => {
|
|
90
95
|
const p = query.payload;
|
|
91
|
-
const
|
|
96
|
+
const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
|
|
97
|
+
const where = buildAuditWhere(scopeFilter.tenantId, {
|
|
98
|
+
...p,
|
|
99
|
+
aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
|
|
100
|
+
});
|
|
92
101
|
|
|
93
102
|
const rows = await selectMany<{
|
|
94
103
|
id: bigint;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { crossTenantOverrideDenied, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
4
|
+
import { type AuditScope, AuditScopes } from "../constants.js";
|
|
5
|
+
|
|
6
|
+
export type AuditScopeFilter = { readonly tenantId: string; readonly aggregateType?: string };
|
|
7
|
+
|
|
8
|
+
// The system tenant also holds personal streams of other features (session
|
|
9
|
+
// revocations, exports, ...); only app-instance streams are released.
|
|
10
|
+
export function resolveAuditScopeFilter(
|
|
11
|
+
user: SessionUser,
|
|
12
|
+
scope: AuditScope | undefined,
|
|
13
|
+
): AuditScopeFilter {
|
|
14
|
+
if (scope !== AuditScopes.system) return { tenantId: user.tenantId };
|
|
15
|
+
const denied = crossTenantOverrideDenied(
|
|
16
|
+
user,
|
|
17
|
+
SYSTEM_TENANT_ID,
|
|
18
|
+
"audit.errors.systemScopeRequiresSystemAdmin",
|
|
19
|
+
);
|
|
20
|
+
if (denied) throw denied;
|
|
21
|
+
return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
|
|
22
|
+
}
|
|
@@ -9,6 +9,9 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
9
9
|
"audit.log.detail.subtitle": {
|
|
10
10
|
en: "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
|
|
11
11
|
},
|
|
12
|
+
"audit.errors.systemScopeRequiresSystemAdmin": {
|
|
13
|
+
en: "Only SystemAdmin may read system events.",
|
|
14
|
+
},
|
|
12
15
|
"audit:nav.auditLog": { en: "Audit" },
|
|
13
16
|
"audit.log.col.when": { en: "When" },
|
|
14
17
|
"audit.log.col.type": { en: "Event" },
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.346.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Subscription webhooks no longer overwrite manual tier grants",
|
|
6
|
+
"detail": "Subscription tier sync keeps manual tier grants\nThe billing webhook sync now skips tier assignments with `source: \"manual\"`, so a `set-tenant-tier` grant is no longer overwritten by Stripe created/canceled events. Rows the sync writes are marked `source: \"billing\"`. `TierAssignmentSources` is exported from tier-engine."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.344.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// so it is now a factory parameter.
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
TierAssignmentSources,
|
|
7
8
|
TierEngineHandlers,
|
|
8
9
|
TierEngineQueries,
|
|
9
10
|
tierAssignmentAggregateId,
|
|
@@ -61,6 +62,24 @@ function asRows(result: unknown): ReadonlyArray<Record<string, unknown>> {
|
|
|
61
62
|
throw new Error("expected a { rows: [...] } list-query result");
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
type ExistingTierAssignment = {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly version: number;
|
|
68
|
+
readonly tier: unknown;
|
|
69
|
+
readonly source: unknown;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function findTierAssignment(
|
|
73
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
74
|
+
tenantId: TenantId,
|
|
75
|
+
): ExistingTierAssignment | undefined {
|
|
76
|
+
const row = rows.find((candidate) => candidate["tenantId"] === tenantId);
|
|
77
|
+
if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
export function createSubscriptionTierSync<TTier extends string>(
|
|
65
84
|
deps: SubscriptionTierSyncDeps<TTier>,
|
|
66
85
|
) {
|
|
@@ -94,15 +113,15 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
94
113
|
tenantId,
|
|
95
114
|
}),
|
|
96
115
|
);
|
|
97
|
-
const assignment = tierAssignmentRows
|
|
98
|
-
if (
|
|
99
|
-
!assignment ||
|
|
100
|
-
typeof assignment["id"] !== "string" ||
|
|
101
|
-
typeof assignment["version"] !== "number"
|
|
102
|
-
) {
|
|
116
|
+
const assignment = findTierAssignment(tierAssignmentRows, tenantId);
|
|
117
|
+
if (!assignment) {
|
|
103
118
|
const created = await routeDeps.dispatchSystemWrite({
|
|
104
119
|
handlerQn: TierEngineHandlers.create,
|
|
105
|
-
payload: {
|
|
120
|
+
payload: {
|
|
121
|
+
id: tierAssignmentAggregateId(tenantId),
|
|
122
|
+
tier: effective,
|
|
123
|
+
source: TierAssignmentSources.billing,
|
|
124
|
+
},
|
|
106
125
|
tenantId,
|
|
107
126
|
});
|
|
108
127
|
if (!created.isSuccess) {
|
|
@@ -113,14 +132,15 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
113
132
|
}
|
|
114
133
|
return null;
|
|
115
134
|
}
|
|
116
|
-
if (assignment
|
|
135
|
+
if (assignment.source === TierAssignmentSources.manual) return null;
|
|
136
|
+
if (assignment.tier === effective) return null;
|
|
117
137
|
|
|
118
138
|
const result = await routeDeps.dispatchSystemWrite({
|
|
119
139
|
handlerQn: TierEngineHandlers.update,
|
|
120
140
|
payload: {
|
|
121
|
-
id: assignment
|
|
122
|
-
version: assignment
|
|
123
|
-
changes: { tier: effective },
|
|
141
|
+
id: assignment.id,
|
|
142
|
+
version: assignment.version,
|
|
143
|
+
changes: { tier: effective, source: TierAssignmentSources.billing },
|
|
124
144
|
},
|
|
125
145
|
tenantId,
|
|
126
146
|
});
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.346.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A failed key erase no longer causes a second delivery of the same dispatch request",
|
|
6
|
+
"detail": "step-dispatcher no longer re-sends after a failed key erase\nWhen erasing the per-dispatch key failed after the outcome was recorded, the redelivered request was sent again and produced a second `step.dispatched`. A redelivery now only repeats the erase."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.346.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Webhook caller headers no longer merge with the default Content-Type or the auth header",
|
|
12
|
+
"detail": "Webhook headers merge case-insensitively\nA caller `Content-Type` replaces the default `application/json` instead of being joined with it. When a caller header collides with the auth header (any casing), the resolved secret value wins."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.346.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Webhook requests time out after 10 seconds instead of blocking the dispatcher",
|
|
18
|
+
"detail": "Webhook requests time out after 10 seconds\nA hanging receiver now ends as `step.dispatch-failed` and no longer stalls the step-dispatcher for every tenant."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.346.0",
|
|
22
|
+
"type": "breaking",
|
|
23
|
+
"title": "r.step.webhook.send drops the unused retry option",
|
|
24
|
+
"detail": "`r.step.webhook.send` no longer accepts `retry`\nThe option was never applied: every dispatch request is delivered once. Passing it is now a type error, and the dispatch-requested payload no longer carries it. Stored events that still contain `retry` are parsed and delivered as before.",
|
|
25
|
+
"migration": "Remove `retry` from `r.step.webhook.send` calls; it was never applied. Each dispatch request is delivered once; a delivery error ends as step.dispatch-failed."
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
"version": "0.345.0",
|
|
4
29
|
"type": "improvement",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import {
|
|
3
|
+
configuredPiiSubjectKms,
|
|
4
|
+
decryptPiiValueForSubject,
|
|
5
|
+
isPiiCiphertext,
|
|
6
|
+
PII_ERASED_SENTINEL,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { type MailSpec, mailSpecSchema } from "./mail-runner.js";
|
|
10
|
+
import { type WebhookSpec, webhookSpecSchema } from "./webhook-runner.js";
|
|
11
|
+
|
|
12
|
+
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
13
|
+
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
14
|
+
// strings because event PII encryption only handles top-level strings.
|
|
15
|
+
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
16
|
+
// MSP-apply boundary, so a payload in another shape must end as
|
|
17
|
+
// dispatch-failed, never reach the runners.
|
|
18
|
+
export const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
19
|
+
z.object({
|
|
20
|
+
stepKind: z.literal("webhook.send"),
|
|
21
|
+
url: z.string(),
|
|
22
|
+
method: webhookSpecSchema.shape.method,
|
|
23
|
+
headersJson: z.string(),
|
|
24
|
+
bodyJson: z.string().optional(),
|
|
25
|
+
auth: webhookSpecSchema.shape.auth,
|
|
26
|
+
}),
|
|
27
|
+
z.object({
|
|
28
|
+
stepKind: z.literal("mail.send"),
|
|
29
|
+
to: z.string(),
|
|
30
|
+
subject: z.string(),
|
|
31
|
+
body: z.string(),
|
|
32
|
+
from: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
|
|
37
|
+
|
|
38
|
+
const rawStepKindSchema = z.object({ stepKind: z.string() });
|
|
39
|
+
|
|
40
|
+
export function rawStepKindOf(payload: unknown): string {
|
|
41
|
+
const parsed = rawStepKindSchema.safeParse(payload);
|
|
42
|
+
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(raw);
|
|
48
|
+
return parsed;
|
|
49
|
+
} catch {
|
|
50
|
+
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
51
|
+
return z.NEVER;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
56
|
+
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
57
|
+
|
|
58
|
+
type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
|
|
59
|
+
|
|
60
|
+
function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
|
|
61
|
+
return payload.stepKind === "mail.send"
|
|
62
|
+
? ["to", "subject", "body", "from"]
|
|
63
|
+
: ["url", "headersJson", "bodyJson"];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function payloadFieldValue(
|
|
67
|
+
payload: DispatchRequestedPayload,
|
|
68
|
+
field: PayloadFieldName,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
|
|
71
|
+
payload.stepKind === "mail.send"
|
|
72
|
+
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
73
|
+
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
74
|
+
return values[field];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type ReadPayloadResult =
|
|
78
|
+
| { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
|
|
79
|
+
| { readonly kind: "erased" }
|
|
80
|
+
| { readonly kind: "unreadable" };
|
|
81
|
+
|
|
82
|
+
export async function readPayloadFields(
|
|
83
|
+
payload: DispatchRequestedPayload,
|
|
84
|
+
): Promise<ReadPayloadResult> {
|
|
85
|
+
const kms = configuredPiiSubjectKms();
|
|
86
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
87
|
+
const fields: Partial<Record<PayloadFieldName, string>> = {};
|
|
88
|
+
for (const field of piiFieldsOf(payload)) {
|
|
89
|
+
const value = payloadFieldValue(payload, field);
|
|
90
|
+
if (value === undefined) continue;
|
|
91
|
+
if (!isPiiCiphertext(value)) {
|
|
92
|
+
fields[field] = value;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!kms) return { kind: "unreadable" };
|
|
96
|
+
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
97
|
+
if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
|
|
98
|
+
fields[field] = plain;
|
|
99
|
+
}
|
|
100
|
+
return { kind: "ready", fields };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type DispatchSpec =
|
|
104
|
+
| { readonly stepKind: "mail.send"; readonly spec: MailSpec }
|
|
105
|
+
| { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
|
|
106
|
+
|
|
107
|
+
// Parse failures return null — the caller records a generic error, never the
|
|
108
|
+
// (decrypted) values.
|
|
109
|
+
export function buildDispatchSpec(
|
|
110
|
+
payload: DispatchRequestedPayload,
|
|
111
|
+
fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
|
|
112
|
+
): DispatchSpec | null {
|
|
113
|
+
if (payload.stepKind === "mail.send") {
|
|
114
|
+
const to = mailToJsonSchema.safeParse(fields.to);
|
|
115
|
+
if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
|
|
116
|
+
return {
|
|
117
|
+
stepKind: "mail.send",
|
|
118
|
+
spec: {
|
|
119
|
+
to: to.data,
|
|
120
|
+
subject: fields.subject,
|
|
121
|
+
body: fields.body,
|
|
122
|
+
...(fields.from !== undefined && { from: fields.from }),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
127
|
+
if (!headers.success || fields.url === undefined) return null;
|
|
128
|
+
let body: unknown;
|
|
129
|
+
if (fields.bodyJson !== undefined) {
|
|
130
|
+
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
131
|
+
if (!parsedBody.success) return null;
|
|
132
|
+
body = parsedBody.data;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
stepKind: "webhook.send",
|
|
136
|
+
spec: {
|
|
137
|
+
url: fields.url,
|
|
138
|
+
method: payload.method,
|
|
139
|
+
headers: headers.data,
|
|
140
|
+
...(body !== undefined && { body }),
|
|
141
|
+
...(payload.auth && { auth: payload.auth }),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -8,12 +8,7 @@
|
|
|
8
8
|
// the audit trail lives in the event log only — no separate status table.
|
|
9
9
|
|
|
10
10
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
11
|
-
import {
|
|
12
|
-
configuredPiiSubjectKms,
|
|
13
|
-
decryptPiiValueForSubject,
|
|
14
|
-
isPiiCiphertext,
|
|
15
|
-
PII_ERASED_SENTINEL,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
|
+
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
12
|
import {
|
|
18
13
|
defineFeature,
|
|
19
14
|
type FeatureDefinition,
|
|
@@ -26,13 +21,17 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
26
21
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
27
22
|
import * as z from "zod";
|
|
28
23
|
import { redactEmailAddresses } from "../shared/index.js";
|
|
29
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
buildDispatchSpec,
|
|
26
|
+
dispatchRequestedPayloadSchema,
|
|
27
|
+
rawStepKindOf,
|
|
28
|
+
readPayloadFields,
|
|
29
|
+
} from "./dispatch-payload.js";
|
|
30
|
+
import { performMailDispatch } from "./mail-runner.js";
|
|
30
31
|
import {
|
|
31
32
|
performWebhookDispatch,
|
|
32
33
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
33
34
|
WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS,
|
|
34
|
-
type WebhookSpec,
|
|
35
|
-
webhookSpecSchema,
|
|
36
35
|
} from "./webhook-runner.js";
|
|
37
36
|
|
|
38
37
|
const log = createFallbackLogger("step-dispatcher");
|
|
@@ -48,33 +47,6 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
48
47
|
|
|
49
48
|
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
50
49
|
|
|
51
|
-
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
52
|
-
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
53
|
-
// strings because event PII encryption only handles top-level strings.
|
|
54
|
-
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
55
|
-
// MSP-apply boundary, so a payload in another shape must end as
|
|
56
|
-
// dispatch-failed, never reach the runners.
|
|
57
|
-
const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
58
|
-
z.object({
|
|
59
|
-
stepKind: z.literal("webhook.send"),
|
|
60
|
-
url: z.string(),
|
|
61
|
-
method: webhookSpecSchema.shape.method,
|
|
62
|
-
headersJson: z.string(),
|
|
63
|
-
bodyJson: z.string().optional(),
|
|
64
|
-
auth: webhookSpecSchema.shape.auth,
|
|
65
|
-
retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
|
|
66
|
-
}),
|
|
67
|
-
z.object({
|
|
68
|
-
stepKind: z.literal("mail.send"),
|
|
69
|
-
to: z.string(),
|
|
70
|
-
subject: z.string(),
|
|
71
|
-
body: z.string(),
|
|
72
|
-
from: z.string().optional(),
|
|
73
|
-
}),
|
|
74
|
-
]);
|
|
75
|
-
|
|
76
|
-
type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
|
|
77
|
-
|
|
78
50
|
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
79
51
|
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
80
52
|
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
@@ -84,110 +56,8 @@ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
|
84
56
|
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
85
57
|
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
86
58
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
function rawStepKindOf(payload: unknown): string {
|
|
90
|
-
const parsed = rawStepKindSchema.safeParse(payload);
|
|
91
|
-
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
95
|
-
try {
|
|
96
|
-
const parsed: unknown = JSON.parse(raw);
|
|
97
|
-
return parsed;
|
|
98
|
-
} catch {
|
|
99
|
-
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
100
|
-
return z.NEVER;
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
105
|
-
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
106
|
-
|
|
107
|
-
type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
|
|
108
|
-
|
|
109
|
-
function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
|
|
110
|
-
return payload.stepKind === "mail.send"
|
|
111
|
-
? ["to", "subject", "body", "from"]
|
|
112
|
-
: ["url", "headersJson", "bodyJson"];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function payloadFieldValue(
|
|
116
|
-
payload: DispatchRequestedPayload,
|
|
117
|
-
field: PayloadFieldName,
|
|
118
|
-
): string | undefined {
|
|
119
|
-
const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
|
|
120
|
-
payload.stepKind === "mail.send"
|
|
121
|
-
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
122
|
-
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
123
|
-
return values[field];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
type ReadPayloadResult =
|
|
127
|
-
| { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
|
|
128
|
-
| { readonly kind: "erased" }
|
|
129
|
-
| { readonly kind: "unreadable" };
|
|
130
|
-
|
|
131
|
-
async function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult> {
|
|
132
|
-
const kms = configuredPiiSubjectKms();
|
|
133
|
-
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
134
|
-
const fields: Partial<Record<PayloadFieldName, string>> = {};
|
|
135
|
-
for (const field of piiFieldsOf(payload)) {
|
|
136
|
-
const value = payloadFieldValue(payload, field);
|
|
137
|
-
if (value === undefined) continue;
|
|
138
|
-
if (!isPiiCiphertext(value)) {
|
|
139
|
-
fields[field] = value;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (!kms) return { kind: "unreadable" };
|
|
143
|
-
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
144
|
-
if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
|
|
145
|
-
fields[field] = plain;
|
|
146
|
-
}
|
|
147
|
-
return { kind: "ready", fields };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
type DispatchSpec =
|
|
151
|
-
| { readonly stepKind: "mail.send"; readonly spec: MailSpec }
|
|
152
|
-
| { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
|
|
153
|
-
|
|
154
|
-
// Parse failures return null — the caller records a generic error, never the
|
|
155
|
-
// (decrypted) values.
|
|
156
|
-
function buildDispatchSpec(
|
|
157
|
-
payload: DispatchRequestedPayload,
|
|
158
|
-
fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
|
|
159
|
-
): DispatchSpec | null {
|
|
160
|
-
if (payload.stepKind === "mail.send") {
|
|
161
|
-
const to = mailToJsonSchema.safeParse(fields.to);
|
|
162
|
-
if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
|
|
163
|
-
return {
|
|
164
|
-
stepKind: "mail.send",
|
|
165
|
-
spec: {
|
|
166
|
-
to: to.data,
|
|
167
|
-
subject: fields.subject,
|
|
168
|
-
body: fields.body,
|
|
169
|
-
...(fields.from !== undefined && { from: fields.from }),
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
174
|
-
if (!headers.success || fields.url === undefined) return null;
|
|
175
|
-
let body: unknown;
|
|
176
|
-
if (fields.bodyJson !== undefined) {
|
|
177
|
-
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
178
|
-
if (!parsedBody.success) return null;
|
|
179
|
-
body = parsedBody.data;
|
|
180
|
-
}
|
|
181
|
-
return {
|
|
182
|
-
stepKind: "webhook.send",
|
|
183
|
-
spec: {
|
|
184
|
-
url: fields.url,
|
|
185
|
-
method: payload.method,
|
|
186
|
-
headers: headers.data,
|
|
187
|
-
...(body !== undefined && { body }),
|
|
188
|
-
...(payload.auth && { auth: payload.auth }),
|
|
189
|
-
},
|
|
190
|
-
};
|
|
59
|
+
function isDispatchOutcome(e: { readonly type: string }): boolean {
|
|
60
|
+
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
191
61
|
}
|
|
192
62
|
|
|
193
63
|
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
@@ -211,9 +81,20 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
211
81
|
const kms = configuredPiiSubjectKms();
|
|
212
82
|
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
213
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
|
+
|
|
214
91
|
// Outcome events are plaintext and generic; the request payload's
|
|
215
92
|
// per-dispatch key is erased right after, so the PII dies with the
|
|
216
|
-
// dispatch instead of living in the event log.
|
|
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.
|
|
217
98
|
const recordOutcome = async (
|
|
218
99
|
type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
|
|
219
100
|
payload: Record<string, unknown>,
|
|
@@ -224,14 +105,18 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
224
105
|
type,
|
|
225
106
|
payload,
|
|
226
107
|
});
|
|
227
|
-
await
|
|
228
|
-
{ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
|
|
229
|
-
{ requestId, eraseReason: "step-dispatch-outcome-recorded" },
|
|
230
|
-
);
|
|
108
|
+
await eraseDispatchKey();
|
|
231
109
|
};
|
|
232
110
|
const recordFailure = (stepKind: string, error: string) =>
|
|
233
111
|
recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
234
112
|
|
|
113
|
+
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
114
|
+
if (stream.some(isDispatchOutcome)) {
|
|
115
|
+
await eraseDispatchKey();
|
|
116
|
+
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
235
120
|
const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
|
|
236
121
|
if (!parsed.success) {
|
|
237
122
|
await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
@@ -247,12 +132,6 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
247
132
|
return;
|
|
248
133
|
}
|
|
249
134
|
if (read.kind === "erased") {
|
|
250
|
-
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
251
|
-
const hasOutcome = stream.some(
|
|
252
|
-
(e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE,
|
|
253
|
-
);
|
|
254
|
-
// skip: redelivery after the key was erased — the outcome is already recorded
|
|
255
|
-
if (hasOutcome) return;
|
|
256
135
|
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
257
136
|
// skip: erased payload recorded via step.dispatch-failed above
|
|
258
137
|
return;
|
|
@@ -115,8 +115,13 @@ export type WebhookDispatchDeps = {
|
|
|
115
115
|
readonly userId: string;
|
|
116
116
|
readonly secrets: SecretsContext | undefined;
|
|
117
117
|
readonly idempotencyKey: string;
|
|
118
|
+
readonly requestTimeoutMs?: number;
|
|
118
119
|
};
|
|
119
120
|
|
|
121
|
+
// One hanging tenant-controlled receiver must not stall the shared
|
|
122
|
+
// step-dispatcher consumer for every tenant.
|
|
123
|
+
export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
|
|
124
|
+
|
|
120
125
|
// Never includes the secret name or value — spec.auth.secret is a
|
|
121
126
|
// tenant-chosen name, but the error still reaches the tenant via the
|
|
122
127
|
// dispatch-failed event, so it stays generic.
|
|
@@ -124,16 +129,27 @@ const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not availa
|
|
|
124
129
|
|
|
125
130
|
export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
126
131
|
|
|
127
|
-
function
|
|
128
|
-
return Object.keys(headers).some((name) => name.toLowerCase() ===
|
|
132
|
+
function hasHeader(headers: Readonly<Record<string, string>>, lowerCaseName: string): boolean {
|
|
133
|
+
return Object.keys(headers).some((name) => name.toLowerCase() === lowerCaseName);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Header names are case-insensitive: two keys differing only in case would be
|
|
137
|
+
// comma-joined by `Headers`, so a replacement must drop every spelling first.
|
|
138
|
+
function setHeader(headers: Record<string, string>, name: string, value: string): void {
|
|
139
|
+
const lowerCaseName = name.toLowerCase();
|
|
140
|
+
for (const existing of Object.keys(headers)) {
|
|
141
|
+
if (existing.toLowerCase() === lowerCaseName) delete headers[existing];
|
|
142
|
+
}
|
|
143
|
+
headers[name] = value;
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
async function buildWebhookHeaders(
|
|
132
147
|
spec: WebhookSpec,
|
|
133
148
|
deps: WebhookDispatchDeps,
|
|
134
149
|
): Promise<{ ok: true; headers: Record<string, string> } | { ok: false; error: string }> {
|
|
135
|
-
const headers: Record<string, string> = {
|
|
136
|
-
if (!
|
|
150
|
+
const headers: Record<string, string> = { ...spec.headers };
|
|
151
|
+
if (!hasHeader(headers, "content-type")) headers["content-type"] = "application/json";
|
|
152
|
+
if (!hasHeader(headers, WEBHOOK_IDEMPOTENCY_KEY_HEADER)) {
|
|
137
153
|
headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
|
|
138
154
|
}
|
|
139
155
|
if (!spec.auth) return { ok: true, headers };
|
|
@@ -154,9 +170,9 @@ async function buildWebhookHeaders(
|
|
|
154
170
|
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
155
171
|
}
|
|
156
172
|
if (spec.auth.kind === "bearer") {
|
|
157
|
-
headers
|
|
173
|
+
setHeader(headers, "authorization", `Bearer ${secret}`);
|
|
158
174
|
} else {
|
|
159
|
-
headers
|
|
175
|
+
setHeader(headers, spec.auth.name, secret);
|
|
160
176
|
}
|
|
161
177
|
return { ok: true, headers };
|
|
162
178
|
}
|
|
@@ -215,6 +231,7 @@ export async function performWebhookDispatch(
|
|
|
215
231
|
...target.requestInit,
|
|
216
232
|
method: spec.method,
|
|
217
233
|
redirect: "manual",
|
|
234
|
+
signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
|
|
218
235
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
219
236
|
});
|
|
220
237
|
if (!res.ok) {
|
|
@@ -15,6 +15,13 @@ export const TierEngineHandlers = {
|
|
|
15
15
|
setTenantTier: "tier-engine:write:set-tenant-tier",
|
|
16
16
|
} as const;
|
|
17
17
|
|
|
18
|
+
// Origin of a tier-assignment row; the billing sync must not touch "manual" rows.
|
|
19
|
+
export const TierAssignmentSources = {
|
|
20
|
+
manual: "manual",
|
|
21
|
+
billing: "billing",
|
|
22
|
+
default: "default",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
18
25
|
// Qualified query handler names.
|
|
19
26
|
export const TierEngineQueries = {
|
|
20
27
|
list: "tier-engine:query:tier-assignment:list",
|
|
@@ -33,10 +33,8 @@ export const tierAssignmentEntity = createEntity({
|
|
|
33
33
|
personal: false,
|
|
34
34
|
reason: "catalog_label",
|
|
35
35
|
}),
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
|
|
39
|
-
// Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
|
|
36
|
+
// Origin of the assignment, see TierAssignmentSources. Optional for back-compat with
|
|
37
|
+
// rows without source. Protects manual grants from being overwritten by the billing sync.
|
|
40
38
|
source: createTextField({
|
|
41
39
|
required: false,
|
|
42
40
|
maxLength: 20,
|
|
@@ -75,7 +75,12 @@ import * as z from "zod";
|
|
|
75
75
|
import { tenantTable } from "../tenant/index.js";
|
|
76
76
|
import { tierAssignmentAggregateId } from "./aggregate-id.js";
|
|
77
77
|
import type { TierMap } from "./compose-app.js";
|
|
78
|
-
import {
|
|
78
|
+
import {
|
|
79
|
+
TIER_ADMIN_SCREEN_ID,
|
|
80
|
+
TIER_ENGINE_FEATURE,
|
|
81
|
+
TierAssignmentSources,
|
|
82
|
+
TierEngineHandlers,
|
|
83
|
+
} from "./constants.js";
|
|
79
84
|
import { tierAssignmentEntity } from "./entity.js";
|
|
80
85
|
import { getActiveTierQuery } from "./handlers/active-tier.query.js";
|
|
81
86
|
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query.js";
|
|
@@ -450,7 +455,7 @@ export function createTierEngineFeature<
|
|
|
450
455
|
const tdb = createTenantDb(rawDb, newTenantId, "system");
|
|
451
456
|
|
|
452
457
|
await tierAssignmentExecutor.create(
|
|
453
|
-
{ id: aggregateId, tier: defaultTier, source:
|
|
458
|
+
{ id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
|
|
454
459
|
systemUser,
|
|
455
460
|
tdb,
|
|
456
461
|
);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { tierAssignmentAggregateId } from "../aggregate-id.js";
|
|
10
|
+
import { TierAssignmentSources } from "../constants.js";
|
|
10
11
|
import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
11
12
|
|
|
12
13
|
// SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
|
|
@@ -21,8 +22,8 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
|
21
22
|
// funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
|
|
22
23
|
// auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
|
|
23
24
|
//
|
|
24
|
-
// `source:
|
|
25
|
-
//
|
|
25
|
+
// `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
|
|
26
|
+
// Upsert: one aggregate per tenant (deterministic aggregate id).
|
|
26
27
|
//
|
|
27
28
|
// Effective-set invalidation: the executor write does not fire the
|
|
28
29
|
// `tier-assignment:postSave` entity hook, and a per-handler postSave would not
|
|
@@ -90,7 +91,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
90
91
|
{
|
|
91
92
|
id: existing.id,
|
|
92
93
|
version: existing.version,
|
|
93
|
-
changes: { tier, source:
|
|
94
|
+
changes: { tier, source: TierAssignmentSources.manual },
|
|
94
95
|
},
|
|
95
96
|
systemUser,
|
|
96
97
|
tdb,
|
|
@@ -101,7 +102,12 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const result = await executor.create(
|
|
104
|
-
{
|
|
105
|
+
{
|
|
106
|
+
id: tierAssignmentAggregateId(tenantId),
|
|
107
|
+
tier,
|
|
108
|
+
source: TierAssignmentSources.manual,
|
|
109
|
+
tenantId,
|
|
110
|
+
},
|
|
105
111
|
systemUser,
|
|
106
112
|
tdb,
|
|
107
113
|
);
|
|
@@ -17,7 +17,12 @@ export {
|
|
|
17
17
|
type TierDefinition,
|
|
18
18
|
type TierMap,
|
|
19
19
|
} from "./compose-app.js";
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
TIER_ENGINE_FEATURE,
|
|
22
|
+
TierAssignmentSources,
|
|
23
|
+
TierEngineHandlers,
|
|
24
|
+
TierEngineQueries,
|
|
25
|
+
} from "./constants.js";
|
|
21
26
|
export { tierAssignmentEntity } from "./entity.js";
|
|
22
27
|
export {
|
|
23
28
|
type CreateTierEngineOptions,
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"name": "audit",
|
|
54
|
-
"description": "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection — the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
54
|
+
"description": "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection — the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
55
55
|
"toggleableDefault": null,
|
|
56
56
|
"requires": [
|
|
57
57
|
"tenant",
|
|
@@ -5,7 +5,7 @@ Workflow / pipeline step that calls an HTTP webhook.
|
|
|
5
5
|
## What it shows
|
|
6
6
|
|
|
7
7
|
- Webhook step registration in a workflow
|
|
8
|
-
-
|
|
8
|
+
- Failure surface for outbound HTTP (one delivery attempt per request)
|
|
9
9
|
- Authenticated webhooks via a tenant-owned secret (`incident:open-authenticated`)
|
|
10
10
|
|
|
11
11
|
## Auth secret
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-sample-webhook-step",
|
|
3
|
-
"description": "Tier-2 r.step.webhook.send showcase: deferred webhook dispatch via step-dispatcher subscription, no outbox table
|
|
3
|
+
"description": "Tier-2 r.step.webhook.send showcase: deferred webhook dispatch via step-dispatcher subscription, no outbox table.",
|
|
4
4
|
"private": true,
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@cosmicdrift/kumiko-bundled-features": "workspace:*",
|