@cosmicdrift/kumiko-samples 0.341.0 → 0.342.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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  9. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  10. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  11. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  12. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  13. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  14. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  15. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  16. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  17. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  18. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  19. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  20. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  21. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  22. package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
  23. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  24. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  25. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  26. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  27. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
  28. package/packages/bundled-features/src/waitlist/options.ts +3 -2
  29. package/samples/apps/use-all-bundled/feature-manifest.json +27 -2
@@ -164,106 +164,104 @@ export function MfaSetupPreauthScreen({
164
164
  title={title ?? t("auth.mfa.setup.title")}
165
165
  subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
166
166
  >
167
- <div className="p-6 pt-0 flex flex-col gap-4">
168
- {error !== null ? (
169
- <Banner variant="error">
170
- {error === "too_many_attempts" && retryAfterSeconds !== null
171
- ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
172
- : t(reasonToKey(error))}
173
- </Banner>
174
- ) : null}
167
+ {error !== null ? (
168
+ <Banner variant="error">
169
+ {error === "too_many_attempts" && retryAfterSeconds !== null
170
+ ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
171
+ : t(reasonToKey(error))}
172
+ </Banner>
173
+ ) : null}
175
174
 
176
- {!setup && (
175
+ {!setup && (
176
+ <Section
177
+ testId="mfa-setup-preauth-intro"
178
+ actions={
179
+ <Button
180
+ variant="primary"
181
+ onClick={() => void startSetup()}
182
+ loading={busy}
183
+ disabled={busy}
184
+ >
185
+ {t("auth.mfa.setup.start")}
186
+ </Button>
187
+ }
188
+ >
189
+ <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
190
+ </Section>
191
+ )}
192
+
193
+ {setup && (
194
+ <Form onSubmit={onSubmit}>
177
195
  <Section
178
- testId="mfa-setup-preauth-intro"
196
+ testId="mfa-setup-preauth-setup"
179
197
  actions={
180
198
  <Button
199
+ type="submit"
181
200
  variant="primary"
182
- onClick={() => void startSetup()}
183
201
  loading={busy}
184
- disabled={busy}
202
+ disabled={busy || !acknowledged || code.length !== 6}
185
203
  >
186
- {t("auth.mfa.setup.start")}
204
+ {t("auth.mfa.setup.confirm")}
187
205
  </Button>
188
206
  }
189
207
  >
190
- <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
191
- </Section>
192
- )}
208
+ <div className="flex flex-col items-center gap-2 text-center">
209
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
210
+ {/* qrcode's own SVG string output, not user input — safe to inline */}
211
+ <div
212
+ className="h-40 w-40"
213
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
214
+ dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
215
+ />
216
+ <span className="text-xs text-muted-foreground">
217
+ {t("auth.mfa.enable.manualEntry")}
218
+ </span>
219
+ <code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
220
+ {setup.secretParam}
221
+ </code>
222
+ </div>
193
223
 
194
- {setup && (
195
- <Form onSubmit={onSubmit}>
196
- <Section
197
- testId="mfa-setup-preauth-setup"
198
- actions={
199
- <Button
200
- type="submit"
201
- variant="primary"
202
- loading={busy}
203
- disabled={busy || !acknowledged || code.length !== 6}
204
- >
205
- {t("auth.mfa.setup.confirm")}
206
- </Button>
207
- }
208
- >
209
- <div className="flex flex-col items-center gap-2 text-center">
210
- <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
211
- {/* qrcode's own SVG string output, not user input — safe to inline */}
212
- <div
213
- className="h-40 w-40"
214
- // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
215
- dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
216
- />
217
- <span className="text-xs text-muted-foreground">
218
- {t("auth.mfa.enable.manualEntry")}
219
- </span>
220
- <code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
221
- {setup.secretParam}
222
- </code>
223
- </div>
224
-
225
- <div className="flex flex-col gap-2">
226
- <span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
227
- <span className="text-xs text-muted-foreground">
228
- {t("auth.mfa.enable.recoveryHint")}
229
- </span>
230
- <code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
231
- {setup.recoveryCodes.join("\n")}
232
- </code>
233
- <Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
234
- <Input
235
- kind="boolean"
236
- id="mfa-setup-preauth-ack"
237
- name="mfa-setup-preauth-ack"
238
- value={acknowledged}
239
- onChange={setAcknowledged}
240
- />
241
- </Field>
242
- </div>
243
-
244
- <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
224
+ <div className="flex flex-col gap-2">
225
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
226
+ <span className="text-xs text-muted-foreground">
227
+ {t("auth.mfa.enable.recoveryHint")}
228
+ </span>
229
+ <code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
230
+ {setup.recoveryCodes.join("\n")}
231
+ </code>
232
+ <Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
245
233
  <Input
246
- kind="text"
247
- id="mfa-setup-preauth-code"
248
- name="mfa-setup-preauth-code"
249
- value={code}
250
- onChange={setCode}
251
- disabled={busy || !acknowledged}
252
- autoComplete="one-time-code"
234
+ kind="boolean"
235
+ id="mfa-setup-preauth-ack"
236
+ name="mfa-setup-preauth-ack"
237
+ value={acknowledged}
238
+ onChange={setAcknowledged}
253
239
  />
254
240
  </Field>
255
- </Section>
256
- </Form>
257
- )}
241
+ </div>
258
242
 
259
- {onCancel ? (
260
- <div className="flex justify-center">
261
- <Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
262
- {t("auth.mfa.verify.backToLogin")}
263
- </Button>
264
- </div>
265
- ) : null}
266
- </div>
243
+ <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
244
+ <Input
245
+ kind="text"
246
+ id="mfa-setup-preauth-code"
247
+ name="mfa-setup-preauth-code"
248
+ value={code}
249
+ onChange={setCode}
250
+ disabled={busy || !acknowledged}
251
+ autoComplete="one-time-code"
252
+ />
253
+ </Field>
254
+ </Section>
255
+ </Form>
256
+ )}
257
+
258
+ {onCancel ? (
259
+ <div className="flex justify-center">
260
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
261
+ {t("auth.mfa.verify.backToLogin")}
262
+ </Button>
263
+ </div>
264
+ ) : null}
267
265
  </AuthCard>
268
266
  );
269
267
  }
