@cosmicdrift/kumiko-framework 0.372.0 → 0.374.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/agents/examples/rate-limiting.md +65 -1
  2. package/agents/widgets.md +1 -0
  3. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  4. package/dist/engine/boot-validator/entity-handler.js +47 -16
  5. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  6. package/dist/engine/boot-validator/query-refs.js +26 -8
  7. package/dist/engine/boot-validator/screens.d.ts +1 -1
  8. package/dist/engine/boot-validator/screens.js +327 -41
  9. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  10. package/dist/engine/build-app-schema.js +7 -3
  11. package/dist/engine/entity-list-searchability.d.ts +5 -0
  12. package/dist/engine/entity-list-searchability.js +35 -0
  13. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  14. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  15. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  16. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  17. package/dist/engine/feature-ui-extensions.js +5 -2
  18. package/dist/engine/index.d.ts +2 -1
  19. package/dist/engine/index.js +1 -0
  20. package/dist/engine/paged-list.d.ts +29 -0
  21. package/dist/engine/paged-list.js +56 -0
  22. package/dist/engine/project-app-schema-for-roles.js +37 -6
  23. package/dist/engine/types/index.d.ts +3 -3
  24. package/dist/engine/types/index.js +1 -1
  25. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  26. package/dist/files/bind-pending-file-refs.js +80 -6
  27. package/dist/files/index.d.ts +2 -0
  28. package/dist/files/index.js +1 -0
  29. package/dist/files/load-save-file-refs.js +3 -1
  30. package/dist/i18n/required-surface-keys.js +50 -2
  31. package/dist/pipeline/dispatch-shared.js +55 -35
  32. package/dist/rate-limit/index.d.ts +1 -1
  33. package/dist/rate-limit/resolver.d.ts +1 -1
  34. package/dist/rate-limit/resolver.js +104 -2
  35. package/dist/rate-limit/unlimited-resolver.js +1 -0
  36. package/dist/ui-types/index.d.ts +1 -1
  37. package/package.json +5 -5
  38. package/src/changes.json +74 -0
@@ -1,6 +1,6 @@
1
1
  import type { TenantDb } from "../db/tenant-db.ts";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
