@cosmicdrift/kumiko-bundled-features 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.
@@ -23,19 +23,31 @@ export type LoginRouteOptions = {
23
23
  readonly onAuthenticated?: () => void;
24
24
  };
25
25
  export declare function createLoginRoute(opts?: LoginRouteOptions): ComponentType<Record<string, never>>;
26
+ /** Builds the login page URL. `returnPath` is the current path + query + hash;
27
+ * append it as `next` yourself (use `NEXT_QUERY_PARAM`) if the login page should return there. */
28
+ export type LoginUrlBuilder = (locale: string, returnPath: string) => string;
26
29
  export type AuthGateOptions = LoginRouteOptions & {
27
- /** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
28
- * visitors are sent there with `next=<current path>`; the built-in login screen
29
- * is not rendered. */
30
- readonly loginUrl?: string;
30
+ /** Login page outside the SPA (root-relative path or http(s) URL), or a function
31
+ * that builds it per locale. Unauthenticated visitors are sent there with
32
+ * `next=<current path>` (a string gets `next` appended; a function decides itself).
33
+ * On the login route itself the gate renders the built-in login screen instead. */
34
+ readonly loginUrl?: string | LoginUrlBuilder;
31
35
  };
32
36
  export type SessionAuthGateOptions = AuthGateOptions & {
33
37
  /** Where logout navigates to (root-relative path or http(s) URL). Without it, logout reloads the page. */
34
38
  readonly postLogoutUrl?: string;
35
39
  };
40
+ type LoginRedirect = {
41
+ readonly kind: "on-login-route";
42
+ } | {
43
+ readonly kind: "redirect";
44
+ readonly url: string;
45
+ };
46
+ export declare function resolveLoginRedirect(loginUrl: string | LoginUrlBuilder, locale: string, { origin, pathname, search, hash }: Pick<Location, "origin" | "pathname" | "search" | "hash">): LoginRedirect;
36
47
  export declare function makeAuthGate(opts?: AuthGateOptions): ComponentType<{
37
48
  children: ReactNode;
38
49
  }>;
39
50
  export declare function makeSessionAuthGate(opts?: SessionAuthGateOptions): ComponentType<{
40
51
  children: ReactNode;
41
52
  }>;
53
+ export {};
@@ -9,8 +9,9 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
9
9
  // damit das Gate der ClientFeatureDefinition-Signatur entspricht
10
10
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
11
11
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
12
+ import { useLocale } from "@cosmicdrift/kumiko-renderer";
12
13
  import { useEffect, useState } from "react";
13
- import { assertNavigableUrl, buildLoginRedirectUrl } from "./auth-redirect.js";
14
+ import { assertNavigableUrl, buildLoginRedirectUrl, followNextAfterLogin, } from "./auth-redirect.js";
14
15
  import { LoginScreen } from "./login-screen.js";
15
16
  import { SessionProvider, useSession } from "./session.js";
16
17
  import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
@@ -58,7 +59,10 @@ export function createLoginRoute(opts = {}) {
58
59
  if (status === "authenticated" && onAuthenticated)
59
60
  return null;
60
61
  if (challengeToken !== null && MfaVerifyComponent) {
61
- return (_jsx(MfaVerifyComponent, { challengeToken: challengeToken, onSuccess: () => setChallengeToken(null), onCancel: () => setChallengeToken(null) }));
62
+ return (_jsx(MfaVerifyComponent, { challengeToken: challengeToken, onSuccess: () => {
63
+ setChallengeToken(null);
64
+ followNextAfterLogin();
65
+ }, onCancel: () => setChallengeToken(null) }));
62
66
  }
63
67
  // Pending preauthSetupToken from LoginScreen's onMfaSetupRequired. Same
64
68
  // reasoning as challengeToken — a UI-only transition, not session state.
@@ -69,7 +73,10 @@ export function createLoginRoute(opts = {}) {
69
73
  // refresh() never rejects: a failed refresh surfaces as status
70
74
  // "error" (bootstrap error screen with retry), so only the
71
75
  // success path needs to clear setupRequest.
72
- void refresh().then(() => setSetupRequest(null));
76
+ void refresh().then(() => {
77
+ setSetupRequest(null);
78
+ followNextAfterLogin();
79
+ });
73
80
  }, onCancel: () => setSetupRequest(null) }));
