@cosmicdrift/kumiko-bundled-features 0.336.1 → 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 (53) 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/seeding.js +6 -0
  5. package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
  6. package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
  7. package/dist/auth-mfa/hmac-token-codec.js +8 -9
  8. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
  9. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -7
  10. package/dist/config/write-helpers.js +2 -2
  11. package/dist/custom-fields/lib/field-access.d.ts +3 -2
  12. package/dist/custom-fields/lib/field-access.js +1 -0
  13. package/dist/delivery/unsubscribe.js +1 -0
  14. package/dist/files-tenant-data/hooks.js +11 -3
  15. package/dist/folders/web/folder-section.js +8 -1
  16. package/dist/jobs/constants.d.ts +1 -0
  17. package/dist/jobs/constants.js +1 -0
  18. package/dist/jobs/handlers/trigger.write.js +7 -2
  19. package/dist/jobs/i18n.js +3 -0
  20. package/dist/metrics/compute.js +16 -5
  21. package/dist/notes-history/web/notes-section.js +6 -1
  22. package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
  23. package/dist/secrets/feature.js +2 -0
  24. package/dist/secrets/handlers/delete.write.js +4 -0
  25. package/dist/secrets/handlers/set.write.js +5 -2
  26. package/dist/secrets/i18n.d.ts +10 -0
  27. package/dist/secrets/i18n.js +10 -0
  28. package/dist/secrets/write-gate.d.ts +10 -0
  29. package/dist/secrets/write-gate.js +36 -0
  30. package/dist/shared/index.d.ts +0 -1
  31. package/dist/shared/index.js +0 -1
  32. package/dist/shared/session-locale-field.js +1 -2
  33. package/dist/shared/session-timezone-field.js +3 -2
  34. package/dist/step-dispatcher/feature.js +4 -3
  35. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  36. package/dist/step-dispatcher/mail-runner.js +5 -0
  37. package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
  38. package/dist/step-dispatcher/webhook-runner.js +9 -0
  39. package/dist/subscription-stripe/plugin-methods.js +7 -0
  40. package/dist/tenant-handover/move-entity-graph.js +11 -3
  41. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
  42. package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
  43. package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
  44. package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
  45. package/dist/workflow-runner/db/queries/due-runs.js +4 -1
  46. package/package.json +9 -9
  47. package/src/agent-tools/changes.json +6 -0
  48. package/src/secrets/changes.json +7 -0
  49. package/src/subscription-stripe/changes.json +6 -0
  50. package/src/tenant-lifecycle/changes.json +8 -1
  51. package/src/workflow-runner/changes.json +6 -0
  52. package/dist/shared/session-field.d.ts +0 -3
  53. package/dist/shared/session-field.js +0 -6
@@ -6,14 +6,38 @@ import * as z from "zod";
6
6
  function entryAsLabels(entry) {
7
7
  return Object.fromEntries(Object.entries(entry).map(([locale, value]) => [locale, translationValueOtherText(value)]));
8
8
  }
