@cosmicdrift/kumiko-bundled-features 0.336.0 → 0.337.1

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 (67) hide show
  1. package/dist/agent-tools/agent-manifest.js +34 -7
  2. package/dist/agent-tools/tool-catalog.js +21 -2
  3. package/dist/audit/handlers/list.query.js +7 -3
  4. package/dist/auth-email-password/handlers/login.write.js +1 -2
  5. package/dist/auth-email-password/seeding.js +6 -0
  6. package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
  7. package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
  8. package/dist/auth-mfa/hmac-token-codec.js +8 -9
  9. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
  10. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -7
  11. package/dist/config/write-helpers.js +2 -2
  12. package/dist/custom-fields/feature.js +3 -2
  13. package/dist/custom-fields/lib/field-access.d.ts +3 -2
  14. package/dist/custom-fields/lib/field-access.js +1 -0
  15. package/dist/delivery/unsubscribe.js +1 -0
  16. package/dist/files-tenant-data/hooks.js +11 -3
  17. package/dist/folders/web/folder-section.js +8 -1
  18. package/dist/inbound-mail-foundation/feature.js +1 -1
  19. package/dist/jobs/constants.d.ts +1 -0
  20. package/dist/jobs/constants.js +1 -0
  21. package/dist/jobs/handlers/trigger.write.js +7 -2
  22. package/dist/jobs/i18n.js +3 -0
  23. package/dist/metrics/compute.js +16 -5
  24. package/dist/notes-history/feature.js +2 -2
  25. package/dist/notes-history/handlers/add-note.write.js +2 -1
  26. package/dist/notes-history/web/notes-section.js +6 -1
  27. package/dist/personal-access-tokens/schema/api-token.js +5 -5
  28. package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
  29. package/dist/secrets/feature.js +2 -0
  30. package/dist/secrets/handlers/delete.write.js +4 -0
  31. package/dist/secrets/handlers/set.write.js +5 -2
  32. package/dist/secrets/i18n.d.ts +10 -0
  33. package/dist/secrets/i18n.js +10 -0
  34. package/dist/secrets/write-gate.d.ts +10 -0
  35. package/dist/secrets/write-gate.js +36 -0
  36. package/dist/shared/index.d.ts +0 -1
  37. package/dist/shared/index.js +0 -1
  38. package/dist/shared/session-locale-field.js +1 -2
  39. package/dist/shared/session-timezone-field.js +3 -2
  40. package/dist/step-dispatcher/feature.js +4 -3
  41. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  42. package/dist/step-dispatcher/mail-runner.js +5 -0
  43. package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
  44. package/dist/step-dispatcher/webhook-runner.js +9 -0
  45. package/dist/subscription-mollie/plugin-methods.js +3 -5
  46. package/dist/subscription-stripe/plugin-methods.js +30 -5
  47. package/dist/tenant/handlers/member-directory.query.js +16 -3
  48. package/dist/tenant/handlers/tenant-directory.query.js +7 -1
  49. package/dist/tenant-handover/feature.js +1 -1
  50. package/dist/tenant-handover/move-entity-graph.js +11 -3
  51. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
  52. package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
  53. package/dist/user/schema/user.js +4 -3
  54. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
  55. package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
  56. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
  57. package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
  58. package/dist/workflow-runner/db/queries/due-runs.js +4 -1
  59. package/package.json +9 -9
  60. package/src/agent-tools/changes.json +6 -0
  61. package/src/secrets/changes.json +7 -0
  62. package/src/subscription-stripe/changes.json +12 -0
  63. package/src/tenant/changes.json +6 -0
  64. package/src/tenant-lifecycle/changes.json +8 -1
  65. package/src/workflow-runner/changes.json +6 -0
  66. package/dist/shared/session-field.d.ts +0 -3
  67. package/dist/shared/session-field.js +0 -6
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
4
4
  import { requireSecretsContext } from "../feature.js";
5
+ import { checkSecretKeyWrite } from "../write-gate.js";
5
6
  export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
