@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +22 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +55 -17
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +14 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +5 -1
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +6 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +38 -5
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +19 -5
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +44 -42
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/constants.ts +1 -0
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +10 -2
- package/packages/bundled-features/src/personal-access-tokens/handlers/availability.query.ts +20 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +10 -0
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -0
- package/packages/bundled-features/src/tier-engine/i18n.ts +1 -0
- package/samples/recipes/workflow-engine/README.md +3 -2
- package/samples/recipes/workflow-engine/src/feature.ts +15 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "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.
|
|
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,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",
|
|
@@ -76,7 +76,16 @@ export type SignupConfirmData =
|
|
|
76
76
|
// actually succeeded (see the handler body below).
|
|
77
77
|
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
78
78
|
}
|
|
79
|
-
| Exclude<LoginResult, { readonly kind: "auth-session" }
|
|
79
|
+
| (Exclude<LoginResult, { readonly kind: "auth-session" }> & {
|
|
80
|
+
// What the response needs to resolve the signup landing although no
|
|
81
|
+
// session is issued yet.
|
|
82
|
+
readonly signup: {
|
|
83
|
+
readonly roles: readonly string[];
|
|
84
|
+
readonly tenantId: TenantId;
|
|
85
|
+
readonly tenantKey: string;
|
|
86
|
+
readonly handover?: ClaimedHandover;
|
|
87
|
+
};
|
|
88
|
+
});
|
|
80
89
|
|
|
81
90
|
export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
82
91
|
|
|
@@ -240,7 +249,18 @@ export function createSignupConfirmHandler(opts: SignupConfirmOptions = {}) {
|
|
|
240
249
|
|
|
241
250
|
if (mfaGate !== undefined) {
|
|
242
251
|
committed = true;
|
|
243
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
isSuccess: true,
|
|
254
|
+
data: {
|
|
255
|
+
...mfaGate,
|
|
256
|
+
signup: {
|
|
257
|
+
roles: session.roles,
|
|
258
|
+
tenantId: provisioned.tenantId,
|
|
259
|
+
tenantKey,
|
|
260
|
+
...(handover !== undefined && { handover }),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
};
|
|
244
264
|
}
|
|
245
265
|
|
|
246
266
|
committed = true;
|
|
@@ -362,9 +362,11 @@ export type SignupConfirmSuccess = {
|
|
|
362
362
|
|
|
363
363
|
// mfa-pending: account exists but the server issued no session because the
|
|
364
364
|
// new roles require a second factor — the user must sign in to enroll.
|
|
365
|
+
// landingPath is where the signup would have landed; the login that follows
|
|
366
|
+
// should end there.
|
|
365
367
|
export type SignupConfirmResult =
|
|
366
368
|
| ({ readonly kind: "signed-in" } & SignupConfirmSuccess)
|
|
367
|
-
| { readonly kind: "mfa-pending" };
|
|
369
|
+
| { readonly kind: "mfa-pending"; readonly landingPath?: string };
|
|
368
370
|
|
|
369
371
|
export async function confirmSignup(
|
|
370
372
|
token: string,
|
|
@@ -383,7 +385,13 @@ export async function confirmSignup(
|
|
|
383
385
|
readonly mfaSetupRequired?: boolean;
|
|
384
386
|
};
|
|
385
387
|
if (body.mfaRequired === true || body.mfaSetupRequired === true) {
|
|
386
|
-
return {
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
data: {
|
|
391
|
+
kind: "mfa-pending",
|
|
392
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
393
|
+
},
|
|
394
|
+
};
|
|
387
395
|
}
|
|
388
396
|
return { ok: true, data: { kind: "signed-in", ...body } };
|
|
389
397
|
}
|
|
@@ -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 {
|
|
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={() =>
|
|
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(() =>
|
|
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)
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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(
|
|
170
|
-
if (loginLocation.origin === origin && loginLocation.pathname === pathname)
|
|
171
|
-
|
|
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
|
|
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
|
|
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 (
|
|
186
|
-
}, [
|
|
223
|
+
if (redirectUrl !== null) window.location.replace(redirectUrl);
|
|
224
|
+
}, [redirectUrl]);
|
|
187
225
|
if (status === "authenticated") return <>{children}</>;
|
|
188
|
-
if (
|
|
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")
|
|
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);
|
|
@@ -22,6 +22,7 @@ import { type FormEvent, type ReactNode, useState } from "react";
|
|
|
22
22
|
import { confirmSignup, type SignupConfirmSuccess } 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
|
|
|
26
27
|
export type SignupCompleteScreenProps = {
|
|
27
28
|
readonly title?: string;
|
|
@@ -76,7 +77,11 @@ export function SignupCompleteScreen({
|
|
|
76
77
|
if (res.ok) {
|
|
77
78
|
if (res.data.kind === "mfa-pending") {
|
|
78
79
|
setMfaPending(true);
|
|
79
|
-
setContinueHref(
|
|
80
|
+
setContinueHref(
|
|
81
|
+
res.data.landingPath === undefined
|
|
82
|
+
? loginHref
|
|
83
|
+
: buildLoginRedirectUrl(loginHref, res.data.landingPath, window.location.origin),
|
|
84
|
+
);
|
|
80
85
|
return;
|
|
81
86
|
}
|
|
82
87
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
@@ -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: {
|
|
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
|
-
|
|
141
|
-
const existing = await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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).
|
|
@@ -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.
|
|
74
|
-
*
|
|
75
|
-
* 3.
|
|
76
|
-
* 4.
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
114
|
+
amount,
|
|
112
115
|
periodStartIso: cap.periodStartIso,
|
|
116
|
+
outsideTransaction: true,
|
|
113
117
|
});
|
|
114
|
-
if (!
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return result;
|
|
118
|
+
if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
|
|
119
|
+
};
|
|
118
120
|
},
|
|
119
121
|
};
|
|
120
122
|
}
|
|
@@ -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",
|
|
@@ -32,6 +32,7 @@ export const PatHandlers = {
|
|
|
32
32
|
export const PatQueries = {
|
|
33
33
|
mine: "personal-access-tokens:query:mine",
|
|
34
34
|
availableScopes: "personal-access-tokens:query:available-scopes",
|
|
35
|
+
availability: "personal-access-tokens:query:availability",
|
|
35
36
|
} as const;
|
|
36
37
|
|
|
37
38
|
// Only the first chars of a minted token are stored (alongside the hash) so the
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
PAT_FEATURE,
|
|
12
12
|
type PatRateLimit,
|
|
13
13
|
} from "./constants.js";
|
|
14
|
+
import { availabilityQuery } from "./handlers/availability.query.js";
|
|
14
15
|
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query.js";
|
|
15
16
|
import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write.js";
|
|
16
17
|
import { listPatQuery } from "./handlers/list.query.js";
|
|
@@ -65,6 +66,10 @@ export type PersonalAccessTokensOptions = {
|
|
|
65
66
|
* { default: false } for fail-closed gating (feature off until a tier grants
|
|
66
67
|
* it). Omit to keep PAT always-on (default). */
|
|
67
68
|
readonly toggleable?: { readonly default: boolean };
|
|
69
|
+
/** Screen (same-feature id or `<feature>:screen:<id>`, must not be gated itself)
|
|
70
|
+
* shown in place of the token screens when the tenant's tier excludes the
|
|
71
|
+
* feature, e.g. an upgrade notice. Default: the standard "unavailable" notice. */
|
|
72
|
+
readonly lockedFallbackScreen?: string;
|
|
68
73
|
// Opt-in MFA re-auth gate for minting a token — wired via
|
|
69
74
|
// mfaVerifierFromFeature (auth-mfa/feature.ts) at app-composition time. No
|
|
70
75
|
// hard dependency on the optional auth-mfa feature.
|
|
@@ -161,13 +166,16 @@ export function createPersonalAccessTokensFeature(
|
|
|
161
166
|
};
|
|
162
167
|
const queries = {
|
|
163
168
|
mine: r.queryHandler(listPatQuery),
|
|
169
|
+
availability: r.queryHandler(availabilityQuery),
|
|
164
170
|
availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
|
|
165
171
|
};
|
|
166
172
|
|
|
167
173
|
// Declarative screens — list-with-revoke + mint-with-reveal. The app
|
|
168
174
|
// places `patListScreen` via r.nav in its logged-in settings area.
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
const lockedFallback =
|
|
176
|
+
options.lockedFallbackScreen === undefined ? {} : { fallback: options.lockedFallbackScreen };
|
|
177
|
+
r.screen({ ...patListScreen, ...lockedFallback });
|
|
178
|
+
r.screen({ ...createPatMintScreen(scopes), ...lockedFallback });
|
|
171
179
|
r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
|
|
172
180
|
|
|
173
181
|
// rateLimit flows into feature.exports so run-prod-app builds the
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
|
|
4
|
+
// Screen gate probe: the dispatcher's feature gate rejects every handler of a
|
|
5
|
+
// toggleable feature the tenant's tier does not include, so this query only
|
|
6
|
+
// answers for tenants that may use tokens — a rejection makes the screens'
|
|
7
|
+
// visibleWhen fall back instead of rendering an unusable token list.
|
|
8
|
+
export const availabilityQuery = defineQueryHandler({
|
|
9
|
+
name: "availability",
|
|
10
|
+
schema: z.object({}),
|
|
11
|
+
access: {
|
|
12
|
+
openToAll: {
|
|
13
|
+
reason:
|
|
14
|
+
"constant answer that only exists to be gated by the feature toggle; it carries no user or tenant data",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
description:
|
|
18
|
+
"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.",
|
|
19
|
+
handler: async () => ({ enabled: true }),
|
|
20
|
+
});
|
|
@@ -9,9 +9,18 @@ import type { PatScopeConfig } from "./scopes.js";
|
|
|
9
9
|
|
|
10
10
|
const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
|
|
11
11
|
|
|
12
|
+
// Both screens render only for tenants whose tier includes the feature; the
|
|
13
|
+
// availability query is rejected by the feature gate otherwise.
|
|
14
|
+
const PAT_SCREEN_VISIBLE_WHEN = {
|
|
15
|
+
query: PatQueries.availability,
|
|
16
|
+
field: "enabled",
|
|
17
|
+
eq: true,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
12
20
|
export const patListScreen: ProjectionListScreenDefinition = {
|
|
13
21
|
id: PAT_SCREEN_ID,
|
|
14
22
|
type: "projectionList",
|
|
23
|
+
visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
|
|
15
24
|
query: PatQueries.mine,
|
|
16
25
|
// The `mine` handler honours `limit` only (no offset/total), so a pager would
|
|
17
26
|
// show page 1 forever: send one max-size request and render no pager.
|
|
@@ -86,6 +95,7 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
|
|
|
86
95
|
return {
|
|
87
96
|
id: PAT_MINT_SCREEN_ID,
|
|
88
97
|
type: "secretMint",
|
|
98
|
+
visibleWhen: PAT_SCREEN_VISIBLE_WHEN,
|
|
89
99
|
handler: PatHandlers.create,
|
|
90
100
|
fields: {
|
|
91
101
|
name: { type: "text", required: true, maxLength: 120 },
|
|
@@ -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",
|
|
@@ -234,6 +234,8 @@ export async function performWebhookDispatch(
|
|
|
234
234
|
signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
|
|
235
235
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
236
236
|
});
|
|
237
|
+
// Only the status is used; an unread body would pin the socket until the timeout fires.
|
|
238
|
+
await res.body?.cancel().catch(() => {});
|
|
237
239
|
if (!res.ok) {
|
|
238
240
|
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
239
241
|
}
|
|
@@ -25,6 +25,7 @@ export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
25
25
|
"tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
|
|
26
26
|
"tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
|
|
27
27
|
"tier-admin.submit": { en: "Assign tier" },
|
|
28
|
+
"tier-admin.success": { en: "Tier assigned: {tenantId} → {tier}" },
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export const defaultTranslations: TranslationsByLocale =
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Workflow Engine
|
|
2
2
|
|
|
3
|
-
Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook
|
|
3
|
+
Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook.
|
|
4
4
|
|
|
5
5
|
## What it shows
|
|
6
6
|
|
|
7
7
|
- Real runnable pipelines (no empty `build: () => []` stubs)
|
|
8
|
-
- Workflow-run lifecycle across wait / waitForEvent
|
|
8
|
+
- Workflow-run lifecycle across wait / waitForEvent
|
|
9
|
+
- `webhook.send` is delivered once by the step-dispatcher; a failed delivery ends as `step.dispatch-failed`. Wrapping it in `r.step.retry` would not repeat it, because the step only enqueues the request and never throws on a delivery error
|
|
9
10
|
|
|
10
11
|
## Source
|
|
11
12
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// workflow-engine Sample — M.4 Tier-3 step-vocabulary showcase.
|
|
2
2
|
//
|
|
3
|
-
// Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send
|
|
4
|
-
//
|
|
3
|
+
// Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send
|
|
4
|
+
// and the workflow-run lifecycle. Each workflow below is a real,
|
|
5
5
|
// runnable pipeline — no empty `build: () => []` stubs. The
|
|
6
6
|
// integration-tests in __tests__/ exercise the suspension/resume cycle
|
|
7
7
|
// against the in-memory fetcher and (separately) the postgres event-store.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// full HandlerContext surface. `r.step.read.findOne` works (db is
|
|
18
18
|
// present), `r.step.callFeature` does NOT yet (no `write`/`writeAs`
|
|
19
19
|
// on apply-ctx). Pipelines should stick to compute / branch / wait /
|
|
20
|
-
//
|
|
20
|
+
// mail.send / webhook.send for now.
|
|
21
21
|
// - The fetcher reads every suspension row whose wakeAt has expired
|
|
22
22
|
// (no `workflow_run_pending` read-side projection yet). Concurrency
|
|
23
23
|
// is safe via the event-store version-conflict path; performance is
|
|
@@ -98,28 +98,24 @@ export const userOnboardingWorkflow: WorkflowDefinition<{ email: string; userId:
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* Webhook-delivery workflow: one deferred webhook per `data.processed` event.
|
|
102
|
+
* `webhook.send` only enqueues a dispatch request; the step-dispatcher
|
|
103
|
+
* delivers it once and a failed delivery ends as `step.dispatch-failed`. A
|
|
104
|
+
* `retry` around it would never fire, because the step itself cannot throw
|
|
105
|
+
* on a delivery error.
|
|
104
106
|
*/
|
|
105
|
-
export const
|
|
107
|
+
export const webhookDeliveryWorkflow: WorkflowDefinition<
|
|
106
108
|
{ data: unknown; webhookUrl: string },
|
|
107
109
|
void
|
|
108
110
|
> = defineWorkflow({
|
|
109
|
-
name: "
|
|
111
|
+
name: "webhook-delivery",
|
|
110
112
|
trigger: { kind: "event", eventType: "data.processed" },
|
|
111
113
|
|
|
112
114
|
steps: stepsPipeline<{ data: unknown; webhookUrl: string }, void>(({ r }) => [
|
|
113
|
-
r.step.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
r.step.webhook.send({
|
|
118
|
-
url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
|
|
119
|
-
body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
|
|
120
|
-
mode: "deferred",
|
|
121
|
-
}),
|
|
122
|
-
],
|
|
115
|
+
r.step.webhook.send({
|
|
116
|
+
url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
|
|
117
|
+
body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
|
|
118
|
+
mode: "deferred",
|
|
123
119
|
}),
|
|
124
120
|
r.step.return({ isSuccess: true, data: undefined }),
|
|
125
121
|
]),
|
|
@@ -155,7 +151,7 @@ export const workflowEngineFeature = defineFeature("workflowEngine", (r) => {
|
|
|
155
151
|
// The runtime only touches trigger/name/idempotencyKey + executes
|
|
156
152
|
// the closure with the real event payload — payload-agnostic.
|
|
157
153
|
registerEventTrigger(r, userOnboardingWorkflow as unknown as WorkflowDefinition);
|
|
158
|
-
registerEventTrigger(r,
|
|
154
|
+
registerEventTrigger(r, webhookDeliveryWorkflow as unknown as WorkflowDefinition);
|
|
159
155
|
// dailyReportWorkflow is cron-triggered — skip MSP registration
|
|
160
156
|
});
|
|
161
157
|
|