74
81
  }
75
82
  return (_jsx(LoginComponent, { ...opts.loginScreenProps, onMfaChallenge: MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge, onMfaSetupRequired: MfaSetupComponent
@@ -78,31 +85,43 @@ export function createLoginRoute(opts = {}) {
78
85
  }
79
86
  return LoginRoute;
80
87
  }
81
- function redirectToLoginUrl(loginUrl) {
82
- const { origin, pathname, search, hash } = window.location;
88
+ // @internal — exported for unit tests only.
89
+ export function resolveLoginRedirect(loginUrl, locale, { origin, pathname, search, hash }) {
90
+ const returnPath = `${pathname}${search}${hash}`;
91
+ const target = typeof loginUrl === "function" ? loginUrl(locale, returnPath) : loginUrl;
92
+ if (typeof loginUrl === "function")
93
+ assertNavigableUrl(target, "loginUrl()");
83
94
  // Compared before `next` is appended: the target always differs from the current
84
95
  // URL once it carries next, so a gate mounted on the login page itself would loop.
85
- const loginLocation = new URL(loginUrl, origin);
86
- if (loginLocation.origin === origin && loginLocation.pathname === pathname)
87
- return;
88
- window.location.replace(buildLoginRedirectUrl(loginUrl, `${pathname}${search}${hash}`, origin));
96
+ const loginLocation = new URL(target, origin);
97
+ if (loginLocation.origin === origin && loginLocation.pathname === pathname) {
98
+ return { kind: "on-login-route" };
99
+ }
100
+ return {
101
+ kind: "redirect",
102
+ url: typeof loginUrl === "function" ? target : buildLoginRedirectUrl(target, returnPath, origin),
103
+ };
89
104
  }
90
105
  export function makeAuthGate(opts = {}) {
91
106
  const { loginUrl } = opts;
92
- if (loginUrl !== undefined)
107
+ if (typeof loginUrl === "string")
93
108
  assertNavigableUrl(loginUrl, "loginUrl");
94
109
  const LoginRoute = createLoginRoute(opts);
95
110
  function AuthGate({ children }) {
96
111
  const { status } = useSession();
97
- const redirectsToLoginUrl = loginUrl !== undefined && status === "unauthenticated";
112
+ const locale = useLocale().locale();
113
+ const loginRedirect = loginUrl !== undefined && status === "unauthenticated"
114
+ ? resolveLoginRedirect(loginUrl, locale, window.location)
115
+ : null;
116
+ const redirectUrl = loginRedirect?.kind === "redirect" ? loginRedirect.url : null;
98
117
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
99
118
  useEffect(() => {
100
- if (redirectsToLoginUrl)
101
- redirectToLoginUrl(loginUrl);
102
- }, [redirectsToLoginUrl]);
119
+ if (redirectUrl !== null)
120
+ window.location.replace(redirectUrl);
121
+ }, [redirectUrl]);
103
122
  if (status === "authenticated")
104
123
  return _jsx(_Fragment, { children: children });
105
- if (redirectsToLoginUrl)
124
+ if (redirectUrl !== null)
106
125
  return null;
107
126
  return _jsx(LoginRoute, {});
108
127
  }
@@ -3,3 +3,4 @@ export declare function isSafeNextPath(candidate: unknown): candidate is string;
3
3
  export declare function readNextFromSearch(search: string): string | null;
4
4
  export declare function assertNavigableUrl(url: string, optionName: string): void;
5
5
  export declare function buildLoginRedirectUrl(loginUrl: string, returnPath: string, currentOrigin: string): string;
6
+ export declare function followNextAfterLogin(location?: Pick<Location, "search" | "pathname" | "replace">): boolean;
@@ -56,3 +56,16 @@ export function buildLoginRedirectUrl(loginUrl, returnPath, currentOrigin) {
56
56
  const isSameOrigin = target.origin === currentOrigin;
57
57
  return isSameOrigin ? `${target.pathname}${target.search}${target.hash}` : target.href;
58
58
  }
59
+ // Login screens call this after a successful login. `next` is re-validated
60
+ // here because it is read from the URL, i.e. attacker-controlled. Returns
61
+ // true when it navigated.
62
+ export function followNextAfterLogin(location = window.location) {
63
+ const next = readNextFromSearch(location.search);
64
+ if (next === null)
65
+ return false;
66
+ // Following a next that points at the login page itself would reload it forever.
67
+ if (new URL(next, "https://next.invalid").pathname === location.pathname)
68
+ return false;
69
+ location.replace(next);
70
+ return true;
71
+ }
@@ -13,6 +13,7 @@ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
13
13
  import { useState } from "react";
14
14
  import { requestEmailVerification, } from "./auth-client.js";
15
15
  import { AuthCard } from "./auth-form-primitives.js";
16
+ import { followNextAfterLogin } from "./auth-redirect.js";
16
17
  import { useSession } from "./session.js";
17
18
  // Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
18
19
  // optional extrahierte Interpolations-Parameter. Ungekannte Codes
@@ -64,8 +65,10 @@ export function LoginScreen({ title, subtitle, submitLabel, forgotPasswordHref,
64
65
  setResendStatus({ kind: "idle" });
65
66
  const res = await session.login({ email, password });
66
67
  setSubmitting(false);
67
- if (res.kind === "success")
68
+ if (res.kind === "success") {
69
+ followNextAfterLogin();
68
70
  return;
71
+ }
69
72
  if (res.kind === "mfa-challenge") {
70
73
  if (onMfaChallenge) {
71
74
  onMfaChallenge(res.challengeToken);
@@ -5,11 +5,14 @@ export type BookCapUsageOptions = {
5
5
  readonly periodStartIso: string;
6
6
  readonly amount?: number;
7
7
  readonly outsideTransaction?: boolean;
8
+ readonly guardCurrentValue?: (currentValue: number) => void;
8
9
  };
9
10
  export declare function bookCapUsage(ctx: HandlerContext, options: BookCapUsageOptions): Promise<WriteResult>;
11
+ export declare function releaseCapUsage(ctx: HandlerContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
10
12
  export type MarkCapSoftWarnedOptions = {
11
13
  readonly capName: string;
12
14
  readonly periodStartIso: string;
15
+ readonly outsideTransaction?: boolean;
13
16
  };
14
17
  export declare function markCapSoftWarned(ctx: HandlerContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
15
18
  export type ReadRollingCapUsageOptions = {
@@ -41,11 +41,21 @@ function requireOutsideTransactionDb(ctx) {
41
41
  return ctx.dbOutsideTransaction;
42
42
  }
43
43
  export async function bookCapUsage(ctx, options) {
44
+ return applyCapDelta(ctx, options, "add");
45
+ }
46
+ // Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
47
+ export async function releaseCapUsage(ctx, options) {
48
+ return applyCapDelta(ctx, options, "subtract");
49
+ }
50
+ async function applyCapDelta(ctx, options, direction) {
44
51
  const parsed = capBookingSchema.parse(options);
45
52
  const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
46
53
  async function attemptWrite(db) {
47
54
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
48
55
  if (existing.length === 0) {
56
+ options.guardCurrentValue?.(0);
57
+ if (direction === "subtract")
58
+ return { isSuccess: true, data: {} };
49
59
  return executor.create({
50
60
  id: aggregateId,
51
61
  capName: parsed.capName,
@@ -60,10 +70,15 @@ export async function bookCapUsage(ctx, options) {
60
70
  }
61
71
  const currentValue = currentRow["value"]; // @cast-boundary db-row
62
72
  const currentVersion = currentRow["version"]; // @cast-boundary db-row
73
+ options.guardCurrentValue?.(currentValue);
63
74
  return executor.update({
64
75
  id: aggregateId,
65
76
  version: currentVersion,
66
- changes: { value: currentValue + parsed.amount },
77
+ changes: {
78
+ value: direction === "add"
79
+ ? currentValue + parsed.amount
80
+ : Math.max(0, currentValue - parsed.amount),
81
+ },
67
82
  }, ctx.user, db);
68
83
  }
69
84
  if (options.outsideTransaction) {
@@ -75,8 +90,8 @@ export async function bookCapUsage(ctx, options) {
75
90
  export async function markCapSoftWarned(ctx, options) {
76
91
  const parsed = capPeriodSchema.parse(options);
77
92
  const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
78
- return retryCounterWriteOnVersionConflict(async () => {
79
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
93
+ async function attemptMark(db) {
94
+ const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
80
95
  if (existing.length === 0) {
81
96
  throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
82
97
  }
@@ -94,8 +109,13 @@ export async function markCapSoftWarned(ctx, options) {
94
109
  id: aggregateId,
95
110
  version: currentVersion,
96
111
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
97
- }, ctx.user, ctx.db);
98
- });
112
+ }, ctx.user, db);
113
+ }
114
+ if (options.outsideTransaction) {
115
+ const outsideDb = requireOutsideTransactionDb(ctx);
116
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
117
+ }
118
+ return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
99
119
  }
100
120
  // Sums usage without throwing — for callers that only need the raw number
101
121
  // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
@@ -53,6 +53,11 @@ export type EnforceCapResult =
53
53
  /** True if this call CROSSED the soft-threshold and notified. */
54
54
  readonly crossed: boolean;
55
55
  };
56
+ export declare function assertBelowHardCap(valueSeenByLastUnit: number, cap: {
57
+ readonly capName: string;
58
+ readonly limit: number;
59
+ readonly profile: CapToleranceProfileName;
60
+ }): void;
56
61
  /**
57
62
  * Synchronous read-and-check of the calling tenant's counter for
58
63
  * (capName, period). Returns:
@@ -68,7 +73,8 @@ export type EnforceCapResult =
68
73
  *
69
74
  * **Sync read implication:** the counter reflects the state at this
70
75
  * exact transaction. Two parallel writes can each see "value < hard"
71
- * and both pass — that's a race. Cap-tolerance-buffers (soft 110% /
76
+ * and both pass — that's a race (withCapEnforcement closes it with an atomic reservation;
77
+ * bare enforceCap stays a read). Cap-tolerance-buffers (soft 110% /
72
78
  * hard 120% for burstable caps) cover this; truly hard slots
73
79
  * (apps-count) need stricter serialization at the create-handler
74
80
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
@@ -163,6 +169,7 @@ export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
163
169
  readonly profile: CapToleranceProfileName;
164
170
  readonly notify: SoftHitNotifier;
165
171
  readonly amount?: number;
172
+ readonly markSoftWarnedOutsideTransaction?: boolean;
166
173
  }): Promise<EnforceCapResult>;
167
174
  /**
168
175
  * Rolling-Window-enforcement + immer-feuert-Notification beim soft-hit.
@@ -21,6 +21,12 @@ export const CAP_TOLERANCES = {
21
21
  /** Egress — Bursty-Traffic legitim, nur extreme Spikes blockieren. */
22
22
  egress: { soft: 1.1, hard: 1.3 },
23
23
  };
24
+ export function assertBelowHardCap(valueSeenByLastUnit, cap) {
25
+ const tolerance = CAP_TOLERANCES[cap.profile];
26
+ if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
27
+ throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance);
28
+ }
29
+ }
24
30
  // =============================================================================
25
31
  // Enforce-Cap helper
26
32
  // =============================================================================
@@ -39,7 +45,8 @@ export const CAP_TOLERANCES = {
39
45
  *
40
46
  * **Sync read implication:** the counter reflects the state at this
41
47
  * exact transaction. Two parallel writes can each see "value < hard"
42
- * and both pass — that's a race. Cap-tolerance-buffers (soft 110% /
48
+ * and both pass — that's a race (withCapEnforcement closes it with an atomic reservation;
49
+ * bare enforceCap stays a read). Cap-tolerance-buffers (soft 110% /
43
50
  * hard 120% for burstable caps) cover this; truly hard slots
44
51
  * (apps-count) need stricter serialization at the create-handler
45
52
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
@@ -54,15 +61,12 @@ export async function enforceCap(ctx, options) {
54
61
  }
55
62
  const tolerance = CAP_TOLERANCES[options.profile];
56
63
  const softThreshold = options.limit * tolerance.soft;
57
- const hardThreshold = options.limit * tolerance.hard;
58
64
  const rows = await ctx.db.selectMany(table, { capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
59
65
  const row = rows[0];
60
66
  const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
61
67
  // The last of `amount` units sees this value before its own increment.
62
68
  const value = storedValue + amount - 1;
63
- if (value >= hardThreshold) {
64
- throw new CapExceededError(options.capName, options.limit, value, tolerance);
65
- }
69
+ assertBelowHardCap(value, options);
66
70
  if (value >= softThreshold) {
67
71
  const lastSoftWarnedAt = row ? row["lastSoftWarnedAt"] : null;
68
72
  return { state: "soft-hit", value, crossed: lastSoftWarnedAt === null };
@@ -200,6 +204,7 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
200
204
  const marked = await markCapSoftWarned(ctx, {
201
205
  capName: options.capName,
202
206
  periodStartIso: options.periodStartIso,
207
+ ...(options.markSoftWarnedOutsideTransaction && { outsideTransaction: true }),
203
208
  });
204
209
  if (!marked.isSuccess)
205
210
  throw reraiseAsKumikoError(marked.error);
@@ -24,16 +24,12 @@ export type CalendarCapResolver = (event: WriteEvent, ctx: HandlerContext) => Pr
24
24
  /**
25
25
  * Wrap a write-handler with calendar-period cap-enforcement.
26
26
  *
27
- * Flow:
27
+ * Flow (all before the handler transaction, in the dispatcher):
28
28
  * 1. resolve cap-spec via `capResolver(event, ctx)`
29
- * 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
30
- * on hard-hit (handler never runs), notifies on soft-hit-crossing
31
- * 3. invoke the wrapped handler
32
- * 4. post-success: book usage via `bookCapUsage` with `amount`
33
- *
34
- * The returned handler-def keeps the original name/schema/access
35
- * untouched — only the handler-fn is wrapped. The dispatcher sees
36
- * the same external contract.
29
+ * 2. `enforceCapAndMaybeNotify` — throws CapExceededError on hard-hit
30
+ * (handler never runs), notifies on soft-hit-crossing
31
+ * 3. reserve `amount` (hard-cap check + increment in one short, immediately committed write)
32
+ * 4. the returned release gives the amount back if the transaction does not commit
37
33
  */
38
34
  export declare function withCapEnforcement(handler: WriteHandlerDef, capResolver: CalendarCapResolver): WriteHandlerDef;
39
35
  export type RollingCapDef = {
@@ -1,72 +1,75 @@
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).
22
25
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
23
- import { bookCapUsage } from "./book-cap-usage.js";
26
+ import { bookCapUsage, releaseCapUsage } from "./book-cap-usage.js";
24
27
  import { CapCounterHandlers } from "./constants.js";
25
- import { enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
28
+ import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
26
29
  /**
27
30
  * Wrap a write-handler with calendar-period cap-enforcement.
28
31
  *
29
- * Flow:
32
+ * Flow (all before the handler transaction, in the dispatcher):
30
33
  * 1. resolve cap-spec via `capResolver(event, ctx)`
31
- * 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
32
- * on hard-hit (handler never runs), notifies on soft-hit-crossing
33
- * 3. invoke the wrapped handler
34
- * 4. post-success: book usage via `bookCapUsage` with `amount`
35
- *
36
- * The returned handler-def keeps the original name/schema/access
37
- * untouched — only the handler-fn is wrapped. The dispatcher sees
38
- * the same external contract.
34
+ * 2. `enforceCapAndMaybeNotify` — throws CapExceededError on hard-hit
35
+ * (handler never runs), notifies on soft-hit-crossing
36
+ * 3. reserve `amount` (hard-cap check + increment in one short, immediately committed write)
37
+ * 4. the returned release gives the amount back if the transaction does not commit
39
38
  */
40
39
  export function withCapEnforcement(handler, capResolver) {
41
40
  return {
42
- name: handler.name,
43
- schema: handler.schema,
44
- access: handler.access,
45
- handler: async (event, ctx) => {
41
+ ...handler,
42
+ reserveBeforeTransaction: async (event, ctx) => {
46
43
  const cap = await capResolver(event, ctx);
47
- // Pre-enforce. Hard-hit throws CapExceededError (extends KumikoError,
48
- // dispatcher auto-maps to HTTP 429 + cap_exceeded). Soft-hit-crossing
49
- // notifies via the supplied notifier + flips lastSoftWarnedAt.
50
44
  await enforceCapAndMaybeNotify(ctx, {
51
45
  capName: cap.capName,
52
46
  periodStartIso: cap.periodStartIso,
53
47
  limit: cap.limit,
54
48
  profile: cap.profile,
55
49
  notify: cap.notify,
50
+ markSoftWarnedOutsideTransaction: true,
51
+ ...(cap.amount !== undefined && { amount: cap.amount }),
56
52
  });
57
- const result = await handler.handler(event, ctx);
58
- // Post-success increment. Skip on failure so a failed write
59
- // doesn't burn cap-quota. amount default 1.
60
- if (result.isSuccess) {
61
- const booked = await bookCapUsage(ctx, {
53
+ const amount = cap.amount ?? 1;
54
+ const reserved = await bookCapUsage(ctx, {
55
+ capName: cap.capName,
56
+ amount,
57
+ periodStartIso: cap.periodStartIso,
58
+ outsideTransaction: true,
59
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
60
+ });
61
+ if (!reserved.isSuccess)
62
+ throw reraiseAsKumikoError(reserved.error);
63
+ return async () => {
64
+ const released = await releaseCapUsage(ctx, {
62
65
  capName: cap.capName,
63
- amount: cap.amount ?? 1,
66
+ amount,
64
67
  periodStartIso: cap.periodStartIso,
68
+ outsideTransaction: true,
65
69
  });
66
- if (!booked.isSuccess)
67
- throw reraiseAsKumikoError(booked.error);
68
- }
69
- return result;
70
+ if (!released.isSuccess)
71
+ throw reraiseAsKumikoError(released.error);
72
+ };
70
73
  },
71
74
  };
72
75
  }
package/package.json CHANGED
@@ -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>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.347.0",
507
- "@cosmicdrift/kumiko-framework": "0.347.0",
508
- "@cosmicdrift/kumiko-headless": "0.347.0",
509
- "@cosmicdrift/kumiko-renderer": "0.347.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.347.0",
511
- "@cosmicdrift/kumiko-types": "0.347.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.348.0",
507
+ "@cosmicdrift/kumiko-framework": "0.348.0",
508
+ "@cosmicdrift/kumiko-headless": "0.348.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.348.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.348.0",
511
+ "@cosmicdrift/kumiko-types": "0.348.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.347.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.347.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.348.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.348.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -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",
@@ -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",