@cosmicdrift/kumiko-samples 0.345.0 → 0.347.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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +5 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/details.query.ts +14 -3
  7. package/packages/bundled-features/src/audit/handlers/list.query.ts +11 -2
  8. package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +22 -0
  9. package/packages/bundled-features/src/audit/i18n.ts +3 -0
  10. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +22 -2
  12. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -2
  13. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +6 -1
  14. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  15. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +31 -11
  16. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  17. package/packages/bundled-features/src/personal-access-tokens/constants.ts +1 -0
  18. package/packages/bundled-features/src/personal-access-tokens/feature.ts +10 -2
  19. package/packages/bundled-features/src/personal-access-tokens/handlers/availability.query.ts +20 -0
  20. package/packages/bundled-features/src/personal-access-tokens/screens.ts +10 -0
  21. package/packages/bundled-features/src/step-dispatcher/changes.json +31 -0
  22. package/packages/bundled-features/src/step-dispatcher/dispatch-payload.ts +144 -0
  23. package/packages/bundled-features/src/step-dispatcher/feature.ts +30 -151
  24. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +25 -6
  25. package/packages/bundled-features/src/tier-engine/constants.ts +7 -0
  26. package/packages/bundled-features/src/tier-engine/entity.ts +2 -4
  27. package/packages/bundled-features/src/tier-engine/feature.ts +8 -2
  28. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +10 -4
  29. package/packages/bundled-features/src/tier-engine/i18n.ts +1 -0
  30. package/packages/bundled-features/src/tier-engine/index.ts +6 -1
  31. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  32. package/samples/recipes/webhook-step/README.md +1 -1
  33. package/samples/recipes/webhook-step/package.json +1 -1
  34. package/samples/recipes/workflow-engine/README.md +3 -2
  35. package/samples/recipes/workflow-engine/src/feature.ts +15 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.345.0",
3
+ "version": "0.347.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.345.0",
3
+ "version": "0.347.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
- "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.",
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
- { tenantId: query.user.tenantId, id: BigInt(query.payload.id) },
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
- "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.",
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 where = buildAuditWhere(query.user.tenantId, p);
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.347.0",
4
+ "type": "fix",
5
+ "title": "Signup-confirm under an MFA policy returns the signup landing path and the activation screen forwards it to login as next",
6
+ "detail": "Signup with an MFA requirement keeps its landing path\nWhen the MFA policy asks for a factor at self-registration, `/auth/signup-confirm` now also returns the `landingPath` that `auth.postAuthLanding` resolves for the signup flow (including a claimed handover). `SignupCompleteScreen` passes it to the login link as `?next=`, so the login that follows can land where a signup without MFA would have."
7
+ },
2
8
  {
3
9
  "version": "0.345.0",
4
10
  "type": "fix",
@@ -76,7 +76,16 @@ export type SignupConfirmData =
76
76
  // actually succeeded (see the handler body below).
77
77
  readonly handover?: { readonly entityType: string; readonly id: string };
78
78
  }
79
- | Exclude<LoginResult, { readonly kind: "auth-session" }>;
79
+ | (Exclude<LoginResult, { readonly kind: "auth-session" }> & {
80
+ // What the response needs to resolve the signup landing although no
81
+ // session is issued yet.
82
+ readonly signup: {
83
+ readonly roles: readonly string[];
84
+ readonly tenantId: TenantId;
85
+ readonly tenantKey: string;
86
+ readonly handover?: ClaimedHandover;
87
+ };
88
+ });
80
89
 
81
90
  export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
82
91
 