3
- import type { FileRefRows } from "./load-save-file-refs.ts";
3
+ import { type FileRefRows } from "./load-save-file-refs.ts";
4
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
5
5
  export declare function bindPendingFileRefs(args: {
6
6
  readonly registry: Registry;
@@ -9,3 +9,33 @@ export declare function bindPendingFileRefs(args: {
9
9
  readonly db: TenantDb;
10
10
  readonly fileRefRows: FileRefRows;
11
11
  }): Promise<void>;
12
+ export type PendingFileRef = {
13
+ readonly id: string;
14
+ readonly storageKey: string;
15
+ readonly mimeType: string;
16
+ readonly size: number;
17
+ readonly version: number;
18
+ };
19
+ type PendingFileRefLookup = {
20
+ readonly fileRefId: string;
21
+ readonly entityName: string;
22
+ readonly fieldName: string;
23
+ readonly user: SessionUser;
24
+ readonly db: TenantDb;
25
+ };
26
+ export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
27
+ export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
28
+ /**
29
+ * Binds one of the caller's own pending uploads to a record from a custom handler, with the
30
+ * same ownership predicate as the save pipeline. Anonymous callers never own an upload.
31
+ *
32
+ * The helper checks the upload, not the target: the caller must already be authorized to write
33
+ * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
34
+ *
35
+ * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
36
+ * winner has committed); anything else throws.
37
+ */
38
+ export declare function bindPendingFileRef(args: PendingFileRefLookup & {
39
+ readonly entityId: string;
40
+ }): Promise<BindPendingFileRefOutcome>;
41
+ export {};
@@ -1,7 +1,9 @@
1
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
1
2
  import { isFileField, isUuid, } from "../engine/types/index.js";
2
3
  import { reraiseAsKumikoError } from "../errors/index.js";
3
4
  import { getFileRefExecutor } from "./create-file-ref.js";
4
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
+ import { loadFileRefRows } from "./load-save-file-refs.js";
5
7
  export function fileRefIdsOf(value) {
6
8
  if (typeof value === "string")
7
9
  return isUuid(value) ? [value] : [];
@@ -15,6 +17,16 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
15
17
  row["fieldName"] === fieldName &&
16
18
  row["insertedById"] === user.id);
17
19
  }
20
+ function streamVersionOf(row) {
21
+ return typeof row["version"] === "number" ? row["version"] : undefined;
22
+ }
23
+ async function bindPendingRow(args) {
24
+ const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
25
+ if (!isPendingUploadFor(row, entityName, fieldName, user))
26
+ return { kind: "not-pending" };
27
+ const bound = await getFileRefExecutor().update({ id: fileRefId, version: streamVersionOf(row), changes: { entityId } }, user, db);
28
+ return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
29
+ }
18
30
  // A form (or a server handler via createFileRefFromBytes) uploads before the
19
31
  // record references the file, so its fileRefs carry entityType + fieldName but
20
32
  // no entityId. Binding them in the save's own transaction makes record-bound
@@ -35,7 +47,6 @@ export async function bindPendingFileRefs(args) {
35
47
  // skip: without the files feature the file_refs table is not part of the schema
36
48
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
37
49
  return;
38
- const executor = getFileRefExecutor();
39
50
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
40
51
  if (!isFileField(fieldDef))
41
52
  continue;
@@ -43,12 +54,75 @@ export async function bindPendingFileRefs(args) {
43
54
  // ones bound earlier; only the ids this save names can be pending uploads.
44
55
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
45
56
  const row = fileRefRows.get(fileRefId);
46
- if (row === undefined || !isPendingUploadFor(row, entityName, fieldName, user))
57
+ if (row === undefined)
47
58
  continue;
48
- const version = typeof row["version"] === "number" ? row["version"] : undefined;
49
- const bound = await executor.update({ id: fileRefId, version, changes: { entityId: String(result.id) } }, user, db);
50
- if (!bound.isSuccess)
51
- throw reraiseAsKumikoError(bound.error);
59
+ const outcome = await bindPendingRow({
60
+ row,
61
+ fileRefId,
62
+ entityName,
63
+ fieldName,
64
+ entityId: String(result.id),
65
+ user,
66
+ db,
67
+ });
68
+ if (outcome.kind === "failed")
69
+ throw reraiseAsKumikoError(outcome.error);
52
70
  }
53
71
  }
54
72
  }
