@cosmicdrift/kumiko-samples 0.347.0 → 0.348.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.347.0",
3
+ "version": "0.348.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.347.0",
3
+ "version": "0.348.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.348.0",
4
+ "type": "fix",
5
+ "title": "AuthGate accepts a loginUrl function, renders LoginScreen on the login route, and LoginScreen follows a validated next",
6
+ "detail": "AuthGate accepts a loginUrl function and LoginScreen follows a validated next\n`AuthGateOptions.loginUrl` may now be a function `(locale, returnPath) => string` in addition to a string. When the user is already on the login route, the gate renders the LoginScreen instead of redirecting. After a successful login (and MFA verify or setup), the screen follows the `next` query parameter, but only for relative same-origin paths: no `//`, no scheme, no backslash, no control characters.",
7
+ "migration": "No action needed. String loginUrl keeps working."
8
+ },
2
9
  {
3
10
  "version": "0.347.0",
4
11
  "type": "fix",
@@ -9,8 +9,13 @@
9
9
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
+ import { useLocale } from "@cosmicdrift/kumiko-renderer";
12
13
  import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
- import { assertNavigableUrl, buildLoginRedirectUrl } from "./auth-redirect.js";
14
+ import {
15
+ assertNavigableUrl,
16
+ buildLoginRedirectUrl,
17
+ followNextAfterLogin,
18
+ } from "./auth-redirect.js";
14
19
  import { LoginScreen, type LoginScreenProps } from "./login-screen.js";
15
20
  import { SessionProvider, useSession } from "./session.js";
16
21
  import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
@@ -108,7 +113,10 @@ export function createLoginRoute(
108
113
  return (
109
114
  <MfaVerifyComponent
110
115
  challengeToken={challengeToken}
111
- onSuccess={() => setChallengeToken(null)}
116
+ onSuccess={() => {
117
+ setChallengeToken(null);
118
+ followNextAfterLogin();
119
+ }}
112
120
  onCancel={() => setChallengeToken(null)}
113
121
  />
114
122
  );
@@ -126,7 +134,10 @@ export function createLoginRoute(
126
134
  // refresh() never rejects: a failed refresh surfaces as status
127
135
  // "error" (bootstrap error screen with retry), so only the
128
136
  // success path needs to clear setupRequest.
129
- void refresh().then(() => setSetupRequest(null));
137
+ void refresh().then(() => {
138
+ setSetupRequest(null);
139
+ followNextAfterLogin();
140
+ });
130
141
  }}
131
142
  onCancel={() => setSetupRequest(null)}
132
143
  />
@@ -150,11 +161,16 @@ export function createLoginRoute(
150
161
  return LoginRoute;
151
162
  }
152
163
 
164
+ /** Builds the login page URL. `returnPath` is the current path + query + hash;
165
+ * append it as `next` yourself (use `NEXT_QUERY_PARAM`) if the login page should return there. */
166
+ export type LoginUrlBuilder = (locale: string, returnPath: string) => string;
167
+
153
168
  export type AuthGateOptions = LoginRouteOptions & {
154
- /** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
155
- * visitors are sent there with `next=<current path>`; the built-in login screen
156
- * is not rendered. */
157
- readonly loginUrl?: string;
169
+ /** Login page outside the SPA (root-relative path or http(s) URL), or a function
170
+ * that builds it per locale. Unauthenticated visitors are sent there with
171
+ * `next=<current path>` (a string gets `next` appended; a function decides itself).
172
+ * On the login route itself the gate renders the built-in login screen instead. */
173
+ readonly loginUrl?: string | LoginUrlBuilder;
158
174
  };
159
175
 
160
176
  export type SessionAuthGateOptions = AuthGateOptions & {
@@ -162,30 +178,52 @@ export type SessionAuthGateOptions = AuthGateOptions & {
162
178
  readonly postLogoutUrl?: string;
163
179
  };
164
180
 
165
- function redirectToLoginUrl(loginUrl: string): void {
166
- const { origin, pathname, search, hash } = window.location;
181
+ type LoginRedirect =
182
+ | { readonly kind: "on-login-route" }
183
+ | { readonly kind: "redirect"; readonly url: string };
184
+
185
+ // @internal — exported for unit tests only.
186
+ export function resolveLoginRedirect(
187
+ loginUrl: string | LoginUrlBuilder,
188
+ locale: string,
189
+ { origin, pathname, search, hash }: Pick<Location, "origin" | "pathname" | "search" | "hash">,
190
+ ): LoginRedirect {
191
+ const returnPath = `${pathname}${search}${hash}`;
192
+ const target = typeof loginUrl === "function" ? loginUrl(locale, returnPath) : loginUrl;
193
+ if (typeof loginUrl === "function") assertNavigableUrl(target, "loginUrl()");
167
194
  // Compared before `next` is appended: the target always differs from the current
168
195
  // URL once it carries next, so a gate mounted on the login page itself would loop.
169
- const loginLocation = new URL(loginUrl, origin);
170
- if (loginLocation.origin === origin && loginLocation.pathname === pathname) return;
171
- window.location.replace(buildLoginRedirectUrl(loginUrl, `${pathname}${search}${hash}`, origin));
196
+ const loginLocation = new URL(target, origin);
197
+ if (loginLocation.origin === origin && loginLocation.pathname === pathname) {
198
+ return { kind: "on-login-route" };
199
+ }
200
+ return {
201
+ kind: "redirect",
202
+ url:
203
+ typeof loginUrl === "function" ? target : buildLoginRedirectUrl(target, returnPath, origin),
204
+ };
172
205
  }
173
206
 
174
207
  export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
175
208
  children: ReactNode;
176
209
  }> {
177
210
  const { loginUrl } = opts;
178
- if (loginUrl !== undefined) assertNavigableUrl(loginUrl, "loginUrl");
211
+ if (typeof loginUrl === "string") assertNavigableUrl(loginUrl, "loginUrl");
179
212
  const LoginRoute = createLoginRoute(opts);
180
213
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
181
214
  const { status } = useSession();
182
- const redirectsToLoginUrl = loginUrl !== undefined && status === "unauthenticated";
215
+ const locale = useLocale().locale();
216
+ const loginRedirect =
217
+ loginUrl !== undefined && status === "unauthenticated"
218
+ ? resolveLoginRedirect(loginUrl, locale, window.location)
219
+ : null;
220
+ const redirectUrl = loginRedirect?.kind === "redirect" ? loginRedirect.url : null;
183
221
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
184
222
  useEffect(() => {
185
- if (redirectsToLoginUrl) redirectToLoginUrl(loginUrl);
186
- }, [redirectsToLoginUrl]);
223
+ if (redirectUrl !== null) window.location.replace(redirectUrl);
224
+ }, [redirectUrl]);
187
225
  if (status === "authenticated") return <>{children}</>;
188
- if (redirectsToLoginUrl) return null;
226
+ if (redirectUrl !== null) return null;
189
227
  return <LoginRoute />;
190
228
  }
