@cosmicdrift/kumiko-bundled-features 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 (42) hide show
  1. package/dist/audit/constants.d.ts +7 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +1 -1
  4. package/dist/audit/handlers/details.query.d.ts +4 -0
  5. package/dist/audit/handlers/details.query.js +14 -3
  6. package/dist/audit/handlers/list.query.d.ts +4 -0
  7. package/dist/audit/handlers/list.query.js +11 -2
  8. package/dist/audit/handlers/resolve-audit-tenant.d.ts +7 -0
  9. package/dist/audit/handlers/resolve-audit-tenant.js +14 -0
  10. package/dist/audit/i18n.js +3 -0
  11. package/dist/auth-email-password/handlers/signup-confirm.write.d.ts +15 -3
  12. package/dist/auth-email-password/handlers/signup-confirm.write.js +12 -1
  13. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  14. package/dist/auth-email-password/web/auth-client.js +7 -1
  15. package/dist/auth-email-password/web/signup-complete-screen.js +4 -1
  16. package/dist/billing-foundation/subscription-tier-sync.js +21 -10
  17. package/dist/personal-access-tokens/constants.d.ts +1 -0
  18. package/dist/personal-access-tokens/constants.js +1 -0
  19. package/dist/personal-access-tokens/feature.d.ts +4 -0
  20. package/dist/personal-access-tokens/feature.js +5 -2
  21. package/dist/personal-access-tokens/handlers/availability.query.d.ts +4 -0
  22. package/dist/personal-access-tokens/handlers/availability.query.js +17 -0
  23. package/dist/personal-access-tokens/screens.js +9 -0
  24. package/dist/step-dispatcher/dispatch-payload.d.ts +49 -0
  25. package/dist/step-dispatcher/dispatch-payload.js +115 -0
  26. package/dist/step-dispatcher/feature.js +21 -120
  27. package/dist/step-dispatcher/webhook-runner.d.ts +2 -0
  28. package/dist/step-dispatcher/webhook-runner.js +24 -6
  29. package/dist/tier-engine/constants.d.ts +5 -0
  30. package/dist/tier-engine/constants.js +6 -0
  31. package/dist/tier-engine/entity.js +2 -4
  32. package/dist/tier-engine/feature.js +3 -2
  33. package/dist/tier-engine/handlers/set-tenant-tier.write.js +10 -4
  34. package/dist/tier-engine/i18n.js +1 -0
  35. package/dist/tier-engine/index.d.ts +1 -1
  36. package/dist/tier-engine/index.js +1 -1
  37. package/package.json +9 -9
  38. package/src/audit/changes.json +6 -0
  39. package/src/auth-email-password/changes.json +6 -0
  40. package/src/billing-foundation/changes.json +6 -0
  41. package/src/personal-access-tokens/changes.json +6 -0
  42. package/src/step-dispatcher/changes.json +31 -0
@@ -9,6 +9,13 @@ export declare const AuditQueries: {
9
9
  readonly list: "audit:query:list";
10
10
  readonly details: "audit:query:details";
11
11
  };
