@cosmicdrift/kumiko-bundled-features 0.181.0 → 0.182.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 +7 -7
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +20 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +12 -3
- package/src/tags/__tests__/tags.integration.test.ts +21 -12
- package/src/tags/handlers/assign-tag.write.ts +8 -0
- package/src/template-resolver/web/client-plugin.tsx +55 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.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>",
|
|
@@ -121,12 +121,12 @@
|
|
|
121
121
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
124
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.182.0",
|
|
125
|
+
"@cosmicdrift/kumiko-framework": "0.182.0",
|
|
126
|
+
"@cosmicdrift/kumiko-headless": "0.182.0",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer": "0.182.0",
|
|
128
|
+
"@cosmicdrift/kumiko-renderer-web": "0.182.0",
|
|
129
|
+
"@cosmicdrift/kumiko-types": "0.182.0",
|
|
130
130
|
"@mollie/api-client": "^4.5.0",
|
|
131
131
|
"imapflow": "^1.3.3",
|
|
132
132
|
"mailparser": "^3.9.8",
|
|
@@ -185,6 +185,26 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
185
185
|
expect(revokedForUserIds).toContain(body.user.id);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
test("a stored user timezone survives the preauth-confirm login (fw#1760)", async () => {
|
|
189
|
+
const email = "tz-preauth@example.com";
|
|
190
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
191
|
+
await asRawClient(stack.db).unsafe(
|
|
192
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
193
|
+
["Asia/Tokyo", userId],
|
|
194
|
+
);
|
|
195
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
196
|
+
|
|
197
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
198
|
+
setupToken,
|
|
199
|
+
code: currentTotpCode(secret),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
const payload = JSON.parse(Buffer.from(body.token.split(".")[1], "base64url").toString());
|
|
205
|
+
expect(payload.timezone).toBe("Asia/Tokyo");
|
|
206
|
+
});
|
|
207
|
+
|
|
188
208
|
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
189
209
|
const email = "wrongcode@example.com";
|
|
190
210
|
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
@@ -9,7 +9,7 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
|
|
|
9
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
10
|
import { Temporal } from "temporal-polyfill";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import { burnToken } from "../../shared";
|
|
12
|
+
import { burnToken, sessionTimezoneField } from "../../shared";
|
|
13
13
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
14
14
|
import { base32Decode } from "../base32";
|
|
15
15
|
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
@@ -134,7 +134,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
134
134
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
135
135
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
136
136
|
id: userId,
|
|
137
|
-
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
137
|
+
})) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
|
|
138
138
|
|
|
139
139
|
if (!userRow) return invalidSetupToken();
|
|
140
140
|
if (
|
|
@@ -179,7 +179,16 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
179
179
|
// verify.write.ts.
|
|
180
180
|
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
181
181
|
|
|
182
|
-
const baseSession: SessionUser = {
|
|
182
|
+
const baseSession: SessionUser = {
|
|
183
|
+
id: userId,
|
|
184
|
+
tenantId,
|
|
185
|
+
roles: mergedRoles,
|
|
186
|
+
// This endpoint logs the user in directly (fw#1760), so a stored
|
|
187
|
+
// timezone must survive the preauth-MFA-enrollment login just like
|
|
188
|
+
// a plain password login does — same helper as verify.write.ts /
|
|
189
|
+
// invite-accept-with-login.write.ts (#1759).
|
|
190
|
+
...sessionTimezoneField(userRow?.timezone),
|
|
191
|
+
};
|
|
183
192
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
184
193
|
const session: SessionUser =
|
|
185
194
|
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
@@ -307,19 +307,28 @@ describe("tags integration — idempotency", () => {
|
|
|
307
307
|
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
// 20 iterations, fresh (tag, entity) per run: this race has two distinct
|
|
311
|
+
// windows (create()-vs-create() unique-violation, and the narrower
|
|
312
|
+
// detail()-miss/restore()-hits window fixed alongside kumiko-framework#1778)
|
|
313
|
+
// that each fire in only a fraction of attempts — a single run isn't a
|
|
314
|
+
// reliable regression guard for either.
|
|
310
315
|
test("two concurrent first-time assigns of the same (tag, entity) both succeed — one row", async () => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
316
|
+
for (let i = 0; i < 20; i++) {
|
|
317
|
+
// Both requests read `existing === null` before either write lands, so
|
|
318
|
+
// both fall through to create(); the loser's create() version_conflicts.
|
|
319
|
+
// The handler must converge that into success instead of surfacing a 409
|
|
320
|
+
// for what is, from the caller's perspective, an idempotent operation.
|
|
321
|
+
const tagId = await createTag(`racy-${i}`);
|
|
322
|
+
const entityId = `credit-race-${i}`;
|
|
323
|
+
const [a, b] = await Promise.all([
|
|
324
|
+
assign(tagId, "credit", entityId),
|
|
325
|
+
assign(tagId, "credit", entityId),
|
|
326
|
+
]);
|
|
327
|
+
expect(a).toBeDefined();
|
|
328
|
+
expect(b).toBeDefined();
|
|
329
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: entityId });
|
|
330
|
+
expect(rows).toHaveLength(1);
|
|
331
|
+
}
|
|
323
332
|
});
|
|
324
333
|
});
|
|
325
334
|
|
|
@@ -42,6 +42,14 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
42
42
|
|
|
43
43
|
const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
|
|
44
44
|
if (restored.isSuccess) return { isSuccess: true as const, data: { id } };
|
|
45
|
+
// A concurrent first-time assign can also land here: the other caller's
|
|
46
|
+
// create() already wrote an active row between our `existing` read
|
|
47
|
+
// above and this restore() call, so restore() correctly refuses with
|
|
48
|
+
// "not_deleted" — but the desired end state is already true. Converge
|
|
49
|
+
// like the create() race below instead of surfacing a spurious error.
|
|
50
|
+
if ((restored.error.details as { reason?: string } | undefined)?.reason === "not_deleted") {
|
|
51
|
+
return { isSuccess: true as const, data: { id } };
|
|
52
|
+
}
|
|
45
53
|
if (restored.error.code !== "not_found") return restored;
|
|
46
54
|
|
|
47
55
|
const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
|
|
@@ -18,12 +18,16 @@ import type {
|
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import {
|
|
21
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
22
|
+
type FeatureSchema,
|
|
23
|
+
useAppFeatures,
|
|
24
|
+
useContentEditor,
|
|
21
25
|
useDispatcher,
|
|
22
26
|
usePrimitives,
|
|
23
27
|
useQuery,
|
|
24
28
|
useTranslation,
|
|
25
29
|
} from "@cosmicdrift/kumiko-renderer";
|
|
26
|
-
import type
|
|
30
|
+
import { type ClientFeatureDefinition, PlainContentEditor } from "@cosmicdrift/kumiko-renderer-web";
|
|
27
31
|
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
28
32
|
import {
|
|
29
33
|
collectionHandlerName,
|
|
@@ -197,6 +201,37 @@ function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): Tre
|
|
|
197
201
|
};
|
|
198
202
|
}
|
|
199
203
|
|
|
204
|
+
// contentFormat lives on the collection the node came from, not on the
|
|
205
|
+
// entity — a node without a collectionId (the hand-wired text-block tree)
|
|
206
|
+
// has none, useContentEditor then falls back to "plain" on its own.
|
|
207
|
+
function findContentFormat(
|
|
208
|
+
features: readonly FeatureSchema[],
|
|
209
|
+
collectionId?: string,
|
|
210
|
+
): string | undefined {
|
|
211
|
+
if (collectionId === undefined) return undefined;
|
|
212
|
+
for (const feature of features) {
|
|
213
|
+
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
214
|
+
if (match !== undefined) return match.contentFormat;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Same lookup as findContentFormat, for the collection's fixed variable
|
|
220
|
+
// names (ContentCollectionDefinition.variableSchema) — the chip bar's data
|
|
221
|
+
// source. A node without a collectionId gets no chips: the hand-wired
|
|
222
|
+
// text-block tree has no collection to declare variables on.
|
|
223
|
+
function findVariableSchema(
|
|
224
|
+
features: readonly FeatureSchema[],
|
|
225
|
+
collectionId?: string,
|
|
226
|
+
): readonly string[] {
|
|
227
|
+
if (collectionId === undefined) return [];
|
|
228
|
+
for (const feature of features) {
|
|
229
|
+
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
230
|
+
if (match !== undefined) return Object.keys(match.variableSchema ?? {});
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
200
235
|
// Edit form: loads the current values via by-slug, lets TenantAdmin and
|
|
201
236
|
// SystemAdmin edit title + content, dispatches set on submit. Other users see
|
|
202
237
|
// the form read-only with a hint banner — write permission stays opinionated
|
|
@@ -240,6 +275,10 @@ function TextBlockEditor({
|
|
|
240
275
|
const dispatcher = useDispatcher();
|
|
241
276
|
const user = useShellUser();
|
|
242
277
|
const t = useTranslation();
|
|
278
|
+
const features = useAppFeatures();
|
|
279
|
+
const contentFormat = findContentFormat(features, collectionId);
|
|
280
|
+
const variables = findVariableSchema(features, collectionId);
|
|
281
|
+
const ContentEditor = useContentEditor(contentFormat);
|
|
243
282
|
const canWrite =
|
|
244
283
|
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
245
284
|
|
|
@@ -351,15 +390,12 @@ function TextBlockEditor({
|
|
|
351
390
|
required
|
|
352
391
|
/>
|
|
353
392
|
</Field>
|
|
354
|
-
<Field id=
|
|
355
|
-
<
|
|
356
|
-
kind="textarea"
|
|
357
|
-
id="text-block-content"
|
|
358
|
-
name="text-block-content"
|
|
393
|
+
<Field id={CONTENT_EDITOR_ELEMENT_ID} label={t("template-resolver.editor.contentLabel")}>
|
|
394
|
+
<ContentEditor
|
|
359
395
|
value={content}
|
|
360
396
|
onChange={setContent}
|
|
361
|
-
|
|
362
|
-
|
|
397
|
+
variables={variables}
|
|
398
|
+
readOnly={disabled}
|
|
363
399
|
/>
|
|
364
400
|
</Field>
|
|
365
401
|
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
@@ -406,6 +442,17 @@ export function textBlocksClient(opts?: {
|
|
|
406
442
|
resolvers: {
|
|
407
443
|
"template-resolver:edit": TextBlockEditor,
|
|
408
444
|
},
|
|
445
|
+
// The default for every "plain" collection (ai-prompt, mail-html without
|
|
446
|
+
// an explicit rich override) — an app-registered "plain" editor on
|
|
447
|
+
// another clientFeature still wins, last-wins same as columnRenderers.
|
|
448
|
+
contentEditors: {
|
|
449
|
+
plain: PlainContentEditor,
|
|
450
|
+
// ponytail: same textarea+chips as "plain" — markdown is stored as
|
|
451
|
+
// text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
|
|
452
|
+
// (see its own file comment); swap for a live-preview widget or a
|
|
453
|
+
// forwarded-ref variant if a collection actually needs one.
|
|
454
|
+
markdown: PlainContentEditor,
|
|
455
|
+
},
|
|
409
456
|
translations: defaultTranslations,
|
|
410
457
|
};
|
|
411
458
|
}
|