6
7
  return defineWriteHandler({
7
8
  name: "set",
@@ -26,8 +27,10 @@ export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
26
27
  // (via r.secret()) > generic default. A feature that declared a
27
28
  // domain-aware redact (Stripe keys: "sk_test...2345") wins over the
28
29
  // framework default unless the caller sent a specific preview.
29
- const keyDef = ctx.registry.getSecretKey(key);
30
- const featureRedact = keyDef?.redact;
30
+ const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, key);
31
+ if (!gate.ok)
32
+ return gate.failure;
33
+ const featureRedact = gate.keyDef?.redact;
31
34
  const redactFn = redactedPreview
32
35
  ? () => redactedPreview
33
36
  : (featureRedact ?? defaultRedact);
@@ -0,0 +1,10 @@
1
+ export declare const SECRETS_I18N: {
2
+ readonly "secrets.errors.unknownKey": {
3
+ readonly en: "Unknown secret key.";
4
+ readonly de: "Unbekannter Geheimnis-Schlüssel.";
5
+ };
6
+ readonly "secrets.errors.writeDenied": {
7
+ readonly en: "You are not allowed to change this secret.";
8
+ readonly de: "Dieses Geheimnis darf nicht geändert werden.";
9
+ };
10
+ };
@@ -0,0 +1,10 @@
1
+ export const SECRETS_I18N = {
2
+ "secrets.errors.unknownKey": {
3
+ en: "Unknown secret key.",
4
+ de: "Unbekannter Geheimnis-Schlüssel.",
5
+ },
6
+ "secrets.errors.writeDenied": {
7
+ en: "You are not allowed to change this secret.",
8
+ de: "Dieses Geheimnis darf nicht geändert werden.",
9
+ },
10
+ };
@@ -0,0 +1,10 @@
1
+ import type { Registry, SecretKeyDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ export type SecretKeyWriteCheck = {
4
+ readonly ok: true;
5
+ readonly keyDef: SecretKeyDefinition | undefined;
6
+ } | {
7
+ readonly ok: false;
8
+ readonly failure: WriteFailure;
9
+ };
10
+ export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string): SecretKeyWriteCheck;
@@ -0,0 +1,36 @@
1
+ import { AccessDeniedError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
2
+ function unknownKeyFailure(key) {
3
+ return {
4
+ ok: false,
5
+ failure: writeFailure(new NotFoundError("secretKey", key, { i18nKey: "secrets.errors.unknownKey" })),
6
+ };
7
+ }
8
+ function namespaceAcceptsKey(namespace, key) {
9
+ const suffix = key.slice(namespace.qualifiedPrefix.length);
10
+ if (suffix.length === 0)
11
+ return false;
12
+ return namespace.nameSchema ? namespace.nameSchema.safeParse(suffix).success : true;
13
+ }
14
+ // Only keys declared via r.secret() or under a declared r.secretNamespace()
15
+ // are writable through the API, so a tenant admin cannot plant arbitrary rows
16
+ // next to the feature-owned ones. writeRoles narrows the handler access
17
+ // further for a key or a whole namespace.
18
+ export function checkSecretKeyWrite(registry, userRoles, key) {
19
+ const keyDef = registry.getSecretKey(key);
20
+ const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
21
+ if (!keyDef && !(namespace && namespaceAcceptsKey(namespace, key))) {
22
+ return unknownKeyFailure(key);
23
+ }
24
+ const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
25
+ if (writeRoles !== undefined && !userRoles.some((role) => writeRoles.includes(role))) {
26
+ return {
27
+ ok: false,
28
+ failure: writeFailure(new AccessDeniedError({
29
+ message: "secret write access denied",
30
+ i18nKey: "secrets.errors.writeDenied",
31
+ details: { requiredRoles: writeRoles },
32
+ })),
33
+ };
34
+ }
35
+ return { ok: true, keyDef };
36
+ }
@@ -14,7 +14,6 @@ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisi
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
16
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
17
- export { sessionField } from "./session-field.js";
18
17
  export { sessionLocaleField } from "./session-locale-field.js";
19
18
  export { sessionTimezoneField } from "./session-timezone-field.js";
20
19
  export { peekTokenSubject, signToken, TokenPurpose, type VerifyResult, verifyToken, } from "./signed-token.js";
@@ -14,7 +14,6 @@ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisi
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
16
16
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
17
- export { sessionField } from "./session-field.js";
18
17
  export { sessionLocaleField } from "./session-locale-field.js";
19
18
  export { sessionTimezoneField } from "./session-timezone-field.js";
20
19
  export { peekTokenSubject, signToken, TokenPurpose, verifyToken, } from "./signed-token.js";
@@ -1,7 +1,6 @@
1
1
  import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
2
- import { sessionField } from "./session-field.js";
3
2
  export function sessionLocaleField(locale) {
4
3
  if (locale === null || locale === undefined || !isValidLocaleTag(locale))
5
4
  return {};
6
- return sessionField("locale", canonicalizeLocaleTag(locale));
5
+ return { locale: canonicalizeLocaleTag(locale) };
7
6
  }
@@ -1,4 +1,5 @@
1
- import { sessionField } from "./session-field.js";
2
1
  export function sessionTimezoneField(timezone) {
3
- return sessionField("timezone", timezone);
2
+ if (timezone === null || timezone === undefined)
3
+ return {};
4
+ return { timezone };
4
5
  }
@@ -12,8 +12,8 @@ import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE,
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
- import { mailSpecSchema, performMailDispatch } from "./mail-runner.js";
16
- import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, webhookSpecSchema, } from "./webhook-runner.js";
15
+ import { mailSpecSchema, performMailDispatch, redactEmailAddresses, } from "./mail-runner.js";
16
+ import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, webhookSpecSchema, } from "./webhook-runner.js";
17
17
  const log = createFallbackLogger("step-dispatcher");