12
+ /** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
13
+ export declare const AuditScopes: {
14
+ readonly tenant: "tenant";
15
+ readonly system: "system";
16
+ };
17
+ export type AuditScope = (typeof AuditScopes)[keyof typeof AuditScopes];
18
+ export declare const AUDIT_SCOPE_VALUES: readonly ["tenant", "system"];
12
19
  /** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
13
20
  export declare const AUDIT_LOG_SCREEN_ID: "audit-log";
14
21
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
@@ -14,6 +14,9 @@ export const AuditQueries = {
14
14
  list: "audit:query:list",
15
15
  details: "audit:query:details",
16
16
  };
17
+ /** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
18
+ export const AuditScopes = { tenant: "tenant", system: "system" };
19
+ export const AUDIT_SCOPE_VALUES = [AuditScopes.tenant, AuditScopes.system];
17
20
  /** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
18
21
  export const AUDIT_LOG_SCREEN_ID = "audit-log";
19
22
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
@@ -20,7 +20,7 @@ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
20
20
  // framework).
21
21
  export function createAuditFeature() {
22
22
  return defineFeature("audit", (r) => {
23
- r.describe("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).");
23
+ r.describe("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).");
24
24
  r.uiHints({
25
25
  displayLabel: "Audit Log",
26
26
  category: "compliance",
@@ -1,6 +1,10 @@
1
1
  import * as z from "zod";
2
2
  export declare const detailsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"details", z.ZodObject<{
3
3
  id: z.ZodString;
4
+ scope: z.ZodOptional<z.ZodEnum<{
5
+ system: "system";
6
+ tenant: "tenant";
7
+ }>>;
4
8
  }, z.core.$strip>, {
5
9
  id: string;
6
10
  aggregateId: string;
@@ -1,19 +1,30 @@
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
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
6
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
7
8
  import * as z from "zod";
9
+ import { AUDIT_SCOPE_VALUES } from "../constants.js";
10
+ import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
8
11
  export const detailsQuery = defineQueryHandler({
9
12
  name: "details",
10
- description: "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.",
13
+ description: '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).',
11
14
  schema: z.object({
12
15
  id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
16
+ scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
13
17
  }),
14
18
  access: { roles: access.admin },
15
19
  handler: async (query, ctx) => {
16
- const rows = await selectMany(ctx.db, eventsTable, { tenantId: query.user.tenantId, id: BigInt(query.payload.id) }, { limit: 1 });
20
+ const scopeFilter = resolveAuditScopeFilter(query.user, query.payload.scope);
21
+ const rows = await selectMany(ctx.db, eventsTable, {
22
+ tenantId: scopeFilter.tenantId,
23
+ ...(scopeFilter.aggregateType !== undefined && {
24
+ aggregateType: scopeFilter.aggregateType,
25
+ }),
26
+ id: BigInt(query.payload.id),
27
+ }, { limit: 1 });
17
28
  const row = rows[0];
18
29
  if (row === undefined)
19
30
  return null;
@@ -18,6 +18,10 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
18
18
  }>>;
19
19
  from: z.ZodOptional<z.ZodISODateTime>;
20
20
  to: z.ZodOptional<z.ZodISODateTime>;
21
+ scope: z.ZodOptional<z.ZodEnum<{
22
+ system: "system";
23
+ tenant: "tenant";
24
+ }>>;
21
25
  }, z.core.$strip>, {
22
26
  rows: {
23
27
  id: string;
@@ -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
@@ -16,6 +18,8 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
16
18
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
19
  import { Temporal } from "temporal-polyfill";
18
20
  import * as z from "zod";
21
+ import { AUDIT_SCOPE_VALUES } from "../constants.js";
22
+ import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
19
23
  const MAX_LIMIT = 100;
20
24
  function buildDateRange(from, to) {
21
25
  if (!from && !to)
@@ -52,7 +56,7 @@ function buildAuditWhere(tenantId, p) {
52
56
  }
53
57
  export const listQuery = defineQueryHandler({
54
58
  name: "list",
55
- description: "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.",
59
+ description: '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.',
56
60
  schema: z
57
61
  .object({
58
62
  cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
@@ -67,6 +71,7 @@ export const listQuery = defineQueryHandler({
67
71
  sortDirection: z.enum(["asc", "desc"]).optional(),
68
72
  from: z.iso.datetime().optional(),
69
73
  to: z.iso.datetime().optional(),
74
+ scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
70
75
  })
71
76
  .refine((v) => !v.from || !v.to || v.from <= v.to, {
72
77
  message: "`from` must be less than or equal to `to`",
@@ -75,7 +80,11 @@ export const listQuery = defineQueryHandler({
75
80
  access: { roles: access.admin },
76
81
  handler: async (query, ctx) => {
77
82
  const p = query.payload;
78
- const where = buildAuditWhere(query.user.tenantId, p);
83
+ const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
84
+ const where = buildAuditWhere(scopeFilter.tenantId, {
85
+ ...p,
86
+ aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
87
+ });
79
88
  const rows = await selectMany(ctx.db, eventsTable, where, {
80
89
  orderBy: {
81
90
  col: query.payload.sort === "type" ? "type" : "createdAt",
@@ -0,0 +1,7 @@
1
+ import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type AuditScope } from "../constants.js";
3
+ export type AuditScopeFilter = {
4
+ readonly tenantId: string;
5
+ readonly aggregateType?: string;
6
+ };
7
+ export declare function resolveAuditScopeFilter(user: SessionUser, scope: AuditScope | undefined): AuditScopeFilter;
@@ -0,0 +1,14 @@
1
+ import { crossTenantOverrideDenied } 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 { AuditScopes } from "../constants.js";
5
+ // The system tenant also holds personal streams of other features (session
6
+ // revocations, exports, ...); only app-instance streams are released.
7
+ export function resolveAuditScopeFilter(user, scope) {
8
+ if (scope !== AuditScopes.system)
9
+ return { tenantId: user.tenantId };
10
+ const denied = crossTenantOverrideDenied(user, SYSTEM_TENANT_ID, "audit.errors.systemScopeRequiresSystemAdmin");
11
+ if (denied)
12
+ throw denied;
13
+ return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
14
+ }
@@ -6,6 +6,9 @@ export const AUDIT_I18N = {
6
6
  "audit.log.detail.subtitle": {
7
7
  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.",
8
8
  },
9
+ "audit.errors.systemScopeRequiresSystemAdmin": {
10
+ en: "Only SystemAdmin may read system events.",
11
+ },
9
12
  "audit:nav.auditLog": { en: "Audit" },
10
13
  "audit.log.col.when": { en: "When" },
11
14
  "audit.log.col.type": { en: "Event" },
@@ -1,4 +1,4 @@
1
- import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
4
4
  export type SignupConfirmData = {
@@ -9,11 +9,23 @@ export type SignupConfirmData = {
9
9
  readonly entityType: string;
10
10
  readonly id: string;
11
11
  };
12
- } | Exclude<LoginResult, {
12
+ } | (Exclude<LoginResult, {
13
13
  readonly kind: "auth-session";
14
- }>;
14
+ }> & {
15
+ readonly signup: {
16
+ readonly roles: readonly string[];
17
+ readonly tenantId: TenantId;
18
+ readonly tenantKey: string;
19
+ readonly handover?: ClaimedHandover;
20
+ };
21
+ });
15
22
  export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
23
+ type ClaimedHandover = {
24
+ readonly entityType: string;
25
+ readonly id: string;
26
+ };
16
27
  export declare function createSignupConfirmHandler(opts?: SignupConfirmOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"signup-confirm", z.ZodObject<{
17
28
  token: z.ZodString;
18
29
  password: z.ZodString;
19
30
  }, z.core.$strip>, SignupConfirmData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
31
+ export {};
@@ -168,7 +168,18 @@ export function createSignupConfirmHandler(opts = {}) {
168
168
  await deleteSignupHandover(ctx.redis, event.payload.token);
169
169
  if (mfaGate !== undefined) {
170
170
  committed = true;
171
- return { isSuccess: true, data: mfaGate };
171
+ return {
172
+ isSuccess: true,
173
+ data: {
174
+ ...mfaGate,
175
+ signup: {
176
+ roles: session.roles,
177
+ tenantId: provisioned.tenantId,
178
+ tenantKey,
179
+ ...(handover !== undefined && { handover }),
180
+ },
181
+ },
182
+ };
172
183
  }
173
184
  committed = true;
174
185
  return {
@@ -116,6 +116,7 @@ export type SignupConfirmResult = ({
116
116
  readonly kind: "signed-in";
117
117
  } & SignupConfirmSuccess) | {
118
118
  readonly kind: "mfa-pending";
119
+ readonly landingPath?: string;
119
120
  };
120
121
  export declare function confirmSignup(token: string, password: string): Promise<{
121
122
  ok: true;
@@ -232,7 +232,13 @@ export async function confirmSignup(token, password) {
232
232
  // @cast-boundary engine-payload
233
233
  const body = (await res.json());
234
234
  if (body.mfaRequired === true || body.mfaSetupRequired === true) {
235
- return { ok: true, data: { kind: "mfa-pending" } };
235
+ return {
236
+ ok: true,
237
+ data: {
238
+ kind: "mfa-pending",
239
+ ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
240
+ },
241
+ };
236
242
  }
237
243
  return { ok: true, data: { kind: "signed-in", ...body } };
238
244
  }
@@ -22,6 +22,7 @@ import { useState } from "react";
22
22
  import { confirmSignup } 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
  export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "/", loginHref = "/login", }) {
26
27
  const t = useTranslation();
27
28
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
@@ -49,7 +50,9 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
49
50
  if (res.ok) {
50
51
  if (res.data.kind === "mfa-pending") {
51
52
  setMfaPending(true);
52
- setContinueHref(loginHref);
53
+ setContinueHref(res.data.landingPath === undefined
54
+ ? loginHref
55
+ : buildLoginRedirectUrl(loginHref, res.data.landingPath, window.location.origin));
53
56
  return;
54
57
  }
55
58
  // Cookies are already set (auto-login). Show a confirmation with an
@@ -2,7 +2,7 @@
2
2
  // near-identical webhook-route.ts app copies in show-pony and publicstatus
3
3
  // (infra#446) — the only per-app variables were the TierName union/default,
4
4
  // so it is now a factory parameter.
5
- import { TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
5
+ import { TierAssignmentSources, TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
6
6
  import { subscriptionAggregateId } from "./aggregate-id.js";
7
7
  import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.js";
8
8
  import { createSubscriptionWebhookRoute } from "./webhook-handler.js";
@@ -25,6 +25,13 @@ function asRows(result) {
25
25
  }
26
26
  throw new Error("expected a { rows: [...] } list-query result");
27
27
  }
28
+ function findTierAssignment(rows, tenantId) {
29
+ const row = rows.find((candidate) => candidate["tenantId"] === tenantId);
30
+ if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
31
+ return undefined;
32
+ }
33
+ return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
34
+ }
28
35
  export function createSubscriptionTierSync(deps) {
29
36
  async function syncTierFromSubscription(tenantId, routeDeps) {
30
37
  try {
@@ -43,13 +50,15 @@ export function createSubscriptionTierSync(deps) {
43
50
  payload: {},
44
51
  tenantId,
45
52
  }));
46
- const assignment = tierAssignmentRows.find((row) => row["tenantId"] === tenantId);
47
- if (!assignment ||
48
- typeof assignment["id"] !== "string" ||
49
- typeof assignment["version"] !== "number") {
53
+ const assignment = findTierAssignment(tierAssignmentRows, tenantId);
54
+ if (!assignment) {
50
55
  const created = await routeDeps.dispatchSystemWrite({
51
56
  handlerQn: TierEngineHandlers.create,
52
- payload: { id: tierAssignmentAggregateId(tenantId), tier: effective },
57
+ payload: {
58
+ id: tierAssignmentAggregateId(tenantId),
59
+ tier: effective,
60
+ source: TierAssignmentSources.billing,
61
+ },
53
62
  tenantId,
54
63
  });
55
64
  if (!created.isSuccess) {
@@ -60,14 +69,16 @@ export function createSubscriptionTierSync(deps) {
60
69
  }
61
70
  return null;
62
71
  }
63
- if (assignment["tier"] === effective)
72
+ if (assignment.source === TierAssignmentSources.manual)
73
+ return null;
74
+ if (assignment.tier === effective)
64
75
  return null;
65
76
  const result = await routeDeps.dispatchSystemWrite({
66
77
  handlerQn: TierEngineHandlers.update,
67
78
  payload: {
68
- id: assignment["id"],
69
- version: assignment["version"],
70
- changes: { tier: effective },
79
+ id: assignment.id,
80
+ version: assignment.version,
81
+ changes: { tier: effective, source: TierAssignmentSources.billing },
71
82
  },
72
83
  tenantId,
73
84
  });
@@ -14,6 +14,7 @@ export declare const PatHandlers: {
14
14
  export declare const PatQueries: {
15
15
  readonly mine: "personal-access-tokens:query:mine";
16
16
  readonly availableScopes: "personal-access-tokens:query:available-scopes";
17
+ readonly availability: "personal-access-tokens:query:availability";
17
18
  };
18
19
  export declare const PAT_PREFIX_DISPLAY_LENGTH = 12;
19
20
  export type PatRateLimit = {
@@ -26,6 +26,7 @@ export const PatHandlers = {
26
26
  export const PatQueries = {
27
27
  mine: "personal-access-tokens:query:mine",
28
28
  availableScopes: "personal-access-tokens:query:available-scopes",
29
+ availability: "personal-access-tokens:query:availability",
29
30
  };
30
31
  // Only the first chars of a minted token are stored (alongside the hash) so the
31
32
  // UI can show "kpat_ab12…" for recognition without ever holding the secret.
@@ -13,6 +13,10 @@ export type PersonalAccessTokensOptions = {
13
13
  readonly toggleable?: {
14
14
  readonly default: boolean;
15
15
  };
16
+ /** Screen (same-feature id or `<feature>:screen:<id>`, must not be gated itself)
17
+ * shown in place of the token screens when the tenant's tier excludes the
18
+ * feature, e.g. an upgrade notice. Default: the standard "unavailable" notice. */
19
+ readonly lockedFallbackScreen?: string;
16
20
  readonly mfaVerifier?: CreatePatOptions["mfaVerifier"];