73
+ async function loadPendingRow(lookup) {
74
+ const { fileRefId, entityName, fieldName, user, db } = lookup;
75
+ // Every anonymous visitor shares one user id, so insertedById cannot tell their uploads apart.
76
+ if (isAnonymousSessionUser(user))
77
+ return undefined;
78
+ if (!isUuid(fileRefId))
79
+ return undefined;
80
+ const row = (await loadFileRefRows(db, [fileRefId], user.tenantId)).get(fileRefId);
81
+ return row !== undefined && isPendingUploadFor(row, entityName, fieldName, user)
82
+ ? row
83
+ : undefined;
84
+ }
85
+ // fileName is left out on purpose: it is personal data and may be encrypted; read it through
86
+ // the fileRef executor's detail when it is needed.
87
+ export async function loadPendingFileRef(lookup) {
88
+ const row = await loadPendingRow(lookup);
89
+ if (row === undefined)
90
+ return undefined;
91
+ const { storageKey, mimeType, size } = row;
92
+ const version = streamVersionOf(row);
93
+ if (typeof storageKey !== "string" ||
94
+ typeof mimeType !== "string" ||
95
+ typeof size !== "number" ||
96
+ version === undefined) {
97
+ return undefined;
98
+ }
99
+ return { id: lookup.fileRefId, storageKey, mimeType, size, version };
100
+ }
101
+ const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
102
+ /**
103
+ * Binds one of the caller's own pending uploads to a record from a custom handler, with the
104
+ * same ownership predicate as the save pipeline. Anonymous callers never own an upload.
105
+ *
106
+ * The helper checks the upload, not the target: the caller must already be authorized to write
107
+ * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
108
+ *
109
+ * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
110
+ * winner has committed); anything else throws.
111
+ */
112
+ export async function bindPendingFileRef(args) {
113
+ const row = await loadPendingRow(args);
114
+ // Without a stream version the update would run unversioned and could overwrite a concurrent bind.
115
+ if (row === undefined || streamVersionOf(row) === undefined)
116
+ return "not-pending";
117
+ const outcome = await bindPendingRow({ ...args, row });
118
+ switch (outcome.kind) {
119
+ case "bound":
120
+ return "bound";
121
+ case "not-pending":
122
+ return "not-pending";
123
+ case "failed":
124
+ if (CONCURRENT_BIND_ERROR_CODES.includes(outcome.error.code))
125
+ return "version-conflict";
126
+ throw reraiseAsKumikoError(outcome.error);
127
+ }
128
+ }
@@ -1,3 +1,5 @@
1
+ export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
2
+ export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
1
3
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
2
4
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
3
5
  export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
@@ -1,3 +1,4 @@
1
+ export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
1
2
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
3
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
4
  // `createFileHandle` is an implementation detail — construct handles via
