@cosmicdrift/kumiko-samples 0.346.0 → 0.347.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 +6 -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/signup-complete-screen.tsx +6 -1
- 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.347.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.347.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,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.347.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Signup-confirm under an MFA policy returns the signup landing path and the activation screen forwards it to login as next",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.345.0",
|
|
4
10
|
"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
|
}
|
|
@@ -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
|
|
@@ -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
|
|