17
21
  readonly autoRevokeOnPasswordChange?: (userId: string) => Promise<number>;
18
22
  };
@@ -3,6 +3,7 @@ import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
3
3
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { API_TOKEN_ENTITY_NAME, PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, } from "./constants.js";
6
+ import { availabilityQuery } from "./handlers/availability.query.js";
6
7
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query.js";
7
8
  import { createPatCreateHandler } from "./handlers/create.write.js";
8
9
  import { listPatQuery } from "./handlers/list.query.js";
@@ -105,12 +106,14 @@ export function createPersonalAccessTokensFeature(options) {
105
106
  };
106
107
  const queries = {
107
108
  mine: r.queryHandler(listPatQuery),
109
+ availability: r.queryHandler(availabilityQuery),
108
110
  availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
109
111
  };
110
112
  // Declarative screens — list-with-revoke + mint-with-reveal. The app
111
113
  // places `patListScreen` via r.nav in its logged-in settings area.
112
- r.screen(patListScreen);
113
- r.screen(createPatMintScreen(scopes));
114
+ const lockedFallback = options.lockedFallbackScreen === undefined ? {} : { fallback: options.lockedFallbackScreen };
115
+ r.screen({ ...patListScreen, ...lockedFallback });
116
+ r.screen({ ...createPatMintScreen(scopes), ...lockedFallback });
114
117
  r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
