@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
@@ -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);
@@ -179,8 +179,7 @@ export function createLoginHandler(opts = {}) {
179
179
  const statusGate = gateEnforceAccountStatus(found);
180
180
  if (!statusGate.ok)
181
181
  return statusGate.result;
182
- // Still needed for candidate ORDER (preferred tenant first) — the actual
183
- // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
182
+ // Still needed for candidate ORDER (preferred tenant first) in gateResolveMembership.
184
183
  const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
185
184
  userId: found.id,
186
185
  })); // @cast-boundary db-runner
@@ -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
  });
@@ -79,8 +79,9 @@ function registerCustomFields(r, variant) {
79
79
  // Event-types — qualified als "custom-fields:event:<short-name>".
80
80
  // Returned EventDefs liefern .name als compile-time literal-typed string,
81
81
  // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
82
- // `value: z.unknown()` has no field-level schema to catalog under piiFields;
83
- // "no PII" is a naming convention (#972), not an enforced guarantee — see #2776.
82
+ // `value: z.unknown()` has no field-level schema to catalog under piiFields
83
+ // (event PII covers string fields only), so "no PII" is a convention here,
84
+ // not an enforced guarantee.
84
85
  const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
85
86
  piiFields: "none",
86
87
  });
@@ -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);
@@ -67,7 +67,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
67
67
  // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
68
68
  // "displayName" stays plaintext (often the mailbox owner's real name for
69
69
  // personal accounts); encrypting it would break the entity-projection read
70
- // path that carries it as `personal: false` (#2776).
70
+ // path that carries it as `personal: false`.
71
71
  r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
72
72
  piiFields: "none",
73
73
  });
@@ -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 };
@@ -71,8 +71,8 @@ export function createNotesHistoryFeature(opts = {}) {
71
71
  // of passing an empty array.
72
72
  if (opts.parents !== undefined && opts.parents.length === 0) {
73
73
  throw new Error("createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
74
- "an empty allowlist rejects every add-note call. Omit `parents` to keep " +
75
- "today's unrestricted behavior instead.");
74
+ "an empty allowlist rejects every add-note call. Omit `parents` to allow " +
75
+ "any registered entity the caller can read as a note parent.");
76
76
  }
77
77
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) => registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents));
78
78
  }
@@ -35,7 +35,8 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
35
35
  try {
36
36
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
37
37
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
38
- // caught — a bare try/catch here would take the note write down with it.
38
+ // caught — so the lookup runs in a savepoint where available, otherwise directly
39
+ // (pool statements are their own units).
39
40
  authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON), async (sp) => {
40
41
  const userRow = await fetchOne(sp, userTable, {
41
42
  id: event.user.id,
@@ -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
@@ -42,27 +42,27 @@ export const apiTokenEntity = createEntity({
42
42
  // store ciphertext while the resolver keeps querying for the plaintext
43
43
  // hash — every bearer-token request would 401 once a KMS is active.
44
44
  // Fixing that needs a blind-index-backed lookup (like email's
45
- // find:"exact" companion column), a resolver.ts rewrite out of scope
46
- // here — see PR body "Offene Fragen".
45
+ // find:"exact" companion column), a resolver.ts rewrite, so this stays
46
+ // unencrypted until a blind-index lookup exists.
47
47
  tokenHash: createTextField({
48
48
  required: true,
49
49
  maxLength: 64,
50
50
  access: { write: access.privileged },
51
51
  personal: false,
52
- reason: "credential_hash_used_as_exact_lookup_key_2809",
52
+ reason: "credential_hash_used_as_exact_lookup_key",
53
53
  }),
54
54
  // Short displayable prefix ("your tokens" list) — not the secret itself,
55
55
  // but still identifies which of the user's tokens this row is. Kept
56
56
  // Klasse 1: list.query.ts returns `r.prefix` straight through with no
57
57
  // decrypt call (unlike `name`, which goes through decryptStoredPii) — a
58
58
  // subject annotation would leak ciphertext to the UI once a KMS is
59
- // active. See PR body "Offene Fragen".
59
+ // active.
60
60
  prefix: createTextField({
61
61
  required: true,
62
62
  maxLength: 16,
63
63
  access: { write: access.privileged },
64
64
  personal: false,
65
- reason: "credential_prefix_read_without_decrypt_2809",
65
+ reason: "credential_prefix_read_without_decrypt",
66
66
  }),
67
67
  // JSON-encoded string[] of granted scope names — mirrors the roles-column
68
68
  // convention (parseRoles-style text); the resolver JSON.parses it.
@@ -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
  });