Skip to content

feat: wave 2 hardening — CVEs, bundle, completeness + design polish#9

Merged
SirsiMaster merged 21 commits into
integration/completionfrom
wave2/hardening
Jun 14, 2026
Merged

feat: wave 2 hardening — CVEs, bundle, completeness + design polish#9
SirsiMaster merged 21 commits into
integration/completionfrom
wave2/hardening

Conversation

@SirsiMaster

Copy link
Copy Markdown
Owner

Wave 2 Hardening — Integration

Stacked on PR #8 (integration/completion). Merges 11 disjoint-glob fix branches into a single hardening wave.

Buckets

Security & deps

  • w2/dep-security — esbuild 0.27.7→0.28.1, tsx 4.21→4.22.4, @grpc/grpc-js 1.9.15→1.9.16 (Dependabot HIGH: gRPC DoS GHSA-5375-pq7m-f5r2 / GHSA-99f4-grh7-6pcq; esbuild dev-server SSRF GHSA-gv7w-rqvm-qjhr). npm audit --omit=dev now reports 0 vulnerabilities.
  • w2/security-authz — close invitation-seizure via verified identity; storage email_verified gate; firestore.rules hardening. opensign/provider.go signing-secret wiring untouched.

Correctness & runtime paths

  • w2/contract-runtime-paths — fix memoir upload route, unify API base, value-aware useCollection key.
  • w2/vault-assets-states — honor ActionResult on vault/asset/chapter/invite writes; vault row a11y.

Performance

  • w2/bundle-perf — split vendor chunks + lazy-load probate (probate.tsx → probate.lazy.tsx). Entry index now 623KB (gzip 155KB); vendor-pdf (1.4MB) is now a lazy chunk.

Design polish (Royal Neo-Deco)

  • w2/design-tokens-css — brand tokens + remove undefined slate CSS vars.
  • w2/design-cards-heirlooms — glass Card variant + heirlooms/soul-log sweep.
  • w2/editor-a11y — accessible shared editor toolbar; directives/obituary hardening.
  • w2/layout-nav-a11y — header/sidebar/landing/search a11y + nav slate sweep.
  • w2/timecapsule-slate-var — royal-ink token for timecapsule icon.
  • w2/a11y-tooling — eslint-plugin-jsx-a11y in lint config.

Net slate-utility occurrences in web/src: 910 → 733 (design sweep reduced, did not regress).

Build / Test status (all green on wave2/hardening)

  • api: go build ./... clean · go vet ./... clean · go test ./internal/... all pass
  • web: npm run build green · npx vitest run208 passed / 14 files
  • functions: npm test21 passed
  • npm ci verifies merged lockfile integrity; npm audit --omit=dev = 0 vulnerabilities

Honest residuals

  • Bundle size (low): web/src/routes/estates.$estateId.probate.lazy.tsx pulls vendor-pdf (1.4MB lazy chunk) → triggers Vite >900KB warning. Non-blocking (lazy-loaded, not in entry). Future: dynamic-import the pdf path within probate.
  • Residual slate utilities (low, pre-existing): 733 slate-* occurrences remain in web/src (incl. text-slate-900 headings in probate route). Pre-existing on base; partially swept this wave. Not introduced here. Follow-up sweep recommended for full Royal Neo-Deco compliance.

🤖 Generated with Claude Code

cyltoncollymore and others added 21 commits June 14, 2026 14:26
…timecapsule route

DELIVERY_TYPES icon at timecapsule.lazy.tsx:1038 used the undefined
var(--color-slate-400) for the unselected state, silently falling back
to inherited color. Map it to var(--ink-muted) (#4A5C7A, royal-tinted
secondary) to match the design-tokens-css brand mapping, so unselected
delivery-trigger icons render the intended Royal Neo-Deco ink. Selected
state unchanged (var(--gold)).

Bucket: timecapsule-slate-var (P2)
Root-cause guard for the clickable-div / missing-label / unlabeled-icon-button
patterns that accumulated unflagged across web/. Wires the jsx-a11y recommended
flat ruleset into web/eslint.config.mjs and pins the named guards as errors:

- click-events-have-key-events  (div onClick without keyboard handler)
- no-static-element-interactions (non-native interactive elements)
- anchor-has-content            (empty/unlabeled anchors)
- control-has-associated-label  (unlabeled controls / icon-only buttons)
- label-has-associated-control  (orphaned form labels)

Now surfaces 93 existing a11y issues at lint time so the per-component fixes
landing in sibling buckets cannot silently regress. Manifest + root lockfile
updated (eslint-plugin-jsx-a11y@^6.10.2). Build + vitest (204) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves open high-severity Dependabot alerts on dev/build-time
transitive dependencies (pure lockfile regeneration, no package.json
edits):