115
118
  // rateLimit flows into feature.exports so run-prod-app builds the
116
119
  // limiter from the same declaration — single source of truth. `scopes`
@@ -0,0 +1,4 @@
1
+ import * as z from "zod";
2
+ export declare const availabilityQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"availability", z.ZodObject<{}, z.core.$strip>, {
3
+ enabled: boolean;
4
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -0,0 +1,17 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ // Screen gate probe: the dispatcher's feature gate rejects every handler of a
4
+ // toggleable feature the tenant's tier does not include, so this query only
5
+ // answers for tenants that may use tokens — a rejection makes the screens'
6
+ // visibleWhen fall back instead of rendering an unusable token list.
7
+ export const availabilityQuery = defineQueryHandler({
8
+ name: "availability",
9
+ schema: z.object({}),
10
+ access: {
11
+ openToAll: {
12
+ reason: "constant answer that only exists to be gated by the feature toggle; it carries no user or tenant data",
13
+ },
14
+ },
15
+ description: "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.",
16
+ handler: async () => ({ enabled: true }),
17
+ });
@@ -1,9 +1,17 @@
1
1
  import { i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants.js";
3
3
  const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
4
+ // Both screens render only for tenants whose tier includes the feature; the
5
+ // availability query is rejected by the feature gate otherwise.
6
+ const PAT_SCREEN_VISIBLE_WHEN = {
7
+ query: PatQueries.availability,
8
+ field: "enabled",
9
+ eq: true,
10
+ };
4
11
  export const patListScreen = {
5
12
  id: PAT_SCREEN_ID,
6
13
  type: "projectionList",
14
+ visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
7
15
  query: PatQueries.mine,
8
16
  // The `mine` handler honours `limit` only (no offset/total), so a pager would
9
17
  // show page 1 forever: send one max-size request and render no pager.
@@ -76,6 +84,7 @@ export function createPatMintScreen(scopes) {
76
84
  return {
77
85
  id: PAT_MINT_SCREEN_ID,
78
86
  type: "secretMint",
87
+ visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
79
88
  handler: PatHandlers.create,
80
89
  fields: {
81
90
  name: { type: "text", required: true, maxLength: 120 },
@@ -0,0 +1,49 @@
1
+ import * as z from "zod";
2
+ import { type MailSpec } from "./mail-runner.js";
3
+ import { type WebhookSpec } from "./webhook-runner.js";
4
+ export declare const dispatchRequestedPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
+ stepKind: z.ZodLiteral<"webhook.send">;
6
+ url: z.ZodString;
7
+ method: z.ZodEnum<{
8
+ PATCH: "PATCH";
9
+ POST: "POST";
10
+ PUT: "PUT";
11
+ }>;
12
+ headersJson: z.ZodString;
13
+ bodyJson: z.ZodOptional<z.ZodString>;
14
+ auth: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
15
+ kind: z.ZodLiteral<"bearer">;
16
+ secret: z.ZodString;
17
+ }, z.core.$strip>, z.ZodObject<{
18
+ kind: z.ZodLiteral<"header">;
19
+ name: z.ZodString;
20
+ secret: z.ZodString;
21
+ }, z.core.$strip>]>>;
22
+ }, z.core.$strip>, z.ZodObject<{
23
+ stepKind: z.ZodLiteral<"mail.send">;
24
+ to: z.ZodString;
25
+ subject: z.ZodString;
26
+ body: z.ZodString;
27
+ from: z.ZodOptional<z.ZodString>;
28
+ }, z.core.$strip>], "stepKind">;
29
+ type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
30
+ export declare function rawStepKindOf(payload: unknown): string;
31
+ type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
32
+ type ReadPayloadResult = {
33
+ readonly kind: "ready";
34
+ readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>>;
35
+ } | {
36
+ readonly kind: "erased";
37
+ } | {
38
+ readonly kind: "unreadable";
39
+ };
40
+ export declare function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult>;
41
+ type DispatchSpec = {
42
+ readonly stepKind: "mail.send";
43
+ readonly spec: MailSpec;
44
+ } | {
45
+ readonly stepKind: "webhook.send";
46
+ readonly spec: WebhookSpec;
47
+ };
48
+ export declare function buildDispatchSpec(payload: DispatchRequestedPayload, fields: Readonly<Partial<Record<PayloadFieldName, string>>>): DispatchSpec | null;
49
+ export {};
@@ -0,0 +1,115 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import { configuredPiiSubjectKms, decryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import * as z from "zod";
4
+ import { mailSpecSchema } from "./mail-runner.js";
5
+ import { webhookSpecSchema } from "./webhook-runner.js";
6
+ // PII fields of the flat payload are ciphertext under the per-dispatch
7
+ // record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
8
+ // strings because event PII encryption only handles top-level strings.
9
+ // Runtime-validated instead of cast — `event.payload` is `unknown` at the
10
+ // MSP-apply boundary, so a payload in another shape must end as
11
+ // dispatch-failed, never reach the runners.
12
+ export const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
13
+ z.object({
14
+ stepKind: z.literal("webhook.send"),
15
+ url: z.string(),
16
+ method: webhookSpecSchema.shape.method,
17
+ headersJson: z.string(),
18
+ bodyJson: z.string().optional(),
19
+ auth: webhookSpecSchema.shape.auth,
20
+ }),
21
+ z.object({
22
+ stepKind: z.literal("mail.send"),
23
+ to: z.string(),
24
+ subject: z.string(),
25
+ body: z.string(),
26
+ from: z.string().optional(),
27
+ }),
28
+ ]);
29
+ const rawStepKindSchema = z.object({ stepKind: z.string() });
30
+ export function rawStepKindOf(payload) {
31
+ const parsed = rawStepKindSchema.safeParse(payload);
32
+ return parsed.success ? parsed.data.stepKind : "unknown";
33
+ }
34
+ const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ return parsed;
38
+ }
39
+ catch {
40
+ refinementCtx.addIssue({ code: "custom", message: "invalid json" });
41
+ return z.NEVER;
42
+ }
43
+ });
44
+ const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
45
+ const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
46
+ function piiFieldsOf(payload) {
47
+ return payload.stepKind === "mail.send"
48
+ ? ["to", "subject", "body", "from"]
49
+ : ["url", "headersJson", "bodyJson"];
50
+ }
51
+ function payloadFieldValue(payload, field) {
52
+ const values = payload.stepKind === "mail.send"
53
+ ? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
54
+ : { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
55
+ return values[field];
56
+ }
57
+ export async function readPayloadFields(payload) {
58
+ const kms = configuredPiiSubjectKms();
59
+ const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
60
+ const fields = {};
61
+ for (const field of piiFieldsOf(payload)) {
62
+ const value = payloadFieldValue(payload, field);
63
+ if (value === undefined)
64
+ continue;
65
+ if (!isPiiCiphertext(value)) {
66
+ fields[field] = value;
67
+ continue;
68
+ }
69
+ if (!kms)
70
+ return { kind: "unreadable" };
71
+ const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
72
+ if (plain === PII_ERASED_SENTINEL)
73
+ return { kind: "erased" };
74
+ fields[field] = plain;
75
+ }
76
+ return { kind: "ready", fields };
77
+ }
78
+ // Parse failures return null — the caller records a generic error, never the
79
+ // (decrypted) values.
80
+ export function buildDispatchSpec(payload, fields) {
81
+ if (payload.stepKind === "mail.send") {
82
+ const to = mailToJsonSchema.safeParse(fields.to);
83
+ if (!to.success || fields.subject === undefined || fields.body === undefined)
84
+ return null;
85
+ return {
86
+ stepKind: "mail.send",
87
+ spec: {
88
+ to: to.data,
89
+ subject: fields.subject,
90
+ body: fields.body,
91
+ ...(fields.from !== undefined && { from: fields.from }),
92
+ },
93
+ };
94
+ }
95
+ const headers = headersJsonSchema.safeParse(fields.headersJson);
96
+ if (!headers.success || fields.url === undefined)
97
+ return null;
98
+ let body;
99
+ if (fields.bodyJson !== undefined) {
100
+ const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
101
+ if (!parsedBody.success)
102
+ return null;
103
+ body = parsedBody.data;
104
+ }
105
+ return {
106
+ stepKind: "webhook.send",
107
+ spec: {
108
+ url: fields.url,
109
+ method: payload.method,
110
+ headers: headers.data,
111
+ ...(body !== undefined && { body }),
112
+ ...(payload.auth && { auth: payload.auth }),
113
+ },
114
+ };
115
+ }
@@ -7,14 +7,15 @@
7
7
  // or `kumiko:system:step.dispatch-failed` back onto the same stream so
8
8
  // the audit trail lives in the event log only — no separate status table.
9
9
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
10
- import { configuredPiiSubjectKms, decryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
10
+ import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
11
11
  import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
13
13
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
14
14
  import * as z from "zod";
15
15
  import { redactEmailAddresses } from "../shared/index.js";
16
- import { mailSpecSchema, performMailDispatch } from "./mail-runner.js";
17
- import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, webhookSpecSchema, } from "./webhook-runner.js";
16
+ import { buildDispatchSpec, dispatchRequestedPayloadSchema, rawStepKindOf, readPayloadFields, } from "./dispatch-payload.js";
17
+ import { performMailDispatch } from "./mail-runner.js";
18
+ import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
18
19
  const log = createFallbackLogger("step-dispatcher");
19
20
  export const stepDispatcherEnvSchema = z.object({
20
21
  [WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
@@ -23,30 +24,6 @@ export const stepDispatcherEnvSchema = z.object({
23
24
  .describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value."),
24
25
  });
25
26
  export { STEP_DISPATCH_AGGREGATE_TYPE };
26
- // PII fields of the flat payload are ciphertext under the per-dispatch
27
- // record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
28
- // strings because event PII encryption only handles top-level strings.
29
- // Runtime-validated instead of cast — `event.payload` is `unknown` at the
30
- // MSP-apply boundary, so a payload in another shape must end as
31
- // dispatch-failed, never reach the runners.
32
- const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
33
- z.object({
34
- stepKind: z.literal("webhook.send"),
35
- url: z.string(),
36
- method: webhookSpecSchema.shape.method,
37
- headersJson: z.string(),
38
- bodyJson: z.string().optional(),
39
- auth: webhookSpecSchema.shape.auth,
40
- retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
41
- }),
42
- z.object({
43
- stepKind: z.literal("mail.send"),
44
- to: z.string(),
45
- subject: z.string(),
46
- body: z.string(),
47
- from: z.string().optional(),
48
- }),
49
- ]);
50
27
  // zod issue messages can echo the invalid value (e.g. a rejected url) back
51
28
  // into the tenant-visible dispatch-failed event — keep this generic.
52
29
  const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
@@ -55,92 +32,8 @@ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
55
32
  // dispatch-failed text outlives the payload erase, so only this is persisted.
56
33
  const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
57
34
  const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
58
- const rawStepKindSchema = z.object({ stepKind: z.string() });
59
- function rawStepKindOf(payload) {
60
- const parsed = rawStepKindSchema.safeParse(payload);
61
- return parsed.success ? parsed.data.stepKind : "unknown";
62
- }
63
- const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
64
- try {
65
- const parsed = JSON.parse(raw);
66
- return parsed;
67
- }
68
- catch {
69
- refinementCtx.addIssue({ code: "custom", message: "invalid json" });
70
- return z.NEVER;
71
- }
72
- });
73
- const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
74
- const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
75
- function piiFieldsOf(payload) {
76
- return payload.stepKind === "mail.send"
77
- ? ["to", "subject", "body", "from"]
78
- : ["url", "headersJson", "bodyJson"];
79
- }
80
- function payloadFieldValue(payload, field) {
81
- const values = payload.stepKind === "mail.send"
82
- ? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
83
- : { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
84
- return values[field];
85
- }
86
- async function readPayloadFields(payload) {
87
- const kms = configuredPiiSubjectKms();
88
- const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
89
- const fields = {};
90
- for (const field of piiFieldsOf(payload)) {
91
- const value = payloadFieldValue(payload, field);
92
- if (value === undefined)
93
- continue;
94
- if (!isPiiCiphertext(value)) {
95
- fields[field] = value;
96
- continue;
97
- }
98
- if (!kms)
99
- return { kind: "unreadable" };
100
- const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
101
- if (plain === PII_ERASED_SENTINEL)
102
- return { kind: "erased" };
103
- fields[field] = plain;
104
- }
105
- return { kind: "ready", fields };
106
- }
107
- // Parse failures return null — the caller records a generic error, never the
108
- // (decrypted) values.
109
- function buildDispatchSpec(payload, fields) {
110
- if (payload.stepKind === "mail.send") {
111
- const to = mailToJsonSchema.safeParse(fields.to);
112
- if (!to.success || fields.subject === undefined || fields.body === undefined)
113
- return null;
114
- return {
115
- stepKind: "mail.send",
116
- spec: {
117
- to: to.data,
118
- subject: fields.subject,
119
- body: fields.body,
120
- ...(fields.from !== undefined && { from: fields.from }),
121
- },
122
- };
123
- }
124
- const headers = headersJsonSchema.safeParse(fields.headersJson);
125
- if (!headers.success || fields.url === undefined)
126
- return null;
127
- let body;
128
- if (fields.bodyJson !== undefined) {
129
- const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
130
- if (!parsedBody.success)
131
- 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
- };
35
+ function isDispatchOutcome(e) {
36
+ return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
144
37
  }
145
38
  export function createStepDispatcherFeature() {
146
39
  return defineFeature("step-dispatcher", (r) => {
@@ -159,9 +52,16 @@ export function createStepDispatcherFeature() {
159
52
  [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
160
53
  const kms = configuredPiiSubjectKms();
161
54
  const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
55
+ const eraseDispatchKey = async () => {
56
+ await kms?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId }, { requestId, eraseReason: "step-dispatch-outcome-recorded" });
57
+ };
162
58
  // Outcome events are plaintext and generic; the request payload's
163
59
  // per-dispatch key is erased right after, so the PII dies with the
164
- // dispatch instead of living in the event log.
60
+ // dispatch instead of living in the event log. The outcome is
61
+ // appended before the erase on purpose: if the erase throws, the
62
+ // outcome survives and a redelivery only repeats the erase. A crash
63
+ // between the send and the outcome append still re-sends under the
64
+ // same Idempotency-Key — the one remaining double-send case.
165
65
  const recordOutcome = async (type, payload) => {
166
66
  await ctx.unsafeAppendEvent({
167
67
  aggregateId: event.aggregateId,
@@ -169,9 +69,15 @@ export function createStepDispatcherFeature() {
169
69
  type,
170
70
  payload,
171
71
  });
172
- await kms?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId }, { requestId, eraseReason: "step-dispatch-outcome-recorded" });
72
+ await eraseDispatchKey();
173
73
  };
174
74
  const recordFailure = (stepKind, error) => recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
75
+ const stream = await ctx.loadAggregate(event.aggregateId);
76
+ if (stream.some(isDispatchOutcome)) {
77
+ await eraseDispatchKey();
78
+ // skip: redelivery after an outcome was recorded — only the erase is repeated
79
+ return;
80
+ }
175
81
  const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
176
82
  if (!parsed.success) {
177
83
  await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
@@ -186,11 +92,6 @@ export function createStepDispatcherFeature() {
186
92
  return;
187
93
  }
188
94
  if (read.kind === "erased") {
189
- const stream = await ctx.loadAggregate(event.aggregateId);
190
- const hasOutcome = stream.some((e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE);
191
- // skip: redelivery after the key was erased — the outcome is already recorded
192
- if (hasOutcome)
193
- return;
194
95
  await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
195
96
  // skip: erased payload recorded via step.dispatch-failed above
196
97
  return;
@@ -46,6 +46,8 @@ export type WebhookDispatchDeps = {
46
46
  readonly userId: string;
47
47
  readonly secrets: SecretsContext | undefined;
48
48
  readonly idempotencyKey: string;
49
+ readonly requestTimeoutMs?: number;
49
50
  };
51
+ export declare const WEBHOOK_REQUEST_TIMEOUT_MS = 10000;
50
52
  export declare const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
51
53
  export declare function performWebhookDispatch(spec: WebhookSpec, deps: WebhookDispatchDeps): Promise<WebhookDispatchResult>;
@@ -79,17 +79,32 @@ let fetchImpl = globalThis.fetch.bind(globalThis);
79
79
  export function setWebhookFetch(fn) {
80
80
  fetchImpl = fn;
81
81
  }
82
+ // One hanging tenant-controlled receiver must not stall the shared
83
+ // step-dispatcher consumer for every tenant.
84
+ export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
82
85
  // Never includes the secret name or value — spec.auth.secret is a
83
86
  // tenant-chosen name, but the error still reaches the tenant via the
84
87
  // dispatch-failed event, so it stays generic.
85
88
  const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
86
89
  export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
87
- function hasIdempotencyKeyHeader(headers) {
88
- return Object.keys(headers).some((name) => name.toLowerCase() === WEBHOOK_IDEMPOTENCY_KEY_HEADER);
90
+ function hasHeader(headers, lowerCaseName) {
91
+ return Object.keys(headers).some((name) => name.toLowerCase() === lowerCaseName);
92
+ }
93
+ // Header names are case-insensitive: two keys differing only in case would be
94
+ // comma-joined by `Headers`, so a replacement must drop every spelling first.
95
+ function setHeader(headers, name, value) {
96
+ const lowerCaseName = name.toLowerCase();
97
+ for (const existing of Object.keys(headers)) {
98
+ if (existing.toLowerCase() === lowerCaseName)
99
+ delete headers[existing];
100
+ }
101
+ headers[name] = value;
89
102
  }
90
103
  async function buildWebhookHeaders(spec, deps) {
91
- const headers = { "content-type": "application/json", ...spec.headers };
92
- if (!hasIdempotencyKeyHeader(headers)) {
104
+ const headers = { ...spec.headers };
105
+ if (!hasHeader(headers, "content-type"))
106
+ headers["content-type"] = "application/json";
107
+ if (!hasHeader(headers, WEBHOOK_IDEMPOTENCY_KEY_HEADER)) {
93
108
  headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
94
109
  }
95
110
  if (!spec.auth)
@@ -114,10 +129,10 @@ async function buildWebhookHeaders(spec, deps) {
114
129
  return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
115
130
  }
116
131
  if (spec.auth.kind === "bearer") {
117
- headers["authorization"] = `Bearer ${secret}`;
132
+ setHeader(headers, "authorization", `Bearer ${secret}`);
118
133
  }
119
134
  else {
120
- headers[spec.auth.name] = secret;
135
+ setHeader(headers, spec.auth.name, secret);
121
136
  }
122
137
  return { ok: true, headers };
123
138
  }
@@ -166,8 +181,11 @@ export async function performWebhookDispatch(spec, deps) {
166
181
  ...target.requestInit,
167
182
  method: spec.method,
168
183
  redirect: "manual",
184
+ signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
169
185
  body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
170
186
  });
187
+ // Only the status is used; an unread body would pin the socket until the timeout fires.
188
+ await res.body?.cancel().catch(() => { });
171
189
  if (!res.ok) {
172
190
  return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
173
191
  }
@@ -4,6 +4,11 @@ export declare const TierEngineHandlers: {
4
4
  readonly update: "tier-engine:write:tier-assignment:update";
5
5
  readonly setTenantTier: "tier-engine:write:set-tenant-tier";
6
6
  };
7
+ export declare const TierAssignmentSources: {
8
+ readonly manual: "manual";
9
+ readonly billing: "billing";
10
+ readonly default: "default";
11
+ };
7
12
  export declare const TierEngineQueries: {
8
13
  readonly list: "tier-engine:query:tier-assignment:list";
9
14
  readonly getActiveTier: "tier-engine:query:get-active-tier";
@@ -13,6 +13,12 @@ export const TierEngineHandlers = {
13
13
  update: "tier-engine:write:tier-assignment:update",
14
14
  setTenantTier: "tier-engine:write:set-tenant-tier",
15
15
  };
16
+ // Origin of a tier-assignment row; the billing sync must not touch "manual" rows.
17
+ export const TierAssignmentSources = {
18
+ manual: "manual",
19
+ billing: "billing",
20
+ default: "default",
21
+ };
16
22
  // Qualified query handler names.
17
23
  export const TierEngineQueries = {
18
24
  list: "tier-engine:query:tier-assignment:list",
@@ -31,10 +31,8 @@ export const tierAssignmentEntity = createEntity({
31
31
  personal: false,
32
32
  reason: "catalog_label",
33
33
  }),
34
- // Woher das Assignment stammt: "manual" (Admin-Grant via tier-admin-Screen),
35
- // "stripe" (future Billing-Sync), "default" (auto-default-on-signup-Hook).
36
- // Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
37
- // Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
34
+ // Origin of the assignment, see TierAssignmentSources. Optional for back-compat with
35
+ // rows without source. Protects manual grants from being overwritten by the billing sync.
38
36
  source: createTextField({
39
37
  required: false,
40
38
  maxLength: 20,
@@ -50,7 +50,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
50
50
  import * as z from "zod";
51
51
  import { tenantTable } from "../tenant/index.js";
52
52
  import { tierAssignmentAggregateId } from "./aggregate-id.js";
53
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants.js";
53
+ import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, } from "./constants.js";
54
54
  import { tierAssignmentEntity } from "./entity.js";
55
55
  import { getActiveTierQuery } from "./handlers/active-tier.query.js";
56
56
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query.js";
@@ -200,6 +200,7 @@ export function createTierEngineFeature(opts = {}) {
200
200
  ],
201
201
  },
202
202
  submitLabel: "tier-admin.submit",
203
+ successMessage: "tier-admin.success",
203
204
  cancelTarget: false,
204
205
  description: "tier-admin.screen.subtitle",
205
206
  access: { roles: ["SystemAdmin"] },
@@ -347,7 +348,7 @@ export function createTierEngineFeature(opts = {}) {
347
348
  roles: ["SystemAdmin"],
348
349
  };
349
350
  const tdb = createTenantDb(rawDb, newTenantId, "system");
350
- await tierAssignmentExecutor.create({ id: aggregateId, tier: defaultTier, source: "default" }, systemUser, tdb);
351
+ await tierAssignmentExecutor.create({ id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default }, systemUser, tdb);
351
352
  }, { phase: HookPhases.inTransaction, escapeHatch: { reason: autoDefaultTierHookReason } });
352
353
  }
353
354
  // Extension-point declaration + self-registration. Pattern analog
@@ -3,6 +3,7 @@ import { buildEntityTable, createEventStoreExecutor, createTenantDb, } from "@co
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
5
  import { tierAssignmentAggregateId } from "../aggregate-id.js";
6
+ import { TierAssignmentSources } from "../constants.js";
6
7
  import { tierAssignmentEntity } from "../entity.js";
7
8
  // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
8
9
  // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
@@ -16,8 +17,8 @@ import { tierAssignmentEntity } from "../entity.js";
16
17
  // funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
17
18
  // auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
18
19
  //
19
- // `source: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
20
- // nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
20
+ // `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
21
+ // Upsert: one aggregate per tenant (deterministic aggregate id).
21
22
  //
22
23
  // Effective-set invalidation: the executor write does not fire the
23
24
  // `tier-assignment:postSave` entity hook, and a per-handler postSave would not
@@ -67,14 +68,19 @@ export function createSetTenantTierWrite(opts = {}) {
67
68
  const result = await executor.update({
68
69
  id: existing.id,
69
70
  version: existing.version,
70
- changes: { tier, source: "manual" },
71
+ changes: { tier, source: TierAssignmentSources.manual },
71
72
  }, systemUser, tdb);
72
73
  if (!result.isSuccess)
73
74
  return result;
74
75
  afterCommit(tenantId, tier);
75
76
  return { isSuccess: true, data: { tenantId, tier, isNew: false } };
76
77
  }
77
- const result = await executor.create({ id: tierAssignmentAggregateId(tenantId), tier, source: "manual", tenantId }, systemUser, tdb);
78
+ const result = await executor.create({
79
+ id: tierAssignmentAggregateId(tenantId),
80
+ tier,
81
+ source: TierAssignmentSources.manual,
82
+ tenantId,
83
+ }, systemUser, tdb);
78
84
  if (!result.isSuccess)
79
85
  return result;
80
86
  afterCommit(tenantId, tier);
@@ -18,5 +18,6 @@ export const TIER_ENGINE_I18N = {
18
18
  "tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
19
19
  "tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
20
20
  "tier-admin.submit": { en: "Assign tier" },
21
+ "tier-admin.success": { en: "Tier assigned: {tenantId} → {tier}" },
21
22
  };
22
23
  export const defaultTranslations = translationsByLocaleFromKeys(TIER_ENGINE_I18N);
@@ -1,6 +1,6 @@
1
1
  export { tierAssignmentAggregateId } from "./aggregate-id.js";
2
2
  export { type AddOnDefinition, type AddOnMap, type ComposeAppInput, type ComposedApp, composeApp, type TierDefinition, type TierMap, } from "./compose-app.js";
3
- export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants.js";
3
+ export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
4
4
  export { tierAssignmentEntity } from "./entity.js";
5
5
  export { type CreateTierEngineOptions, createTierEngineFeature, tierEngineFeature, } from "./feature.js";
6
6
  export { createTierResolver, type TierResolver, type TierResolverDeps, } from "./tier-resolver.js";
@@ -8,7 +8,7 @@
8
8
  // - `TierEngineHandlers` / `TierEngineQueries` — qualified handler names
9
9
  export { tierAssignmentAggregateId } from "./aggregate-id.js";
10
10
  export { composeApp, } from "./compose-app.js";
11
- export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants.js";
11
+ export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
12
12
  export { tierAssignmentEntity } from "./entity.js";
13
13
  export { createTierEngineFeature, tierEngineFeature, } from "./feature.js";
14
14
  export { createTierResolver, } from "./tier-resolver.js";
package/package.json CHANGED
@@ -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>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.345.0",
507
- "@cosmicdrift/kumiko-framework": "0.345.0",
508
- "@cosmicdrift/kumiko-headless": "0.345.0",
509
- "@cosmicdrift/kumiko-renderer": "0.345.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.345.0",
511
- "@cosmicdrift/kumiko-types": "0.345.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.347.0",
507
+ "@cosmicdrift/kumiko-framework": "0.347.0",
508
+ "@cosmicdrift/kumiko-headless": "0.347.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.347.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.347.0",
511
+ "@cosmicdrift/kumiko-types": "0.347.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.345.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.345.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.347.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.347.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",