191
229
  return AuthGate;
@@ -58,3 +58,17 @@ export function buildLoginRedirectUrl(
58
58
  const isSameOrigin = target.origin === currentOrigin;
59
59
  return isSameOrigin ? `${target.pathname}${target.search}${target.hash}` : target.href;
60
60
  }
61
+
62
+ // Login screens call this after a successful login. `next` is re-validated
63
+ // here because it is read from the URL, i.e. attacker-controlled. Returns
64
+ // true when it navigated.
65
+ export function followNextAfterLogin(
66
+ location: Pick<Location, "search" | "pathname" | "replace"> = window.location,
67
+ ): boolean {
68
+ const next = readNextFromSearch(location.search);
69
+ if (next === null) return false;
70
+ // Following a next that points at the login page itself would reload it forever.
71
+ if (new URL(next, "https://next.invalid").pathname === location.pathname) return false;
72
+ location.replace(next);
73
+ return true;
74
+ }
@@ -17,6 +17,7 @@ import {
17
17
  requestEmailVerification,
18
18
  } from "./auth-client.js";
19
19
  import { AuthCard } from "./auth-form-primitives.js";
20
+ import { followNextAfterLogin } from "./auth-redirect.js";
20
21
  import { useSession } from "./session.js";
21
22
 
22
23
  // Resend-Status für den "Bestätigungs-Mail erneut senden"-Flow, der bei