18
18
  export const stepDispatcherEnvSchema = z.object({
19
19
  [WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
@@ -144,6 +144,7 @@ function buildDispatchSpec(payload, fields) {
144
144
  export function createStepDispatcherFeature() {
145
145
  return defineFeature("step-dispatcher", (r) => {
146
146
  r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.");
147
+ r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
147
148
  r.uiHints({
148
149
  displayLabel: "Step Dispatcher · Deferred Side-Effects",
149
150
  category: "infrastructure",
@@ -216,7 +217,7 @@ export function createStepDispatcherFeature() {
216
217
  if (payload.stepKind === "mail.send") {
217
218
  log.warn("mail dispatch failed", {
218
219
  aggregateId: event.aggregateId,
219
- reason: result.error,
220
+ reason: redactEmailAddresses(result.error),
220
221
  });
221
222
  await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
222
223
  }
@@ -15,3 +15,4 @@ export type MailDispatchResult = {
15
15
  };
16
16
  export declare function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void;
17
17
  export declare function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult>;
18
+ export declare function redactEmailAddresses(text: string): string;
@@ -19,3 +19,8 @@ export function setMailRunner(fn) {
19
19
  export async function performMailDispatch(spec) {
20
20
  return mailRunner(spec);
21
21
  }
22
+ // Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
23
+ // address must not outlive the crypto-shredded dispatch payload in logs.
24
+ export function redactEmailAddresses(text) {
25
+ return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
26
+ }
@@ -7,6 +7,13 @@ export declare function readAllowedPrivateWebhookHostsFromEnv(env?: Readonly<Rec
7
7
  export declare function setWebhookHostLookup(fn: typeof lookup | undefined): void;
8
8
  export declare const WEBHOOK_AUTH_SECRET_KEY_PREFIX = "step-dispatcher:webhook-auth.";
9
9
  export declare function webhookAuthSecretKey(name: string): string;
10
+ export declare const WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS: {
11
+ readonly label: {
12
+ readonly en: "Webhook auth secrets";
13
+ };
14
+ readonly scope: "tenant";
15
+ readonly nameSchema: z.ZodString;
16
+ };
10
17
  export declare const webhookSpecSchema: z.ZodObject<{
11
18
  url: z.ZodString;
12
19
  method: z.ZodEnum<{
@@ -50,6 +50,15 @@ const webhookAuthSecretNameSchema = z
50
50
  .min(1)
51
51
  .max(WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH)
52
52
  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
53
+ // The same schema validates `auth.secret` in the step spec and the suffix the
54
+ // secrets handlers accept under the namespace, so the two cannot drift. The
55
+ // prefix itself is derived from the feature name by r.secretNamespace; the
56
+ // boot test pins it to WEBHOOK_AUTH_SECRET_KEY_PREFIX.
57
+ export const WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS = {
58
+ label: { en: "Webhook auth secrets" },
59
+ scope: "tenant",
60
+ nameSchema: webhookAuthSecretNameSchema,
61
+ };
53
62
  export const webhookSpecSchema = z.object({
54
63
  url: z.string(),
55
64
  method: z.enum(["POST", "PUT", "PATCH"]),
@@ -32,11 +32,9 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
32
32
  customerId = customer.id;
33
33
  }
34
34
  // mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
35
- // setup. verify-webhook.ts's ensureSubscriptionForMandate already
36
- // gates on sequenceType === "first" — a oneoff payment falls into
37
- // its "not our domain" branch and creates NO Mollie subscription.
38
- // Webhook resolution for oneoff payments is separate follow-up
39
- // work (offlot#109), not part of this contract.
35
+ // setup. Oneoff payments carry no subscriptionId and are not
36
+ // sequenceType "first", so verifyAndParseMollieWebhook drops them before
37
+ // any mandate/subscription handling.
40
38
  const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
41
39
  // payments.create ist overloaded (Promise OR void mit callback);
42
40
  // explicit cast auf Promise<Payment>-overload.
@@ -1,5 +1,6 @@
1
1
  // Stripe-Plugin-Methoden für die POST-tenant-resolution-Phase:
2
- // createCheckoutSession, createPortalSession, cancelSubscription.
2
+ // createCheckoutSession, createPortalSession, cancelSubscription,
3
+ // retrieveSubscription, retrievePrices, planSwitchSession.
3
4
  //
4
5
  // Werden vom Plugin-build (feature.ts) als methods auf dem
5
6
  // SubscriptionProviderPlugin registriert. Anders als
@@ -8,7 +9,7 @@
8
9
  // runtime auf (api-key aus system-secrets, audited), statt aus einem
9
10
  // mount-time-Closure. Key-Rotation wirkt damit ohne Redeploy.
10
11
  //
11
- // **Type-Ableitung:** die options-shapes der drei methods werden
12
+ // **Type-Ableitung:** die options-shapes der methods werden
12
13
  // **direkt vom Plugin-Contract** abgeleitet (`Parameters<NonNullable
13
14
  // <SubscriptionProviderPlugin["...method"]>>[1]`). Wenn Foundation den
14
15
  // Contract erweitert (z.B. neuer optionaler Field), bemerkt der
@@ -18,6 +19,11 @@ import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features
18
19
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
19
20
  import Stripe from "stripe";
20
21
  import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
22
+ function isUnknownCustomerStripeError(error) {
23
+ return (isResourceMissingStripeError(error) &&
24
+ error instanceof Stripe.errors.StripeInvalidRequestError &&
25
+ error.param === "customer");
26
+ }
21
27
  export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation = true } = {}) {
22
28
  return async (ctx, options) => {
23
29
  // #104-Invariante: ohne billing-live darf keine Stripe-Session
@@ -28,7 +34,7 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
28
34
  await runtime.assertBillingLive(ctx);
29
35
  const stripe = await runtime.clientForCtx(ctx);
30
36
  const mode = options.mode ?? "subscription";
31
- const session = await stripe.checkout.sessions.create({
37
+ const sessionParams = (providerCustomerId) => ({
32
38
  mode,
33
39
  line_items: [{ price: options.priceId, quantity: 1 }],
34
40
  success_url: options.successUrl,
@@ -48,10 +54,22 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
48
54
  // Without a customer, Stripe's default "if_required" makes a guest
49
55
  // checkout when invoice_creation is off; the webhook then has no
50
56
  // customer id and drops the paid payment as ignored.
51
- ...(!options.providerCustomerId && { customer_creation: "always" }),
57
+ ...(!providerCustomerId && { customer_creation: "always" }),
52
58
  }),
53
- ...(options.providerCustomerId && { customer: options.providerCustomerId }),
59
+ ...(providerCustomerId && { customer: providerCustomerId }),
54
60
  });
61
+ let session;
62
+ try {
63
+ session = await stripe.checkout.sessions.create(sessionParams(options.providerCustomerId));
64
+ }
65
+ catch (error) {
66
+ if (!options.providerCustomerId || !isUnknownCustomerStripeError(error))
67
+ throw error;
68
+ // Stripe account switch (test -> live) or a customer deleted in the dashboard leaves a
69
+ // stored customer id Stripe no longer knows; checkout creates a fresh customer instead.
70
+ ctx.log?.warn("subscription-stripe: stored customer unknown to Stripe, retrying without it");
71
+ session = await stripe.checkout.sessions.create(sessionParams(undefined));
72
+ }
55
73
  if (!session.url) {
56
74
  // Defensive: Stripe returns a hosted url for both modes today —
57
75
  // guards against future API drift.
@@ -252,6 +270,13 @@ async function resolvePortalConfiguration(stripe, runtime, cache, portalConfigCa
252
270
  const price = cache.get(priceId);
253
271
  return price ? [price] : [];
254
272
  });
273
+ // A transient prices.retrieve failure would otherwise yield a different
274
+ // priceSetHash and persist an incomplete portal configuration at Stripe.
275
+ if (prices.length !== allowedPriceIds.length) {
276
+ throw new UnprocessableError("price_unavailable", {
277
+ i18nKey: "billing-foundation.errors.priceUnavailable",
278
+ });
279
+ }
255
280
  const hash = priceSetHash(prices);
256
281
  const cached = portalConfigCache.get(hash);
257
282
  if (cached)
@@ -13,9 +13,14 @@ const KMS_POOL_CONCURRENCY = 4;
13
13
  // are decrypted and filtered in memory. The cap keeps a SystemAdmin's global
14
14
  // search from decrypting every user.
15
15
  const SEARCH_SCAN_CAP = 1000;
16
+ // Display names are encrypted, so id is the only stable SQL-side key; uuid text
17
+ // order equals the uuid column's byte order.
18
+ function compareIds(a, b) {
19
+ return a < b ? -1 : a > b ? 1 : 0;
20
+ }
16
21
  async function loadMemberUserIds(db, tenantId) {
17
22
  const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
18
- return [...new Set(memberships.map((row) => String(row["userId"])))];
23
+ return [...new Set(memberships.map((row) => String(row["userId"])))].sort(compareIds);
19
24
  }
20
25
  // Label source behind every `user:user` reference column (fw#3107). The
21
26
  // entity-convention lookup, user:query:user:list, is a SystemAdmin
@@ -52,12 +57,20 @@ export const memberDirectoryQuery = definePagedQueryHandler({
52
57
  let users;
53
58
  if (isSystemAdmin(query.user)) {
54
59
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
55
- users = await selectMany(db, userTable, undefined, { limit: scanLimit });
60
+ users = await selectMany(db, userTable, undefined, {
61
+ limit: scanLimit,
62
+ orderBy: { col: "id", direction: "asc" },
63
+ });
56
64
  }
57
65
  else {
58
66
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
59
67
  const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
60
- users = userIds.length > 0 ? await selectMany(db, userTable, { id: [...userIds] }) : [];
68
+ users =
69
+ userIds.length > 0
70
+ ? await selectMany(db, userTable, { id: [...userIds] }, {
71
+ orderBy: { col: "id", direction: "asc" },
72
+ })
73
+ : [];
61
74
  }
62
75
  const resolved = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
63
76
  const id = String(user.id);
@@ -35,7 +35,13 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
35
35
  let tenants;
36
36
  if (isSystemAdmin(query.user)) {
37
37
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
38
- tenants = await selectMany(db, tenantTable, undefined, { limit: scanLimit });
38
+ tenants = await selectMany(db, tenantTable, undefined, {
39
+ limit: scanLimit,
40
+ orderBy: [
41
+ { col: "name", direction: "asc" },
42
+ { col: "id", direction: "asc" },
43
+ ],
44
+ });
39
45
  }
40
46
  else {
41
47
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
@@ -13,7 +13,7 @@ export function createTenantHandoverFeature(opts = {}) {
13
13
  r.describe("Try-before-signup ownership handover: claims the rows of a declared-transferable " +
14
14
  "entity graph (root plus its parentRef-linked children) from an anonymous/public " +
15
15
  "tenant into the caller's own tenant, legitimized by a row-bound grant the anonymous " +
16
- "flow minted. Idempotent, audited via a `<entityType>.tenantHandover` domain event.");
16
+ "flow minted. Idempotent, audited via a `tenant-handover:event:claimed` event on its own `tenant-handover-claim` aggregate.");
17
17
  r.uiHints({
18
18
  displayLabel: "Tenant Handover",
19
19
  category: "identity",
@@ -175,7 +175,9 @@ export async function moveTransferGraph(args) {
175
175
  { entityName: rootEntityName, rowIds: [rootRowId] },
176
176
  ];
177
177
  for (let round = 0; round < MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
178
- const discovered = [];
178
+ // Merged per type so an edge leaving a type runs once per round, not once
179
+ // per edge that reached it.
180
+ const discovered = new Map();
179
181
  for (const { entityName, rowIds } of pending) {
180
182
  for (const edge of adjacency.get(entityName) ?? []) {
181
183
  const childIds = await moveEdgeRows({
@@ -204,7 +206,13 @@ export async function moveTransferGraph(args) {
204
206
  // (`note.vehicleId` and `note.campaignId`), and assigning would drop the
205
207
  // earlier count.
206
208
  movedCounts[edge.entityName] = (movedCounts[edge.entityName] ?? 0) + childIds.length;
207
- discovered.push({ entityName: edge.entityName, rowIds: childIds });
209
+ const discoveredIds = discovered.get(edge.entityName);
210
+ if (discoveredIds === undefined) {
211
+ discovered.set(edge.entityName, [...childIds]);
212
+ }
213
+ else {
214
+ discoveredIds.push(...childIds);
215
+ }
208
216
  await transferAggregateStreams(db, {
209
217
  aggregateType: edge.entityName,
210
218
  aggregateIds: childIds,
@@ -223,7 +231,7 @@ export async function moveTransferGraph(args) {
223
231
  }));
224
232
  }
225
233
  }
226
- pending = discovered;
234
+ pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
227
235
  }
228
236
  // The depth limit is a safety net, not a licence to move part of a graph: if
229
237
  // rounds ran out while rows remain whose type still leads somewhere, the
@@ -4,6 +4,8 @@ export type TenantLifecycleGate = {
4
4
  readonly status: string;
5
5
  readonly gracePeriodEnd: string | null;
6
6
  };
7
+ /** @internal exported for the bounded-cache test */
8
+ export declare const GATE_CACHE_MAX_ENTRIES = 5000;
7
9
  export declare function invalidateTenantLifecycleGate(tenantId: TenantId): void;
8
10
  /** @internal test-only — resetTestTables/resetTestTable wipe rows directly
9
11
  * via SQL, bypassing the write handlers that invalidate normally. Tests
@@ -11,4 +13,6 @@ export declare function invalidateTenantLifecycleGate(tenantId: TenantId): void;
11
13
  * call this in beforeEach or a later test can read an earlier test's
12
14
  * cached status for the same tenantId. */
13
15
  export declare function resetTenantLifecycleGateCacheForTests(): void;
16
+ /** @internal test-only */
17
+ export declare function tenantLifecycleGateCacheSizeForTests(): number;
14
18
  export declare function resolveTenantLifecycleGate(db: DbRunner, tenantId: TenantId): Promise<TenantLifecycleGate | null>;
@@ -11,9 +11,12 @@ import { tenantTable } from "../tenant/index.js";
11
11
  // never arrives).
12
12
  // Split into its own module (not run-tenant-destroy.ts) so stages.ts can
13
13
  // invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
14
- // ponytail: unbounded Map — fine while tenant counts stay in the thousands (a
15
- // few bytes/entry); swap for a bounded LRU if that ever changes.
14
+ // The anonymous path feeds client-supplied X-Tenant ids in here, so unknown ids
15
+ // (cached as null) must not grow the Map without bound: past the cap, expired
16
+ // entries are swept and, if still full, the oldest-inserted entries are evicted.
16
17
  const GATE_TTL_MS = 3000;
18
+ /** @internal exported for the bounded-cache test */
19
+ export const GATE_CACHE_MAX_ENTRIES = 5000;
17
20
  const gateCache = new Map();
18
21
  export function invalidateTenantLifecycleGate(tenantId) {
19
22
  gateCache.delete(tenantId);
@@ -26,6 +29,25 @@ export function invalidateTenantLifecycleGate(tenantId) {
26
29
  export function resetTenantLifecycleGateCacheForTests() {
27
30
  gateCache.clear();
28
31
  }
32
+ /** @internal test-only */
33
+ export function tenantLifecycleGateCacheSizeForTests() {
34
+ return gateCache.size;
35
+ }
36
+ function cacheGate(tenantId, value) {
37
+ if (gateCache.size >= GATE_CACHE_MAX_ENTRIES && !gateCache.has(tenantId)) {
38
+ const now = Date.now();
39
+ for (const [id, entry] of gateCache) {
40
+ if (entry.expiresAt <= now)
41
+ gateCache.delete(id);
42
+ }
43
+ for (const id of gateCache.keys()) {
44
+ if (gateCache.size < GATE_CACHE_MAX_ENTRIES)
45
+ break;
46
+ gateCache.delete(id);
47
+ }
48
+ }
49
+ gateCache.set(tenantId, { value, expiresAt: Date.now() + GATE_TTL_MS });
50
+ }
29
51
  export async function resolveTenantLifecycleGate(db, tenantId) {
30
52
  const cached = gateCache.get(tenantId);
31
53
  if (cached && cached.expiresAt > Date.now())
@@ -33,13 +55,13 @@ export async function resolveTenantLifecycleGate(db, tenantId) {
33
55
  const rows = await selectMany(db, tenantTable, { id: tenantId });
34
56
  const row = rows[0];
35
57
  if (!row) {
36
- gateCache.set(tenantId, { value: null, expiresAt: Date.now() + GATE_TTL_MS });
58
+ cacheGate(tenantId, null);
37
59
  return null;
38
60
  }
39
61
  const gate = {
40
62
  status: row.status,
41
63
  gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
42
64
  };
43
- gateCache.set(tenantId, { value: gate, expiresAt: Date.now() + GATE_TTL_MS });
65
+ cacheGate(tenantId, gate);
44
66
  return gate;
45
67
  }
@@ -72,12 +72,13 @@ export const userEntity = createEntity({
72
72
  // login-critical read stays raw, so verifyPassword would compare a
73
73
  // plaintext password against ciphertext and every login would fail once
74
74
  // a KMS is active. Already an irreversible one-way hash, access-gated to
75
- // privileged read/write — see PR body "Offene Fragen".
75
+ // privileged read/write. Needs a blind-index lookup before this can be
76
+ // subject-encrypted.
76
77
  passwordHash: createTextField({
77
78
  maxLength: 255,
78
79
  access: { read: access.privileged, write: access.privileged },
79
80
  personal: false,
80
- reason: "credential_hash_read_without_decrypt_2809",
81
+ reason: "credential_hash_read_without_decrypt",
81
82
  }),
82
83
  // Profile — user-editable. Real name in most apps → PII. Searchable via
83
84
  // the derived Meili index; events/projection stay ciphertext (#1610).
@@ -164,7 +165,7 @@ export const userEntity = createEntity({
164
165
  reason: "technical_reference",
165
166
  }),
166
167
  },
167
- // fw#2134 — email is `personal: "self", find: "exact"`, so the column holds
168
+ // email is `personal: "self", find: "fuzzy"` (lookupable), so the column holds
168
169
  // per-row ciphertext: a plain unique index on it can't catch duplicates.
169
170
  // `unique: true` over a lookupable column makes buildEntityTable /
170
171
  // deriveEntityTableMeta emit a second, partial unique index over the
@@ -49,10 +49,10 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
49
49
  schema: z.object({ token: z.string().min(1) }),
50
50
  access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
51
51
  escapeHatch: {
52
- reason: "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
52
+ reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
53
53
  "...) to compute the grace period end — the anonymous token holder has no read access " +
54
- "to that tenant-config projection. It also appends the user lifecycle event on the " +
55
- "SYSTEM_TENANT_ID user stream.",
54
+ "to that tenant-config projection. startDeletionGracePeriod also appends the user " +
55
+ "lifecycle event on the SYSTEM_TENANT_ID user stream.",
56
56
  },
57
57
  agent: { expose: false },
58
58
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
@@ -73,10 +73,12 @@ export async function startDeletionGracePeriod(ctx, userId, resolveGracePeriodSp
73
73
  const gracePeriodEnd = addDurationSpec(T.Now.instant(), await resolveGracePeriodSpec());
74
74
  const { applied } = await updateUserLifecycle(lifecycleRunner, userId, { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null }, { expect: { status: USER_STATUS.Active, ...additionalExpect } });
75
75
  if (!applied) {
76
+ // userRow predates the write, so its status is stale for the concurrent loser.
77
+ const currentRow = await ctx.db.global(userTable).fetchOne({ id: userId });
76
78
  return {
77
79
  ok: false,
78
80
  error: new UnprocessableError("user_not_in_active_state", {
79
- details: { currentStatus: userRow["status"] },
81
+ details: { currentStatus: currentRow?.["status"] ?? userRow["status"] },
80
82
  }),
81
83
  };
82
84
  }
@@ -21,10 +21,10 @@ export function createRequestDeletionHandler(opts = {}) {
21
21
  },
22
22
  },
23
23
  escapeHatch: {
24
- reason: "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
24
+ reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
25
25
  "...) to compute the grace period end — the calling user's own identity has no read " +
26
- "access to that tenant-config projection. It also appends the user lifecycle event on " +
27
- "the SYSTEM_TENANT_ID user stream.",
26
+ "access to that tenant-config projection. startDeletionGracePeriod also appends the " +
27
+ "user lifecycle event on the SYSTEM_TENANT_ID user stream.",
28
28
  },
29
29
  description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
30
30
  agent: { risk: "high" },
@@ -4,4 +4,5 @@ export type DueRow = {
4
4
  readonly run_id: string;
5
5
  readonly step_index: number;
6
6
  };
7
+ export declare const DUE_RUNS_BATCH_SIZE = 100;
7
8
  export declare function selectDueWorkflowRunPending(db: DbConnection, tenantId: TenantId): Promise<readonly DueRow[]>;
@@ -1,9 +1,12 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ // Oldest first and capped, so a backlog after an outage drains over several
3
+ // ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
4
+ export const DUE_RUNS_BATCH_SIZE = 100;
2
5
  // Dedup happens solely via resume-run's VersionConflictError-checked claim
3
6
  // (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
4
7
  // row loses that race and no-ops. No row locks: in an autocommit statement
5
8
  // they would be released right after the SELECT and guard nothing.
6
9
  export async function selectDueWorkflowRunPending(db, tenantId) {
7
10
  // kumiko-lint-ignore raw-sql tenant-scoped due-row pickup
8
- return (await asRawClient(db).unsafe(`SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now()`, [tenantId]));
11
+ return (await asRawClient(db).unsafe(`SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() ORDER BY wake_at ASC LIMIT ${DUE_RUNS_BATCH_SIZE}`, [tenantId]));
9
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.336.0",
3
+ "version": "0.337.1",
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>",
@@ -483,12 +483,12 @@
483
483
  }
484
484
  },
485
485
  "dependencies": {
486
- "@cosmicdrift/kumiko-dispatcher-live": "0.336.0",
487
- "@cosmicdrift/kumiko-framework": "0.336.0",
488
- "@cosmicdrift/kumiko-headless": "0.336.0",
489
- "@cosmicdrift/kumiko-renderer": "0.336.0",
490
- "@cosmicdrift/kumiko-renderer-web": "0.336.0",
491
- "@cosmicdrift/kumiko-types": "0.336.0",
486
+ "@cosmicdrift/kumiko-dispatcher-live": "0.337.1",
487
+ "@cosmicdrift/kumiko-framework": "0.337.1",
488
+ "@cosmicdrift/kumiko-headless": "0.337.1",
489
+ "@cosmicdrift/kumiko-renderer": "0.337.1",
490
+ "@cosmicdrift/kumiko-renderer-web": "0.337.1",
491
+ "@cosmicdrift/kumiko-types": "0.337.1",
492
492
  "@mollie/api-client": "^4.5.0",
493
493
  "@node-rs/argon2": "^2.0.2",
494
494
  "@types/mailparser": "^3.4.6",
@@ -985,8 +985,8 @@
985
985
  ],
986
986
  "devDependencies": {
987
987
  "@testing-library/user-event": "^14.6.1",
988
- "@cosmicdrift/kumiko-locale-de": "0.336.0",
989
- "@cosmicdrift/kumiko-locale-es": "0.336.0",
988
+ "@cosmicdrift/kumiko-locale-de": "0.337.1",
989
+ "@cosmicdrift/kumiko-locale-es": "0.337.1",
990
990
  "jsqr": "^1.4.0"
991
991
  }
992
992
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "agent tool name collisions are disambiguated, audit search is substring, encrypted config bounds errors do not echo the value",
6
+ "detail": "The agent manifest reports a detail screen's configured `idParam` and builds its label index once per manifest. Handler tools whose names collide after sanitizing or truncation are no longer dropped; the later one gets a QN-hash suffix. The audit log search matches event types by substring instead of exact equality. Config bounds errors no longer echo an encrypted value. The MFA account label never falls back to the internal user id. The tenant caps list resolves limits in parallel."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "breaking",