- esbuild 0.27.7 -> 0.28.1 (Dependabot #172 GHSA-gv7w-rqvm-qjhr high
  dev-server SSRF; #171 GHSA-g7r4-m6w7-qqqr low). tsx lifts 4.21->4.22.4
  to pull esbuild ~0.28; vite already permits 0.28.x.
- @grpc/grpc-js 1.9.15 -> 1.9.16 (Dependabot #170 GHSA-5375-pq7m-f5r2;
  #169 GHSA-99f4-grh7-6pcq, both high memory-allocation DoS). Existing
  ~1.9.0 constraint from @firebase/firestore already permits 1.9.16.

Verified: npm ci resolves clean (0 vulnerabilities), web build green,
npm ls shows esbuild@0.28.1 and @grpc/grpc-js@1.9.16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault row a11y

Fire-and-forget ActionResult calls swallowed {success:false} (actions never
throw), showing false success or silent failure. Surface error/success feedback
at every call site and roll back optimistic UI on failure.

- vault.lazy.tsx: visibility picker (Everyone + per-heir) now awaits
  updateVaultDocument; on failure reverts setVisibleTo to the prior value and
  toasts "Could not update who can see this document" (privacy hazard fix).
  handleDelete gains an else-branch toast on failed archive; handleDownload
  toasts on the swallowed catch. Document rows converted from clickable <div>
  to a real <button type="button"> with aria-label "Open <name>" and a focus
  ring so preview/download are keyboard-operable.
- assets.tsx: handleAddAsset/handleUpdateAsset/handleArchiveAsset capture the
  ActionResult — success toast + close modal on success, error toast on failure.
- life-chapters.tsx: handleRemoveEntry gates its success toast on result.success,
  else toasts the error, matching the file's other handlers.
- InviteTeamMember.tsx: handleRevoke sets the error banner on a failed revoke
  instead of silently no-op'ing.

estate-actions.ts already returns correct ActionResults; no change needed there.

Bucket: vault-assets-states (P1). Build + vitest (204) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Neo-Deco)

design-tokens-css (P1). Repair the brand design-system core in globals.css and
the shared estate components that carried undefined var(--color-slate-*) inline
styles (invalid in this Tailwind v4 setup — slate is not registered as a CSS var,
so text color silently broke).

globals.css:
- Anchor :root defaults for the glass-card system (--card-bg/--card-border/
  --card-shadow/--card-shadow-hover/--card-backdrop) so .glass-card and the
  forthcoming ui/card variant (design-cards-heirlooms bucket) resolve outside a
  themed surface; add reusable --gold-glow token + .gold-glow/.gold-glow-hover.
- Add on-brand neutrals (--neutral-border #DCE3EE, --neutral-faint #F4F6FB) to
  replace generic slate for borders/faint fills.
- Re-tone the status-pulse/.status-dot accent from emerald green to Gold
  (firewall: never emerald/green/teal as a brand accent).
- Delete the dead industrial .sirsi-card/.sirsi-metric-*/.sirsi-table-*/
  .sirsi-status-led-*/.sirsi-progress-* block (sharp edges, slate, monospace,
  emerald LED) — referenced by nothing, the exact Royal Neo-Deco anti-pattern.

SectionHeader.tsx:
- Re-tone off-brand SECTION_THEMES off the firewall: memories #9D174D->gold,
  letters #4D7C4D->deep royal, my-people #0F766E->heritage royal, the-vault
  #334155->deep royal, life-chapters #7C3AED->gold. Royal + Gold anchors.
- Fix section-title color: var(--color-slate-900) -> var(--ink).

SectionEmptyState.tsx:
- Fix empty-section heading color: var(--color-slate-900) -> var(--ink).

SettlementGantt.tsx:
- Pass concrete brand hex to recharts (CSS vars don't resolve in recharts tick/
  axis props): axis ticks #4A5C7A/#142848, axis line #DCE3EE; re-tone tooltip +
  legend slate utilities to brand ink. Urgency bar colors kept (semantic).

Rule 27 (no generic slate/grey). web build + 204 vitest tests green.
obituary/timecapsule slate slices delegated to editor-a11y/timecapsule-slate-var.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carve stable vendor code out of the main app entry and demand-load the
heavy probate route so the eager index chunk drops from 1.22MB to 621KB.

vite.config.ts:
- Add build.rollupOptions.output.manualChunks splitting vendor-react,
  vendor-firebase, vendor-charts (recharts+d3), vendor-motion
  (framer-motion), vendor-editor (tiptap/prosemirror), and a named
  vendor-pdf chunk for the already-dynamic @react-pdf/renderer.
- Set chunkSizeWarningLimit to 900.
- Resolves framer-motion entry-coupling on the estate layout route via
  the vendor-motion chunk (no edit to estates.$estateId.tsx).

estates.$estateId.probate.tsx -> .lazy.tsx:
- Move the 645-line ProbatePage component (which imports SettlementGantt,
  pulling recharts) into estates.$estateId.probate.lazy.tsx via
  createLazyFileRoute. Reduce the eager route to createFileRoute only so
  recharts is no longer on the main entry — demand-loaded per route.
- SettlementGantt.tsx is NOT touched; the split is purely route-level.

routeTree.gen.ts regenerated by the TanStack router plugin to wire the
lazy probate route.

Build green (npm run build), 204/204 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge email_verified gate

P0 security-authz residuals (wave-2). Two hardened gates that the wave-1
email_verified defense left half-open on the write side and in storage.

CRITICAL — invitation-seizure (functions/index.js, autoMatchInvitation):
The trigger keyed the invitation match + estate_users access grant off
userData.email, which is client-writable. An attacker could verify an
account they control, then write users/{uid}.email = 'victim-invited@...'
to seize the executor/heir role on that invitation — defeating the wave-1
firestore.rules isEstateRole() email_verified gate (which only guards reads).
Fix: resolve identity via admin.auth().getUser(uid); use rec.email
(lowercased/trimmed); return when !rec.emailVerified; match invitations
against the VERIFIED auth email and ignore userData.email entirely.

Defense-in-depth (firestore.rules, users/{userId}):
- create: pin request.resource.data.email == request.auth.token.email so a
  profile email a user does not own can never be persisted (closes any
  downstream path still reading users/{uid}.email).
- update: add 'email' to the diff denylist — email is identity, set once at
  registration, never client-mutable thereafter.

storage.rules isEstateMember():
Mirrored the firestore.rules isEstateRole() email_verified gate. The same
estate_users junction firestore refuses to honor for an unverified user was
being honored by storage for direct downloads. Now requires
request.auth.token.email_verified == true before honoring the junction /
principalId. Low active impact (reads go through Go-API signed URLs that
bypass these rules) but it is the same hardened gate, now consistent.

Tests: functions/index.test.js — autoMatch fixtures updated to the verified-
identity path; added 3 seizure-defense tests (unverified-account-grants-
nothing, forged-profile-email-never-queried, getUser-throws-no-fabricated-
identity). 21/21 green. No legal text fabricated (Rule 9). No gate weakened
to pass a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ening

Editor a11y (directives + obituary rich-text editors):
- Extract one shared EditorToolbar component (exported from
  directives.lazy.tsx, imported by obituary.lazy.tsx) — both editors
  were byte-identical inline toolbars.
- Wrap toolbar in role="toolbar" aria-label="Text formatting".
- Add explicit aria-label to every icon-only Button (Bold, Italic,
  Heading, Bullet list, Numbered list, Undo, Redo).
- Add aria-pressed={isActive} to all toggle buttons so active state is
  exposed to AT instead of being conveyed by background color alone.

Directives "Confirm Signed":
- Replace fire-and-forget updateDirective with a result-checked call on
  this legal document — success toast only on result.success, else
  toast.error(result.error || fallback).

Obituary export HTML (delegated from design-tokens-css, same file):
- Hardcode hex for the emailed obituary HTML, which renders outside app
  CSS scope and cannot resolve var(--color-slate-*) tokens. Royal
  Neo-Deco ink #142848 / muted #4A5C7A (+ literal surface hex).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…weep

layout-nav-a11y bucket (P1). Accessibility hardening across the chrome a new
owner first encounters, plus the Rule-27 grayscale-text sweep on the onboarding
funnel and sidebar nav.

AdminHeader.tsx
- Global search input: aria-label="Search estate", role=combobox, aria-expanded,
  aria-controls (listbox id), aria-autocomplete=list, aria-activedescendant
  tracking the highlighted option; aria-label="Clear search" on the X button.
- Photo viewer: replaced hand-rolled overlay with accessible Dialog/DialogContent
  + VisuallyHidden DialogTitle (focus trap, Escape, focus restoration, labeled
  close) mirroring Sidebar's pattern.
- "View As" Owner/Incapacity/Settlement: role=radiogroup + role=radio /
  aria-checked so the selected mode is not color-only.

SearchResults.tsx
- Exported stable SEARCH_LISTBOX_ID + searchOptionId(index); each option now
  carries a stable id and the shell carries the listbox id.
- Populated the aria-live region: "N results" / "No results found".

Sidebar.tsx
- Profile avatar: wrapped photo-open affordance in a focusable
  <button aria-label="View profile photo"> (only when a photo exists); plain
  Avatar otherwise.
- Inactive nav items: text-slate-400 -> text-[var(--ink-muted)],
  hover:text-slate-900 -> hover:text-[var(--royal)] (Rule 27).

index.tsx
- Both password show/hide toggles: aria-label (Show/Hide password) + aria-pressed.

estates.create.tsx
- Onboarding funnel grayscale sweep (Rule 27): headings/values text-slate-900 ->
  text-[var(--ink)]; descriptions/labels/hints text-slate-500/400 ->
  text-[var(--ink-muted)]. Placeholder slate-300 left intentionally faint.

Build green (vite build + 204 vitest tests pass). No new lint errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…useCollection key

Bucket: contract-runtime-paths (P0) — frontend↔backend contract & runtime-URL correctness.

- memoirs.lazy.tsx: POST video memoirs to the registered route
  /api/v1/memoirs/upload-video (was unregistered /api/v1/youtube/upload →
  always 404, silently breaking the white-glove video-memoir feature).
  Field names + {error:{message}} error shape already align with
  HandleUploadVideo (api/cmd/api/main.go:385-388, api/internal/youtube/handler.go).
- Add memoir-upload-contract.test.ts asserting the posted URL matches the
  registered route and never the old one.
- Collapse per-file `VITE_API_URL || 'http://localhost:8080'` re-derivations to
  the single safe API_BASE helper exported from lib/client.ts (localhost only
  when the page itself is on localhost; else VITE_API_URL or same-origin).
  Imported in pricing.tsx, tier-gating.ts, dashboard.lazy.tsx, lockbox.lazy.tsx,
  estates.$estateId.tsx, SettlementPanel.tsx, memoirs.lazy.tsx. Removes the
  single point of failure where a missing .env.production silently routed
  payments + 8 journeys to localhost:8080.
- firestore.ts useCollection: key the effect on the FULL normalized constraint
  (type + field path + op + value + direction + limit) via normalizeConstraint,
  not just c.type — fixes the latent stale-list bug where a value change on a
  fixed field path (e.g. where('userId','==',X)) failed to re-subscribe.
  Adds regression tests for re-subscribe-on-value-change and no-churn-on-equal.
- lockbox.lazy.tsx handleArchive: surface the archiveLockboxItem result with
  success/error toasts (was fully silent), matching handleSave.
- memoirs.lazy.tsx VideoCard/PhotoCard: thumbnails are keyboard-accessible
  <button> with aria-label (Play/View {title}); removed redundant onClick from
  the now-non-interactive <h4> titles.

Royal Neo-Deco preserved. No security weakened. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ui/card.tsx: add variant="glass" (--card-bg backdrop-blur + gold hairline
border + gold-glow hover) and bake the brand hover/transition into both
variants (transition-all duration-300, hover:-translate-y-0.5, gold border
+ --card-shadow-hover on hover, gold focus-visible ring). Own the radius
scale here (cards rounded-3xl / rounded-2xl@sm, inner chips rounded-xl) and
drop CardFooter's bg-muted/border-t that fought glass. Token fallbacks are
inlined so glass renders on the white flagship screens outside .glass-theme.

heirlooms.tsx:
- stats + heirloom cards now use Card variant="glass"; removed per-route
  rounded-*/border/hover overrides (radius now unified on the primitive).
- Rule-27 sweep: slate text -> --ink/--ink-muted, slate fills -> --royal/5
  & --gold/8, slate borders -> --royal hairlines.
- visibility picker no longer fire-and-forget: persistVisibility awaits
  updateHeirloom, reverts setVisibleTo + toasts on failure.
- archive toast gated on result.success (toast.error with reason otherwise).

soul-log.lazy.tsx: EntryCard + ShepherdCard use Card variant="glass";
Rule-27 slate text/fill/border sweep (dark video frames preserved).

build green (vite build), 204/204 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	package-lock.json
@github-actions

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit d1c5150):

https://finalwishes-prod--pr9-wave2-hardening-1jdpj46c.web.app

(expires Sun, 21 Jun 2026 18:38:38 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 6c224b6590e2084ad0c46f0efd6a68ed08b63fa3

@SirsiMaster SirsiMaster merged commit eae6b13 into integration/completion Jun 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants