@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +47 -6
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
- package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
- package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
- package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
- package/packages/bundled-features/src/channel-email/types.ts +4 -0
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +3 -2
- 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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
204
|
+
{t("auth.mfa.setup.confirm")}
|
|
187
205
|
</Button>
|
|
188
206
|
}
|
|
189
207
|
>
|
|
190
|
-
<
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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="
|
|
247
|
-
id="mfa-setup-preauth-
|
|
248
|
-
name="mfa-setup-preauth-
|
|
249
|
-
value={
|
|
250
|
-
onChange={
|
|
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
|
-
</
|
|
256
|
-
</Form>
|
|
257
|
-
)}
|
|
241
|
+
</div>
|
|
258
242
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
<
|
|
89
|
-
<
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 {
|
|
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
|
-
["
|
|
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}
|
|
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
|
-
|
|
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": {
|