@@ -33,12 +33,14 @@ export async function loadFileRefRows(db, ids, tenantId) {
33
33
  // skip: nothing referenced, nothing to load
34
34
  if (ids.length === 0)
35
35
  return new Map();
36
+ // Versions before rows: a bind committing in between leaves a stale row paired with an older
37
+ // version, so the binding update conflicts instead of overwriting the winner's entityId.
38
+ const streamVersions = await getStreamVersions(tenantDbRunner(db), [...ids], db.tenantId);
36
39
  const rows = await selectMany(db, fileRefsTable, {
37
40
  id: { in: [...ids] },
38
41
  tenantId,
39
42
  isDeleted: false,
40
43
  });
41
- const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
42
44
  return new Map(rows.map((row) => {
43
45
  const id = String(row["id"]);
44
46
  const streamVersion = streamVersions.get(id);
@@ -45,6 +45,19 @@ function pushKey(out, value, treatAsKey = false) {
45
45
  function editFieldName(f) {
46
46
  return typeof f === "string" ? f : f.field;
47
47
  }
48
+ // Placeholder, affix texts and the fill button label of a field spec; a `{ field }`
49
+ // affix reads another form field and is no key.
50
+ function pushEditFieldTextKeys(out, spec, treatAsKey = false) {
51
+ // skip: shorthand field name carries no texts
52
+ if (typeof spec === "string")
53
+ return;
54
+ pushKey(out, spec.placeholder, treatAsKey);
55
+ for (const affix of [spec.prefix, spec.suffix]) {
56
+ if (typeof affix === "string")
57
+ pushKey(out, affix, treatAsKey);
58
+ }
59
+ pushKey(out, spec.fillAction?.label, treatAsKey);
60
+ }
48
61
  // Group titles are translated exactly like the section title (computeEditViewModel).
49
62
  function pushSectionTitles(out, section, treatAsKey = false) {
50
63
  pushKey(out, section.title, treatAsKey);
@@ -67,15 +80,20 @@ function pushActionFormShapedKeys(out, featureName, step) {
67
80
  pushSectionTitles(out, section);
68
81
  for (const f of sectionFieldSpecs(section)) {
69
82
  out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, editFieldName(f)));
83
+ pushEditFieldTextKeys(out, f);
70
84
  }
71
85
  }
72
86
  }
73
87
  function pushRowActionKeys(out, action) {
74
88
  pushKey(out, action.label);
75
- if (action.kind === "writeHandler" || action.kind === undefined) {
89
+ if (action.kind === "writeHandler" ||
90
+ action.kind === "clientAction" ||
91
+ action.kind === undefined) {
76
92
  pushKey(out, action.confirm);
77
93
  pushKey(out, action.confirmLabel);
78
94
  }
95
+ if (action.kind === "clientAction")
96
+ pushKey(out, action.successMessage);
79
97
  if (action.kind === "drawer") {
80
98
  pushKey(out, action.title);
81
99
  pushKey(out, action.subtitle);
@@ -90,6 +108,8 @@ function pushDashboardScreenKeys(out, dashboard) {
90
108
  pushKey(out, option.label);
91
109
  pushKey(out, dashboard.scope?.badge);
92
110
  pushKey(out, dashboard.scope?.notice);
111
+ for (const action of dashboard.actions ?? [])
112
+ pushRowActionKeys(out, action);
93
113
  }
94
114
  function pushDashboardPanelKeys(out, panel) {
95
115
  // skip: custom-Panel übersetzt sich selbst, kein Key hier
@@ -98,8 +118,12 @@ function pushDashboardPanelKeys(out, panel) {
98
118
  pushKey(out, panel.label);
99
119
  if (panel.kind === "chart" || panel.kind === "stat-group")
100
120
  pushKey(out, panel.subtitle);
101
- if (panel.kind === "chart")
121
+ if (panel.kind === "text")
122
+ pushDashboardTextBlockKeys(out, panel);
123
+ if (panel.kind === "chart") {
124
+ pushKey(out, panel.unit);
102
125
  pushDashboardChartRangeKeys(out, panel);
126
+ }
103
127
  if (panel.kind === "chart" ||
104
128
  panel.kind === "list" ||
105
129
  panel.kind === "feed" ||
@@ -107,6 +131,9 @@ function pushDashboardPanelKeys(out, panel) {
107
131
  pushKey(out, panel.emptyLabel);
108
132
  pushKey(out, panel.emptyHint);
109
133
  }
134
+ pushDashboardChildLabelKeys(out, panel);
135
+ }
136
+ function pushDashboardChildLabelKeys(out, panel) {
110
137
  if (panel.kind === "stat-group") {
111
138
  for (const stat of panel.stats)
112
139
  pushKey(out, stat.label);
@@ -119,6 +146,20 @@ function pushDashboardPanelKeys(out, panel) {
119
146
  }
120
147
  }
121
148
  }
149
+ function pushDashboardTextBlockKeys(out, panel) {
150
+ for (const block of panel.blocks) {
151
+ // skip: code is shown verbatim, not translated
152
+ if (block.kind === "code")
153
+ continue;
154
+ if (block.kind === "list") {
155
+ for (const item of block.items)
156
+ pushKey(out, item);
157
+ }
158
+ else {
159
+ pushKey(out, block.text);
160
+ }
161
+ }
162
+ }
122
163
  function pushDashboardChartRangeKeys(out, panel) {
123
164
  for (const option of panel.ranges?.options ?? [])
124
165
  pushKey(out, option.label);
@@ -243,6 +284,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
243
284
  continue; // rejected at boot, unreachable here
244
285
  pushSectionTitles(out, section);
245
286
  for (const f of sectionFieldSpecs(section)) {
287
+ pushEditFieldTextKeys(out, f);
246
288
  const fieldName = editFieldName(f);
247
289
  const override = edit.fieldLabels?.[fieldName];
248
290
  if (override !== undefined)
@@ -260,6 +302,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
260
302
  pushKey(out, form.summary?.subtitle);
261
303
  for (const action of form.footerActions ?? [])
262
304
  pushKey(out, action.label);
305
+ for (const link of form.successLinks ?? [])
306
+ pushKey(out, link.label);
263
307
  for (const fieldName of Object.keys(form.fields)) {
264
308
  const override = form.fieldLabels?.[fieldName];
265
309
  if (override !== undefined)
@@ -276,6 +320,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
276
320
  continue; // rejected at boot, unreachable here
277
321
  pushSectionTitles(out, section);
278
322
  for (const f of sectionFieldSpecs(section)) {
323
+ pushEditFieldTextKeys(out, f);
279
324
  const fieldName = editFieldName(f);
280
325
  const override = form.fieldLabels?.[fieldName];
281
326
  if (override !== undefined)
@@ -328,6 +373,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
328
373
  pushSectionTitles(out, section, treatDotFormAsKey);
329
374
  pushKey(out, section.description, treatDotFormAsKey);
330
375
  for (const f of sectionFieldSpecs(section)) {
376
+ pushEditFieldTextKeys(out, f, treatDotFormAsKey);
331
377
  const fieldName = editFieldName(f);
332
378
  const override = config.fieldLabels?.[fieldName];
333
379
  if (override !== undefined)
@@ -366,6 +412,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
366
412
  pushKey(out, section.submitLabel);
367
413
  // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
368
414
  for (const f of section.fields) {
415
+ pushEditFieldTextKeys(out, f);
369
416
  const fieldName = editFieldName(f);
370
417
  out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
371
418
  }
@@ -373,6 +420,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
373
420
  }
374
421
  pushSectionTitles(out, section);
375
422
  for (const f of sectionFieldSpecs(section)) {
423
+ pushEditFieldTextKeys(out, f);
376
424
  const fieldName = editFieldName(f);
377
425
  const override = detail.fieldLabels?.[fieldName];
378
426
  if (override !== undefined)
@@ -5,7 +5,7 @@ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTrans
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
6
  import { mayOverrideTenant } from "../engine/cross-tenant.js";
7
7
  import { createSystemUser } from "../engine/system-user.js";
8
- import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
8
+ import { declaredRateLimitOptions, isRateLimitDisabled, resolveAgentExposure, } from "../engine/types/index.js";
9
9
  import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
@@ -1107,16 +1107,28 @@ const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT = {
1107
1107
  limit: 600,
1108
1108
  windowSeconds: 60,
1109
1109
  };
1110
- function resolveEffectiveRateLimit(rateLimit, isSystemScope, user, resolver) {
1111
- if (rateLimit)
1112
- return rateLimit;
1110
+ function resolveEffectiveRateLimits(rateLimit, isSystemScope, user, resolver) {
1111
+ const declared = declaredRateLimitOptions(rateLimit);
1112
+ if (declared.length > 0)
1113
+ return declared;
1113
1114
  if (!isSystemScope)
1114
- return undefined;
1115
+ return [];
1115
1116
  if (!resolver)
1116
- return undefined;
1117
+ return [];
1117
1118
  if (isSystemIdentity(user))
1118
- return undefined;
1119
- return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
1119
+ return [];
1120
+ return [DEFAULT_SYSTEM_SCOPE_RATE_LIMIT];
1121
+ }
1122
+ // A single bucket keeps the plain enforce path; several go through one atomic
1123
+ // all-or-nothing call so a rejection by one bucket never charges the others.
1124
+ async function enforceBuckets(resolver, requests) {
1125
+ const [only, ...rest] = requests;
1126
+ // skip: no bucket to charge
1127
+ if (!only)
1128
+ return;
1129
+ await (rest.length === 0
1130
+ ? resolver.enforce(only.bucket, only.config)
1131
+ : resolver.enforceAll(requests));
1120
1132
  }
1121
1133
  // L3 rate limit gate. Called by both query and write paths before
1122
1134
  // access-check. Reasoning:
@@ -1135,42 +1147,48 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
1135
1147
  // skip: handler explicitly opts out via rateLimit: { disabled: true }
1136
1148
  if (isRateLimitDisabled(rateLimit))
1137
1149
  return;
1138
- const effective = resolveEffectiveRateLimit(rateLimit, isSystemScope, user, context.rateLimit);
1150
+ const effective = resolveEffectiveRateLimits(rateLimit, isSystemScope, user, context.rateLimit);
1139
1151
  // skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
1140
- if (!effective)
1152
+ if (effective.length === 0)
1141
1153
  return;
1142
1154
  const reqCtx = requestContext.get();
1143
- const bucket = buildBucketKey(effective, {
1144
- handlerName,
1145
- user,
1146
- ip: reqCtx?.ip,
1147
- });
1148
- // skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
1149
- // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
1150
- if (bucket.kind === "skip")
1151
- return;
1152
- if (!context.rateLimit) {
1153
- throw new InternalError({
1154
- message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
1155
- });
1156
- }
1157
1155
  // A nested dispatch re-hitting a bucket its entry dispatch already charged
1158
1156
  // (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
1159
1157
  // The mark is set only after a successful enforce and includes the limit
1160
1158
  // parameters: a denied or failed check never lets a later nested call through,
1161
1159
  // and a nested handler with a stricter limit on the same bucket is still checked.
1162
1160
  const charged = reqCtx?.chargedRateLimitBuckets;
1163
- const chargedKey = `${bucket.key}|${effective.limit}|${effective.windowSeconds}|${effective.cost}`;
1164
- // skip: this exact bucket, limit and window was already charged by the entry dispatch
1165
- if (charged?.has(chargedKey))
1161
+ const pending = [];
1162
+ for (const option of effective) {
1163
+ const bucket = buildBucketKey(option, { handlerName, user, ip: reqCtx?.ip });
1164
+ // skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
1165
+ // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
1166
+ if (bucket.kind === "skip")
1167
+ continue;
1168
+ const chargedKey = `${bucket.key}|${option.limit}|${option.windowSeconds}|${option.cost}`;
1169
+ // skip: this exact bucket, limit and window was already charged by the entry dispatch
1170
+ if (charged?.has(chargedKey))
1171
+ continue;
1172
+ pending.push({
1173
+ request: {
1174
+ bucket: bucket.key,
1175
+ config: { limit: option.limit, windowSeconds: option.windowSeconds, cost: option.cost },
1176
+ },
1177
+ chargedKey,
1178
+ });
1179
+ }
1180
+ // skip: every bucket was skipped or already charged — nothing left to enforce
1181
+ if (pending.length === 0)
1166
1182
  return;
1167
- try {
1168
- await context.rateLimit.enforce(bucket.key, {
1169
- limit: effective.limit,
1170
- windowSeconds: effective.windowSeconds,
1171
- cost: effective.cost,
1183
+ if (!context.rateLimit) {
1184
+ throw new InternalError({
1185
+ message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
1172
1186
  });
1173
- charged?.add(chargedKey);
1187
+ }
1188
+ try {
1189
+ await enforceBuckets(context.rateLimit, pending.map(({ request }) => request));
1190
+ for (const { chargedKey } of pending)
1191
+ charged?.add(chargedKey);
1174
1192
  }
1175
1193
  catch (error) {
1176
1194
  // Backend failure (Redis down): fail closed as 503, same as L1/L2.
@@ -1203,6 +1221,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1203
1221
  message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
1204
1222
  });
1205
1223
  }
1224
+ const requests = [];
1206
1225
  for (const { per, limit, windowSeconds, cost, normalize } of limits) {
1207
1226
  const value = data[per.payloadField];
1208
1227
  // skip: optional field not sent — nothing to bucket on
@@ -1213,9 +1232,10 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1213
1232
  message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
1214
1233
  });
1215
1234
  }
1216
- const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1217
- await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
1235
+ const bucket = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1236
+ requests.push({ bucket, config: { limit, windowSeconds, cost } });
1218
1237
  }
1238
+ await enforceBuckets(context.rateLimit, requests);
1219
1239
  }
1220
1240
  // Build the per-hook context every auth-claims invocation gets. Claims
1221
1241
  // hooks run OUTSIDE any request transaction (login is itself the root
@@ -1,4 +1,4 @@
1
1
  export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.ts";
2
2
  export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
3
- export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
3
+ export { createRateLimitResolver, type RateLimitBucketRequest, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
4
4
  export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.ts";
@@ -1,6 +1,6 @@
1
1
  import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
2
2
  import type { Redis } from "ioredis";
3
- export type { RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
3
+ export type { RateLimitBucketRequest, RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
4
4
  export type RateLimitResolverOptions = {
5
5
  readonly redis: Redis;
6
6
  readonly keyPrefix?: string;
@@ -1,5 +1,5 @@
1
1
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
- import { RateLimitError } from "../errors/index.js";
2
+ import { InternalError, RateLimitError } from "../errors/index.js";
3
3
  import { RedisKeys } from "../pipeline/redis-keys.js";
4
4
  import { toPublicBucketName } from "./bucket.js";
5
5
  // Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
@@ -105,6 +105,64 @@ end
105
105
 
106
106
  return { math.floor(tokens), retryAfterMs }
107
107
  `;
108
+ // All-or-nothing variant for several buckets. Every bucket is refilled and
109
+ // checked first; tokens are deducted only when each one has enough, so a
110
+ // rejected request charges no bucket. Multi-key EVAL needs all keys in one
111
+ // hash slot on Redis Cluster; the framework uses standalone Redis.
112
+ //
113
+ // KEYS[i] — bucket keys
114
+ // ARGV[1] — nowMs
115
+ // ARGV[2 + 4(i-1) .. 5 + 4(i-1)] — limit, refillRatePerMs, cost, ttlSeconds of KEYS[i]
116
+ //
117
+ // Returns the flat list { allAllowed (1|0), remaining_1, retryAfterMs_1, remaining_2, ... }.
118
+ // retryAfterMs_i is 0 for buckets that had enough tokens.
119
+ const TOKEN_BUCKET_ALL_LUA = `
120
+ local nowMs = tonumber(ARGV[1])
121
+ local count = #KEYS
122
+ local tokens = {}
123
+ local costs = {}
124
+ local retries = {}
125
+ local allAllowed = 1
126
+
127
+ for i = 1, count do
128
+ local base = 2 + (i - 1) * 4
129
+ local limit = tonumber(ARGV[base])
130
+ local refillRatePerMs = tonumber(ARGV[base + 1])
131
+ costs[i] = tonumber(ARGV[base + 2])
132
+
133
+ local data = redis.call('HMGET', KEYS[i], 'tokens', 'ts')
134
+ local current = tonumber(data[1])
135
+ local ts = tonumber(data[2])
136
+ if current == nil then
137
+ current = limit
138
+ else
139
+ local elapsed = math.max(0, nowMs - ts)
140
+ current = math.min(limit, current + elapsed * refillRatePerMs)
141
+ end
142
+ tokens[i] = current
143
+
144
+ if current >= costs[i] then
145
+ retries[i] = 0
146
+ else
147
+ retries[i] = math.ceil((costs[i] - current) / refillRatePerMs)
148
+ allAllowed = 0
149
+ end
150
+ end
151
+
152
+ local result = { allAllowed }
153
+ for i = 1, count do
154
+ if allAllowed == 1 then
155
+ tokens[i] = tokens[i] - costs[i]
156
+ end
157
+ local base = 2 + (i - 1) * 4
158
+ redis.call('HMSET', KEYS[i], 'tokens', tokens[i], 'ts', nowMs)
159
+ redis.call('EXPIRE', KEYS[i], tonumber(ARGV[base + 3]))
160
+ result[#result + 1] = math.floor(tokens[i])
161
+ result[#result + 1] = retries[i]
162
+ end
163
+
164
+ return result
165
+ `;
108
166
  const REGISTERED = new WeakSet();
109
167
  function ensureCommand(redis) {
110
168
  if (!REGISTERED.has(redis)) {
@@ -116,6 +174,7 @@ function ensureCommand(redis) {
116
174
  numberOfKeys: 1,
117
175
  lua: TOKEN_BUCKET_PEEK_LUA,
118
176
  });
177
+ redis.defineCommand("kumikoRateLimitAll", { lua: TOKEN_BUCKET_ALL_LUA });
119
178
  REGISTERED.add(redis);
120
179
  }
121
180
  return redis; // @cast-boundary engine-bridge
@@ -154,6 +213,49 @@ export function createRateLimitResolver(opts) {
154
213
  resetAt: decision.resetAt.toString(),
155
214
  });
156
215
  }
216
+ async function enforceAll(requests) {
217
+ if (requests.length === 0)
218
+ return [];
219
+ const nowMs = now();
220
+ const keys = requests.map(({ bucket }) => `${prefix}${bucket}`);
221
+ // The script writes each key back once, so a repeated key would deduct only one of its costs.
222
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
223
+ if (duplicate !== undefined) {
224
+ throw new InternalError({
225
+ message: `enforceAll received the bucket "${duplicate.slice(prefix.length)}" more than once — merge the requests into one.`,
226
+ });
227
+ }
228
+ const perBucketArgs = requests.flatMap(({ config }) => [
229
+ String(config.limit),
230
+ String(config.limit / (config.windowSeconds * 1000)),
231
+ String(config.cost ?? 1),
232
+ String(Math.max(1, config.windowSeconds * 2)),
233
+ ]);
234
+ const [allAllowedFlag, ...flat] = await client.kumikoRateLimitAll(keys.length, ...keys, String(nowMs), ...perBucketArgs);
235
+ const outcomes = requests.map(({ bucket, config }, index) => {
236
+ const retryAfterMs = flat[index * 2 + 1] ?? 0;
237
+ const decision = {
238
+ allowed: retryAfterMs === 0,
239
+ limit: config.limit,
240
+ remaining: flat[index * 2] ?? 0,
241
+ retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
242
+ windowSeconds: config.windowSeconds,
243
+ resetAt: Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs),
244
+ };
245
+ return { bucket, retryAfterMs, decision };
246
+ });
247
+ if (allAllowedFlag === 1)
248
+ return outcomes.map(({ decision }) => decision);
249
+ const rejected = outcomes.reduce((worst, outcome) => outcome.retryAfterMs > worst.retryAfterMs ? outcome : worst);
250
+ throw new RateLimitError({
251
+ bucket: toPublicBucketName(rejected.bucket),
252
+ limit: rejected.decision.limit,
253
+ windowSeconds: rejected.decision.windowSeconds,
254
+ remaining: rejected.decision.remaining,
255
+ retryAfterSeconds: rejected.decision.retryAfterSeconds,
256
+ resetAt: rejected.decision.resetAt.toString(),
257
+ });
258
+ }
157
259
  async function peek(bucket, config) {
158
260
  const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
159
261
  const nowMs = now();
@@ -171,5 +273,5 @@ export function createRateLimitResolver(opts) {
171
273
  resetAt,
172
274
  };
173
275
  }
174
- return { check, enforce, peek };
276
+ return { check, enforce, enforceAll, peek };
175
277
  }
@@ -16,6 +16,7 @@ export function createUnlimitedRateLimitResolver() {
16
16
  return {
17
17
  check: async (_bucket, config) => fullBucket(config),
18
18
  enforce: async (_bucket, config) => fullBucket(config),
19
+ enforceAll: async (requests) => requests.map(({ config }) => fullBucket(config)),
19
20
  peek: async (_bucket, config) => fullBucket(config),
20
21
  };
21
22
  }
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.ts";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
12
- export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
12
+ export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
13
13
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
14
14
  export type { TargetRef } from "../engine/types/target-ref.ts";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.ts";