9
+ const labelsIndexByTranslations = new WeakMap();
10
+ /** Every tail starting at a key boundary (`:` or `.`, with and without the
11
+ * separator) maps to the labels of the first key that ends with it. */
12
+ function buildSuffixIndex(translations) {
13
+ const index = new Map();
14
+ for (const [key, entry] of Object.entries(translations)) {
15
+ const labels = entryAsLabels(entry);
16
+ const addTail = (start) => {
17
+ const tail = key.slice(start);
18
+ if (!index.has(tail))
19
+ index.set(tail, labels);
20
+ };
21
+ addTail(0);
22
+ for (let position = 0; position < key.length; position++) {
23
+ if (key[position] === ":" || key[position] === ".") {
24
+ addTail(position);
25
+ addTail(position + 1);
26
+ }
27
+ }
28
+ }
29
+ return index;
30
+ }
9
31
  /** Translation keys carry a feature prefix the manifest can't reconstruct
10
- * (`showcase:entity:item:field:title`), so match on the suffix instead. */
32
+ * (`showcase:entity:item:field:title`), so match on the suffix instead. The
33
+ * suffix must start at a `:` or `.` boundary of the key. */
11
34
  function labelsForSuffix(translations, suffix) {
12
- for (const [key, labels] of Object.entries(translations)) {
13
- if (key === suffix || key.endsWith(suffix))
14
- return entryAsLabels(labels);
35
+ let index = labelsIndexByTranslations.get(translations);
36
+ if (index === undefined) {
37
+ index = buildSuffixIndex(translations);
38
+ labelsIndexByTranslations.set(translations, index);
15
39
  }
16
- return {};
40
+ return index.get(suffix) ?? {};
17
41
  }
18
42
  /** Nav/screen/workspace gating is opt-in: no rule means visible to everyone
19
43
  * (NavDefinition.access). Handlers are the opposite — `hasAccess` is
@@ -205,6 +229,9 @@ function lastSegment(qualifiedId) {
205
229
  const idx = qualifiedId.lastIndexOf(":");
206
230
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
207
231
  }
232
+ function detailIdParam(screen) {
233
+ return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
234
+ }
208
235
  function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
209
236
  const allNavs = [...navs.values()];
210
237
  const result = [];
@@ -239,8 +266,8 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
239
266
  description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
240
267
  }),
241
268
  ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
242
- // A detail screen takes the row id; everything else is parameterless.
243
- params: screen.detailFor !== undefined ? ["id"] : [],
269
+ // A detail screen takes the row id under its idParam; everything else is parameterless.
270
+ params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
244
271
  workspaces: screenWorkspaces,
245
272
  ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
246
273
  ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
@@ -206,6 +206,25 @@ export function toolNameForQn(qn) {
206
206
  const sanitized = joined.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
207
207
  return sanitized.slice(0, MAX_TOOL_NAME_LENGTH);
208
208
  }
209
+ // FNV-1a: only needs to be stable and well-spread, not collision-proof — a hash clash
210
+ // on top of a name clash falls through to the sink's drop-later-tool guard.
211
+ function shortQnHash(qn) {
212
+ let hash = 0x811c9dc5;
213
+ for (let index = 0; index < qn.length; index++) {
214
+ hash = Math.imul(hash ^ qn.charCodeAt(index), 0x01000193) >>> 0;
215
+ }
216
+ return hash.toString(16).padStart(8, "0");
217
+ }
218
+ /** Sanitizing, verb-dropping and truncation make distinct QNs share a tool name; the
219
+ * first QN in manifest order keeps the plain name, later ones get a QN-hash suffix so
220
+ * no handler silently disappears from the catalog. */
221
+ function uniqueToolNameForQn(qn, usedNames) {
222
+ const plainName = toolNameForQn(qn);
223
+ if (!usedNames.has(plainName))
224
+ return plainName;
225
+ const hashSuffix = `_${shortQnHash(qn)}`;
226
+ return `${plainName.slice(0, MAX_TOOL_NAME_LENGTH - hashSuffix.length)}${hashSuffix}`;
227
+ }
209
228
  export const OPEN_FORM_TOOL_NAME = "open_form";
210
229
  function buildNavigateTool(manifest) {
211
230
  const entityScreens = new Map();
@@ -359,7 +378,7 @@ function addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink) {
359
378
  continue;
360
379
  if (entityListDetailQns.has(handler.qn))
361
380
  continue;
362
- const name = toolNameForQn(handler.qn);
381
+ const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
363
382
  addTool(sink, { name, description: handlerDescription(handler), inputSchema: handler.inputSchema }, {
364
383
  kind: "server",
365
384
  op: "query",
@@ -375,7 +394,7 @@ function addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink) {
375
394
  continue;
376
395
  if (denyQns.has(handler.qn))
377
396
  continue;
378
- const name = toolNameForQn(handler.qn);
397
+ const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
379
398
  const detailQn = handler.entity !== undefined ? detailQnByEntity.get(handler.entity) : undefined;
380
399
  const injectsVersion = detailQn !== undefined && schemaRequiresField(handler.inputSchema, "version");
381
400
  const inputSchema = injectsVersion
@@ -27,15 +27,19 @@ function buildDateRange(from, to) {
27
27
  range.lte = Temporal.Instant.from(to);
28
28
  return range;
29
29
  }
30
+ function escapeLikePattern(value) {
31
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
32
+ }
30
33
  function buildAuditWhere(tenantId, p) {
31
34
  const where = { tenantId };
32
35
  if (p.aggregateType)
33
36
  where["aggregateType"] = p.aggregateType;
34
37
  if (p.aggregateId)
35
38
  where["aggregateId"] = p.aggregateId;
36
- const type = p.eventType ?? p.search;
37
- if (type)
38
- where["type"] = type;
39
+ if (p.eventType)
40
+ where["type"] = p.eventType;
41
+ else if (p.search)
42
+ where["type"] = { like: `%${escapeLikePattern(p.search)}%` };
39
43
  if (p.userId)
40
44
  where["createdBy"] = p.userId;
41
45
  const range = buildDateRange(p.from, p.to);
@@ -151,6 +151,12 @@ export async function seedAdmin(db, options) {
151
151
  return { id: userId };
152
152
  }
153
153
  function compareInsertedAt(a, b) {
154
+ // selectMany<ActiveUserRow> is an unchecked row-shape claim. A codec that
155
+ // yields another type would make both comparisons false and silently fall
156
+ // back to id order, which can crown the younger duplicate as survivor.
157
+ if (typeof a.epochNanoseconds !== "bigint" || typeof b.epochNanoseconds !== "bigint") {
158
+ throw new Error("compareInsertedAt: user.insertedAt is not a Temporal.Instant");
159
+ }
154
160
  if (a.epochNanoseconds < b.epochNanoseconds)
155
161
  return -1;
156
162
  if (a.epochNanoseconds > b.epochNanoseconds)
@@ -22,6 +22,9 @@ import { csrfHeader } from "./auth-client.js";
22
22
  import { resolvePostAuthHref } from "./auth-form-logic.js";
23
23
  import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
24
24
  import { SessionContext, UNAUTHENTICATED } from "./session.js";
25
+ function isMfaPending(data) {
26
+ return data.tenantId === undefined;
27
+ }
25
28
  function grantedRoles(data) {
26
29
  if (data.user?.roles !== undefined)
27
30
  return data.user.roles;
@@ -46,7 +49,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
46
49
  // challenge / setup requirement, but no session cookie was minted: send the
47
50
  // user to the login screen (now a member) instead of a tenant URL.
48
51
  const finishAccept = (data) => {
49
- if (data.mfaRequired === true || data.mfaSetupRequired === true) {
52
+ if (isMfaPending(data)) {
50
53
  window.location.assign(loginHref);
51
54
  return;
52
55
  }
@@ -9,6 +9,18 @@ import { signMfaSetupToken } from "../mfa-setup-token.js";
9
9
  import { buildOtpauthUri } from "../otpauth-uri.js";
10
10
  import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes.js";
11
11
  import { generateTotpSecret } from "../totp.js";
12
+ const FALLBACK_ACCOUNT_LABEL = "account";
13
+ // The authenticator app shows this label, so an internal user id must never leak into it.
14
+ function readableAccountLabel(me) {
15
+ if (typeof me !== "object" || me === null)
16
+ return FALLBACK_ACCOUNT_LABEL;
17
+ for (const field of ["email", "displayName"]) {
18
+ const value = Reflect.get(me, field);
19
+ if (typeof value === "string" && value.length > 0)
20
+ return value;
21
+ }
22
+ return FALLBACK_ACCOUNT_LABEL;
23
+ }
12
24
  // Stateless setup: no `userMfa` row is created here. The generated secret +
13
25
  // recovery-code hashes are signed into a short-lived `setupToken` (see
14
26
  // mfa-setup-token.ts) that `enable-confirm` verifies. An abandoned setup
@@ -41,8 +53,7 @@ export function createEnableStartHandler(opts) {
41
53
  if (!accountLabel) {
42
54
  // The declarative secretMint screen has no client component left to
43
55
  // read the session email off — derive the otpauth label server-side.
44
- const me = (await ctx.queryAs(event.user, UserQueries.me, {})); // @cast-boundary engine-payload
45
- accountLabel = me?.email ?? event.user.id;
56
+ accountLabel = readableAccountLabel(await ctx.queryAs(event.user, UserQueries.me, {}));
46
57
  }
47
58
  const secret = generateTotpSecret();
48
59
  const recoveryCodes = generateRecoveryCodes();
@@ -7,15 +7,15 @@ function hmacSign(input, secret) {
7
7
  // minted for one MFA step from verifying as another (e.g. a preauth-setup token
8
8
  // replayed as a challenge token), since both share the same secret and payload shape.
9
9
  export function createHmacTokenCodec(domain, isPayload) {
10
- function isEncodedBody(value) {
10
+ function splitEncodedBody(value) {
11
11
  if (typeof value !== "object" || value === null)
12
- return false;
12
+ return null;
13
13
  const v = value;
14
14
  const expiresAtMs = v["expiresAtMs"];
15
15
  if (typeof expiresAtMs !== "number")
16
- return false;
16
+ return null;
17
17
  const { expiresAtMs: _drop, ...rest } = v;
18
- return isPayload(rest);
18
+ return isPayload(rest) ? { payload: rest, expiresAtMs } : null;
19
19
  }
20
20
  return {
21
21
  sign(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
@@ -46,14 +46,13 @@ export function createHmacTokenCodec(domain, isPayload) {
46
46
  catch {
47
47
  return { ok: false, reason: "malformed" };
48
48
  }
49
- if (!isEncodedBody(parsed))
49
+ const body = splitEncodedBody(parsed);
50
+ if (!body)
50
51
  return { ok: false, reason: "malformed" };
51
- if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) >
52
- 0) {
52
+ if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(body.expiresAtMs)) > 0) {
53
53
  return { ok: false, reason: "expired" };
54
54
  }
55
- const { expiresAtMs, ...payload } = parsed;
56
- return { ok: true, payload: payload, expiresAtMs };
55
+ return { ok: true, payload: body.payload, expiresAtMs: body.expiresAtMs };
57
56
  },
58
57
  };
59
58
  }
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
+ // Consumers that type-check this source never include the ambient declaration on their own.
4
+ /// <reference path="./qrcode-browser.d.ts" />
3
5
  // Pre-auth twin of the declarative auth-mfa-enable secretMint screen: a user
4
6
  // blocked at login by MFA enforcement and not yet enrolled lands here
5
7
  // straight out of the login flow — there is no session yet. Identity comes
@@ -25,13 +25,8 @@ function capTierKey(capId, tier) {
25
25
  return JSON.stringify([capId, tier]);
26
26
  }
27
27
  async function resolveLimitsByCapAndTier(caps, tiers, context) {
28
- const limitByCapAndTier = new Map();
29
- for (const cap of caps) {
30
- for (const tier of tiers) {
31
- limitByCapAndTier.set(capTierKey(cap.id, tier), await cap.limit(tier, context));
32
- }
33
- }
34
- return limitByCapAndTier;
28
+ const entries = await Promise.all(caps.flatMap((cap) => [...tiers].map(async (tier) => [capTierKey(cap.id, tier), await cap.limit(tier, context)])));
29
+ return new Map(entries);
35
30
  }
36
31
  function isSortableField(field) {
37
32
  return SORTABLE_FIELDS.includes(field);
@@ -217,7 +217,7 @@ export function validateBounds(value, keyDef) {
217
217
  path: "value",
218
218
  code: "out_of_bounds",
219
219
  i18nKey: "errors.validation.out_of_bounds",
220
- params: { value, min, max: max ?? null },
220
+ params: { ...echoableValue(keyDef, value), min, max: max ?? null },
221
221
  },
222
222
  ],
223
223
  });
@@ -229,7 +229,7 @@ export function validateBounds(value, keyDef) {
229
229
  path: "value",
230
230
  code: "out_of_bounds",
231
231
  i18nKey: "errors.validation.out_of_bounds",
232
- params: { value, min: min ?? null, max },
232
+ params: { ...echoableValue(keyDef, value), min: min ?? null, max },
233
233
  },
234
234
  ],
235
235
  });
@@ -1,4 +1,5 @@
1
1
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { type SerializedFieldShape } from "./parse-serialized-field.js";
3
4
  export type FieldAccessCheckResult = {
4
5
  ok: true;
@@ -13,6 +14,6 @@ export type LoadedFieldDefinition = {
13
14
  found: true;
14
15
  field: SerializedFieldShape | null;
15
16
  };
16
- export declare function loadFieldDefinition(runner: DbRunner, tenantId: string, entityName: string, fieldKey: string): Promise<LoadedFieldDefinition>;
17
+ export declare function loadFieldDefinition(runner: DbRunner, tenantId: TenantId, entityName: string, fieldKey: string): Promise<LoadedFieldDefinition>;
17
18
  export declare function fieldWriteAccessDeniedRoles(field: SerializedFieldShape | null, userRoles: ReadonlyArray<string>): ReadonlyArray<string> | null;
18
- export declare function checkFieldAccessForWrite(runner: DbRunner, tenantId: string, entityName: string, fieldKey: string, userRoles: ReadonlyArray<string>): Promise<FieldAccessCheckResult>;
19
+ export declare function checkFieldAccessForWrite(runner: DbRunner, tenantId: TenantId, entityName: string, fieldKey: string, userRoles: ReadonlyArray<string>): Promise<FieldAccessCheckResult>;
@@ -3,6 +3,7 @@
3
3
  // value-validation off one DB read (no double fetch).
4
4
  import { selectSerializedFieldDefinition } from "../db/queries/field-access.js";
5
5
  import { parseSerializedField } from "./parse-serialized-field.js";
6
+ // `runner` is deliberately unscoped: tenant isolation rests solely on `tenantId`.
6
7
  export async function loadFieldDefinition(runner, tenantId, entityName, fieldKey) {
7
8
  const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
8
9
  if (serialized === null)
@@ -168,6 +168,7 @@ function isResubscribeLimitReached(error) {
168
168
  const details = error.details;
169
169
  return (typeof details === "object" &&
170
170
  details !== null &&
171
+ "reason" in details &&
171
172
  details.reason === DeliveryErrors.resubscribeLimitReached);
172
173
  }
173
174
  async function dispatchResubscribeWrite(verified, deps) {
@@ -47,7 +47,7 @@
47
47
  // not one query per key.
48
48
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
49
49
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
50
- import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
50
+ import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
51
51
  import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
52
52
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
53
53
  // Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
@@ -83,6 +83,13 @@ export const fileRefTenantDestroyHook = async (ctx) => {
83
83
  }
84
84
  }
85
85
  };
86
+ // The bytes live under the tenant that uploaded them (`<tenantId>/…` or
87
+ // `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
88
+ // provider — not the destroyed destination tenant's — holds them.
89
+ function storageKeyOwnerTenant(storageKey, fallback) {
90
+ const [first, second] = storageKey.split("/");
91
+ return parseTenantId(first) ?? (first === "exports" ? parseTenantId(second) : null) ?? fallback;
92
+ }
86
93
  async function deleteHandedOverBinary(ctx, storageKey) {
87
94
  if (!ctx.fileProviderResolver) {
88
95
  // skip: same graceful-degradation stance as fileRefStorageDestroyHook —
@@ -91,12 +98,13 @@ async function deleteHandedOverBinary(ctx, storageKey) {
91
98
  // skip: no provider wired, so there is no store to delete from — logged above.
92
99
  return;
93
100
  }
101
+ const ownerTenantId = storageKeyOwnerTenant(storageKey, ctx.tenantId);
94
102
  let provider;
95
103
  try {
96
- provider = await ctx.fileProviderResolver(ctx.tenantId);
104
+ provider = await ctx.fileProviderResolver(ownerTenantId);
97
105
  }
98
106
  catch (err) {
99
- ctx.log?.(`[files-tenant-data] no file provider resolvable for tenant ${ctx.tenantId}: ${err instanceof Error ? err.message : String(err)} — handed-over binary for ${storageKey} NOT deleted`);
107
+ ctx.log?.(`[files-tenant-data] no file provider resolvable for tenant ${ownerTenantId}: ${err instanceof Error ? err.message : String(err)} — handed-over binary for ${storageKey} NOT deleted`);
100
108
  // skip: provider resolution failed, so there is nothing this call can delete — logged above.
101
109
  return;
102
110
  }
@@ -25,7 +25,14 @@ export function FolderSection({ entityName, entityId, }) {
25
25
  const dispatcher = useDispatcher();
26
26
  const enabled = entityId !== null;
27
27
  const catalog = useQuery(FoldersQueries.folderList, {}, { enabled });
28
- const assignments = useQuery(FoldersQueries.assignmentList, { filter: { field: "entityId", op: "eq", value: entityId } }, { enabled });
28
+ const assignments = useQuery(FoldersQueries.assignmentList, {
29
+ // entityType server-side, not just entityId: it collapses the parent-ref
30
+ // read gate to this one host entity instead of every registered one.
31
+ filters: [
32
+ { field: "entityType", op: "eq", value: entityName },
33
+ { field: "entityId", op: "eq", value: entityId },
34
+ ],
35
+ }, { enabled });
29
36
  const [newName, setNewName] = useState("");
30
37
  const [busy, setBusy] = useState(false);
31
38
  const [errorKey, setErrorKey] = useState(null);
@@ -14,6 +14,7 @@ export declare const JobErrors: {
14
14
  readonly notFound: "not_found";
15
15
  readonly onlyFailedCanRetry: "only_failed_jobs_can_be_retried";
16
16
  readonly notManual: "job_not_manual";
17
+ readonly invalidPayload: "job_invalid_payload";
17
18
  readonly payloadErased: "job_payload_erased";
18
19
  };
19
20
  /** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
@@ -19,6 +19,7 @@ export const JobErrors = {
19
19
  notFound: "not_found",
20
20
  onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
21
21
  notManual: "job_not_manual",
22
+ invalidPayload: "job_invalid_payload",
22
23
  payloadErased: "job_payload_erased",
23
24
  };
24
25
  /** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
@@ -3,6 +3,11 @@ import { NotFoundError, UnprocessableError, validationErrorFromZod, writeFailure
3
3
  import * as z from "zod";
4
4
  import { JobErrors } from "../constants.js";
5
5
  import { isManualTrigger } from "../is-manual-trigger.js";
6
+ function invalidPayloadError() {
7
+ return new UnprocessableError(JobErrors.invalidPayload, {
8
+ i18nKey: "jobs.errors.invalidPayload",
9
+ });
10
+ }
6
11
  export const triggerWrite = defineWriteHandler({
7
12
  name: "trigger",
8
13
  description: "Starts one manually-triggerable job with the given payload after validating it against the job's schema; use it to run a maintenance or import job on demand.",
@@ -32,12 +37,12 @@ export const triggerWrite = defineWriteHandler({
32
37
  try {
33
38
  const parsed = JSON.parse(event.payload.payload);
34
39
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
35
- return writeFailure(new UnprocessableError(JobErrors.notManual));
40
+ return writeFailure(invalidPayloadError());
36
41
  }
37
42
  rawPayload = parsed;
38
43
  }
39
44
  catch {
40
- return writeFailure(new UnprocessableError(JobErrors.notManual));
45
+ return writeFailure(invalidPayloadError());
41
46
  }
42
47
  }
43
48
  else if (event.payload.payload !== undefined) {
package/dist/jobs/i18n.js CHANGED
@@ -38,6 +38,9 @@ export const JOBS_I18N = {
38
38
  "jobs.errors.notManual": {
39
39
  en: "This job cannot be triggered manually.",
40
40
  },
41
+ "jobs.errors.invalidPayload": {
42
+ en: "The payload must be a valid JSON object.",
43
+ },
41
44
  "jobs.errors.notFound": { en: "Not found." },
42
45
  "jobs.errors.onlyFailedCanRetry": {
43
46
  en: "Only failed runs can be retried.",
@@ -35,20 +35,31 @@ async function fetchAggregates(db, metric, wheres, bucketDimension) {
35
35
  const aggregate = (groupBy, where, extra = {}) => db.aggregate(metric.source, { measure: metric.measure, groupBy, ...extra }, where);
36
36
  const { groupBy, stackBy } = metric;
37
37
  const { current } = wheres;
38
+ const groupsPromise = groupBy === undefined
39
+ ? Promise.resolve([])
40
+ : aggregate([{ field: groupBy }], current, { orderByValue: "desc", limit: MAX_GROUP_ROWS });
41
+ // Stacks/series are only rendered for the top groups. A NULL group key can't be
42
+ // matched by IN, so a null among the top groups falls back to the unrestricted scan.
43
+ const groupedWhere = async () => {
44
+ if (groupBy === undefined)
45
+ return current;
46
+ const keys = (await groupsPromise).map((row) => row.keys[0]);
47
+ return keys.length === 0 || keys.includes(null) || keys.includes(undefined)
48
+ ? current
49
+ : { ...current, [groupBy]: keys };
50
+ };
38
51
  const [total, previous, points, groups, stacks, series] = await Promise.all([
39
52
  aggregate([], current),
40
53
  wheres.previous === undefined ? Promise.resolve(undefined) : aggregate([], wheres.previous),
41
54
  bucketDimension !== undefined && groupBy === undefined
42
55
  ? aggregate([bucketDimension], current)
43
56
  : Promise.resolve([]),
44
- groupBy === undefined
45
- ? Promise.resolve([])
46
- : aggregate([{ field: groupBy }], current, { orderByValue: "desc", limit: MAX_GROUP_ROWS }),
57
+ groupsPromise,
47
58
  groupBy !== undefined && stackBy !== undefined
48
- ? aggregate([{ field: groupBy }, { field: stackBy }], current)
59
+ ? groupedWhere().then((where) => aggregate([{ field: groupBy }, { field: stackBy }], where))
49
60
  : Promise.resolve([]),
50
61
  bucketDimension !== undefined && groupBy !== undefined
51
- ? aggregate([{ field: groupBy }, bucketDimension], current)
62
+ ? groupedWhere().then((where) => aggregate([{ field: groupBy }, bucketDimension], where))
52
63
  : Promise.resolve([]),
53
64
  ]);
54
65
  return { total, previous, points, groups, stacks, series };
@@ -26,7 +26,12 @@ export function NotesSection({ entityName, entityId, }) {
26
26
  const dispatcher = useDispatcher();
27
27
  const enabled = entityId !== null;
28
28
  const notes = useQuery(NotesHistoryQueries.noteList, {
29
- filter: { field: "entityId", op: "eq", value: entityId },
29
+ // entityType server-side, not just entityId: it collapses the parent-ref
30
+ // read gate to this one host entity instead of every registered one.
31
+ filters: [
32
+ { field: "entityType", op: "eq", value: entityName },
33
+ { field: "entityId", op: "eq", value: entityId },
34
+ ],
30
35
  sort: "insertedAt",
31
36
  sortDirection: "desc",
32
37
  // ponytail: fixed page, no "load more" — an entity with a note history
@@ -1,8 +1,5 @@
1
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
2
- type BucketCaller = {
3
- readonly id: string;
4
- readonly tenantId: string;
5
- readonly roles: readonly string[];
6
- };
3
+ type BucketCaller = Pick<SessionUser, "id" | "tenantId" | "roles">;
7
4
  export declare function bucketAccessDenied(bucket: string, caller: BucketCaller): AccessDeniedError | undefined;
8
5
  export {};
@@ -7,6 +7,7 @@ import { createDeleteHandler } from "./handlers/delete.write.js";
7
7
  import { createListHandler } from "./handlers/list.query.js";
8
8
  import { rotateJob } from "./handlers/rotate.job.js";
9
9
  import { createSetHandler } from "./handlers/set.write.js";
10
+ import { SECRETS_I18N } from "./i18n.js";
10
11
  import { secretReadSchema } from "./secrets-context.js";
11
12
  import { tenantSecretEntity, tenantSecretsTable } from "./table.js";
12
13
  const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the new master key";
@@ -102,6 +103,7 @@ export function createSecretsFeature(opts = {}) {
102
103
  // covering both boot-time validation and rendering without a web
103
104
  // client-plugin for this feature.
104
105
  r.translations({ keys: SETTINGS_HUB_I18N });
106
+ r.translations({ keys: SECRETS_I18N });
105
107
  // ES entity: set/delete go through the executor, `tenantSecret.created/
106
108
  // .updated/.deleted` events land on the aggregate stream. Reads fire a
107
109
  // separate `tenantSecretRead` event per call (see secrets-context.get
@@ -3,6 +3,7 @@ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
5
5
  import { requireSecretsContext } from "../feature.js";
6
+ import { checkSecretKeyWrite } from "../write-gate.js";
6
7
  export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
7
8
  return defineWriteHandler({
8
9
  name: "delete",
@@ -14,6 +15,9 @@ export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
14
15
  access,
15
16
  handler: async (event, ctx) => {
16
17
  const secrets = requireSecretsContext(ctx, "secrets:write:delete");
18
+ const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key);
19
+ if (!gate.ok)
20
+ return gate.failure;
17
21
  const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
18
22
  deletedBy: event.user.id,
19
23
  });
@@ -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"]),
@@ -270,6 +270,13 @@ async function resolvePortalConfiguration(stripe, runtime, cache, portalConfigCa
270
270
  const price = cache.get(priceId);
271
271
  return price ? [price] : [];
272
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
+ }
273
280
  const hash = priceSetHash(prices);
274
281
  const cached = portalConfigCache.get(hash);
275
282
  if (cached)
@@ -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
  }
@@ -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
  }
@@ -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.1",
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.1",
487
- "@cosmicdrift/kumiko-framework": "0.336.1",
488
- "@cosmicdrift/kumiko-headless": "0.336.1",
489
- "@cosmicdrift/kumiko-renderer": "0.336.1",
490
- "@cosmicdrift/kumiko-renderer-web": "0.336.1",
491
- "@cosmicdrift/kumiko-types": "0.336.1",
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.1",
989
- "@cosmicdrift/kumiko-locale-es": "0.336.1",
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",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "breaking",
5
+ "title": "secrets:set and secrets:delete accept only keys declared via r.secret; r.secret takes writeRoles",
6
+ "detail": "Both handlers reject a key that no feature declared with `r.secret` (404, i18n key `secrets.errors.unknownKey`). `r.secret` takes `writeRoles`: when set, only users holding one of those roles may set or delete that key (403, i18n key `secrets.errors.writeDenied`). The roles narrow the handler access, so both checks must pass. An empty `writeRoles` array throws at declaration.",
7
+ "migration": "Declare every key you set through `secrets:write:set` via `r.secret`. Rows stored under undeclared keys stay in the table but can no longer be set or deleted through the API. A key that only SystemAdmin may write declares `writeRoles: [\"SystemAdmin\"]`, and the secrets feature must then grant SystemAdmin handler access via `createSecretsFeature({ roles: [\"TenantAdmin\", \"SystemAdmin\"] })`. Tests that set a key no feature declares need the same fix: the kumiko-studio test `bundled-stack.integration.test.ts` sets `ai-foundation:secret:anthropic-api-key`, which no feature declares; kumiko-ai-foundation 0.39.1 and 0.40.1 declare the Anthropic key as `ai-provider-anthropic:secret:anthropic-api-key`."
8
+ },
2
9
  {
3
10
  "version": "0.335.0",
4
11
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "plan switch rejects unretrievable prices, concurrent request-deletion loser reports the fresh user status",
6
+ "detail": "The Stripe plan switch now fails with `price_unavailable` when an allowed price cannot be retrieved, instead of creating an incomplete Stripe portal configuration under a different price-set hash. A concurrent losing `request-deletion` now reports the user's current status in `user_not_in_active_state` instead of the stale `active`."
7
+ },
2
8
  {
3
9
  "version": "0.336.1",
4
10
  "type": "fix",
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "tenant destroy deletes handed-over binaries via the uploader's provider, trigger reports invalid payloads distinctly, mail failure logs redact addresses",
6
+ "detail": "A tenant destroy deletes a handed-over binary through the file provider of the tenant that uploaded it, not the destroyed tenant's. `jobs:write:trigger` answers a broken or non-object JSON payload with `job_invalid_payload` instead of `job_not_manual`. Grouped metrics restrict their stack and series queries to the top groups. The notes and folders sections filter by `entityType` on the server. A failed mail dispatch logs the adapter error without email addresses."
7
+ }
8
+ ]
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "workflow resume batches due runs, tenant handover merges discovered rows per type",
6
+ "detail": "The workflow resume job now picks up at most 100 due runs per tick, oldest first, so a backlog drains over several ticks. Tenant handover merges rows discovered per entity type so each outgoing edge runs once per round."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,3 +0,0 @@
1
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
- /** Omit null/undefined session keys so callers can spread without wiping defaults. */
3
- export declare function sessionField<K extends "locale" | "timezone">(key: K, value: string | null | undefined): Partial<Pick<SessionUser, K>>;
@@ -1,6 +0,0 @@
1
- /** Omit null/undefined session keys so callers can spread without wiping defaults. */
2
- export function sessionField(key, value) {
3
- if (value === null || value === undefined)
4
- return {};
5
- return { [key]: value };
6
- }