fix(moderator): stop the lookup panel hiding an open case, and stop offering rulings that cannot land

Three things, all from the audit on this PR.

1. User Lookup showed the newest restriction of ANY type — three correlated
   subqueries, `ORDER BY ur.id DESC LIMIT 1`, no type predicate. That was sound
   while a user could hold at most one open row. Per-type dedupe lets two coexist,
   so a Pending generation case sitting behind a later Upheld bot-account row
   rendered as no open restriction at all: the account stays muted, the ruling
   form is never drawn, and nobody can see the open case.

   Now `ORDER BY (ur.status = 'Pending') DESC, ur.id DESC` — a Pending case
   outranks a merely newer one; among several Pending rows, the newest wins; a
   resolved row is shown only when there is no open one. The panel still speaks
   for ONE row (a header chip and a single form; the audit queue is where a list
   belongs), and the ordering is now written once and called three times rather
   than copied per column, so the three cannot stop naming the same row.

2. `RULINGS_WIRED_FOR` / `unwiredRulingReason` move into `$lib/restriction-types`
   and are imported by the audit route instead of re-spelled there. The refusal
   that protects the account now lives in the main app's `resolveUserRestriction`;
   the route check is KEPT, not for defence in depth but for ordering — `ban` bans
   and THEN rules, so a refusal arriving inside the verdict call would leave the
   account banned against a restriction nobody can close. The seam test pins this
   copy to the main app's in both directions, list and wording.

3. The Bot account queue rendered live Uphold / Remove / Ban forms whose only
   possible outcome was a 400, and the retool panel offered Overturn / Uphold on a
   row the verdict path refuses. Both now disable those controls and say why. The
   server-side refusals are unchanged — they hold against a posted id, which
   nothing rendered can.

Also: `restrictionById`'s comment claimed `unwiredRuling` governs which types an
action can be handed; `flagSuspicious` calls it with `type: 'any'` and rules on
nothing. The comment now says what the code does and why flagging is deliberately
type-agnostic. And `capturingDb`'s `params` is `unknown[][]`, not a `readonly`
contract cast away at its one use.