@@ -240,7 +249,18 @@ export function createSignupConfirmHandler(opts: SignupConfirmOptions = {}) {
240
249
 
241
250
  if (mfaGate !== undefined) {
242
251
  committed = true;
243
- return { isSuccess: true, data: mfaGate };
252
+ return {
253
+ isSuccess: true,
254
+ data: {
255
+ ...mfaGate,
256
+ signup: {
257
+ roles: session.roles,
258
+ tenantId: provisioned.tenantId,
259
+ tenantKey,
260
+ ...(handover !== undefined && { handover }),
261
+ },
262
+ },
263
+ };
244
264
  }
245
265
 
246
266
  committed = true;
@@ -362,9 +362,11 @@ export type SignupConfirmSuccess = {
362
362
 
363
363
  // mfa-pending: account exists but the server issued no session because the
364
364
  // new roles require a second factor — the user must sign in to enroll.
365
+ // landingPath is where the signup would have landed; the login that follows
366
+ // should end there.
365
367
  export type SignupConfirmResult =
366
368
  | ({ readonly kind: "signed-in" } & SignupConfirmSuccess)
367
- | { readonly kind: "mfa-pending" };
369
+ | { readonly kind: "mfa-pending"; readonly landingPath?: string };
368
370
 
369
371
  export async function confirmSignup(
370
372
  token: string,
@@ -383,7 +385,13 @@ export async function confirmSignup(
383
385
  readonly mfaSetupRequired?: boolean;
384
386
  };
385
387
  if (body.mfaRequired === true || body.mfaSetupRequired === true) {
386
- return { ok: true, data: { kind: "mfa-pending" } };
388
+ return {
389
+ ok: true,
390
+ data: {
391
+ kind: "mfa-pending",
392
+ ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
393
+ },
394
+ };
387
395
  }
388
396
  return { ok: true, data: { kind: "signed-in", ...body } };
389
397
  }
@@ -22,6 +22,7 @@ import { type FormEvent, type ReactNode, useState } from "react";
22
22
  import { confirmSignup, type SignupConfirmSuccess } from "./auth-client.js";
23
23
  import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic.js";
24
24
  import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
25
+ import { buildLoginRedirectUrl } from "./auth-redirect.js";
25
26
 
26
27
  export type SignupCompleteScreenProps = {
27
28
  readonly title?: string;
@@ -76,7 +77,11 @@ export function SignupCompleteScreen({
76
77
  if (res.ok) {
77
78
  if (res.data.kind === "mfa-pending") {
78
79
  setMfaPending(true);
79
- setContinueHref(loginHref);
80
+ setContinueHref(
81
+ res.data.landingPath === undefined
82
+ ? loginHref
83
+ : buildLoginRedirectUrl(loginHref, res.data.landingPath, window.location.origin),
84
+ );
80
85
  return;
81
86
  }
82
87
  // Cookies are already set (auto-login). Show a confirmation with an
@@ -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.find((row) => row["tenantId"] === tenantId);
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: { id: tierAssignmentAggregateId(tenantId), tier: effective },
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["tier"] === effective) return null;
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["id"],
122
- version: assignment["version"],
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,10 @@
1
1
  [
2
+ {
3
+ "version": "0.347.0",
4
+ "type": "improvement",
5
+ "title": "API-token screens hide behind the tier gate and can fall back to an upgrade screen via lockedFallbackScreen",
6
+ "detail": "Token screens follow the tier gate of personal-access-tokens\nThe list and mint screens carry a `visibleWhen` on the new `personal-access-tokens:query:availability` probe. For a tenant whose tier excludes the feature (`toggleable`), the dispatcher already rejected every token handler with `feature_disabled`; now the screens also show the unavailable notice instead of an empty list with a broken Create button. A new option `lockedFallbackScreen` names a screen (for example an upgrade notice) to show in its place. Without `toggleable` nothing changes: the feature stays always on."
7
+ },
2
8
  {
3
9
  "version": "0.336.0",
4
10
  "type": "fix",
@@ -32,6 +32,7 @@ export const PatHandlers = {
32
32
  export const PatQueries = {
33
33
  mine: "personal-access-tokens:query:mine",
34
34
  availableScopes: "personal-access-tokens:query:available-scopes",
35
+ availability: "personal-access-tokens:query:availability",
35
36
  } as const;
36
37
 
37
38
  // Only the first chars of a minted token are stored (alongside the hash) so the
@@ -11,6 +11,7 @@ import {
11
11
  PAT_FEATURE,
12
12
  type PatRateLimit,
13
13
  } from "./constants.js";
14
+ import { availabilityQuery } from "./handlers/availability.query.js";
14
15
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query.js";
15
16
  import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write.js";
16
17
  import { listPatQuery } from "./handlers/list.query.js";
@@ -65,6 +66,10 @@ export type PersonalAccessTokensOptions = {
65
66
  * { default: false } for fail-closed gating (feature off until a tier grants
66
67
  * it). Omit to keep PAT always-on (default). */
67
68
  readonly toggleable?: { readonly default: boolean };
69
+ /** Screen (same-feature id or `<feature>:screen:<id>`, must not be gated itself)
70
+ * shown in place of the token screens when the tenant's tier excludes the
71
+ * feature, e.g. an upgrade notice. Default: the standard "unavailable" notice. */
72
+ readonly lockedFallbackScreen?: string;
68
73
  // Opt-in MFA re-auth gate for minting a token — wired via
69
74
  // mfaVerifierFromFeature (auth-mfa/feature.ts) at app-composition time. No
70
75
  // hard dependency on the optional auth-mfa feature.
@@ -161,13 +166,16 @@ export function createPersonalAccessTokensFeature(
161
166
  };
162
167
  const queries = {
163
168
  mine: r.queryHandler(listPatQuery),
169
+ availability: r.queryHandler(availabilityQuery),
164
170
  availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
165
171
  };
166
172
 
167
173
  // Declarative screens — list-with-revoke + mint-with-reveal. The app
168
174
  // places `patListScreen` via r.nav in its logged-in settings area.
169
- r.screen(patListScreen);
170
- r.screen(createPatMintScreen(scopes));
175
+ const lockedFallback =
176
+ options.lockedFallbackScreen === undefined ? {} : { fallback: options.lockedFallbackScreen };
177
+ r.screen({ ...patListScreen, ...lockedFallback });
178
+ r.screen({ ...createPatMintScreen(scopes), ...lockedFallback });
171
179
  r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
172
180
 
173
181
  // rateLimit flows into feature.exports so run-prod-app builds the
@@ -0,0 +1,20 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+
4
+ // Screen gate probe: the dispatcher's feature gate rejects every handler of a
5
+ // toggleable feature the tenant's tier does not include, so this query only
6
+ // answers for tenants that may use tokens — a rejection makes the screens'
7
+ // visibleWhen fall back instead of rendering an unusable token list.
8
+ export const availabilityQuery = defineQueryHandler({
9
+ name: "availability",
10
+ schema: z.object({}),
11
+ access: {
12
+ openToAll: {
13
+ reason:
14
+ "constant answer that only exists to be gated by the feature toggle; it carries no user or tenant data",
15
+ },
16
+ },
17
+ description:
18
+ "Reports that personal access tokens are available to the caller's tenant; fails with feature_disabled when the tenant's tier excludes them. The token screens use it to decide whether to render.",
19
+ handler: async () => ({ enabled: true }),
20
+ });
@@ -9,9 +9,18 @@ import type { PatScopeConfig } from "./scopes.js";
9
9
 
10
10
  const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
11
11
 
12
+ // Both screens render only for tenants whose tier includes the feature; the
13
+ // availability query is rejected by the feature gate otherwise.
14
+ const PAT_SCREEN_VISIBLE_WHEN = {
15
+ query: PatQueries.availability,
16
+ field: "enabled",
17
+ eq: true,
18
+ } as const;
19
+
12
20
  export const patListScreen: ProjectionListScreenDefinition = {
13
21
  id: PAT_SCREEN_ID,
14
22
  type: "projectionList",
23
+ visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
15
24
  query: PatQueries.mine,
16
25
  // The `mine` handler honours `limit` only (no offset/total), so a pager would
17
26
  // show page 1 forever: send one max-size request and render no pager.
@@ -86,6 +95,7 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
86
95
  return {
87
96
  id: PAT_MINT_SCREEN_ID,
88
97
  type: "secretMint",
98
+ visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
89
99
  handler: PatHandlers.create,
90
100
  fields: {
91
101
  name: { type: "text", required: true, maxLength: 120 },
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "0.347.0",
4
+ "type": "fix",
5
+ "title": "Webhook dispatch cancels the unread response body so a stalling receiver cannot hold the connection",
6
+ "detail": "Webhook dispatch releases the connection right after the status\nThe step-dispatcher only needs the response status, so it now cancels the unread response body instead of leaving the socket open until the 10 s request timeout fires."
7
+ },
8
+ {
9
+ "version": "0.346.0",
10
+ "type": "fix",
11
+ "title": "A failed key erase no longer causes a second delivery of the same dispatch request",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.346.0",
16
+ "type": "fix",
17
+ "title": "Webhook caller headers no longer merge with the default Content-Type or the auth header",
18
+ "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."
19
+ },
20
+ {
21
+ "version": "0.346.0",
22
+ "type": "fix",
23
+ "title": "Webhook requests time out after 10 seconds instead of blocking the dispatcher",
24
+ "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."
25
+ },
26
+ {
27
+ "version": "0.346.0",
28
+ "type": "breaking",
29
+ "title": "r.step.webhook.send drops the unused retry option",
30
+ "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.",
31
+ "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."
32
+ },
2
33
  {
3
34
  "version": "0.345.0",
4
35
  "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 { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner.js";
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
- const rawStepKindSchema = z.object({ stepKind: z.string() });
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 kms?.eraseKey(
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 hasIdempotencyKeyHeader(headers: Readonly<Record<string, string>>): boolean {
128
- return Object.keys(headers).some((name) => name.toLowerCase() === WEBHOOK_IDEMPOTENCY_KEY_HEADER);
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> = { "content-type": "application/json", ...spec.headers };
136
- if (!hasIdempotencyKeyHeader(headers)) {
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["authorization"] = `Bearer ${secret}`;
173
+ setHeader(headers, "authorization", `Bearer ${secret}`);
158
174
  } else {
159
- headers[spec.auth.name] = secret;
175
+ setHeader(headers, spec.auth.name, secret);
160
176
  }
161
177
  return { ok: true, headers };
162
178
  }
@@ -215,8 +231,11 @@ 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
  });
237
+ // Only the status is used; an unread body would pin the socket until the timeout fires.
238
+ await res.body?.cancel().catch(() => {});
220
239
  if (!res.ok) {
221
240
  return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
222
241
  }
@@ -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
- // Woher das Assignment stammt: "manual" (Admin-Grant via tier-admin-Screen),
37
- // "stripe" (future Billing-Sync), "default" (auto-default-on-signup-Hook).
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 { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants.js";
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";
@@ -292,6 +297,7 @@ export function createTierEngineFeature<
292
297
  ],
293
298
  },
294
299
  submitLabel: "tier-admin.submit",
300
+ successMessage: "tier-admin.success",
295
301
  cancelTarget: false,
296
302
  description: "tier-admin.screen.subtitle",
297
303
  access: { roles: ["SystemAdmin"] },
@@ -450,7 +456,7 @@ export function createTierEngineFeature<
450
456
  const tdb = createTenantDb(rawDb, newTenantId, "system");
451
457
 
452
458
  await tierAssignmentExecutor.create(
453
- { id: aggregateId, tier: defaultTier, source: "default" },
459
+ { id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
454
460
  systemUser,
455
461
  tdb,
456
462
  );
@@ -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: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
25
- // nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
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: "manual" },
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
- { id: tierAssignmentAggregateId(tenantId), tier, source: "manual", tenantId },
105
+ {
106
+ id: tierAssignmentAggregateId(tenantId),
107
+ tier,
108
+ source: TierAssignmentSources.manual,
109
+ tenantId,
110
+ },
105
111
  systemUser,
106
112
  tdb,
107
113
  );
@@ -25,6 +25,7 @@ export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
25
25
  "tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
26
26
  "tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
27
27
  "tier-admin.submit": { en: "Assign tier" },
28
+ "tier-admin.success": { en: "Tier assigned: {tenantId} → {tier}" },
28
29
  };
29
30
 
30
31
  export const defaultTranslations: TranslationsByLocale =
@@ -17,7 +17,12 @@ export {
17
17
  type TierDefinition,
18
18
  type TierMap,
19
19
  } from "./compose-app.js";
20
- export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants.js";
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
- - Retry / failure surface for outbound HTTP
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, retry from event-stream.",
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:*",
@@ -1,11 +1,12 @@
1
1
  # Workflow Engine
2
2
 
3
- Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook, retry.
3
+ Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook.
4
4
 
5
5
  ## What it shows
6
6
 
7
7
  - Real runnable pipelines (no empty `build: () => []` stubs)
8
- - Workflow-run lifecycle across wait / waitForEvent / retry
8
+ - Workflow-run lifecycle across wait / waitForEvent
9
+ - `webhook.send` is delivered once by the step-dispatcher; a failed delivery ends as `step.dispatch-failed`. Wrapping it in `r.step.retry` would not repeat it, because the step only enqueues the request and never throws on a delivery error
9
10
 
10
11
  ## Source
11
12
 
@@ -1,7 +1,7 @@
1
1
  // workflow-engine Sample — M.4 Tier-3 step-vocabulary showcase.
2
2
  //
3
- // Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send,
4
- // retry, and the workflow-run lifecycle. Each workflow below is a real,
3
+ // Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send
4
+ // and the workflow-run lifecycle. Each workflow below is a real,
5
5
  // runnable pipeline — no empty `build: () => []` stubs. The
6
6
  // integration-tests in __tests__/ exercise the suspension/resume cycle
7
7
  // against the in-memory fetcher and (separately) the postgres event-store.
@@ -17,7 +17,7 @@
17
17
  // full HandlerContext surface. `r.step.read.findOne` works (db is
18
18
  // present), `r.step.callFeature` does NOT yet (no `write`/`writeAs`
19
19
  // on apply-ctx). Pipelines should stick to compute / branch / wait /
20
- // retry / mail.send / webhook.send for now.
20
+ // mail.send / webhook.send for now.
21
21
  // - The fetcher reads every suspension row whose wakeAt has expired
22
22
  // (no `workflow_run_pending` read-side projection yet). Concurrency
23
23
  // is safe via the event-store version-conflict path; performance is
@@ -98,28 +98,24 @@ export const userOnboardingWorkflow: WorkflowDefinition<{ email: string; userId:
98
98
  });
99
99
 
100
100
  /**
101
- * Retry-with-backoff workflow: wraps a deferred webhook in retry(3,
102
- * exponential). The retry step suspends the run between attempts and
103
- * the resume-loop re-enters it after the backoff window.
101
+ * Webhook-delivery workflow: one deferred webhook per `data.processed` event.
102
+ * `webhook.send` only enqueues a dispatch request; the step-dispatcher
103
+ * delivers it once and a failed delivery ends as `step.dispatch-failed`. A
104
+ * `retry` around it would never fire, because the step itself cannot throw
105
+ * on a delivery error.
104
106
  */
105
- export const resilientWebhookWorkflow: WorkflowDefinition<
107
+ export const webhookDeliveryWorkflow: WorkflowDefinition<
106
108
  { data: unknown; webhookUrl: string },
107
109
  void
108
110
  > = defineWorkflow({
109
- name: "resilient-webhook",
111
+ name: "webhook-delivery",
110
112
  trigger: { kind: "event", eventType: "data.processed" },
111
113
 
112
114
  steps: stepsPipeline<{ data: unknown; webhookUrl: string }, void>(({ r }) => [
113
- r.step.retry({
114
- times: 3,
115
- backoff: "exponential",
116
- do: [
117
- r.step.webhook.send({
118
- url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
119
- body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
120
- mode: "deferred",
121
- }),
122
- ],
115
+ r.step.webhook.send({
116
+ url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
117
+ body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
118
+ mode: "deferred",
123
119
  }),
124
120
  r.step.return({ isSuccess: true, data: undefined }),
125
121
  ]),
@@ -155,7 +151,7 @@ export const workflowEngineFeature = defineFeature("workflowEngine", (r) => {
155
151
  // The runtime only touches trigger/name/idempotencyKey + executes
156
152
  // the closure with the real event payload — payload-agnostic.
157
153
  registerEventTrigger(r, userOnboardingWorkflow as unknown as WorkflowDefinition);
158
- registerEventTrigger(r, resilientWebhookWorkflow as unknown as WorkflowDefinition);
154
+ registerEventTrigger(r, webhookDeliveryWorkflow as unknown as WorkflowDefinition);
159
155
  // dailyReportWorkflow is cron-triggered — skip MSP registration
160
156
  });
161
157