@@ -85,33 +85,31 @@ export function MfaVerifyScreen({
85
85
  title={title ?? t("auth.mfa.verify.title")}
86
86
  subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
87
87
  >
88
- <div className="p-6 pt-0 flex flex-col gap-4">
89
- <Form onSubmit={onSubmit}>
90
- <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
91
- <Input
92
- kind="text"
93
- id="mfa-verify-code"
94
- name="mfa-verify-code"
95
- value={code}
96
- onChange={setCode}
97
- disabled={submitting}
98
- required
99
- autoComplete="one-time-code"
100
- />
101
- </Field>
102
- {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
103
- <Button type="submit" loading={submitting} disabled={submitting}>
104
- {submitting
105
- ? t("auth.mfa.verify.submitting")
106
- : (submitLabel ?? t("auth.mfa.verify.submit"))}
88
+ <Form onSubmit={onSubmit}>
89
+ <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
90
+ <Input
91
+ kind="text"
92
+ id="mfa-verify-code"
93
+ name="mfa-verify-code"
94
+ value={code}
95
+ onChange={setCode}
96
+ disabled={submitting}
97
+ required
98
+ autoComplete="one-time-code"
99
+ />
100
+ </Field>
101
+ {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
102
+ <Button type="submit" loading={submitting} disabled={submitting}>
103
+ {submitting
104
+ ? t("auth.mfa.verify.submitting")
105
+ : (submitLabel ?? t("auth.mfa.verify.submit"))}
106
+ </Button>
107
+ {onCancel ? (
108
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
109
+ {t("auth.mfa.verify.backToLogin")}
107
110
  </Button>
108
- {onCancel ? (
109
- <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
110
- {t("auth.mfa.verify.backToLogin")}
111
- </Button>
112
- ) : null}
113
- </Form>
114
- </div>
111
+ ) : null}
112
+ </Form>
115
113
  </AuthCard>
116
114
  );
117
115
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "EmailMessage.fromName sets the From display name",
6
+ "detail": "channel-email: fromName sets the display name of the From header\n`EmailMessage` gets an optional `fromName`, and the email channel passes `data.fromName` through like `replyTo`. The SMTP transport combines it with the address of `message.from` or the transport default, so `from: \"App <noreply@x>\"` plus `fromName: \"Tenant via veridom\"` sends from `noreply@x` under the new name. Control characters in the name are replaced by spaces before sending, and an empty name behaves like no name. The PII ciphertext guard refuses a ciphertext `fromName`."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -53,11 +53,13 @@ function listUnsubscribeHeaders(
53
53
 
54
54
  function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
55
55
  from?: string;
56
+ fromName?: string;
56
57
  replyTo?: string;
57
58
  headers?: Readonly<Record<string, string>>;
58
59
  } {
59
60
  if (!data) return {};
60
61
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
62
+ const fromName = typeof data["fromName"] === "string" ? data["fromName"] : undefined;
61
63
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
62
64
  const autoHeaders = listUnsubscribeHeaders(data);
63
65
  const explicitHeaders = stringHeaders(data["headers"]);
@@ -71,7 +73,12 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
71
73
  (!overridesUnsubscribe && autoHeaders) || explicitHeaders
72
74
  ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
73
75
  : undefined;
74
- return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
76
+ return {
77
+ ...(from && { from }),
78
+ ...(fromName && { fromName }),
79
+ ...(replyTo && { replyTo }),
80
+ ...(headers && { headers }),
81
+ };
75
82
  }
76
83
 
77
84
  export type EmailChannelOptions = {
@@ -12,16 +12,17 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
12
12
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
13
13
  // in prod.
14
14
  export function guardEmailMessage(message: EmailMessage): EmailMessage {
15
- // Every envelope address, not just `to`: a ciphertext From or Reply-To is
15
+ // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
16
16
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
17
17
  for (const [label, address] of [
18
- ["recipient", message.to],
19
- ["from", message.from],
20
- ["reply-to", message.replyTo],
18
+ ["recipient address", message.to],
19
+ ["from address", message.from],
20
+ ["from name", message.fromName],
21
+ ["reply-to address", message.replyTo],
21
22
  ] as const) {
22
23
  if (address?.includes(CIPHERTEXT_MARKER)) {
23
24
  throw new Error(
24
- `[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
25
+ `[channel-email] refusing to send: ${label} is a PII ciphertext ` +
25
26
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
26
27
  );
27
28
  }
@@ -17,6 +17,7 @@
17
17
  // dedizierter Queue (BullMQ + retry) drüberlegen.
18
18
 
19
19
  import { createTransport, type Transporter } from "nodemailer";
20
+ import addressparser from "nodemailer/lib/addressparser";
20
21
  import type { EmailMessage, EmailTransport } from "./types.js";
21
22
 
22
23
  export type SmtpTransportOptions = {
@@ -48,6 +49,31 @@ export type SmtpTransportOptions = {
48
49
  readonly servername?: string;
49
50
  };
50
51
 
52
+ function isControlCharacter(char: string): boolean {
53
+ const code = char.charCodeAt(0);
54
+ return code <= 0x1f || code === 0x7f;
55
+ }
56
+
57
+ // Control characters in a display name would let a caller inject headers.
58
+ function sanitizedDisplayName(name: string | undefined): string | undefined {
59
+ const cleaned = name
60
+ ?.split("")
61
+ .map((char) => (isControlCharacter(char) ? " " : char))
62
+ .join("")
63
+ .trim();
64
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
65
+ }
66
+
67
+ function fromHeader(
68
+ address: string,
69
+ fromName: string | undefined,
70
+ ): string | { readonly name: string; readonly address: string } {
71
+ const name = sanitizedDisplayName(fromName);
72
+ if (name === undefined) return address;
73
+ const [parsed] = addressparser(address, { flatten: true });
74
+ return parsed?.address ? { name, address: parsed.address } : address;
75
+ }
76
+
51
77
  export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
52
78
  const transporter: Transporter = createTransport({
53
79
  host: options.host,
@@ -62,7 +88,7 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
62
88
  return {
63
89
  async send(message: EmailMessage): Promise<void> {
64
90
  await transporter.sendMail({
65
- from: message.from ?? options.from,
91
+ from: fromHeader(message.from ?? options.from, message.fromName),
66
92
  to: message.to,
67
93
  subject: message.subject,
68
94
  html: message.html,
@@ -7,6 +7,10 @@ export type EmailMessage = {
7
7
  // app-wide default). Set when one send must originate from a specific
8
8
  // mailbox — a reply from the address the original mail reached, not noreply@.
9
9
  readonly from?: string;
10
+ // Display name for the From header. Combined with the address of `from`
11
+ // (or the transport default), so a per-tenant sender name needs no
12
+ // hand-built "Name <addr>" string.
13
+ readonly fromName?: string;
10
14
  // Reply-To header: where a recipient's answer goes when it differs from From.
11
15
  readonly replyTo?: string;
12
16
  // Extra RFC-5322 headers — In-Reply-To / References thread a reply into the
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "Waitlist notifyRecipient may return a promise",
6
+ "detail": "Waitlist `notifyRecipient` may return a promise, so apps can read the admin address from config"
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "improvement",
@@ -140,7 +140,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
140
140
  );
141
141
 
142
142
  await sendBestEffort(ctx, "admin notice", async (notify) => {
143
- const adminRecipient = opts.notifyRecipient?.(ctx);
143
+ const adminRecipient = await opts.notifyRecipient?.(ctx);
144
144
  // skip: no admin recipient configured
145
145
  if (!adminRecipient) return;
146
146
  await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
@@ -17,8 +17,9 @@ export type WaitlistInviteOptions =
17
17
 
18
18
  export type WaitlistOptions = {
19
19
  /** Address for an admin notification mail on each new entry. Null, an empty
20
- * string or an absent option sends none. Best effort, never fails the submit. */
21
- readonly notifyRecipient?: (ctx: HandlerContext) => string | null;
20
+ * string or an absent option sends none. Best effort, never fails the submit,
21
+ * also when the resolver throws or rejects. */
22
+ readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
22
23
  /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
23
24
  readonly invite?: WaitlistInviteOptions;
24
25
  /** Default: 5 submits per IP per 10 minutes. */
@@ -55,14 +55,39 @@
55
55
  "toggleableDefault": null,
56
56
  "requires": [
57
57
  "tenant",
58
- "user"
58
+ "user",
59
+ "config"
59
60
  ],
60
61
  "optionalRequires": [],
61
62
  "configReads": [],
62
63
  "exposesApis": [],
63
64
  "usesApis": [],
64
65
  "extensionsUsed": [],
65
- "configKeys": [],
66
+ "configKeys": [
67
+ {
68
+ "key": "escape-hatch-retention-days",
69
+ "qualifiedName": "audit:config:escape-hatch-retention-days",
70
+ "type": "number",
71
+ "scope": "system",
72
+ "default": 90,
73
+ "encrypted": false,
74
+ "computed": false,
75
+ "validated": false,
76
+ "options": null,
77
+ "bounds": {
78
+ "min": 1
79
+ },
80
+ "pattern": null,
81
+ "writeRoles": [
82
+ "system"
83
+ ],
84
+ "readRoles": [
85
+ "TenantAdmin",
86
+ "Admin",
87
+ "SystemAdmin"
88
+ ]
89
+ }
90
+ ],
66
91
  "secrets": [],
67
92
  "writeHandlers": [],
68
93
  "uiHints": {