@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.181.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.181.0",
125
- "@cosmicdrift/kumiko-framework": "0.181.0",
126
- "@cosmicdrift/kumiko-headless": "0.181.0",
127
- "@cosmicdrift/kumiko-renderer": "0.181.0",
128
- "@cosmicdrift/kumiko-renderer-web": "0.181.0",
129
- "@cosmicdrift/kumiko-types": "0.181.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 = { id: userId, tenantId, roles: mergedRoles };
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
- // Both requests read `existing === null` before either write lands, so
312
- // both fall through to create(); the loser's create() version_conflicts.
313
- // The handler must converge that into success instead of surfacing a 409
314
- // for what is, from the caller's perspective, an idempotent operation.
315
- const tagId = await createTag("racy");
316
- const [a, b] = await Promise.all([
317
- assign(tagId, "credit", "credit-race"),
318
- assign(tagId, "credit", "credit-race"),
319
- ]);
320
- expect(a).toBeDefined();
321
- expect(b).toBeDefined();
322
- expect(await countAssignments(admin.tenantId)).toBe(1);
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 { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
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="text-block-content" label={t("template-resolver.editor.contentLabel")}>
355
- <Input
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
- disabled={disabled}
362
- rows={14}
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
  }