Tests: new `user-lookup-restriction-row.test.ts` asserts the Pending-first
ordering, the total tiebreak and the per-account scope on all three compiled
subqueries; new `restriction-types.test.ts` covers the shared predicate the
disabled controls read. The audit route gains an invariant guard that its refusal
is the shared predicate's own output rather than a second copy. There is no
component-test harness in this app, so the rendering half of (3) is not covered by
a test.
This commit is contained in:
ZacxDev
2026-09-03 18:11:52 -05:00
parent d35adbf59a
commit ff97751d20
10 changed files with 310 additions and 27 deletions
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
RESTRICTION_TYPE,
RESTRICTION_TYPES,
RESTRICTION_TYPE_LABELS,
RULINGS_WIRED_FOR,
unwiredRulingReason,
} from '$lib/restriction-types';
/**
* The one predicate three surfaces read: the audit queue's `resolve`/`ban` refusal, the audit queue's
* disabled ruling buttons, and the retool User Lookup panel's disabled ruling form. It was open-coded
* in the route before #4609's audit; a predicate spelled at N sites is wrong at N1 of them, and here
* the sites disagreeing means a live button whose only possible outcome is a rejected call.
*
* The refusal that MATTERS is enforced by the main app, inside `resolveUserRestriction` — this list is
* the same rule read forward so a form that cannot succeed is never offered. The two are pinned to
* each other by `src/server/services/__tests__/restriction-type-seam.test.ts`.
*/
describe('unwiredRulingReason', () => {
it('permits a ruling on every wired-for type', () => {
// Non-vacuous: there is at least one, and it is the queue's default.
expect(RULINGS_WIRED_FOR.length).toBeGreaterThan(0);
expect(RULINGS_WIRED_FOR).toContain(RESTRICTION_TYPE);
for (const type of RULINGS_WIRED_FOR) expect(unwiredRulingReason(type)).toBeNull();
});
it('refuses every filed type that has no verdict path, naming it', () => {
const unwired = RESTRICTION_TYPES.filter((t) => !RULINGS_WIRED_FOR.includes(t));
// The guard is only worth anything while some type is refused; today that is `bot-account`.
expect(unwired.length).toBeGreaterThan(0);
for (const type of unwired) {
const reason = unwiredRulingReason(type);
// The type is named because a moderator has to be able to tell WHICH queue is review-only, and
// the message doubles as the audit route's `fail(400)` body.
expect(reason).toContain(`"${type}"`);
expect(reason).toContain('NOT resolved');
}
});
it('refuses a type nobody has heard of, rather than defaulting it in', () => {
// The value reaching this can come off a database row, so it is not confined to the union.
for (const type of ['', 'GENERATION', 'generation ', 'nonsense'])
expect(unwiredRulingReason(type)).not.toBeNull();
});
it('keeps a label for every filed type, so a refused queue can still be named on screen', () => {
expect(Object.keys(RESTRICTION_TYPE_LABELS).sort()).toEqual([...RESTRICTION_TYPES].sort());
});
});
@@ -25,3 +25,28 @@ export const RESTRICTION_TYPE_LABELS: Record<RestrictionType, string> = {
generation: 'Generation', generation: 'Generation',
'bot-account': 'Bot account', 'bot-account': 'Bot account',
}; };
/**
* The types a VERDICT can be handed to — narrower than `RESTRICTION_TYPES`, which is what may be
* filed and reviewed.
*
* The refusal itself is enforced by the main app, one level below every ruling surface, in
* `resolveUserRestriction` — this app's ruling forms all post through `/api/mod/restriction/resolve`.
* This list is the same rule read forward instead of backward: it is what lets a form that cannot
* possibly succeed be disabled rather than merely rejected, and what lets the audit queue refuse a
* ban BEFORE it bans (that action bans and then rules, so a late refusal would leave the account
* banned against a restriction nobody can close).
*
* 🔴 Kept identical to the main app's `RULINGS_WIRED_FOR` by
* `src/server/services/__tests__/restriction-type-seam.test.ts`, which reads this file as text. The
* two apps are separate builds with no runtime import path between them, so a pinned copy is the
* strongest available form of "one rule, one place" — do not fork it by hand.
*/
export const RULINGS_WIRED_FOR: readonly RestrictionType[] = ['generation'];
/** Why a ruling may not be handed to a row of this type, or `null` when it may. */
export function unwiredRulingReason(type: string): string | null {
return (RULINGS_WIRED_FOR as readonly string[]).includes(type)
? null
: `Rulings are not yet available for "${type}" restrictions — the verdict path still sends generation-specific notices. This restriction was NOT resolved.`;
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
/**
* Which UserRestriction row the User Lookup header and its ruling form speak for.
*
* 🔴 The panel shows ONE row, and until #4609 it picked "newest of any type" — `ORDER BY ur.id DESC
* LIMIT 1`, no type predicate. That was sound only while a user could hold at most one open row.
* Restrictions now dedupe PER TYPE, so two open cases can coexist, and the old ordering had a silent
* failure mode: a Pending generation case sitting behind a LATER Upheld bot-account row rendered as
* *no open restriction at all* — the account stays muted, the ruling form is never drawn, and nobody
* looking at the account can see there is an open case.
*
* Asserted against the COMPILED SQL. There is no database in this tier, and the ordering is the whole
* behaviour — it decides which of several rows the moderator is shown, and it typechecks and lints
* identically either way. Same instrument the sibling `restriction-type-filter.test.ts` uses, for the
* same reason.
*/
const captured = vi.hoisted(() => [] as string[]);
// Built inside the factory, not in `vi.hoisted`: hoisted blocks run before this file's own imports.
vi.mock('$lib/server/db', async () => {
const { capturingDb } = await import('../../../test/capture-sql');
// One canned row, so `executeTakeFirst` resolves and the chain does not stop early.
const db = capturingDb(captured, [{ id: 1 }]);
return { dbRead: db, dbWrite: db };
});
// The identity query is the only thing under test here; its module's other exports reach a second
// database and an HTTP service, and none of them is on this path.
const { getIdentity } = await import('../user-lookup.service');
const identitySql = async (): Promise<string> => {
captured.length = 0;
await getIdentity(42);
// 🔴 Count first. A chain that stopped early would leave `captured` empty while every assertion over
// its contents passed vacuously.
expect(captured).toHaveLength(1);
return captured[0].replace(/\s+/g, ' ');
};
/**
* The three correlated subqueries that read the account's restriction, sliced from the head of each
* `FROM "UserRestriction" ur` to its `LIMIT 1`.
*/
const restrictionSubqueries = (sql: string): string[] =>
sql
.split('FROM "UserRestriction" ur')
.slice(1)
.map((chunk) => {
const end = chunk.indexOf('LIMIT 1');
expect(end).toBeGreaterThan(-1);
return chunk.slice(0, end).trim();
});
describe('user lookup — which restriction the panel speaks for', () => {
it('reads three restriction columns, which is what the panel needs to render a ruling form', async () => {
const sql = await identitySql();
// Positive control on the slicer, and the thing that makes every assertion below non-vacuous: a
// rename or a fourth column shows up here rather than silently reducing the loops to no-ops.
expect(restrictionSubqueries(sql)).toHaveLength(3);
for (const alias of ['restrictionStatus', 'restrictionType', 'restrictionId'])
expect(sql).toContain(`as "${alias}"`);
});
/**
* 🔴 The regression. A Pending row outranks a merely newer one, so an open case cannot be hidden
* behind a later resolved one of another type.
*/
it('prefers a Pending row over a newer one, in every restriction column', async () => {
const sql = await identitySql();
for (const sub of restrictionSubqueries(sql))
expect(sub).toContain(`ORDER BY (ur.status = 'Pending') DESC, ur.id DESC`);
});
/**
* The tiebreak has to be TOTAL, or the three subqueries are free to resolve to different rows and
* the panel renders one row's status against another's id — a ruling form posting an id that does
* not belong to the case it is describing.
*/
it('breaks the tie on a unique column, so the three columns name one row', async () => {
const sql = await identitySql();
const subs = restrictionSubqueries(sql);
for (const sub of subs) expect(sub).toContain('ur.id DESC');
// Identical apart from the column each selects — which is the only reason they agree. (An
// INVARIANT GUARD: true before this change too. Kept because the fix replaced three hand-written
// copies with one helper, and this is what stops them being hand-written again.)
const withoutSelectList = subs.map((s) => s.slice(s.indexOf('WHERE')));
expect(new Set(withoutSelectList).size).toBe(1);
});
it('scopes each subquery to the account being looked up', async () => {
const sql = await identitySql();
for (const sub of restrictionSubqueries(sql)) expect(sub).toContain(`ur."userId" = u.id`);
});
});
@@ -381,7 +381,38 @@ export async function getLeaderboardRanks(userId: number): Promise<LeaderboardRa
); );
} }
async function getIdentity(userId: number): Promise<UserIdentity | null> { /**
* One field of the ONE restriction row this panel speaks for.
*
* 🔴 `ORDER BY (ur.status = 'Pending') DESC` first, `ur.id DESC` second — a PENDING case outranks a
* merely newer one. The old ordering was newest-of-any-type, which was sound only while a user could
* hold at most one open row. Restrictions now dedupe PER TYPE, so two open cases can coexist: a
* Pending generation case sitting behind a later Upheld bot-account row rendered as *no open
* restriction at all*, leaving the account muted, the ruling form unrendered, and nobody able to see
* the open case. Preferring Pending is what makes that impossible in either direction.
*
* The panel shows ONE row on purpose — it is a header chip plus a single ruling form, and the audit
* queue is where a list of cases belongs. So the one it shows is the one that can still be acted on;
* among several Pending rows it shows the newest, and a resolved row is only ever shown when there is
* no open one.
*
* Written once and called three times rather than spelled out per column: the three subqueries must
* name the SAME row, and three copies of an ordering rule is three places for it to stop agreeing.
* `ur.id DESC` makes the order total, so all three resolve to that same row deterministically.
* `expr` is a literal from this module, never caller input.
*/
function restrictionField<T>(expr: string) {
return sql<T>`(
SELECT ${sql.raw(expr)} FROM "UserRestriction" ur
WHERE ur."userId" = u.id
ORDER BY (ur.status = 'Pending') DESC, ur.id DESC
LIMIT 1
)`;
}
// Exported for the SQL-shape test — `getUserLookup` fans out over two databases, so reaching this
// query through it would be a test about the mocks rather than about the ordering above.
export async function getIdentity(userId: number): Promise<UserIdentity | null> {
const row = await dbRead const row = await dbRead
.selectFrom('User as u') .selectFrom('User as u')
.select([ .select([
@@ -418,18 +449,9 @@ async function getIdentity(userId: number): Promise<UserIdentity | null> {
sql<number>`(SELECT COUNT(*)::int FROM "CsamReport" cr WHERE cr."userId" = u.id)`.as( sql<number>`(SELECT COUNT(*)::int FROM "CsamReport" cr WHERE cr."userId" = u.id)`.as(
'csamReportCount' 'csamReportCount'
), ),
sql<string | null>`( restrictionField<string | null>('ur.status::text').as('restrictionStatus'),
SELECT ur.status::text FROM "UserRestriction" ur restrictionField<string | null>('ur.type').as('restrictionType'),
WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1 restrictionField<number | null>('ur.id').as('restrictionId'),
)`.as('restrictionStatus'),
sql<string | null>`(
SELECT ur.type FROM "UserRestriction" ur
WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1
)`.as('restrictionType'),
sql<number | null>`(
SELECT ur.id FROM "UserRestriction" ur
WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1
)`.as('restrictionId'),
// The ticket asked for open reports against the account "very clearly at the top". A report // The ticket asked for open reports against the account "very clearly at the top". A report
// nobody has ruled on changes what every other panel means, and it was reachable only by // nobody has ruled on changes what every other panel means, and it was reachable only by
// navigating to the Reports section and reading a list. // navigating to the Reports section and reading a list.
@@ -11,6 +11,8 @@ export {
RESTRICTION_TYPE, RESTRICTION_TYPE,
RESTRICTION_TYPES, RESTRICTION_TYPES,
RESTRICTION_TYPE_LABELS, RESTRICTION_TYPE_LABELS,
RULINGS_WIRED_FOR,
unwiredRulingReason,
type RestrictionType, type RestrictionType,
} from '$lib/restriction-types'; } from '$lib/restriction-types';
@@ -10,6 +10,7 @@ import {
saveSuspiciousMatches, saveSuspiciousMatches,
RESTRICTION_TYPE, RESTRICTION_TYPE,
RESTRICTION_TYPES, RESTRICTION_TYPES,
unwiredRulingReason,
type RestrictionRow, type RestrictionRow,
} from '$lib/server/user-restriction.service'; } from '$lib/server/user-restriction.service';
@@ -65,8 +66,13 @@ export const load: PageServerLoad = async ({ url }) => {
// `type: 'any'` on purpose. A form posts to `?/resolve`, which REPLACES the query string — so an action // `type: 'any'` on purpose. A form posts to `?/resolve`, which REPLACES the query string — so an action
// never sees the `type` the moderator was looking at, and defaulting the lookup would 404 every row // never sees the `type` the moderator was looking at, and defaulting the lookup would 404 every row
// outside the default queue. The id is a primary key, so dropping the predicate cannot widen the // outside the default queue. The id is a primary key, so dropping the predicate cannot widen the
// result; what it changes is which types an action can be handed, and `unwiredRuling` below is what // result; what it changes is which types an action can be handed.
// governs that. //
// Each action decides that for itself, and they do NOT all decide the same way — so this helper deliberately
// makes no such decision. `resolve` and `ban` call `unwiredRuling` because they hand the row to a verdict
// path that is still generation-shaped. `flagSuspicious` does not, and should not: it copies selected
// triggers into the shared suspicious-match list, writes nothing to the account, and tells the user
// nothing. A prompt worth flagging is worth flagging whatever queue it was raised in.
async function restrictionById(id: number): Promise<RestrictionRow | null> { async function restrictionById(id: number): Promise<RestrictionRow | null> {
const { items } = await getGenerationRestrictions({ const { items } = await getGenerationRestrictions({
page: 1, page: 1,
@@ -88,13 +94,22 @@ async function restrictionById(id: number): Promise<RestrictionRow | null> {
* *
* This is a refusal rather than a hidden button because the check has to hold against a posted id, not * This is a refusal rather than a hidden button because the check has to hold against a posted id, not
* just against what the page chose to render. Lifting it means parameterising that verdict path first. * just against what the page chose to render. Lifting it means parameterising that verdict path first.
* (The buttons are disabled as well now see `RestrictionDetail.svelte`. That is an addition to this
* check, never a substitute for it.)
*
* 🔴 KEPT even though the refusal is now enforced by `resolveUserRestriction` itself which is what
* closes the surfaces this check could never reach: the retool User Lookup panel, the tRPC router and
* the REST endpoint were all unguarded while it lived only here. This is not defence in depth; it is
* ORDERING. The `ban` action bans and THEN rules, so a refusal arriving inside the verdict call would
* leave the account banned against a restriction nobody can close the stranded Pending row that
* handler exists to avoid. It also renders as an inline message rather than a failed API call.
*
* The predicate is IMPORTED, never re-spelled: `unwiredRulingReason` is one function in
* `$lib/restriction-types`, pinned to the main app's `RULINGS_WIRED_FOR` by the seam test. A second
* spelling here is exactly how the two would drift.
*/ */
const RULINGS_WIRED_FOR: readonly string[] = [RESTRICTION_TYPE];
function unwiredRuling(row: RestrictionRow): string | null { function unwiredRuling(row: RestrictionRow): string | null {
return RULINGS_WIRED_FOR.includes(row.type) return unwiredRulingReason(row.type);
? null
: `Rulings are not yet available for "${row.type}" restrictions — the verdict path still sends generation-specific notices. This restriction was NOT resolved.`;
} }
export const actions: Actions = { export const actions: Actions = {
@@ -9,6 +9,7 @@
import { LINK_CLASS, dateTime } from '$lib/format'; import { LINK_CLASS, dateTime } from '$lib/format';
import { userLookupUrl } from '$lib/entity-url'; import { userLookupUrl } from '$lib/entity-url';
import type { RestrictionRow } from '$lib/server/user-restriction.service'; import type { RestrictionRow } from '$lib/server/user-restriction.service';
import { unwiredRulingReason } from '$lib/restriction-types';
import UserWorkflowsPanel from '$lib/components/UserWorkflowsPanel.svelte'; import UserWorkflowsPanel from '$lib/components/UserWorkflowsPanel.svelte';
import TriggerCard from './TriggerCard.svelte'; import TriggerCard from './TriggerCard.svelte';
import StatusBadge from './StatusBadge.svelte'; import StatusBadge from './StatusBadge.svelte';
@@ -34,6 +35,13 @@
const selected = new SvelteSet<string>(); const selected = new SvelteSet<string>();
const toggle = (key: string) => (selected.has(key) ? selected.delete(key) : selected.add(key)); const toggle = (key: string) => (selected.has(key) ? selected.delete(key) : selected.add(key));
// The SERVER refuses these rows too, and that refusal is the guard — it holds against a posted id,
// which nothing rendered here can. This only stops the moderator being INVITED to click a button
// that can never do anything: an Uphold form on a bot-account row is a live control whose only
// possible outcome is a 400. Flagging triggers stays available, because it writes nothing to the
// account and is not a verdict.
const unwiredReason = $derived(unwiredRulingReason(restriction.type));
let banning = $state(false); let banning = $state(false);
// `onSubmit` picks the successor row while the current list still holds it — the reload that follows // `onSubmit` picks the successor row while the current list still holds it — the reload that follows
@@ -82,21 +90,28 @@
{/if} {/if}
{#if restriction.status === 'Pending'} {#if restriction.status === 'Pending'}
{#if unwiredReason}
<p class="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
{unwiredReason}
</p>
{/if}
<div class="mb-3 flex flex-wrap items-center gap-2"> <div class="mb-3 flex flex-wrap items-center gap-2">
<form method="POST" action="?/resolve" use:enhance={rule.enhance}> <form method="POST" action="?/resolve" use:enhance={rule.enhance}>
<input type="hidden" name="userRestrictionId" value={restriction.id} /> <input type="hidden" name="userRestrictionId" value={restriction.id} />
<input type="hidden" name="userId" value={restriction.userId} /> <input type="hidden" name="userId" value={restriction.userId} />
<input type="hidden" name="status" value="Upheld" /> <input type="hidden" name="status" value="Upheld" />
<Button type="submit" size="sm" variant="destructive" disabled={rule.submitting}>Uphold mute</Button> <Button type="submit" size="sm" variant="destructive" disabled={rule.submitting || !!unwiredReason}>Uphold mute</Button>
</form> </form>
<form method="POST" action="?/resolve" use:enhance={rule.enhance}> <form method="POST" action="?/resolve" use:enhance={rule.enhance}>
<input type="hidden" name="userRestrictionId" value={restriction.id} /> <input type="hidden" name="userRestrictionId" value={restriction.id} />
<input type="hidden" name="userId" value={restriction.userId} /> <input type="hidden" name="userId" value={restriction.userId} />
<input type="hidden" name="status" value="Overturned" /> <input type="hidden" name="status" value="Overturned" />
<Button type="submit" size="sm" disabled={rule.submitting}>Remove mute</Button> <Button type="submit" size="sm" disabled={rule.submitting || !!unwiredReason}>Remove mute</Button>
</form> </form>
{#if canBan} {#if canBan}
<Button size="sm" variant="outline" onclick={() => (banning = !banning)}>Ban user</Button> <!-- Disabled for the same reason, and it is the sharper case: this action bans and THEN rules,
so on a row that cannot be ruled on it would ban the account and strand the Pending row. -->
<Button size="sm" variant="outline" disabled={!!unwiredReason} onclick={() => (banning = !banning)}>Ban user</Button>
{/if} {/if}
{#if selected.size > 0} {#if selected.size > 0}
@@ -43,6 +43,9 @@ vi.mock('$lib/server/access', () => ({
})); }));
const { load, actions } = await import('../+page.server'); const { load, actions } = await import('../+page.server');
const { RESTRICTION_TYPES, RULINGS_WIRED_FOR, unwiredRulingReason } = await import(
'$lib/restriction-types'
);
type LoadResult = { type: string; items: unknown[] }; type LoadResult = { type: string; items: unknown[] };
const runLoad = (search = '') => const runLoad = (search = '') =>
@@ -203,6 +206,30 @@ describe('generator-restrictions actions — ruling scope', () => {
); );
}); });
/**
* An INVARIANT GUARD this passed before the route stopped spelling its own message, and is
* recorded as such rather than counted as regression coverage.
*
* What it pins is that the route READS the shared predicate rather than carrying a second copy. The
* refusal that actually protects the account now lives in the main app's `resolveUserRestriction`
* (this route posts through it), and the two are pinned to each other by the seam test but only if
* this end of the chain is the shared list and not a private one that happens to agree today.
*/
it.each(RESTRICTION_TYPES.filter((t) => !RULINGS_WIRED_FOR.includes(t)))(
'refuses %s with the shared predicates own words, not a second copy of them',
async (type) => {
getGenerationRestrictions.mockResolvedValue({ items: [row({ type })], totalCount: 1 });
const result = (await actions.resolve(
formEvent({ userRestrictionId: '5', status: 'Upheld' })
)) as { status: number; data: { error: string } };
expect(result.data.error).toBe(unwiredRulingReason(type));
// Non-vacuous: `toBe(null)` would also pass if the action had returned success.
expect(unwiredRulingReason(type)).not.toBeNull();
}
);
// A by-id lookup must not be filtered by the default type: a form posts to `?/resolve`, which // A by-id lookup must not be filtered by the default type: a form posts to `?/resolve`, which
// replaces the query string, so the action cannot know which queue the row came from. // replaces the query string, so the action cannot know which queue the row came from.
it('looks a restriction up across every type', async () => { it('looks a restriction up across every type', async () => {
@@ -10,6 +10,7 @@
import { fetchSupport } from './user-support'; import { fetchSupport } from './user-support';
import { REWARDS_ELIGIBILITY } from './enforcement-options'; import { REWARDS_ELIGIBILITY } from './enforcement-options';
import { FormState } from '$lib/form-state.svelte'; import { FormState } from '$lib/form-state.svelte';
import { unwiredRulingReason } from '$lib/restriction-types';
import ErrorAlert from '$lib/components/ErrorAlert.svelte'; import ErrorAlert from '$lib/components/ErrorAlert.svelte';
type Identity = NonNullable<LayoutData['result']>['identity']; type Identity = NonNullable<LayoutData['result']>['identity'];
@@ -42,6 +43,13 @@
const support = $derived(browser ? fetchSupport(identity.id, version) : null); const support = $derived(browser ? fetchSupport(identity.id, version) : null);
const mutesUrl = $derived(userLookupUrl(identity.id, 'mutes')); const mutesUrl = $derived(userLookupUrl(identity.id, 'mutes'));
// A restriction of a type with no verdict path cannot be ruled on ANYWHERE — `resolveUserRestriction`
// in the main app refuses it, which is what this panel posts through. Without this the panel offered
// Overturn/Uphold on such a row, and clicking Overturn would have sent a "your generation access has
// been restored" notice and reset the account's prompt-violation counter over an unrelated case.
// Falls back to `generation` for a null type, matching the label below.
const unwiredReason = $derived(unwiredRulingReason(identity.restrictionType ?? 'generation'));
// `reload: true` because these writes change `identity`, which DOES come from `load` — unlike the // `reload: true` because these writes change `identity`, which DOES come from `load` — unlike the
// panels fed by `/api/*`, where reloading re-runs the reaction scan for nothing. // panels fed by `/api/*`, where reloading re-runs the reaction scan for nothing.
// //
@@ -87,6 +95,14 @@
A <strong>{identity.restrictionType ?? 'generation'}</strong> restriction on this account is A <strong>{identity.restrictionType ?? 'generation'}</strong> restriction on this account is
awaiting a ruling. Unmuting alone leaves it Pending — rule on it here instead. awaiting a ruling. Unmuting alone leaves it Pending — rule on it here instead.
</p> </p>
{#if unwiredReason}
<p class="mb-2 text-sm text-amber-200">
{unwiredReason} Review it in the
<a href="/audit/generator-restrictions?type={identity.restrictionType}" class={LINK_CLASS}>
restriction queue
</a>.
</p>
{/if}
<form method="POST" action="?/resolveRestriction" use:enhance={form.enhance} class="grid gap-2"> <form method="POST" action="?/resolveRestriction" use:enhance={form.enhance} class="grid gap-2">
<input type="hidden" name="userRestrictionId" value={identity.restrictionId} /> <input type="hidden" name="userRestrictionId" value={identity.restrictionId} />
<input type="hidden" name="userId" value={identity.id} /> <input type="hidden" name="userId" value={identity.id} />
@@ -97,7 +113,13 @@
/> />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<!-- One field, two submits: a submit button contributes a single name/value pair. --> <!-- One field, two submits: a submit button contributes a single name/value pair. -->
<Button type="submit" name="status" value="Overturned" size="sm" disabled={form.submitting}> <Button
type="submit"
name="status"
value="Overturned"
size="sm"
disabled={form.submitting || !!unwiredReason}
>
Overturn — lift it Overturn — lift it
</Button> </Button>
<Button <Button
@@ -106,7 +128,7 @@
value="Upheld" value="Upheld"
size="sm" size="sm"
variant="destructive" variant="destructive"
disabled={form.submitting} disabled={form.submitting || !!unwiredReason}
> >
Uphold — keep them muted Uphold — keep them muted
</Button> </Button>
+6 -2
View File
@@ -32,11 +32,15 @@ import {
* cannot see it: `where('ur.type','=',x)` compiles to `"ur"."type" = $1` for every `x`, so a mutant that * cannot see it: `where('ur.type','=',x)` compiles to `"ur"."type" = $1` for every `x`, so a mutant that
* ignores its argument and hardcodes a literal emits byte-identical SQL and survives an assertion over * ignores its argument and hardcodes a literal emits byte-identical SQL and survives an assertion over
* `sql`. `params[i]` holds the parameters of `sql[i]`. * `sql`. `params[i]` holds the parameters of `sql[i]`.
*
* `params` is a MUTABLE `unknown[][]` and says so. Being written is the parameter's whole purpose, and
* a `readonly` annotation that has to be cast away at the one place it is used is a contract the code
* contradicts it reads as a promise to callers that this function does not touch their array.
*/ */
export function capturingDb( export function capturingDb(
sql: string[], sql: string[],
rows: unknown[] = [], rows: unknown[] = [],
params?: readonly unknown[][] params?: unknown[][]
): Kysely<never> { ): Kysely<never> {
class CannedRowDriver extends DummyDriver { class CannedRowDriver extends DummyDriver {
async acquireConnection(): Promise<DatabaseConnection> { async acquireConnection(): Promise<DatabaseConnection> {
@@ -62,7 +66,7 @@ export function capturingDb(
if (e.level !== 'query') return; if (e.level !== 'query') return;
sql.push(e.query.sql); sql.push(e.query.sql);
// Pushed in the same branch so the two arrays stay index-aligned by construction. // Pushed in the same branch so the two arrays stay index-aligned by construction.
(params as unknown[][] | undefined)?.push([...e.query.parameters]); params?.push([...e.query.parameters]);
}, },
}); });
} }