@@ -142,7 +143,10 @@ export function LoginScreen({
142
143
  setResendStatus({ kind: "idle" });
143
144
  const res = await session.login({ email, password });
144
145
  setSubmitting(false);
145
- if (res.kind === "success") return;
146
+ if (res.kind === "success") {
147
+ followNextAfterLogin();
148
+ return;
149
+ }
146
150
  if (res.kind === "mfa-challenge") {
147
151
  if (onMfaChallenge) {
148
152
  onMfaChallenge(res.challengeToken);
@@ -59,6 +59,8 @@ export type BookCapUsageOptions = {
59
59
  readonly periodStartIso: string;
60
60
  readonly amount?: number;
61
61
  readonly outsideTransaction?: boolean;
62
+ // Runs on every attempt with the freshly read value, so a throw rejects the booking against the state it would actually be applied to (a version-conflict retry re-reads and re-checks).
63
+ readonly guardCurrentValue?: (currentValue: number) => void;
62
64
  };
63
65
 
64
66
  function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
@@ -73,6 +75,22 @@ function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
73
75
  export async function bookCapUsage(
74
76
  ctx: HandlerContext,
75
77
  options: BookCapUsageOptions,
78
+ ): Promise<WriteResult> {
79
+ return applyCapDelta(ctx, options, "add");
80
+ }
81
+
82
+ // Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
83
+ export async function releaseCapUsage(
84
+ ctx: HandlerContext,
85
+ options: Omit<BookCapUsageOptions, "guardCurrentValue">,
86
+ ): Promise<WriteResult> {
87
+ return applyCapDelta(ctx, options, "subtract");
88
+ }
89
+
90
+ async function applyCapDelta(
91
+ ctx: HandlerContext,
92
+ options: BookCapUsageOptions,
93
+ direction: "add" | "subtract",
76
94
  ): Promise<WriteResult> {
77
95
  const parsed = capBookingSchema.parse(options);
78
96
  const aggregateId = capCounterAggregateId(
@@ -84,6 +102,8 @@ export async function bookCapUsage(
84
102
  async function attemptWrite(db: TenantDb): Promise<WriteResult> {
85
103
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
86
104
  if (existing.length === 0) {
105
+ options.guardCurrentValue?.(0);
106
+ if (direction === "subtract") return { isSuccess: true, data: {} };
87
107
  return executor.create(
88
108
  {
89
109
  id: aggregateId,
@@ -103,11 +123,17 @@ export async function bookCapUsage(
103
123
  }
104
124
  const currentValue = currentRow["value"] as number; // @cast-boundary db-row
105
125
  const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
126
+ options.guardCurrentValue?.(currentValue);
106
127
  return executor.update(
107
128
  {
108
129
  id: aggregateId,
109
130
  version: currentVersion,
110
- changes: { value: currentValue + parsed.amount },
131
+ changes: {
132
+ value:
133
+ direction === "add"
134
+ ? currentValue + parsed.amount
135
+ : Math.max(0, currentValue - parsed.amount),
136
+ },
111
137
  },
112
138
  ctx.user,
113
139
  db,
@@ -124,6 +150,7 @@ export async function bookCapUsage(
124
150
  export type MarkCapSoftWarnedOptions = {
125
151
  readonly capName: string;
126
152
  readonly periodStartIso: string;
153
+ readonly outsideTransaction?: boolean;
127
154
  };
128
155
 
129
156
  export async function markCapSoftWarned(
@@ -137,8 +164,8 @@ export async function markCapSoftWarned(
137
164
  parsed.periodStartIso,
138
165
  );
139
166
 
140
- return retryCounterWriteOnVersionConflict(async () => {
141
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
167
+ async function attemptMark(db: TenantDb): Promise<WriteResult> {
168
+ const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
142
169
  if (existing.length === 0) {
143
170
  throw new Error(
144
171
  `cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
@@ -162,9 +189,15 @@ export async function markCapSoftWarned(
162
189
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
163
190
  },
164
191
  ctx.user,
165
- ctx.db,
192
+ db,
166
193
  );
167
- });
194
+ }
195
+
196
+ if (options.outsideTransaction) {
197
+ const outsideDb = requireOutsideTransactionDb(ctx);
198
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
199
+ }
200
+ return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
168
201
  }
169
202
 
170
203
  export type ReadRollingCapUsageOptions = {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.348.0",
4
+ "type": "fix",
5
+ "title": "withCapEnforcement reserves cap usage before the handler transaction so parallel calls cannot exceed the hard limit",
6
+ "detail": "withCapEnforcement reserves cap usage before the handler transaction\n`withCapEnforcement` used to check the cap and book the usage in two separate steps, so parallel calls could all pass the same stale read and exceed the hard limit. The wrapper now only declares the cap through the new `WriteHandlerDef.reserveBeforeTransaction` hook. The dispatcher runs the hook after the access, feature and schema checks and before the handler transaction opens. The hard-cap check and the increment are one short, version-guarded write that commits at once, so no connection is held across the handler and capped calls on the same counter still run in parallel. The returned release gives the amount back (never below 0) after the transaction ended without committing: rollback, failure result, throw or failed commit. Each reservation covers exactly one top-level execution: a capped handler reached through a nested `ctx.write` or run a second time in the same command is rejected. A wrapped handler now keeps its own `rateLimit`, `escapeHatch` and `additionalRateLimits` (the old wrapper dropped them). The reservation runs before the rate-limit gate, so a rate-limited caller can still cause reserve and release writes. If a COMMIT fails with an unknown outcome, the release can under-count. `bookCapUsage` accepts a `guardCurrentValue` callback, `markCapSoftWarned` and `enforceCapAndMaybeNotify` can write outside the handler transaction, and the soft-warning pre-check now honors `amount`. `withRollingCapEnforcement` is unchanged and keeps its check-then-book race.",
7
+ "migration": "No action needed. Calls above the hard limit are now rejected with cap_exceeded even when they race. A capped handler must not be called through ctx.write from another handler."
8
+ },
2
9
  {
3
10
  "version": "0.338.0",
4
11
  "type": "improvement",
@@ -63,6 +63,20 @@ export type EnforceCapResult =
63
63
  readonly crossed: boolean;
64
64
  };
65
65
 
66
+ export function assertBelowHardCap(
67
+ valueSeenByLastUnit: number,
68
+ cap: {
69
+ readonly capName: string;
70
+ readonly limit: number;
71
+ readonly profile: CapToleranceProfileName;
72
+ },
73
+ ): void {
74
+ const tolerance = CAP_TOLERANCES[cap.profile];
75
+ if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
76
+ throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance);
77
+ }
78
+ }
79
+
66
80
  // =============================================================================
67
81
  // Enforce-Cap helper
68
82
  // =============================================================================
@@ -82,7 +96,8 @@ export type EnforceCapResult =
82
96
  *
83
97
  * **Sync read implication:** the counter reflects the state at this
84
98
  * exact transaction. Two parallel writes can each see "value < hard"
85
- * and both pass — that's a race. Cap-tolerance-buffers (soft 110% /
99
+ * and both pass — that's a race (withCapEnforcement closes it with an atomic reservation;
100
+ * bare enforceCap stays a read). Cap-tolerance-buffers (soft 110% /
86
101
  * hard 120% for burstable caps) cover this; truly hard slots
87
102
  * (apps-count) need stricter serialization at the create-handler
88
103
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
@@ -112,7 +127,6 @@ export async function enforceCap(
112
127
 
113
128
  const tolerance = CAP_TOLERANCES[options.profile];
114
129
  const softThreshold = options.limit * tolerance.soft;
115
- const hardThreshold = options.limit * tolerance.hard;
116
130
 
117
131
  const rows = await ctx.db.selectMany(
118
132
  table,
@@ -125,9 +139,7 @@ export async function enforceCap(
125
139
  // The last of `amount` units sees this value before its own increment.
126
140
  const value = storedValue + amount - 1;
127
141
 
128
- if (value >= hardThreshold) {
129
- throw new CapExceededError(options.capName, options.limit, value, tolerance);
130
- }
142
+ assertBelowHardCap(value, options);
131
143
 
132
144
  if (value >= softThreshold) {
133
145
  const lastSoftWarnedAt = row ? row["lastSoftWarnedAt"] : null;
@@ -299,6 +311,7 @@ export async function enforceCapAndMaybeNotify(
299
311
  readonly profile: CapToleranceProfileName;
300
312
  readonly notify: SoftHitNotifier;
301
313
  readonly amount?: number;
314
+ readonly markSoftWarnedOutsideTransaction?: boolean;
302
315
  },
303
316
  ): Promise<EnforceCapResult> {
304
317
  const result = await enforceCap(ctx, {
@@ -325,6 +338,7 @@ export async function enforceCapAndMaybeNotify(
325
338
  const marked = await markCapSoftWarned(ctx, {
326
339
  capName: options.capName,
327
340
  periodStartIso: options.periodStartIso,
341
+ ...(options.markSoftWarnedOutsideTransaction && { outsideTransaction: true }),
328
342
  });
329
343
  if (!marked.isSuccess) throw reraiseAsKumikoError(marked.error);
330
344
  }
@@ -1,21 +1,24 @@
1
- // withCapEnforcement / withRollingCapEnforcement — handler-wrapper die
2
- // pre-call enforceCap-And-Notify + post-call booking um den
3
- // gewrappten Handler legen.
1
+ // withCapEnforcement / withRollingCapEnforcement: handler wrappers that put the
2
+ // pre-call enforceCapAndMaybeNotify and a reservation around the wrapped handler.
4
3
  //
5
- // **Warum Wrapper statt manuelle Calls im Handler:**
6
- // Pattern-konsistenz. Wer einen cap-bedingten Handler schreibt,
7
- // darf nicht vergessen den counter zu incrementen oder den enforce-
8
- // pre-call zu machen — beides ist atomic-mit-dem-Handler-zusammen.
9
- // Wrapper macht das Pattern explizit + co-located.
4
+ // **Why a wrapper instead of manual calls in the handler:** a cap-bound handler
5
+ // must not forget the enforce pre-call, the reservation or the release on failure;
6
+ // the wrapper keeps the pattern explicit and co-located.
7
+ //
8
+ // **Calendar reservation:** the wrapper only declares the cap; the dispatcher runs the
9
+ // reservation (reserveBeforeTransaction) before the handler transaction opens, in its own short
10
+ // committed write, and releases it after that transaction ended without committing (rollback,
11
+ // failure result, failed COMMIT). No connection is held across the handler, so capped requests
12
+ // cannot exhaust the pool, and the counter stream is not locked while the handler runs. If a
13
+ // COMMIT fails with an unknown outcome the release can under-count; a wrapped handler must not
14
+ // book the same counter itself. Reached through a nested ctx.write the cap is not reserved, so
15
+ // the dispatcher rejects that call unless the top-level batch reserved the same handler.
10
16
  //
11
- // **Atomicity caveat:** calendar booking runs in-process via bookCapUsage
12
- // (see book-cap-usage.ts).
13
17
  // Rolling booking still dispatches the SystemAdmin-only increment-rolling
14
- // handler. In-process booking would work today only because the entity
15
- // executor appends without the event-ownership check (that check runs in
16
- // appendDomainEventCore alone); relying on that gap would break once the
17
- // executor path enforces ownership, so rolling callers need a SystemAdmin
18
- // identity until cap-counter declares an explicit foreign-booking opt-in.
18
+ // handler and has no reservation: it would need a compensating event type,
19
+ // a changed readRollingCapUsage and a version-guarded append behind the
20
+ // SystemAdmin dispatch. Rolling callers need a SystemAdmin identity until
21
+ // cap-counter declares an explicit foreign-booking opt-in.
19
22
  //
20
23
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
21
24
  // (enforce-cap.ts).
@@ -26,9 +29,10 @@ import type {
26
29
  WriteHandlerDef,
27
30
  } from "@cosmicdrift/kumiko-framework/engine";
28
31
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
29
- import { bookCapUsage } from "./book-cap-usage.js";
32
+ import { bookCapUsage, releaseCapUsage } from "./book-cap-usage.js";
30
33
  import { CapCounterHandlers } from "./constants.js";
31
34
  import {
35
+ assertBelowHardCap,
32
36
  type CapToleranceProfileName,
33
37
  enforceCapAndMaybeNotify,
34
38
  enforceRollingCapAndMaybeNotify,
@@ -68,53 +72,51 @@ export type CalendarCapResolver = (
68
72
  /**
69
73
  * Wrap a write-handler with calendar-period cap-enforcement.
70
74
  *
71
- * Flow:
75
+ * Flow (all before the handler transaction, in the dispatcher):
72
76
  * 1. resolve cap-spec via `capResolver(event, ctx)`
73
- * 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
74
- * on hard-hit (handler never runs), notifies on soft-hit-crossing
75
- * 3. invoke the wrapped handler
76
- * 4. post-success: book usage via `bookCapUsage` with `amount`
77
- *
78
- * The returned handler-def keeps the original name/schema/access
79
- * untouched — only the handler-fn is wrapped. The dispatcher sees
80
- * the same external contract.
77
+ * 2. `enforceCapAndMaybeNotify` — throws CapExceededError on hard-hit
78
+ * (handler never runs), notifies on soft-hit-crossing
79
+ * 3. reserve `amount` (hard-cap check + increment in one short, immediately committed write)
80
+ * 4. the returned release gives the amount back if the transaction does not commit
81
81
  */
82
82
  export function withCapEnforcement(
83
83
  handler: WriteHandlerDef,
84
84
  capResolver: CalendarCapResolver,
85
85
  ): WriteHandlerDef {
86
86
  return {
87
- name: handler.name,
88
- schema: handler.schema,
89
- access: handler.access,
90
- handler: async (event, ctx) => {
87
+ ...handler,
88
+ reserveBeforeTransaction: async (event, ctx) => {
91
89
  const cap = await capResolver(event, ctx);
92
90
 
93
- // Pre-enforce. Hard-hit throws CapExceededError (extends KumikoError,
94
- // dispatcher auto-maps to HTTP 429 + cap_exceeded). Soft-hit-crossing
95
- // notifies via the supplied notifier + flips lastSoftWarnedAt.
96
91
  await enforceCapAndMaybeNotify(ctx, {
97
92
  capName: cap.capName,
98
93
  periodStartIso: cap.periodStartIso,
99
94
  limit: cap.limit,
100
95
  profile: cap.profile,
101
96
  notify: cap.notify,
97
+ markSoftWarnedOutsideTransaction: true,
98
+ ...(cap.amount !== undefined && { amount: cap.amount }),
102
99
  });
103
100
 
104
- const result = await handler.handler(event, ctx);
101
+ const amount = cap.amount ?? 1;
102
+ const reserved = await bookCapUsage(ctx, {
103
+ capName: cap.capName,
104
+ amount,
105
+ periodStartIso: cap.periodStartIso,
106
+ outsideTransaction: true,
107
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
108
+ });
109
+ if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
105
110
 
106
- // Post-success increment. Skip on failure so a failed write
107
- // doesn't burn cap-quota. amount default 1.
108
- if (result.isSuccess) {
109
- const booked = await bookCapUsage(ctx, {
111
+ return async () => {
112
+ const released = await releaseCapUsage(ctx, {
110
113
  capName: cap.capName,
111
- amount: cap.amount ?? 1,
114
+ amount,
112
115
  periodStartIso: cap.periodStartIso,
116
+ outsideTransaction: true,
113
117
  });
114
- if (!booked.isSuccess) throw reraiseAsKumikoError(booked.error);
115
- }
116
-
117
- return result;
118
+ if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
119
+ };
118
120
  },
119
121
  };
120
122
  }