@cosmicdrift/kumiko-bundled-features 0.346.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/dist/auth-email-password/handlers/signup-confirm.write.d.ts +15 -3
- package/dist/auth-email-password/handlers/signup-confirm.write.js +12 -1
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +7 -1
- package/dist/auth-email-password/web/auth-gate.d.ts +16 -4
- package/dist/auth-email-password/web/auth-gate.js +34 -15
- package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
- package/dist/auth-email-password/web/auth-redirect.js +13 -0
- package/dist/auth-email-password/web/login-screen.js +4 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +4 -1
- package/dist/cap-counter/book-cap-usage.d.ts +3 -0
- package/dist/cap-counter/book-cap-usage.js +25 -5
- package/dist/cap-counter/enforce-cap.d.ts +8 -1
- package/dist/cap-counter/enforce-cap.js +10 -5
- package/dist/cap-counter/with-cap-enforcement.d.ts +5 -9
- package/dist/cap-counter/with-cap-enforcement.js +46 -43
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.d.ts +4 -0
- package/dist/personal-access-tokens/feature.js +5 -2
- package/dist/personal-access-tokens/handlers/availability.query.d.ts +4 -0
- package/dist/personal-access-tokens/handlers/availability.query.js +17 -0
- package/dist/personal-access-tokens/screens.js +9 -0
- package/dist/step-dispatcher/webhook-runner.js +2 -0
- package/dist/tier-engine/feature.js +1 -0
- package/dist/tier-engine/i18n.js +1 -0
- package/package.json +9 -9
- package/src/auth-email-password/changes.json +13 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/personal-access-tokens/changes.json +6 -0
- package/src/step-dispatcher/changes.json +6 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
|
|
4
4
|
export type SignupConfirmData = {
|
|
@@ -9,11 +9,23 @@ export type SignupConfirmData = {
|
|
|
9
9
|
readonly entityType: string;
|
|
10
10
|
readonly id: string;
|
|
11
11
|
};
|
|
12
|
-
} | Exclude<LoginResult, {
|
|
12
|
+
} | (Exclude<LoginResult, {
|
|
13
13
|
readonly kind: "auth-session";
|
|
14
|
-
}
|
|
14
|
+
}> & {
|
|
15
|
+
readonly signup: {
|
|
16
|
+
readonly roles: readonly string[];
|
|
17
|
+
readonly tenantId: TenantId;
|
|
18
|
+
readonly tenantKey: string;
|
|
19
|
+
readonly handover?: ClaimedHandover;
|
|
20
|
+
};
|
|
21
|
+
});
|
|
15
22
|
export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
23
|
+
type ClaimedHandover = {
|
|
24
|
+
readonly entityType: string;
|
|
25
|
+
readonly id: string;
|
|
26
|
+
};
|
|
16
27
|
export declare function createSignupConfirmHandler(opts?: SignupConfirmOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"signup-confirm", z.ZodObject<{
|
|
17
28
|
token: z.ZodString;
|
|
18
29
|
password: z.ZodString;
|
|
19
30
|
}, z.core.$strip>, SignupConfirmData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
31
|
+
export {};
|
|
@@ -168,7 +168,18 @@ export function createSignupConfirmHandler(opts = {}) {
|
|
|
168
168
|
await deleteSignupHandover(ctx.redis, event.payload.token);
|
|
169
169
|
if (mfaGate !== undefined) {
|
|
170
170
|
committed = true;
|
|
171
|
-
return {
|
|
171
|
+
return {
|
|
172
|
+
isSuccess: true,
|
|
173
|
+
data: {
|
|
174
|
+
...mfaGate,
|
|
175
|
+
signup: {
|
|
176
|
+
roles: session.roles,
|
|
177
|
+
tenantId: provisioned.tenantId,
|
|
178
|
+
tenantKey,
|
|
179
|
+
...(handover !== undefined && { handover }),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
172
183
|
}
|
|
173
184
|
committed = true;
|
|
174
185
|
return {
|
|
@@ -116,6 +116,7 @@ export type SignupConfirmResult = ({
|
|
|
116
116
|
readonly kind: "signed-in";
|
|
117
117
|
} & SignupConfirmSuccess) | {
|
|
118
118
|
readonly kind: "mfa-pending";
|
|
119
|
+
readonly landingPath?: string;
|
|
119
120
|
};
|
|
120
121
|
export declare function confirmSignup(token: string, password: string): Promise<{
|
|
121
122
|
ok: true;
|
|
@@ -232,7 +232,13 @@ export async function confirmSignup(token, password) {
|
|
|
232
232
|
// @cast-boundary engine-payload
|
|
233
233
|
const body = (await res.json());
|
|
234
234
|
if (body.mfaRequired === true || body.mfaSetupRequired === true) {
|
|
235
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
data: {
|
|
238
|
+
kind: "mfa-pending",
|
|
239
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
236
242
|
}
|
|
237
243
|
return { ok: true, data: { kind: "signed-in", ...body } };
|
|
238
244
|
}
|
|
@@ -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)
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
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: () =>
|
|
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(() =>
|
|
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
|
-
|
|
82
|
-
|
|
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(
|
|
86
|
-
if (loginLocation.origin === origin && loginLocation.pathname === pathname)
|
|
87
|
-
return;
|
|
88
|
-
|
|
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
|
|
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
|
|
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 (
|
|
101
|
-
|
|
102
|
-
}, [
|
|
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 (
|
|
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);
|
|
@@ -22,6 +22,7 @@ import { useState } from "react";
|
|
|
22
22
|
import { confirmSignup } from "./auth-client.js";
|
|
23
23
|
import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic.js";
|
|
24
24
|
import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
|
|
25
|
+
import { buildLoginRedirectUrl } from "./auth-redirect.js";
|
|
25
26
|
export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "/", loginHref = "/login", }) {
|
|
26
27
|
const t = useTranslation();
|
|
27
28
|
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
@@ -49,7 +50,9 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
|
|
|
49
50
|
if (res.ok) {
|
|
50
51
|
if (res.data.kind === "mfa-pending") {
|
|
51
52
|
setMfaPending(true);
|
|
52
|
-
setContinueHref(
|
|
53
|
+
setContinueHref(res.data.landingPath === undefined
|
|
54
|
+
? loginHref
|
|
55
|
+
: buildLoginRedirectUrl(loginHref, res.data.landingPath, window.location.origin));
|
|
53
56
|
return;
|
|
54
57
|
}
|
|
55
58
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
@@ -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: {
|
|
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
|
-
|
|
79
|
-
const existing = await
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
* 3.
|
|
32
|
-
* 4.
|
|
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
|
|
2
|
-
// pre-call
|
|
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
|
-
// **
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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.
|
|
32
|
-
*
|
|
33
|
-
* 3.
|
|
34
|
-
* 4.
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
66
|
+
amount,
|
|
64
67
|
periodStartIso: cap.periodStartIso,
|
|
68
|
+
outsideTransaction: true,
|
|
65
69
|
});
|
|
66
|
-
if (!
|
|
67
|
-
throw reraiseAsKumikoError(
|
|
68
|
-
}
|
|
69
|
-
return result;
|
|
70
|
+
if (!released.isSuccess)
|
|
71
|
+
throw reraiseAsKumikoError(released.error);
|
|
72
|
+
};
|
|
70
73
|
},
|
|
71
74
|
};
|
|
72
75
|
}
|
|
@@ -14,6 +14,7 @@ export declare const PatHandlers: {
|
|
|
14
14
|
export declare const PatQueries: {
|
|
15
15
|
readonly mine: "personal-access-tokens:query:mine";
|
|
16
16
|
readonly availableScopes: "personal-access-tokens:query:available-scopes";
|
|
17
|
+
readonly availability: "personal-access-tokens:query:availability";
|
|
17
18
|
};
|
|
18
19
|
export declare const PAT_PREFIX_DISPLAY_LENGTH = 12;
|
|
19
20
|
export type PatRateLimit = {
|
|
@@ -26,6 +26,7 @@ export const PatHandlers = {
|
|
|
26
26
|
export const PatQueries = {
|
|
27
27
|
mine: "personal-access-tokens:query:mine",
|
|
28
28
|
availableScopes: "personal-access-tokens:query:available-scopes",
|
|
29
|
+
availability: "personal-access-tokens:query:availability",
|
|
29
30
|
};
|
|
30
31
|
// Only the first chars of a minted token are stored (alongside the hash) so the
|
|
31
32
|
// UI can show "kpat_ab12…" for recognition without ever holding the secret.
|
|
@@ -13,6 +13,10 @@ export type PersonalAccessTokensOptions = {
|
|
|
13
13
|
readonly toggleable?: {
|
|
14
14
|
readonly default: boolean;
|
|
15
15
|
};
|
|
16
|
+
/** Screen (same-feature id or `<feature>:screen:<id>`, must not be gated itself)
|
|
17
|
+
* shown in place of the token screens when the tenant's tier excludes the
|
|
18
|
+
* feature, e.g. an upgrade notice. Default: the standard "unavailable" notice. */
|
|
19
|
+
readonly lockedFallbackScreen?: string;
|
|
16
20
|
readonly mfaVerifier?: CreatePatOptions["mfaVerifier"];
|
|
17
21
|
readonly autoRevokeOnPasswordChange?: (userId: string) => Promise<number>;
|
|
18
22
|
};
|
|
@@ -3,6 +3,7 @@ import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
|
3
3
|
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { API_TOKEN_ENTITY_NAME, PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, } from "./constants.js";
|
|
6
|
+
import { availabilityQuery } from "./handlers/availability.query.js";
|
|
6
7
|
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query.js";
|
|
7
8
|
import { createPatCreateHandler } from "./handlers/create.write.js";
|
|
8
9
|
import { listPatQuery } from "./handlers/list.query.js";
|
|
@@ -105,12 +106,14 @@ export function createPersonalAccessTokensFeature(options) {
|
|
|
105
106
|
};
|
|
106
107
|
const queries = {
|
|
107
108
|
mine: r.queryHandler(listPatQuery),
|
|
109
|
+
availability: r.queryHandler(availabilityQuery),
|
|
108
110
|
availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
|
|
109
111
|
};
|
|
110
112
|
// Declarative screens — list-with-revoke + mint-with-reveal. The app
|
|
111
113
|
// places `patListScreen` via r.nav in its logged-in settings area.
|
|
112
|
-
|
|
113
|
-
r.screen(
|
|
114
|
+
const lockedFallback = options.lockedFallbackScreen === undefined ? {} : { fallback: options.lockedFallbackScreen };
|
|
115
|
+
r.screen({ ...patListScreen, ...lockedFallback });
|
|
116
|
+
r.screen({ ...createPatMintScreen(scopes), ...lockedFallback });
|
|
114
117
|
r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
|
|
115
118
|
// rateLimit flows into feature.exports so run-prod-app builds the
|
|
116
119
|
// limiter from the same declaration — single source of truth. `scopes`
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export declare const availabilityQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"availability", z.ZodObject<{}, z.core.$strip>, {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
// Screen gate probe: the dispatcher's feature gate rejects every handler of a
|
|
4
|
+
// toggleable feature the tenant's tier does not include, so this query only
|
|
5
|
+
// answers for tenants that may use tokens — a rejection makes the screens'
|
|
6
|
+
// visibleWhen fall back instead of rendering an unusable token list.
|
|
7
|
+
export const availabilityQuery = defineQueryHandler({
|
|
8
|
+
name: "availability",
|
|
9
|
+
schema: z.object({}),
|
|
10
|
+
access: {
|
|
11
|
+
openToAll: {
|
|
12
|
+
reason: "constant answer that only exists to be gated by the feature toggle; it carries no user or tenant data",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
description: "Reports that personal access tokens are available to the caller's tenant; fails with feature_disabled when the tenant's tier excludes them. The token screens use it to decide whether to render.",
|
|
16
|
+
handler: async () => ({ enabled: true }),
|
|
17
|
+
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants.js";
|
|
3
3
|
const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
|
|
4
|
+
// Both screens render only for tenants whose tier includes the feature; the
|
|
5
|
+
// availability query is rejected by the feature gate otherwise.
|
|
6
|
+
const PAT_SCREEN_VISIBLE_WHEN = {
|
|
7
|
+
query: PatQueries.availability,
|
|
8
|
+
field: "enabled",
|
|
9
|
+
eq: true,
|
|
10
|
+
};
|
|
4
11
|
export const patListScreen = {
|
|
5
12
|
id: PAT_SCREEN_ID,
|
|
6
13
|
type: "projectionList",
|
|
14
|
+
visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
|
|
7
15
|
query: PatQueries.mine,
|
|
8
16
|
// The `mine` handler honours `limit` only (no offset/total), so a pager would
|
|
9
17
|
// show page 1 forever: send one max-size request and render no pager.
|
|
@@ -76,6 +84,7 @@ export function createPatMintScreen(scopes) {
|
|
|
76
84
|
return {
|
|
77
85
|
id: PAT_MINT_SCREEN_ID,
|
|
78
86
|
type: "secretMint",
|
|
87
|
+
visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
|
|
79
88
|
handler: PatHandlers.create,
|
|
80
89
|
fields: {
|
|
81
90
|
name: { type: "text", required: true, maxLength: 120 },
|
|
@@ -184,6 +184,8 @@ export async function performWebhookDispatch(spec, deps) {
|
|
|
184
184
|
signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
|
|
185
185
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
186
186
|
});
|
|
187
|
+
// Only the status is used; an unread body would pin the socket until the timeout fires.
|
|
188
|
+
await res.body?.cancel().catch(() => { });
|
|
187
189
|
if (!res.ok) {
|
|
188
190
|
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
189
191
|
}
|
|
@@ -200,6 +200,7 @@ export function createTierEngineFeature(opts = {}) {
|
|
|
200
200
|
],
|
|
201
201
|
},
|
|
202
202
|
submitLabel: "tier-admin.submit",
|
|
203
|
+
successMessage: "tier-admin.success",
|
|
203
204
|
cancelTarget: false,
|
|
204
205
|
description: "tier-admin.screen.subtitle",
|
|
205
206
|
access: { roles: ["SystemAdmin"] },
|
package/dist/tier-engine/i18n.js
CHANGED
|
@@ -18,5 +18,6 @@ export const TIER_ENGINE_I18N = {
|
|
|
18
18
|
"tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
|
|
19
19
|
"tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
|
|
20
20
|
"tier-admin.submit": { en: "Assign tier" },
|
|
21
|
+
"tier-admin.success": { en: "Tier assigned: {tenantId} → {tier}" },
|
|
21
22
|
};
|
|
22
23
|
export const defaultTranslations = translationsByLocaleFromKeys(TIER_ENGINE_I18N);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
507
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
508
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
509
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
510
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
511
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
1029
|
-
"@cosmicdrift/kumiko-locale-es": "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,17 @@
|
|
|
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
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.347.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Signup-confirm under an MFA policy returns the signup landing path and the activation screen forwards it to login as next",
|
|
13
|
+
"detail": "Signup with an MFA requirement keeps its landing path\nWhen the MFA policy asks for a factor at self-registration, `/auth/signup-confirm` now also returns the `landingPath` that `auth.postAuthLanding` resolves for the signup flow (including a claimed handover). `SignupCompleteScreen` passes it to the login link as `?next=`, so the login that follows can land where a signup without MFA would have."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.345.0",
|
|
4
17
|
"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",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.347.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "API-token screens hide behind the tier gate and can fall back to an upgrade screen via lockedFallbackScreen",
|
|
6
|
+
"detail": "Token screens follow the tier gate of personal-access-tokens\nThe list and mint screens carry a `visibleWhen` on the new `personal-access-tokens:query:availability` probe. For a tenant whose tier excludes the feature (`toggleable`), the dispatcher already rejected every token handler with `feature_disabled`; now the screens also show the unavailable notice instead of an empty list with a broken Create button. A new option `lockedFallbackScreen` names a screen (for example an upgrade notice) to show in its place. Without `toggleable` nothing changes: the feature stays always on."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.336.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.347.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Webhook dispatch cancels the unread response body so a stalling receiver cannot hold the connection",
|
|
6
|
+
"detail": "Webhook dispatch releases the connection right after the status\nThe step-dispatcher only needs the response status, so it now cancels the unread response body instead of leaving the socket open until the 10 s request timeout fires."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.346.0",
|
|
4
10
|
"type": "fix",
|