feat(opaque): add change password
This commit is contained in:
@@ -54,7 +54,13 @@ vi.mock('@serenity-kit/opaque', () => ({
|
|||||||
|
|
||||||
import { apiFetch, ApiError } from '$lib/api/client';
|
import { apiFetch, ApiError } from '$lib/api/client';
|
||||||
import * as opaque from '@serenity-kit/opaque';
|
import * as opaque from '@serenity-kit/opaque';
|
||||||
import { opaqueLogin, opaqueRegister } from './opaque';
|
import {
|
||||||
|
__resetOpaqueParamsCache,
|
||||||
|
fetchOpaqueParams,
|
||||||
|
opaqueLogin,
|
||||||
|
opaqueRegister,
|
||||||
|
syncOpaqueEnvelope
|
||||||
|
} from './opaque';
|
||||||
|
|
||||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
const fin = opaque.client.finishLogin as unknown as ReturnType<typeof vi.fn>;
|
const fin = opaque.client.finishLogin as unknown as ReturnType<typeof vi.fn>;
|
||||||
@@ -87,6 +93,10 @@ function errJson(status: number, body: unknown) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset the params-fetch cache — tests below assert first-call
|
||||||
|
// behaviour, and the singleton cache would carry a prior test's
|
||||||
|
// resolved params into the next test if left alone.
|
||||||
|
__resetOpaqueParamsCache();
|
||||||
// Reset finishLogin to the truthy default; individual tests override.
|
// Reset finishLogin to the truthy default; individual tests override.
|
||||||
fin.mockReturnValue({
|
fin.mockReturnValue({
|
||||||
finishLoginRequest: 'FIN-L',
|
finishLoginRequest: 'FIN-L',
|
||||||
@@ -193,3 +203,61 @@ describe('opaqueLogin', () => {
|
|||||||
expect(err).toMatchObject({ status: 429, errorType: 'RateLimited' });
|
expect(err).toMatchObject({ status: 429, errorType: 'RateLimited' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchOpaqueParams', () => {
|
||||||
|
it('returns the payload and caches it (second call = no HTTP)', async () => {
|
||||||
|
f.mockResolvedValueOnce(okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF }));
|
||||||
|
const first = await fetchOpaqueParams();
|
||||||
|
expect(first.enabled).toBe(true);
|
||||||
|
expect(first.ciphersuiteVersion).toBe(1);
|
||||||
|
expect(first.ksf).toEqual(KSF);
|
||||||
|
|
||||||
|
// Second call MUST NOT hit the wire — the operator contract is
|
||||||
|
// that params change requires a page reload, so caching is safe.
|
||||||
|
const second = await fetchOpaqueParams();
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to enabled=false on a broken /params (no crash)', async () => {
|
||||||
|
f.mockResolvedValueOnce(errJson(500, {}));
|
||||||
|
const params = await fetchOpaqueParams();
|
||||||
|
expect(params.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncOpaqueEnvelope', () => {
|
||||||
|
it('is a no-op when params.enabled=false', async () => {
|
||||||
|
f.mockResolvedValueOnce(okJson({ enabled: false, ciphersuiteVersion: 0, ksf: KSF }));
|
||||||
|
await syncOpaqueEnvelope('any-password');
|
||||||
|
// Only the /params fetch — no register/start or register/finish.
|
||||||
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
|
expect(f.mock.calls[0][0]).toBe('/api/auth/opaque/params');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the register handshake when params.enabled=true', async () => {
|
||||||
|
f.mockResolvedValueOnce(okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF }))
|
||||||
|
.mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' }))
|
||||||
|
.mockResolvedValueOnce(okJson({}, 204));
|
||||||
|
await syncOpaqueEnvelope('correct horse battery staple');
|
||||||
|
// /params + /register/start + /register/finish
|
||||||
|
expect(f).toHaveBeenCalledTimes(3);
|
||||||
|
expect(f.mock.calls[0][0]).toBe('/api/auth/opaque/params');
|
||||||
|
expect(f.mock.calls[1][0]).toBe('/api/auth/opaque/register/start');
|
||||||
|
expect(f.mock.calls[2][0]).toBe('/api/auth/opaque/register/finish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows opaqueRegister errors — silent-migration retry recovers', async () => {
|
||||||
|
// /params succeeds, register/start returns a server error. The
|
||||||
|
// contract is "swallow, log, don't throw" so the caller (change-
|
||||||
|
// password success handler) doesn't surface a user-facing toast
|
||||||
|
// for a migration-hint step they didn't ask for.
|
||||||
|
f.mockResolvedValueOnce(
|
||||||
|
okJson({ enabled: true, ciphersuiteVersion: 1, ksf: KSF })
|
||||||
|
).mockResolvedValueOnce(errJson(500, { error_type: 'InternalError' }));
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await expect(syncOpaqueEnvelope('pw')).resolves.toBeUndefined();
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,6 +54,95 @@ export interface OpaqueKsfConfig {
|
|||||||
parallelism: number;
|
parallelism: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape of `GET /api/auth/opaque/params`. `enabled: false` means
|
||||||
|
* the server's OPAQUE substrate is off — the SPA should short-circuit
|
||||||
|
* all `syncOpaqueEnvelope` / `opaqueLogin` calls and stay on the legacy
|
||||||
|
* password path. Numeric fields carry safe defaults regardless so a
|
||||||
|
* client that ignored the flag wouldn't nil-deref.
|
||||||
|
*/
|
||||||
|
export interface OpaqueServerParams {
|
||||||
|
enabled: boolean;
|
||||||
|
ciphersuiteVersion: number;
|
||||||
|
ksf: OpaqueKsfConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache of the params response. Fetched once per page load
|
||||||
|
* (per SPA runtime), invalidated only by a hard refresh — this matches
|
||||||
|
* the operator contract that changing OPAQUE env vars requires a server
|
||||||
|
* restart, and the SPA reload that follows picks up the new values.
|
||||||
|
*
|
||||||
|
* Unresolved `null` = we haven't tried yet. A settled promise (or a
|
||||||
|
* thrown one) is what subsequent callers await, so concurrent first
|
||||||
|
* touches collapse into ONE `GET /params` round-trip.
|
||||||
|
*/
|
||||||
|
let opaqueParamsInflight: Promise<OpaqueServerParams> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: drop the params cache so the next call re-fetches.
|
||||||
|
* Exposed as `__resetOpaqueParamsCache` to signal "internal — call
|
||||||
|
* from tests only." Runtime code MUST NOT use this; the operator
|
||||||
|
* contract is that params change requires a page reload.
|
||||||
|
*/
|
||||||
|
export function __resetOpaqueParamsCache(): void {
|
||||||
|
opaqueParamsInflight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch (and cache) the server's OPAQUE params. See [`OpaqueServerParams`]. */
|
||||||
|
export function fetchOpaqueParams(): Promise<OpaqueServerParams> {
|
||||||
|
if (opaqueParamsInflight) return opaqueParamsInflight;
|
||||||
|
opaqueParamsInflight = (async () => {
|
||||||
|
const res = await apiFetch('/api/auth/opaque/params', {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// Treat a broken /params as "OPAQUE not available" rather
|
||||||
|
// than propagating an error — the SPA should degrade to
|
||||||
|
// legacy password auth, not crash. Cache the negative
|
||||||
|
// result so we don't hammer a broken endpoint.
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
ciphersuiteVersion: 0,
|
||||||
|
ksf: { memoryKib: 0, iterations: 0, parallelism: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (await res.json()) as OpaqueServerParams;
|
||||||
|
})();
|
||||||
|
return opaqueParamsInflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silent OPAQUE registration after a passphrase-touching action
|
||||||
|
* (signup completion, change-password, silent migration on legacy
|
||||||
|
* login). Fetches params on demand, runs the two-round OPAQUE
|
||||||
|
* register handshake with `password`, and swallows errors — a
|
||||||
|
* failure here leaves the envelope stale, but a subsequent legacy
|
||||||
|
* login will retry via the silent-migration hook. Callers should
|
||||||
|
* clear their local copy of `password` from memory as soon as this
|
||||||
|
* settles (either await or catch — the promise resolves in both
|
||||||
|
* paths so `.finally(() => clearPw())` is the idiomatic wire).
|
||||||
|
*
|
||||||
|
* Callers MUST hold a valid session — the register endpoints are
|
||||||
|
* session-authenticated (they bind the envelope to the current
|
||||||
|
* user_id). Post-signup / post-change-password sessions qualify.
|
||||||
|
*/
|
||||||
|
export async function syncOpaqueEnvelope(password: string): Promise<void> {
|
||||||
|
const params = await fetchOpaqueParams();
|
||||||
|
if (!params.enabled) return; // Substrate off — no-op.
|
||||||
|
try {
|
||||||
|
await opaqueRegister(password, params.ksf, params.ciphersuiteVersion);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal: legacy login still works, silent migration will
|
||||||
|
// retry on next legacy /api/auth/login. Log to console so a
|
||||||
|
// developer poking at DevTools sees the failure but the user
|
||||||
|
// doesn't get a confusing toast for something they didn't ask
|
||||||
|
// for. Reset the cache so the next call re-probes /params —
|
||||||
|
// the failure might have been a transient outage.
|
||||||
|
console.warn('OPAQUE envelope sync failed (silent migration will retry):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
/** Build the shape `@serenity-kit/opaque` expects for its `keyStretching` opt. */
|
/** Build the shape `@serenity-kit/opaque` expects for its `keyStretching` opt. */
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export async function changePassword(currentPw: string, newPw: string): Promise<
|
|||||||
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
|
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
|
||||||
|
// Re-mint the OPAQUE envelope under the new passphrase — session
|
||||||
|
// stays valid across change-password (backend doesn't invalidate),
|
||||||
|
// so the session-authenticated register endpoints are reachable
|
||||||
|
// straight away. Non-fatal on failure: silent migration on next
|
||||||
|
// legacy login recovers the envelope. See
|
||||||
|
// `$lib/api/endpoints/opaque.ts::syncOpaqueEnvelope`.
|
||||||
|
//
|
||||||
|
// Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM
|
||||||
|
// bundle out of the profile route's initial chunk — the module
|
||||||
|
// only loads for users who actually reach the change-password
|
||||||
|
// success path.
|
||||||
|
const { syncOpaqueEnvelope } = await import('$lib/api/endpoints/opaque');
|
||||||
|
await syncOpaqueEnvelope(newPw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAvatar(image: string | null): Promise<void> {
|
export async function updateAvatar(image: string | null): Promise<void> {
|
||||||
|
|||||||
@@ -468,41 +468,41 @@ mod integration_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mark_migrated_stamps_once_and_is_idempotent() {
|
async fn mark_migrated_stamps_once_and_is_idempotent() {
|
||||||
let repo = test_repo().await;
|
let repo = test_repo().await;
|
||||||
let user =
|
let user = seed_user(
|
||||||
seed_user(&repo, &format!("opaque-mig-{}@example.invalid", Uuid::new_v4())).await;
|
&repo,
|
||||||
|
&format!("opaque-mig-{}@example.invalid", Uuid::new_v4()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Read the initial NULL state
|
// Read the initial NULL state
|
||||||
let initial: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
let initial: (Option<chrono::DateTime<chrono::Utc>>,) =
|
||||||
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
|
||||||
)
|
.bind(user)
|
||||||
.bind(user)
|
.fetch_one(repo.pool())
|
||||||
.fetch_one(repo.pool())
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
initial.0.is_none(),
|
initial.0.is_none(),
|
||||||
"new user starts with no opaque_migrated_at"
|
"new user starts with no opaque_migrated_at"
|
||||||
);
|
);
|
||||||
|
|
||||||
repo.mark_migrated(user).await.expect("first mark");
|
repo.mark_migrated(user).await.expect("first mark");
|
||||||
let first: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
let first: (Option<chrono::DateTime<chrono::Utc>>,) =
|
||||||
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
|
||||||
)
|
.bind(user)
|
||||||
.bind(user)
|
.fetch_one(repo.pool())
|
||||||
.fetch_one(repo.pool())
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
let first_ts = first.0.expect("timestamp set after first mark");
|
let first_ts = first.0.expect("timestamp set after first mark");
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
repo.mark_migrated(user).await.expect("second mark");
|
repo.mark_migrated(user).await.expect("second mark");
|
||||||
let second: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
let second: (Option<chrono::DateTime<chrono::Utc>>,) =
|
||||||
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
|
||||||
)
|
.bind(user)
|
||||||
.bind(user)
|
.fetch_one(repo.pool())
|
||||||
.fetch_one(repo.pool())
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
second.0.unwrap(),
|
second.0.unwrap(),
|
||||||
first_ts,
|
first_ts,
|
||||||
|
|||||||
@@ -256,7 +256,10 @@ mod tests {
|
|||||||
let id = cache.store(state, Some(Uuid::new_v4()));
|
let id = cache.store(state, Some(Uuid::new_v4()));
|
||||||
// Second call after take must miss — single-use semantic.
|
// Second call after take must miss — single-use semantic.
|
||||||
let taken = cache.take(id).expect("first take retrieves the state");
|
let taken = cache.take(id).expect("first take retrieves the state");
|
||||||
assert!(taken.user_id.is_some(), "user_id round-trips through the stash");
|
assert!(
|
||||||
|
taken.user_id.is_some(),
|
||||||
|
"user_id round-trips through the stash"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
cache.take(id).is_none(),
|
cache.take(id).is_none(),
|
||||||
"second take must miss — exchange_id is single-use"
|
"second take must miss — exchange_id is single-use"
|
||||||
@@ -306,6 +309,9 @@ mod tests {
|
|||||||
let cache = OpaqueLoginExchange::new();
|
let cache = OpaqueLoginExchange::new();
|
||||||
let id = cache.store(build_server_login_state(), None);
|
let id = cache.store(build_server_login_state(), None);
|
||||||
let taken = cache.take(id).expect("take dummy stash");
|
let taken = cache.take(id).expect("take dummy stash");
|
||||||
assert!(taken.user_id.is_none(), "dummy-branch stash carries no user_id");
|
assert!(
|
||||||
|
taken.user_id.is_none(),
|
||||||
|
"dummy-branch stash carries no user_id"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::dtos::user_dto::AuthResponseDto;
|
use crate::application::dtos::user_dto::AuthResponseDto;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::infrastructure::services::opaque_login_exchange::{
|
use crate::infrastructure::services::opaque_login_exchange::{ExchangeId, OpaqueLoginExchange};
|
||||||
ExchangeId, OpaqueLoginExchange,
|
|
||||||
};
|
|
||||||
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
|
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::CurrentUserId;
|
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||||
@@ -381,9 +379,9 @@ pub async fn login_ke1(
|
|||||||
let repo = require_opaque_repo(&state)?;
|
let repo = require_opaque_repo(&state)?;
|
||||||
let exchange = require_opaque_exchange(&state)?;
|
let exchange = require_opaque_exchange(&state)?;
|
||||||
|
|
||||||
let cred_bytes = B64.decode(dto.start_login_request.trim()).map_err(|_| {
|
let cred_bytes = B64
|
||||||
malformed("startLoginRequest is not valid base64")
|
.decode(dto.start_login_request.trim())
|
||||||
})?;
|
.map_err(|_| malformed("startLoginRequest is not valid base64"))?;
|
||||||
let cred_request = CredentialRequest::<OxiCloudSuite>::deserialize(&cred_bytes)
|
let cred_request = CredentialRequest::<OxiCloudSuite>::deserialize(&cred_bytes)
|
||||||
.map_err(|_| malformed("startLoginRequest failed to deserialize"))?;
|
.map_err(|_| malformed("startLoginRequest failed to deserialize"))?;
|
||||||
|
|
||||||
@@ -513,9 +511,9 @@ pub async fn login_ke3(
|
|||||||
invalid_credentials()
|
invalid_credentials()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let cred_bytes = B64.decode(dto.finish_login_request.trim()).map_err(|_| {
|
let cred_bytes = B64
|
||||||
malformed("finishLoginRequest is not valid base64")
|
.decode(dto.finish_login_request.trim())
|
||||||
})?;
|
.map_err(|_| malformed("finishLoginRequest is not valid base64"))?;
|
||||||
let cred_final = CredentialFinalization::<OxiCloudSuite>::deserialize(&cred_bytes)
|
let cred_final = CredentialFinalization::<OxiCloudSuite>::deserialize(&cred_bytes)
|
||||||
.map_err(|_| malformed("finishLoginRequest failed to deserialize"))?;
|
.map_err(|_| malformed("finishLoginRequest failed to deserialize"))?;
|
||||||
|
|
||||||
@@ -552,22 +550,19 @@ pub async fn login_ke3(
|
|||||||
// Fetch the user entity — needed by mint_session_for_authenticated_user
|
// Fetch the user entity — needed by mint_session_for_authenticated_user
|
||||||
// (it calls dispatch_login + register_login + generates tokens
|
// (it calls dispatch_login + register_login + generates tokens
|
||||||
// from the user's role/email/etc.).
|
// from the user's role/email/etc.).
|
||||||
let user = auth
|
let user = auth.get_user_entity(user_id).await.map_err(|_| {
|
||||||
.get_user_entity(user_id)
|
// User row vanished between KE1's envelope fetch and now
|
||||||
.await
|
// (delete race). Same shape as bad passphrase — never
|
||||||
.map_err(|_| {
|
// leak "you passed the crypto but the account is gone."
|
||||||
// User row vanished between KE1's envelope fetch and now
|
tracing::warn!(
|
||||||
// (delete race). Same shape as bad passphrase — never
|
target: "audit",
|
||||||
// leak "you passed the crypto but the account is gone."
|
event = "opaque.login_ke3_rejected",
|
||||||
tracing::warn!(
|
reason = "user_gone_after_ke3",
|
||||||
target: "audit",
|
user_id = %user_id,
|
||||||
event = "opaque.login_ke3_rejected",
|
"👮🏻♂️ OPAQUE KE3: user disappeared between KE1 and KE3"
|
||||||
reason = "user_gone_after_ke3",
|
);
|
||||||
user_id = %user_id,
|
invalid_credentials()
|
||||||
"👮🏻♂️ OPAQUE KE3: user disappeared between KE1 and KE3"
|
})?;
|
||||||
);
|
|
||||||
invalid_credentials()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Mint the session BEFORE stamping opaque_migrated_at — if the
|
// Mint the session BEFORE stamping opaque_migrated_at — if the
|
||||||
// session mint fails (rare, but not impossible under DB failure),
|
// session mint fails (rare, but not impossible under DB failure),
|
||||||
@@ -653,9 +648,7 @@ pub struct OpaqueParamsResponse {
|
|||||||
),
|
),
|
||||||
tag = "auth"
|
tag = "auth"
|
||||||
)]
|
)]
|
||||||
pub async fn opaque_params(
|
pub async fn opaque_params(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
// Reads from OpaqueService when substrate is wired; falls back
|
// Reads from OpaqueService when substrate is wired; falls back
|
||||||
// to the OpaqueConfig defaults otherwise so an
|
// to the OpaqueConfig defaults otherwise so an
|
||||||
// `enabled=false` payload still has plausible-shape numeric
|
// `enabled=false` payload still has plausible-shape numeric
|
||||||
|
|||||||
Reference in New Issue
Block a user