@cosmicdrift/kumiko-bundled-features 0.243.0 → 0.243.1

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.243.0",
3
+ "version": "0.243.1",
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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.243.0",
134
- "@cosmicdrift/kumiko-framework": "0.243.0",
135
- "@cosmicdrift/kumiko-headless": "0.243.0",
136
- "@cosmicdrift/kumiko-renderer": "0.243.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.243.0",
138
- "@cosmicdrift/kumiko-types": "0.243.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.243.1",
134
+ "@cosmicdrift/kumiko-framework": "0.243.1",
135
+ "@cosmicdrift/kumiko-headless": "0.243.1",
136
+ "@cosmicdrift/kumiko-renderer": "0.243.1",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.243.1",
138
+ "@cosmicdrift/kumiko-types": "0.243.1",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.243.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.243.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.243.1",
168
+ "@cosmicdrift/kumiko-locale-es": "0.243.1"
169
169
  }
170
170
  }
@@ -105,15 +105,62 @@ async function waitForMount(view: ReturnType<typeof render>, testId: string): Pr
105
105
  });
106
106
  }
107
107
 
108
+ // Reproduces the real host: RenderEdit renders exactly one <form
109
+ // testId="render-edit-form"> around every singleton projectionDetail screen
110
+ // (render-edit.tsx), and both sections mount as children of that form via
111
+ // ExtensionSectionMount. A section that renders its own <form> nests invalid
112
+ // DOM inside it — the security bug this guards against: the submit button
113
+ // falls back to a native GET navigation, putting the password(s) in the URL
114
+ // query instead of dispatching a write.
115
+ function renderInsideHostForm(node: ReactNode) {
116
+ const writes: Array<{ type: string; payload: unknown }> = [];
117
+ const view = render(<form data-testid="render-edit-form">{node}</form>, {
118
+ wrapper: withProviders(writes),
119
+ });
120
+ return { view, writes };
121
+ }
122
+
108
123
  describe("ChangeEmailSection", () => {
109
- test("renders on a real <form> (Enter-to-submit, password-manager boundary), shows the current email, no raw i18n keys", async () => {
124
+ test("renders no <form> of its own (would nest inside the host RenderEdit <form>), shows the current email, no raw i18n keys", async () => {
110
125
  const { view } = renderChangeEmailSection("marc@example.com");
111
126
  await waitForMount(view, "change-email-root");
112
- expect(view.getByTestId("profile-email").tagName).toBe("FORM");
127
+ expect(view.container.querySelector("form")).toBeNull();
113
128
  expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
114
129
  expect(view.container.textContent).not.toContain("profile.");
115
130
  });
116
131
 
132
+ // Security regression guard: mounted inside the real host <form> (as
133
+ // RenderEdit does in production), a click on the submit button must still
134
+ // reach the write dispatcher — not fall back to the host form's native
135
+ // GET submission, which would put currentPassword/newEmail in the URL.
136
+ test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-email (no nested <form>, no native submit fallback)", async () => {
137
+ const { view, writes } = renderInsideHostForm(
138
+ <ChangeEmailSection
139
+ entityName="user"
140
+ entityId={null}
141
+ values={{ email: "old@example.com" }}
142
+ patch={() => {}}
143
+ />,
144
+ );
145
+ await waitForMount(view, "profile-email");
146
+ expect(view.container.querySelectorAll("form")).toHaveLength(1);
147
+
148
+ const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
149
+ const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
150
+ if (!emailInput || !pwInput) throw new Error("email form inputs not found");
151
+ fireEvent.change(emailInput, { target: { value: "new@example.com" } });
152
+ fireEvent.change(pwInput, { target: { value: "current-pw" } });
153
+ fireEvent.click(view.getByTestId("profile-email-submit"));
154
+
155
+ await waitFor(() => {
156
+ if (writes.length === 0) throw new Error("no write dispatched yet");
157
+ });
158
+ expect(writes[0]).toEqual({
159
+ type: "user-profile:write:change-email",
160
+ payload: { currentPassword: "current-pw", newEmail: "new@example.com" },
161
+ });
162
+ });
163
+
117
164
  test("submits change-email with currentPassword + newEmail, patches the host's email on success", async () => {
118
165
  const { view, writes, patchCalls } = renderChangeEmailSection("old@example.com");
119
166
  await waitForMount(view, "profile-email");
@@ -212,13 +259,40 @@ describe("ChangeEmailSection", () => {
212
259
  });
213
260
 
214
261
  describe("ChangePasswordSection", () => {
215
- test("renders on a real <form>, no raw i18n keys", async () => {
262
+ test("renders no <form> of its own (would nest inside the host RenderEdit <form>), no raw i18n keys", async () => {
216
263
  const { view } = renderChangePasswordSection();
217
264
  await waitForMount(view, "change-password-root");
218
- expect(view.getByTestId("profile-password").tagName).toBe("FORM");
265
+ expect(view.container.querySelector("form")).toBeNull();
219
266
  expect(view.container.textContent).not.toContain("profile.");
220
267
  });
221
268
 
269
+ // Security regression guard: mounted inside the real host <form> (as
270
+ // RenderEdit does in production), a click on the submit button must still
271
+ // reach the write dispatcher — not fall back to the host form's native
272
+ // GET submission, which would put old+new passwords in the URL.
273
+ test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-password (no nested <form>, no native submit fallback)", async () => {
274
+ const { view, writes } = renderInsideHostForm(<ChangePasswordSection />);
275
+ await waitForMount(view, "profile-password");
276
+ expect(view.container.querySelectorAll("form")).toHaveLength(1);
277
+
278
+ const oldPw = view.container.querySelector<HTMLInputElement>("#profile-old-password");
279
+ const newPw = view.container.querySelector<HTMLInputElement>("#profile-new-password");
280
+ const confirmPw = view.container.querySelector<HTMLInputElement>("#profile-confirm-password");
281
+ if (!oldPw || !newPw || !confirmPw) throw new Error("password form inputs not found");
282
+ fireEvent.change(oldPw, { target: { value: "current-pw" } });
283
+ fireEvent.change(newPw, { target: { value: "new-pw-1" } });
284
+ fireEvent.change(confirmPw, { target: { value: "new-pw-1" } });
285
+ fireEvent.click(view.getByTestId("profile-password-submit"));
286
+
287
+ await waitFor(() => {
288
+ if (writes.length === 0) throw new Error("no write dispatched yet");
289
+ });
290
+ expect(writes[0]).toEqual({
291
+ type: "auth-email-password:write:change-password",
292
+ payload: { oldPassword: "current-pw", newPassword: "new-pw-1" },
293
+ });
294
+ });
295
+
222
296
  test("mismatched confirm password blocks the submit (no write dispatched)", async () => {
223
297
  const { view, writes } = renderChangePasswordSection();
224
298
  await waitForMount(view, "profile-password");
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.243.1",
4
+ "type": "fix",
5
+ "title": "Security fix: ChangePasswordSection/ChangeEmailSection no longer nest a <form> inside the host RenderEdit form.",
6
+ "detail": "Since `profile` became a declarative `projectionDetail` screen (fw#2312), RenderEdit wraps the whole singleton screen in its own host `<form>`. `ChangePasswordSection` and `ChangeEmailSection` were each rendering their own `<Form>` inside that host, creating invalid nested `<form>` DOM. The submit button silently fell back to a native GET submission of the outer form instead of dispatching a write, putting the entered password(s) in the URL query string (browser history, `Referer` header, proxy/server access logs — `ChangePasswordSection` leaked both the old and the new password). Both sections now follow the same no-nested-form pattern `write-form-section.tsx` uses: a plain container with a `type=\"button\"` that calls the write dispatcher directly. testIds are unchanged; Enter-to-submit inside these two sections is an accepted, deliberate trade-off (same one `write-form-section.tsx` already makes)."
7
+ },
2
8
  {
3
9
  "version": "0.241.0",
4
10
  "type": "breaking",
@@ -13,7 +13,6 @@ import {
13
13
  usePrimitives,
14
14
  useTranslation,
15
15
  } from "@cosmicdrift/kumiko-renderer";
16
- import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
17
16
  import { type ReactNode, useState } from "react";
18
17
  import { AuthHandlers } from "../../auth-email-password/constants";
19
18
  import { requestEmailVerification } from "../../auth-email-password/web";
@@ -46,12 +45,15 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
46
45
 
47
46
  // Mounted by the renderer's ExtensionSectionMount (already wrapped in
48
47
  // <Section title={...} testId="section-extension-...">) — takes no props of
49
- // its own. BareFormProvider keeps <Form> from rendering a second card/title
50
- // inside that Section (same pattern as AuthCard, see
51
- // auth-email-password/web/auth-form-primitives.tsx).
48
+ // its own. Deliberately NOT a <Form>: RenderEdit already renders a host
49
+ // <form> around the whole singleton screen (render-edit.tsx), so a second
50
+ // nested <form> here is invalid DOM and silently degrades the submit button
51
+ // into a native GET navigation instead of a write dispatch (same failure
52
+ // mode write-form-section.tsx documents and avoids the same way — a plain
53
+ // button click, not a second <form>).
52
54
  export function ChangePasswordSection(): ReactNode {
53
55
  const t = useTranslation();
54
- const { Form, Field, Input, Button } = usePrimitives();
56
+ const { Field, Input, Button } = usePrimitives();
55
57
  const dispatcher = useDispatcher();
56
58
  const [oldPassword, setOldPassword] = useState("");
57
59
  const [newPassword, setNewPassword] = useState("");
@@ -82,55 +84,55 @@ export function ChangePasswordSection(): ReactNode {
82
84
 
83
85
  const submitting = status.kind === "submitting";
84
86
  return (
85
- <BareFormProvider>
86
- <Form
87
- testId="profile-password"
88
- onSubmit={onSubmit}
89
- actions={
90
- <Button type="submit" disabled={submitting} testId="profile-password-submit">
91
- {t("profile.password.submit")}
92
- </Button>
93
- }
94
- >
95
- <Field id="profile-old-password" label={t("profile.password.old")} required>
96
- <Input
97
- kind="password"
98
- id="profile-old-password"
99
- name="profile-old-password"
100
- value={oldPassword}
101
- onChange={setOldPassword}
102
- disabled={submitting}
103
- required
104
- autoComplete="current-password"
105
- />
106
- </Field>
107
- <Field id="profile-new-password" label={t("profile.password.new")} required>
108
- <Input
109
- kind="password"
110
- id="profile-new-password"
111
- name="profile-new-password"
112
- value={newPassword}
113
- onChange={setNewPassword}
114
- disabled={submitting}
115
- required
116
- autoComplete="new-password"
117
- />
118
- </Field>
119
- <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
120
- <Input
121
- kind="password"
122
- id="profile-confirm-password"
123
- name="profile-confirm-password"
124
- value={confirm}
125
- onChange={setConfirm}
126
- disabled={submitting}
127
- required
128
- autoComplete="new-password"
129
- />
130
- </Field>
131
- <StatusBanner status={status} />
132
- </Form>
133
- </BareFormProvider>
87
+ <div data-testid="profile-password" className="flex flex-col gap-4">
88
+ <Field id="profile-old-password" label={t("profile.password.old")} required>
89
+ <Input
90
+ kind="password"
91
+ id="profile-old-password"
92
+ name="profile-old-password"
93
+ value={oldPassword}
94
+ onChange={setOldPassword}
95
+ disabled={submitting}
96
+ required
97
+ autoComplete="current-password"
98
+ />
99
+ </Field>
100
+ <Field id="profile-new-password" label={t("profile.password.new")} required>
101
+ <Input
102
+ kind="password"
103
+ id="profile-new-password"
104
+ name="profile-new-password"
105
+ value={newPassword}
106
+ onChange={setNewPassword}
107
+ disabled={submitting}
108
+ required
109
+ autoComplete="new-password"
110
+ />
111
+ </Field>
112
+ <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
113
+ <Input
114
+ kind="password"
115
+ id="profile-confirm-password"
116
+ name="profile-confirm-password"
117
+ value={confirm}
118
+ onChange={setConfirm}
119
+ disabled={submitting}
120
+ required
121
+ autoComplete="new-password"
122
+ />
123
+ </Field>
124
+ <StatusBanner status={status} />
125
+ <div className="flex items-center justify-end gap-2">
126
+ <Button
127
+ type="button"
128
+ disabled={submitting}
129
+ onClick={() => onSubmit()}
130
+ testId="profile-password-submit"
131
+ >
132
+ {t("profile.password.submit")}
133
+ </Button>
134
+ </div>
135
+ </div>
134
136
  );
135
137
  }
136
138
 
@@ -142,7 +144,7 @@ export function ChangePasswordSection(): ReactNode {
142
144
  // relying on a full-screen refetch to un-stale the "current email" line.
143
145
  export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): ReactNode {
144
146
  const t = useTranslation();
145
- const { Form, Field, Input, Button, Text } = usePrimitives();
147
+ const { Field, Input, Button, Text } = usePrimitives();
146
148
  const dispatcher = useDispatcher();
147
149
  const [newEmail, setNewEmail] = useState("");
148
150
  const [currentPassword, setCurrentPassword] = useState("");
@@ -188,45 +190,45 @@ export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): Re
188
190
 
189
191
  const submitting = status.kind === "submitting";
190
192
  return (
191
- <BareFormProvider>
192
- <Form
193
- testId="profile-email"
194
- onSubmit={onSubmit}
195
- actions={
196
- <Button type="submit" disabled={submitting} testId="profile-email-submit">
197
- {t("profile.email.submit")}
198
- </Button>
199
- }
200
- >
201
- <Text variant="muted" testId="profile-email-current">
202
- {t("profile.email.current")}: {currentEmail}
203
- </Text>
204
- <Field id="profile-new-email" label={t("profile.email.new")} required>
205
- <Input
206
- kind="email"
207
- id="profile-new-email"
208
- name="profile-new-email"
209
- value={newEmail}
210
- onChange={setNewEmail}
211
- disabled={submitting}
212
- required
213
- autoComplete="email"
214
- />
215
- </Field>
216
- <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
217
- <Input
218
- kind="password"
219
- id="profile-email-password"
220
- name="profile-email-password"
221
- value={currentPassword}
222
- onChange={setCurrentPassword}
223
- disabled={submitting}
224
- required
225
- autoComplete="current-password"
226
- />
227
- </Field>
228
- <StatusBanner status={status} />
229
- </Form>
230
- </BareFormProvider>
193
+ <div data-testid="profile-email" className="flex flex-col gap-4">
194
+ <Text variant="muted" testId="profile-email-current">
195
+ {t("profile.email.current")}: {currentEmail}
196
+ </Text>
197
+ <Field id="profile-new-email" label={t("profile.email.new")} required>
198
+ <Input
199
+ kind="email"
200
+ id="profile-new-email"
201
+ name="profile-new-email"
202
+ value={newEmail}
203
+ onChange={setNewEmail}
204
+ disabled={submitting}
205
+ required
206
+ autoComplete="email"
207
+ />
208
+ </Field>
209
+ <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
210
+ <Input
211
+ kind="password"
212
+ id="profile-email-password"
213
+ name="profile-email-password"
214
+ value={currentPassword}
215
+ onChange={setCurrentPassword}
216
+ disabled={submitting}
217
+ required
218
+ autoComplete="current-password"
219
+ />
220
+ </Field>
221
+ <StatusBanner status={status} />
222
+ <div className="flex items-center justify-end gap-2">
223
+ <Button
224
+ type="button"
225
+ disabled={submitting}
226
+ onClick={() => onSubmit()}
227
+ testId="profile-email-submit"
228
+ >
229
+ {t("profile.email.submit")}
230
+ </Button>
231
+ </div>
232
+ </div>
231
233
  );
232
234
  }