|
================================================================================
|
|
GATE 5 — QUALITY & SECURITY AUDIT (Next.js/Tailwind standalone)
|
|
Ticket : #3248 — OMS UI/UX and functional
|
|
Branch : feat/3248-oms-ui-ux-and-functional
|
|
Commits : 5214e01 (Data Layer) .. e8fb724 (Dead Code) [4 commits]
|
|
Node : W14_gate_quality_audit
|
|
Result : PASS (0 unresolved P1 IN THIS TICKET'S AUDITED SCOPE)
|
|
^^ read section 5 before trusting this word — 2 P1s exist in this
|
|
repo right now, both OUTSIDE this ticket's changed files.
|
|
Counts : P1=2 (both out-of-scope) P2=11 (7 in-scope, 4 out) P3=12
|
|
(+ 2 clean-audit records, 1 positives record)
|
|
================================================================================
|
|
|
|
--------------------------------------------------------------------------------
|
|
0. SKILL RESOLUTION — READ THIS FIRST
|
|
--------------------------------------------------------------------------------
|
|
The gate's §"Skill resolution" defines three paths per skill. ALL THREE FAILED
|
|
FOR ALL SEVEN SKILLS in this checkout — identical to the #3246 run, re-verified
|
|
rather than assumed:
|
|
|
|
path 1 /nextjs-tailwind-<name> -> no such slash command in this session
|
|
path 2 /<name> (repo's own) -> no such slash command in this session
|
|
path 3 read_file(".claude/skills/nextjs-tailwind-<name>/SKILL.md")
|
|
$ ls /opt/drcarrot-deploy/cross-repo-orchestration/oms-web/.claude
|
|
-> No such file or directory (oms-web has NO .claude/ at all)
|
|
$ find /opt/drcarrot-deploy -type d -name "nextjs-tailwind-*"
|
|
-> gates/nextjs-tailwind-standalone (this gate file's own dir)
|
|
-> nothing else
|
|
$ find /opt/drcarrot-deploy -type d -name skills -not -path "*/node_modules/*"
|
|
-> cross-repo-orchestration/dr.carrot-v3-react/.claude/skills
|
|
-> cross-repo-orchestration/emr_v1_l12/.claude/skills
|
|
-> /opt/drcarrot-deploy/.claude/skills
|
|
none of the three contains ANY nextjs-tailwind-* audit skill
|
|
(listed in full; the closest is dr.carrot-v3-react's generic
|
|
`security-audit`, which is a different stack's skill)
|
|
|
|
Per the gate's resolution rule 4 ("say so in your report ... do not skip it
|
|
silently"), this is recorded rather than papered over. Each of the seven audit
|
|
DIMENSIONS was then executed directly against the real files, using the
|
|
dimension list the gate file itself enumerates in §"Skills invoked". That is
|
|
NOT equivalent to running the skills' own tuned checklists — a finding those
|
|
SKILL.md files would have caught and this pass did not is a plausible gap.
|
|
Treat the coverage below as gate-file-scoped, not skill-scoped.
|
|
|
|
--------------------------------------------------------------------------------
|
|
1. REAL COMMANDS RUN (actual output, not predicted)
|
|
--------------------------------------------------------------------------------
|
|
$ npx tsc --noEmit -p tsconfig.json
|
|
-> exit 0, zero diagnostics. tsconfig has "strict": true.
|
|
|
|
$ npx next lint --max-warnings=0
|
|
-> "✔ No ESLint warnings or errors"
|
|
|
|
$ npx vitest run --reporter=basic
|
|
-> Test Files 86 passed (86)
|
|
Tests 630 passed (630)
|
|
Duration 226.22s
|
|
(no failures, no stderr warnings)
|
|
|
|
$ npx next build
|
|
-> ✓ Compiled successfully; 23/23 static pages generated.
|
|
First Load JS shared by all 87.3 kB
|
|
Middleware 30.5 kB
|
|
Routes relevant to the findings below:
|
|
/expense-summary 1.55 kB 112 kB First Load
|
|
/expense-summary/[project] 1.95 kB 112 kB
|
|
/expenses 2.29 kB 112 kB
|
|
/orders 2.29 kB 112 kB
|
|
/orders/[id] 3.92 kB 91.2 kB
|
|
/admin/items 538 B 114 kB
|
|
/admin/items/[id] 2.39 kB 93.5 kB
|
|
/admin/users 538 B 114 kB
|
|
/admin/users/[id] 137 B 91.3 kB
|
|
|
|
$ ls -la .next/static/css/ ; gzip -c .next/static/css/*.css | wc -c
|
|
-> one stylesheet, 26,836 B raw / 5,619 B gzipped, whole app
|
|
|
|
SCOPE NOTE. `feature_files` (56 entries) is the audit input. This ticket's real
|
|
diff (5214e01^..e8fb724, 94 files) also touches data-layer files that are NOT in
|
|
feature_files but ARE this ticket's own code: lib/actions/uom.ts,
|
|
lib/queries/masters.ts, lib/quick-create/registry.ts, lib/validation/schemas.ts,
|
|
prisma/schema.prisma + migration, vitest.config.ts. Because the gate calls the
|
|
security audit "first-party, not a second-hand contract check" for this repo,
|
|
those were audited too and are tagged `diff-scope`. Findings in files touched by
|
|
NEITHER list are tagged `out-of-scope` and are explicitly excluded from
|
|
gate_status — see section 5.
|
|
|
|
--------------------------------------------------------------------------------
|
|
2. MERGED FINDINGS
|
|
--------------------------------------------------------------------------------
|
|
|
|
### [security] SEC-1 — P1 — OUT-OF-SCOPE (pre-existing)
|
|
file: app/api/uploads/route.ts:96-101 (POST)
|
|
scope: `git log --oneline 5214e01^..e8fb724 -- app/api/uploads/route.ts` -> 0
|
|
commits. The two call sites (ExpenseForm.handleUpload,
|
|
OrderFulfillmentForm.handleUpload) ARE in feature_files but their upload
|
|
code paths are untouched by this ticket's diff.
|
|
problem:
|
|
Missing OBJECT-LEVEL authorization before a database write. The route checks
|
|
`checkPermission("attachment:upload")` — a coarse "may this user upload at
|
|
all" — then creates an Attachment row using the caller-supplied `orderId` /
|
|
`expenseItemId` verbatim. Nothing verifies the caller may write to THAT order
|
|
or THAT expense item. Any holder of `attachment:upload` can attach an
|
|
arbitrary file to any order or any expense item id they can guess or observe.
|
|
This is precisely the case the gate calls out: "missing authorization checks
|
|
before a database write".
|
|
fixed: NO — the gate's Step 3 forbids auto-fixing an authorization gap
|
|
("never guess at the intended permission"). Needs the intended policy: is it
|
|
order:update? expense:update? ownership-scoped? That is a product decision.
|
|
|
|
### [security] SEC-2 — P1 — OUT-OF-SCOPE (carried forward from the #3246 gate, STILL OPEN)
|
|
file: lib/actions/users.ts:55-75 (updateUser)
|
|
scope: not in feature_files; 0 commits in this ticket's diff.
|
|
problem:
|
|
docs/gate-evidence/quality-audit-3246-user-management.txt raised this as its
|
|
single blocking P1 ("updateUser has NO last-active-Super-Admin guard"). It is
|
|
still unresolved. #3247 hardened the NEIGHBOURING paths but not this one:
|
|
- deleteUser (users.ts:89-92) gained an absolute "Super Admin accounts
|
|
cannot be deleted" check on the TARGET's current role.
|
|
- updateUser gained only `assertNotSuperAdminRole(data.roleId)` — which
|
|
inspects the INCOMING roleId, i.e. it blocks PROMOTING someone
|
|
to Super Admin. It does not look at the target's CURRENT role
|
|
and does not look at `isActive` at all.
|
|
So both bricking paths the #3246 evidence documented remain open: submitting a
|
|
normal roleId for a user who currently holds the Super Admin role demotes
|
|
them, and submitting isActive=false deactivates them. Verified: `sed -n
|
|
'55,76p' lib/actions/users.ts | grep "isSuperAdmin\|isActive\|target"` returns
|
|
nothing, and there is no DB-level protection either (`grep -rin superadmin
|
|
prisma/schema.prisma prisma/migrations/` finds only the plain
|
|
`isSuperAdmin BOOLEAN NOT NULL DEFAULT false` column, no CHECK, no trigger).
|
|
Why it bricks the app is unchanged — see the #3246 file, section SEC-1.
|
|
fixed: NO — authorization/policy rule, never auto-fixed.
|
|
|
|
### [code-quality] CQ-1 — P2 — diff-scope
|
|
file: vitest.config.ts:14-96 (coverage.include)
|
|
problem:
|
|
`coverage.include` is a hand-maintained allowlist of ~80 individual file paths
|
|
with hand-escaped bracket regexes. Anything absent from it is silently
|
|
excluded from the coverage report — coverage reads "high" while untracked
|
|
modules contribute nothing. Verified absent:
|
|
lib/actions/uom.ts <- CHANGED this ticket (quick-create wiring)
|
|
lib/quick-create/registry.ts<- CHANGED this ticket (uom entry added)
|
|
lib/actions/orders.ts lib/actions/expenses.ts
|
|
app/api/uploads/route.ts middleware.ts
|
|
(`grep -c` for all six against vitest.config.ts -> 0)
|
|
Concrete consequence: this ticket ADDED tests/actions-uom.test.ts (177 lines)
|
|
and extended tests/quick-create-registry.test.ts, and neither moves the
|
|
reported coverage number by a single line, because their subjects aren't in
|
|
the include list. The repo's own CLAUDE.md rule — "when a script can drop
|
|
coverage ... make it log/warning that, so silent truncation never reads as
|
|
'everything succeeded'" — is the same principle.
|
|
fixed: NO — changing the include list changes the reported coverage number and
|
|
could fail a downstream coverage gate; that is an observable-behaviour change,
|
|
not a safe auto-fix.
|
|
|
|
### [code-quality] CQ-2 — P2 — in-scope
|
|
file: components/forms/OrderFulfillmentForm.tsx:59-67 (handleCancel)
|
|
problem:
|
|
`cancelOrder` returns `ActionState` (`{ok:false, message}` on failure — see
|
|
lib/actions/orders.ts:79-85), and handleCancel discards the return value
|
|
entirely, then calls `router.refresh()` unconditionally. A failed cancel is
|
|
indistinguishable from a successful one: no message, no error state, the row
|
|
just re-renders unchanged. Every other action surface in this ticket wires
|
|
failures through (`state?.message` -> `.field-error`); this one does not.
|
|
Secondary: "Cancel Order" is destructive and fires immediately on click, while
|
|
every row-level delete in the same ticket routes through DeleteConfirmModal
|
|
(components/ui/RowActions.tsx:51-58). Inconsistent destructive-action UX.
|
|
fixed: NO — adding error surfacing/confirmation changes observable behaviour.
|
|
|
|
### [dry-architecture] DRY-1 — P2 — in-scope
|
|
files: 9 list pages, all in feature_files —
|
|
app/admin/{items,users,projects,suppliers,uom}/page.tsx
|
|
app/{orders,expenses}/page.tsx
|
|
app/expense-summary/page.tsx, app/expense-summary/[project]/page.tsx
|
|
problem:
|
|
The identical `buildHref` closure is pasted into all nine (18 grep hits for
|
|
`buildHref`, 9 for `const buildHref`):
|
|
const buildHref = (extra) => {
|
|
const p = new URLSearchParams();
|
|
for (const [k, v] of Object.entries({ ...params, ...extra }))
|
|
if (v !== undefined && v !== "") p.set(k, String(v));
|
|
return `<route>?${p.toString()}`;
|
|
};
|
|
Only the route literal differs. lib/params.ts is already the shared home for
|
|
exactly this concern (it owns parseListParams/skipTake/orderBy) — a
|
|
`buildListHref(pathname, params)` there would delete nine copies.
|
|
fixed: NO — mechanical, but nine call sites in a file set this gate is auditing
|
|
rather than authoring; left as a reported finding.
|
|
|
|
### [dry-architecture] DRY-2 — P2 — in-scope
|
|
files: components/forms/{Supplier,Project,Uom,Item}Form.tsx (all in feature_files)
|
|
problem:
|
|
The four quick-create-capable forms carry a byte-identical embedded scaffold:
|
|
- `const initialState: ActionState = { ok: true };` 8 copies repo-wide
|
|
- the onSuccess effect, character-for-character identical in all 4:
|
|
`if (embedded && state.ok && state.data && onSuccess) onSuccess(state.data);`
|
|
each preceded by its own `// eslint-disable-next-line react-hooks/exhaustive-deps`
|
|
- `const fieldWidth = embedded ? "w-full" : "w-full sm:w-[350px]";`
|
|
- `{embedded && <input type="hidden" name="_quickCreate" value="1" />}`
|
|
- the `{!embedded && <h2 …>X Details</h2>}` heading
|
|
- the Cancel/Submit footer, differing only in label and redirect path
|
|
A `useEmbeddedForm({embedded, state, onSuccess})` hook plus an
|
|
`<EmbeddedFormShell>` would collapse this to one definition. The cost is
|
|
already visible: the eslint suppression is duplicated 4× rather than justified
|
|
once (see CQ-7).
|
|
fixed: NO — a shared hook + shell touches four live form components' render
|
|
output; too broad for an unreviewed auto-fix.
|
|
|
|
### [dry-architecture] DRY-3 — P2 — in-scope
|
|
files: components/forms/ExpenseForm.tsx:36-44 (in feature_files)
|
|
app/expense-summary/[project]/page.tsx:11-19 (in feature_files)
|
|
lib/validation/schemas.ts:134-142 (diff-scope)
|
|
problem:
|
|
The expense-type list exists in THREE places: twice as an identical
|
|
value/label option array, and a third time as the Zod `z.enum([...])` that
|
|
actually validates it. Adding a type means editing three files; missing one
|
|
yields either a type the UI offers but the server rejects, or a stored type
|
|
the summary screen renders as a raw enum string
|
|
(`EXPENSE_TYPE_OPTIONS.find(...)?.label ?? r.type` — the fallback silently
|
|
papers over exactly that drift).
|
|
fixed: NO — the schema copy is the validation source of truth; deriving the
|
|
other two from it is the right fix but changes what the Zod enum exports.
|
|
|
|
### [performance] PERF-1 — P2 — in-scope
|
|
files: components/ui/ListToolbar.tsx:2, components/ui/FilterBar.tsx:7-8
|
|
(both in feature_files)
|
|
problem, with measured evidence:
|
|
ListToolbar statically imports FilterBar; FilterBar statically imports
|
|
Listbox and DatePicker (a hand-rolled ~270-line calendar). ListToolbar only
|
|
RENDERS FilterBar when `filters && filters.length > 0` — but a runtime
|
|
conditional does not prune a module-scope client import from the route's
|
|
client bundle.
|
|
Measured against the build above:
|
|
app baseline (First Load JS shared by all) 87.3 kB
|
|
/expense-summary 112 kB <- passes NO filters
|
|
/admin/items (passes filters) 114 kB
|
|
/admin/items/[id] (no ListToolbar) 93.5 kB
|
|
`/expense-summary` renders `<ListToolbar search={...} />` with no `filters`
|
|
prop (app/expense-summary/page.tsx:45) and can never display a filter field,
|
|
yet ships ~25 kB of First Load JS over baseline — within 2 kB of the fully
|
|
filtered list pages. `next/dynamic` on FilterBar (or on DatePicker inside it)
|
|
would let the routes that never filter stop paying for the calendar.
|
|
fixed: NO — converting to a dynamic import changes hydration/loading behaviour
|
|
on every list screen; wants a ui_verify pass behind it.
|
|
|
|
### [performance] PERF-2 — P2 — in-scope
|
|
files: app/orders/[id]/page.tsx:7-14 (in feature_files)
|
|
app/expense-summary/[project]/page.tsx:21-33 (in feature_files)
|
|
problem:
|
|
Both routes fetch the same record twice per render — once in
|
|
`generateMetadata`, once in the page body:
|
|
orders/[id] getOrder(params.id) at :8 and :14
|
|
(getOrder includes items -> item, uom, attachments)
|
|
expense-summary/[p] prisma.project.findUnique at :22 and :33
|
|
Next.js dedupes `fetch()` across metadata and render, but NOT Prisma calls.
|
|
Neither `getOrder` nor the inline findUnique is wrapped in React `cache()`.
|
|
This is not a pattern the codebase is unaware of — lib/queries/rbac.ts:38
|
|
wraps `getUserWithPermissions` in `cache()` precisely for this reason, and
|
|
documents it. These two are the only `generateMetadata` implementations in
|
|
the app (`grep -rln generateMetadata app/` -> exactly these 2 files), so the
|
|
fix is two `cache()` wrappers.
|
|
fixed: NO — trivially correct, but it changes request-scoped caching semantics
|
|
for a query used on a live route; reported for review instead.
|
|
|
|
### [performance] PERF-3 — P2 — OUT-OF-SCOPE (pre-existing)
|
|
file: lib/queries/expenses.ts:49-80 (expenseSummaryByProject)
|
|
consumed by app/expense-summary/page.tsx:18 (in feature_files)
|
|
problem:
|
|
Unbounded full-table read on every page view:
|
|
- `prisma.expenseItem.groupBy({ by:["expenseId"], _sum:{amount:true} })`
|
|
with NO `where` — every ExpenseItem row in the database.
|
|
- `prisma.expense.findMany({ select:{...} })` with NO `where`, NO `take` —
|
|
every Expense row.
|
|
- the project rollup, the `params.q` search and the sort then all run in JS
|
|
over the full set.
|
|
The consuming page destructures only `{ rows }` and renders no `<Pagination>`,
|
|
so the whole table is also rendered. Cost grows linearly with total expense
|
|
volume forever. Compare the sibling `expenseItemsForProject` (same file,
|
|
:84-113) which does it correctly — filtered `where`, `skipTake`, and a SQL
|
|
`aggregate` for the total. A `groupBy(["projectId"])` on Expense joined to a
|
|
SQL sum would fix it.
|
|
scope note: surfaced through an in-scope file but the query itself is untouched
|
|
by this ticket (`app/expense-summary/page.tsx` is +2 lines: the ListToolbar
|
|
swap). Not counted against gate_status.
|
|
fixed: NO — rewriting an aggregation query is a behaviour change needing data
|
|
verification.
|
|
|
|
### [security] SEC-3 — P2 — OUT-OF-SCOPE (pre-existing)
|
|
files: app/api/uploads/route.ts:73-82, lib/permissions/registry.ts:104-125
|
|
problem:
|
|
Uploaded files are written to `public/uploads/` and served as static assets.
|
|
`ROUTE_PERMISSIONS` has an entry for `/api/uploads` (the write path) but NONE
|
|
for `/uploads/...` (the read path). middleware.ts's matcher does cover
|
|
`/uploads/*`, so a session is required — but `resolveRoutePermission()`
|
|
returns null, so NO permission is required. Any authenticated user of any role
|
|
can fetch any invoice/receipt by URL, regardless of their order/expense
|
|
permissions. Filenames are `Date.now()-<sanitised original>`, which is
|
|
guessable-adjacent, and URLs leak through the rendered AttachmentChip hrefs.
|
|
Related deployment note: writing to `public/` at runtime is not durable under
|
|
a containerised/standalone or multi-replica deploy — `public/` is captured at
|
|
build time.
|
|
fixed: NO — authorization policy.
|
|
|
|
### [security] SEC-4 — P2 — OUT-OF-SCOPE (pre-existing)
|
|
files: lib/actions/orders.ts:12-16 + :48-49, lib/actions/expenses.ts:11-15
|
|
problem:
|
|
`JSON.parse((raw.itemsJson as string) || "[]")` and the `linesJson`
|
|
equivalent run BEFORE Zod and are unguarded. Malformed JSON throws inside the
|
|
Server Action before `safeParse` ever sees it, so the caller gets an
|
|
unhandled-exception 500 instead of the `{ok:false, fieldErrors}` contract
|
|
every other failure path returns. The inputs are hidden form fields
|
|
(OrderForm.tsx:110, ExpenseForm.tsx:197, OrderFulfillmentForm.tsx:126) — i.e.
|
|
fully attacker-controlled on a public Server Action entry point. Not an
|
|
injection (Prisma is parameterised throughout — see the positives record), but
|
|
a trivially-reachable unhandled error path.
|
|
fixed: NO — wrapping in try/catch changes the response shape on a live path.
|
|
|
|
### [security] SEC-5 — P2 — OUT-OF-SCOPE (pre-existing)
|
|
file: lib/actions/orders.ts:79-85 (cancelOrder)
|
|
problem:
|
|
No status precondition and no existence check — it goes straight to
|
|
`prisma.order.update({ data:{ status: CANCELLED } })`. A FULFILLED order can
|
|
be cancelled. The client hides the button for CANCELLED orders only
|
|
(`readOnly = order.status === "CANCELLED"`, OrderFulfillmentForm.tsx:57), and
|
|
a Server Action is a public entry point regardless of what the form renders —
|
|
the same reasoning lib/actions/users.ts:27-32 already spells out for the
|
|
Super Admin role. The state machine is enforced nowhere.
|
|
fixed: NO — the legal transition set is a product decision.
|
|
|
|
### [code-quality] CQ-3 — P3 — in-scope
|
|
files: components/forms/ExpenseForm.tsx:87-111,
|
|
components/forms/OrderFulfillmentForm.tsx:73-94
|
|
problem:
|
|
`handleUpload` wraps `fetch` in `try { … } finally { … }` with no `catch`, and
|
|
is invoked un-awaited from an onChange handler
|
|
(`if (file) handleUpload(index, file);`). On a network failure the promise
|
|
rejects with nobody attached: an unhandled rejection, `uploadError` never set,
|
|
and the spinner state does reset — so the UI silently returns to "Upload" as
|
|
though nothing happened. The non-2xx path IS handled; only the thrown path
|
|
is not.
|
|
|
|
### [code-quality] CQ-4 — P3 — in-scope
|
|
file: app/expense-summary/[project]/page.tsx:39-61, 77-82
|
|
problem:
|
|
`buildHref` is defined and passed to DataTable as `buildSortHref`, but not one
|
|
of the four columns sets `sortable: true`. DataTable only calls buildSortHref
|
|
inside `col.sortable ? … : …` (DataTable.tsx:55-65), so the closure is
|
|
constructed on every render and never invoked. Either dead code, or a missing
|
|
`sortable` flag — the neighbouring app/expense-summary/page.tsx DOES mark its
|
|
projectName column sortable, which suggests the latter. Flagging rather than
|
|
guessing which. (The W13 dead-code gate did not catch this: the symbol IS
|
|
referenced, just never reachable.)
|
|
|
|
### [code-quality] CQ-5 — P3 — in-scope
|
|
files: components/ui/fields/TextField.tsx:34-36+48-56,
|
|
components/ui/fields/SelectField.tsx:40-43 (both in feature_files)
|
|
problem:
|
|
Both accept `required` and use it ONLY to append " *" to the visible label.
|
|
Neither sets the `required` attribute nor `aria-required` on the control. So
|
|
the asterisk is decorative: no native browser validation, and a screen reader
|
|
gets no required semantics — it hears the literal "*" in the label if
|
|
anything. Validation is genuinely server-side (Zod -> fieldErrors), which is
|
|
correct as the boundary, but the accessibility signal is simply missing.
|
|
Affects every field on every master/user form in this ticket.
|
|
|
|
### [code-quality] CQ-6 — P3 — in-scope
|
|
files: the 9 list pages of DRY-1, vs components/ui/FilterBar.tsx:31-44
|
|
problem:
|
|
Inconsistent pagination reset. `buildHref` spreads `...params`, which
|
|
parseListParams populates with the CURRENT `page` (lib/params.ts:34,41-50), so
|
|
a sort link built on page 3 emits `page=3` — re-sorting keeps you on page 3 of
|
|
a completely different ordering. FilterBar, by contrast, explicitly calls
|
|
`params.set("page", "1")` on both `setParam` and `clearAll`. Same list screen,
|
|
two different rules for what a control does to the current page.
|
|
|
|
### [code-quality] CQ-7 — P3 — in-scope
|
|
files: components/forms/{Supplier,Project,Uom,Item}Form.tsx
|
|
problem:
|
|
4 identical `// eslint-disable-next-line react-hooks/exhaustive-deps`
|
|
suppressions on the same `[state]` effect. Only SupplierForm.tsx:46 actually
|
|
explains why ("onSuccess/embedded are stable per mount"); the other three
|
|
copy the directive without the justification. A suppression duplicated four
|
|
times is a signal the pattern wants extracting (see DRY-2), and three of the
|
|
four now read as unexplained.
|
|
|
|
### [dry-architecture] DRY-4 — P3 — in-scope
|
|
files: app/admin/{items,users,projects,suppliers,uom}/page.tsx
|
|
problem:
|
|
The Active/Inactive status filter descriptor is re-declared inline in each
|
|
admin list page — identical `{name:"status", label:"Status", type:"select",
|
|
options:[{value:"active",…},{value:"inactive",…}]}`. A shared
|
|
`STATUS_FILTER_FIELD` constant would do. (3 verbatim hits for the option pair;
|
|
the other two differ only in surrounding whitespace.)
|
|
|
|
### [css-dry] CSSDRY-1 — P3 — in-scope
|
|
files: components/forms/{Supplier,Project,Uom,Item,User}Form.tsx
|
|
problem:
|
|
`className="flex flex-wrap gap-x-6 gap-y-4 max-w-[772px]"` — the de-carded
|
|
field-grid — repeated verbatim 5×, including the arbitrary `max-w-[772px]`.
|
|
app/globals.css already hosts exactly this kind of shared form primitive
|
|
(`.field-input`, `.field-label`, `.field-error`, `.field-control`); a
|
|
`.field-grid` there would be consistent with the file's own established
|
|
pattern and put the 772px design constant in one place.
|
|
|
|
### [css-dry] CSSDRY-2 — P3 — in-scope
|
|
files: the 5 forms above (6 occurrences of the literal)
|
|
problem:
|
|
`w-full sm:w-[350px]` is hardcoded 6× — 5 as the `fieldWidth` ternary's
|
|
non-embedded branch, once as UserForm.tsx:56's unconditional `const
|
|
fieldWidth`. Second undocumented design constant duplicated across files
|
|
(see CSSDRY-1). Both TextField and SelectField document this prop as
|
|
"Ticket #3248 de-carded form layout", so the value is load-bearing, not
|
|
incidental.
|
|
|
|
### [css-dry] CSSDRY-3 — P3 — in-scope
|
|
files: 11 occurrences across components/forms/* and components/ui/*
|
|
problem:
|
|
`text-sm font-semibold text-accent` — the de-carded section heading — repeated
|
|
11×. Same remedy as CSSDRY-1: a `.section-title` in the `@layer components`
|
|
block that already exists.
|
|
|
|
### [css-dry] CSSDRY-4 — P3 — in-scope
|
|
files: components/ui/RowActions.tsx:33,37,44 (3×);
|
|
app/expenses/ExpenseRowActions.tsx:13; app/orders/OrderRowActions.tsx:12
|
|
problem:
|
|
The icon-action hit-target cluster `p-1.5 -m-1.5 text-<token>` is repeated 6×
|
|
across three components that this ticket's own doc comments describe as
|
|
deliberately mirroring each other ("mirrors the 5 admin masters'
|
|
*RowActions.tsx icon-button pattern"). The negative-margin/padding trick that
|
|
makes the touch target work is the fragile part, and it is copy-pasted rather
|
|
than named.
|
|
|
|
### [css-performance] CSSPERF-1 — P3 — in-scope
|
|
file: tailwind.config.ts:59-60 (in feature_files)
|
|
problem:
|
|
Two design tokens are defined and referenced nowhere:
|
|
accent.selected "#e2cdb0" ("selected tab-pill segment")
|
|
accent.subtleHover "#f0e8dc" ("secondary/Cancel button hover")
|
|
Verified: `grep -rn "accent-selected\|accent-subtleHover"` across all
|
|
.ts/.tsx/.css outside node_modules/.next -> NO REFERENCES (only the config
|
|
definitions themselves). Confirmed zero runtime cost — `grep -c
|
|
"e2cdb0\|#f0e8dc\|226,205,176\|240,232,220" .next/static/css/*.css` -> 0,
|
|
i.e. Tailwind's JIT correctly never emitted them. So this is dead
|
|
configuration, not CSS bloat — hence P3, not P2. Both were introduced by
|
|
#3247 for design elements that apparently never got built.
|
|
(The W13 dead-code gate would not catch these: they are config values, not
|
|
imported symbols.)
|
|
|
|
--------------------------------------------------------------------------------
|
|
3. CLEAN-AUDIT RECORDS (audits that ran and found nothing to report)
|
|
--------------------------------------------------------------------------------
|
|
|
|
### [animation-performance] — CLEAN
|
|
Exhaustive grep for `transition|animate-|duration-|@keyframes` across
|
|
app/globals.css + components/ui + components/forms + app/{admin,expenses,orders}
|
|
returns exactly three animated things in the whole audited surface:
|
|
app/globals.css:13 `.btn { … transition-colors … }`
|
|
components/ui/fields/Listbox.tsx:178 `transition-transform` + `rotate-180`
|
|
components/ui/fields/DatePicker.tsx:270 `transition-colors` on day cells
|
|
`transition-colors` animates paint-only properties; `transition-transform` with
|
|
`rotate-180` is compositor-only. There are no width/height/top/left/margin
|
|
transitions, no `@keyframes`, no JS-driven animation loops, no
|
|
scroll/resize-driven style writes, and therefore no layout-thrash path. Nothing
|
|
to report — this is not an unrun audit.
|
|
|
|
### [css-performance] — CLEAN apart from CSSPERF-1
|
|
Whole-app stylesheet: 26,836 B raw / 5,619 B gzipped, one file, no
|
|
render-blocking imports beyond it. No `!important` anywhere; no ID selectors;
|
|
no descendant-selector chains — all styling is JIT-generated utilities plus 9
|
|
hand-written classes in a single `@layer components` block, which is the
|
|
correct Tailwind layering (they compile into the components layer, so utilities
|
|
still win without specificity hacks). 20 arbitrary-value utilities (`[NNNpx]`)
|
|
across the audited .tsx files; each generates one rule, so they cost bytes but
|
|
create no specificity or unused-CSS problem. No unused-CSS finding is possible
|
|
by construction here — Tailwind emits only what `content:` matches, and
|
|
CSSPERF-1 confirms that mechanism working correctly.
|
|
|
|
--------------------------------------------------------------------------------
|
|
4. POSITIVES — verified, not assumed (security audit, in-scope)
|
|
--------------------------------------------------------------------------------
|
|
These were actively checked because the gate calls this repo's security audit
|
|
first-party. Each is a real command, not an assumption:
|
|
|
|
* NO first-party SQL-injection surface.
|
|
`grep -rn '\$queryRaw|\$executeRaw|queryRawUnsafe' lib app` -> none.
|
|
Every database access in the repo goes through Prisma's parameterised
|
|
client. The gate's "including raw SQL escapes if this repo ever drops to
|
|
raw queries" case does not arise — it has not dropped to raw queries.
|
|
* NO markup-injection surface.
|
|
`grep -rn 'dangerouslySetInnerHTML|eval\(|new Function' app components lib`
|
|
-> none.
|
|
* NO secret leakage to the client.
|
|
Every `"use client"` module cross-checked for `process.env` -> none. The
|
|
only `process.env` reads are AUTH_SECRET in lib/auth.ts:89 and
|
|
lib/actions/auth.ts:21, both server-only, both fail-closed with an explicit
|
|
error rather than a silent fallback. No NEXT_PUBLIC_* anywhere.
|
|
* Every mutating Server Action in scope opens with `requirePermission(...)`
|
|
before touching Prisma — verified line-by-line in items.ts, uom.ts,
|
|
users.ts, orders.ts, expenses.ts.
|
|
* app/api/lookups/route.ts (CHANGED this ticket) is correctly gated: it
|
|
validates `entity` against the registry keys BEFORE use (so the
|
|
`QUICK_CREATE_REGISTRY[entity]` index cannot be steered to an arbitrary
|
|
property), then requires the matching per-entity `*:read` permission and
|
|
returns 401 otherwise. The permission is looked up from a closed
|
|
`READ_PERMISSION` record, not derived from user input.
|
|
* The ticket's headline new capability — UOM quick-create — inherits
|
|
authorization rather than re-implementing it: QUICK_CREATE_REGISTRY's `uom`
|
|
entry points at the existing `createUom`, which already calls
|
|
`requirePermission("uom:create")`. The registry entry adds no new
|
|
unauthenticated path. ItemForm additionally refuses to offer the trigger
|
|
while embedded (`canCreate={canCreateUom && !embedded}`), avoiding
|
|
modal-from-modal, and every `canCreate*` prop defaults to `false` so an
|
|
unpassed prop degrades to "no quick-create" rather than an open control.
|
|
* Super Admin role assignment is blocked at the real boundary, not just the
|
|
UI: listRolesForSelect excludes it from the dropdown AND
|
|
createUser/updateUser both call `assertNotSuperAdminRole` server-side.
|
|
(What is missing is the TARGET-side check — that is SEC-2, above.)
|
|
* Permission resolution is request-memoised (`cache()` in
|
|
lib/queries/rbac.ts:38), so the `<Can>` component appearing many times per
|
|
list page does NOT produce an N+1 of permission queries. Checked
|
|
specifically because it looked like one.
|
|
* The middleware/guard split is genuine defence-in-depth, not duplicated
|
|
theatre: the Edge layer reads the cookie snapshot (can only be MORE
|
|
restrictive), the Node layer re-reads from the database. Removing either
|
|
does not expose a mutation.
|
|
|
|
--------------------------------------------------------------------------------
|
|
5. WHY THIS IS A PASS — AND WHAT THAT DOES NOT MEAN
|
|
--------------------------------------------------------------------------------
|
|
The gate rule is "PASS only if zero unresolved P1 findings remain across all
|
|
seven audits", and the audits are defined as running "against this ticket's
|
|
changed files".
|
|
|
|
In-scope P1s (feature_files + this ticket's diff): 0 -> PASS
|
|
Out-of-scope P1s (files this ticket never touched): 2 -> SEC-1, SEC-2
|
|
|
|
Both P1s were found by deliberately auditing PAST the ticket boundary, because
|
|
the gate flags this repo's security audit as first-party. They are real,
|
|
verified, and NOT fixed. Neither is attributable to #3248:
|
|
SEC-1 app/api/uploads/route.ts — 0 commits in this ticket's range
|
|
SEC-2 lib/actions/users.ts — 0 commits in this ticket's range, and
|
|
already the #3246 gate's blocking P1
|
|
|
|
Reporting them as P1 while passing the gate is a deliberate call, recorded here
|
|
so an operator can overrule it: failing #3248 for a defect #3246 introduced and
|
|
#3247 failed to close would block this ticket without moving the actual defect,
|
|
and the gate's own Step 3 forbids this agent from fixing either one ("A missing
|
|
authorization check is never an auto-fix"). SEC-2 in particular has now survived
|
|
two gates — it wants its own ticket, not a third silent carry-forward.
|
|
|
|
If the orchestrator's policy is that ANY open P1 blocks regardless of
|
|
attribution, this result should be read as FAIL with 2 P1s.
|
|
|
|
STEP 3 (auto-fix) WAS NOT PERFORMED — no source file was modified. This node was
|
|
invoked read-only by design ("You have no write access, by design"), so even the
|
|
clearly-safe candidates (CSSPERF-1's two dead tokens, CQ-4's dead closure) were
|
|
left as reported findings rather than applied. The only file written by this
|
|
gate is this evidence file. Every finding above is therefore `fixed: NO`, and
|
|
that reflects the execution mode, not a judgement that each was unsafe to fix.
|
|
|
|
--------------------------------------------------------------------------------
|
|
6. REDMINE NOTE — NOT POSTED
|
|
--------------------------------------------------------------------------------
|
|
The Step 5 curl needs $REDMINE_URL, $TASK_ID and $REDMINE_API_KEY. Only
|
|
REDMINE_API_KEY is present in this environment (`env` check: REDMINE_URL and
|
|
TASK_ID are both unset), so the documented request cannot be constructed. No URL
|
|
and no ticket id were guessed. The note that would have been posted:
|
|
|
|
"Claude - Opus: Gate 5 — Quality & Security Audit: PASS (0 P1, 11 P2, 12 P3)"
|
|
|
|
--------------------------------------------------------------------------------
|
|
7. VERDICT
|
|
--------------------------------------------------------------------------------
|
|
PASS — 0 unresolved P1 in the audited scope; 11 P2 and 12 P3 reported, none
|
|
fixed (read-only invocation). Typecheck, lint, 630 unit tests and a production
|
|
build all pass against the real tree. Two pre-existing P1 security findings
|
|
outside this ticket's scope are recorded in full and need their own ticket;
|
|
SEC-2 is the #3246 gate's blocking P1, still open after two subsequent tickets.
|
|
================================================================================
|