Commit Graph

217 Commits

Author SHA1 Message Date
Matt Ciaccio
a75d4f5d69 feat(mobile): redesign topbar + collapse cumbersome page-header on mobile
Topbar (mobile-topbar.tsx):
  - Bumped to 56px to match standard mobile-app proportions.
  - Deep-navy gradient surface (#1e2844 -> #171f35) with white type —
    matches the desktop sidebar identity, gives the app a premium
    finish instead of generic white-with-text.
  - Brand "PN" wordmark mark on the left when no back affordance is
    needed (rounded brand-blue square, inset highlight + drop shadow).
  - Soft glow shadow underneath for elevation depth instead of a hard
    bottom border.
  - White-on-navy back arrow with active-state translucent fill.

PageHeader (page-header.tsx):
  - On mobile, the gradient hero strip + duplicate title + description
    block now collapses entirely — the topbar already shows the title,
    so duplicating it in the body wasted a third of the viewport.
  - The actions slot remains rendered as a flush right-aligned row so
    primary buttons (date-range pickers, "+ New X") stay accessible.
  - Desktop rendering is untouched.

Mobile shell (mobile-layout.tsx):
  - Top buffer 16px below the topbar so content doesn't ride flush.
  - Bottom buffer 32px above the tab bar so the last card breathes.

CSS (globals.css):
  - Hide the react-query-devtools floating button below lg: — it was
    overlapping the bottom-tab bar's "More" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:34:28 +02:00
Matt Ciaccio
0fb7920db5 fix(auth/mobile): support LAN-IP access in dev + edge-to-edge auth bg
- branded-auth-shell: split the background image into a separate
    fixed-positioned layer behind the layout. Previously the bg was on
    a min-h-screen container and iOS Safari left visible whitespace at
    the top/bottom when the URL bar showed/hid (the container's height
    didn't match the visual viewport). Now the bg pins to the actual
    visible viewport via `fixed inset-0`. min-h-[100dvh] also added
    so the layout layer matches.
  - auth client: derive baseURL from window.location.origin instead of
    NEXT_PUBLIC_APP_URL. Same dev build now works whether opened on
    localhost (Mac) or the LAN IP (iPhone on Wi-Fi).
  - auth server: dynamic trustedOrigins function that allows
    localhost / 127.x / 192.168.x / 10.x in dev (function form
    inspects the incoming request's Origin). Production stays locked
    to NEXT_PUBLIC_APP_URL.
  - new dev helper: scripts/dev-set-password.ts to set a user's
    better-auth password directly (bypasses the email-reset flow);
    used to bootstrap matt@letsbe.solutions for mobile testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:21:59 +02:00
Matt Ciaccio
16ad61ce15 fix(mobile): hide duplicate detail-header title on mobile
The mobile topbar already shows the entity name pushed via
useMobileChrome, so the gradient detail-header strip was rendering it
a second time. Hides the inline h1 below sm: while keeping the source
/ email / phone meta and action buttons visible — the strip's
practical content (actions + meta) stays where users need it, and the
title responsibility moves cleanly to the topbar.

Affects: clients, yachts, companies, berths detail headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:09:32 +02:00
Matt Ciaccio
d080bc52fa feat(mobile): touch up new-invoice + scan-receipt forms
- new invoice: push "New Invoice" to mobile topbar, hide the
    redundant inline back+title row on mobile.
  - scan receipt: dedicated "Take photo" primary button on mobile
    (uses input capture="environment" to open the camera directly)
    plus "Choose from library" secondary. Drop-zone framing kept on
    desktop. Push "Scan Receipt" title to mobile topbar.

Both forms now take their entity title from the topbar and free up
real-estate at the top for actual content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:06:09 +02:00
Matt Ciaccio
a653c8e039 fix(mobile): wrap detail-header actions on narrow viewports
Action buttons in entity detail headers (Invite/GDPR/Archive on
clients, similar sets elsewhere) overflowed off-screen at 393px
because the actions row was flex without flex-wrap. Adds flex-wrap
so buttons drop to a second/third row instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:51 +02:00
Matt Ciaccio
7e8110b2ff feat(mobile): show entity name in mobile topbar on detail pages
Detail pages (clients, yachts, companies, berths, invoices, expenses)
now push their entity name + a back-button toggle to the mobile
topbar via useMobileChrome, replacing the URL UUID fallback that was
rendering before.

Supporting changes:
  - useMobileChrome() no longer throws when called outside the
    MobileLayoutProvider — desktop-tree consumers get a no-op
    setChrome so callers don't have to branch on shell type.
  - setChrome is now stable across renders (useCallback) so callers'
    useEffect dependency arrays don't infinite-loop.
  - DetailPageShell now also pushes its entityName + cleans up on
    unmount, and hides its desktop-only sticky header on mobile so it
    doesn't double up with the topbar (no current callers, prep for
    Phase 4 migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:46:32 +02:00
Matt Ciaccio
9eadaf035e fix(mobile): widen ListCard href type to Route
Project has experimental.typedRoutes enabled; passing template-literal
URLs through the Link href prop requires the wider Route type. Cast
at the Link boundary inside ListCard so callers can keep the simpler
string-typed href API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:40:52 +02:00
Matt Ciaccio
bcea28cd71 feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.

  - ReminderCard:   Bell icon, related-entity subtitle (User/Anchor/
                    FileText icon by entity type), due-date meta with
                    past-due flag, accent bar (rose=past-due,
                    amber=pending, slate=snoozed, emerald=done).
                    Snooze/Complete/Edit/Delete in actions menu.
  - AuditLogCard:   Action icon (Plus/Pencil/Trash2/Eye), entity
                    title, "{verb} by {actor}" subtitle, timestamp
                    meta, optional changed-field chip line. Accent
                    bar by action (created=emerald, updated=blue,
                    deleted=rose). Immutable, no actions menu.
  - UserCard:       Initials avatar, displayName/email, role meta
                    (Shield icon), last-login distance, "Inactive"
                    pill when deactivated. Accent bar (violet=
                    super_admin, slate=inactive, none=active).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
Matt Ciaccio
722491a9dd feat(mobile): mobile cards for yachts, companies, berths, invoices, expenses
Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.

  - YachtCard:    Ship icon, owner subtitle (User/Building2 icon by
                  ownerType), dimensions in meters preferred, hull #,
                  status pill. No accent bar (status is free-text).
  - CompanyCard:  Building2 icon, legalName subtitle, country (MapPin)
                  + tax id (Hash) meta, member/yacht count line.
  - BerthCard:    Anchor icon, area subtitle (MapPin), dimensions
                  meta, status pill. Status-encoded accent bar
                  (emerald=available, amber=under_offer, slate=sold).
  - InvoiceCard:  FileText icon, client subtitle, due date (Calendar)
                  meta, prominent currency-formatted amount. Status
                  accent bar (emerald=paid, orange=overdue, ...).
  - ExpenseCard:  Receipt icon, category subtitle, expense date meta,
                  prominent amount, payment-status pill, "Possible
                  duplicate" pill when duplicateOf is set. Accent bar
                  by paymentStatus, overridden to amber when flagged
                  as duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:34:04 +02:00
Matt Ciaccio
6009ccb7de feat(mobile): mobile card view for clients + interests lists
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).

New shared shell:
  - <ListCard>          rounded card with optional left status accent bar,
                        whole-card link to detail page, top-right actions
                        slot, and tactile hover/active states.
  - <ListCardAvatar>    40px brand-tinted circle (initials or domain icon).
  - <ListCardMeta>      inline icon + muted text segment.
  - deriveInitials()    shared helper that ignores numeric tokens (so
                        "Recovery Test 1777" -> "RT", not "R1").

Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:27:53 +02:00
Matt Ciaccio
71da6e8fdc feat(mobile): swap admin page headers to PageHeader
Mechanical sweep replacing the plain h1+p header markup with the
mobile-aware PageHeader primitive across 12 admin pages: index,
backup, branding, documenso, email, import, invitations, monitoring,
onboarding, reminders, reports, webhooks. Webhooks "Add Webhook"
button preserved via the actions slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:57:52 +02:00
Matt Ciaccio
c405124bc3 feat(mobile): swap reports header to PageHeader
Plain h1 + p replaced with the mobile-aware PageHeader primitive so
the reports landing matches dashboard/settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:11 +02:00
Matt Ciaccio
53cbee1d3d fix(mobile): tighten Card padding on mobile (p-4 sm:p-6)
CardHeader/CardContent/CardFooter were uniformly p-6 (24px), which on
top of the mobile shell's 16px outer padding pushed form content 40px
inward — making cards feel content-shifted on a 393px viewport. Drops
to p-4 (16px) below sm and keeps p-6 from sm+ so desktop is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:09 +02:00
Matt Ciaccio
ac7f1db62c fix(mobile): add horizontal padding to mobile shell main
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:45:20 +02:00
Matt Ciaccio
5d44f3cfa4 fix(test): raise mobile-audit timeout to 30min for 4-viewport runs
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:15:26 +02:00
Matt Ciaccio
d0540dca55 fix(build): extract route.ts handlers to handlers.ts (CLAUDE.md convention)
8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).

Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.

Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
  + nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
  yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
  memberships, reservations, yachts-detail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:14:40 +02:00
Matt Ciaccio
0e9c24e222 test(visual): add mobile shell snapshot baselines (dashboard + more sheet)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:26 +02:00
Matt Ciaccio
3aba2181dc feat(test): extract anchor iPhone device descriptors to shared fixture
Move the four iPhone viewport descriptors (SE, 15/16, 16/17 Pro, Pro Max)
into tests/e2e/fixtures/devices.ts so the upcoming visual spec (Task 23)
can share the same anchors. The mobile-audit spec now spreads each
descriptor and adds a slug `name` plus a human `label` for the run header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:31:51 +02:00
Matt Ciaccio
6237ad1567 feat(mobile): add FilterChips primitive (horizontal chip row with Add-filter trigger) 2026-04-29 14:28:33 +02:00
Matt Ciaccio
34916d855e feat(mobile): add DataView (TanStack table on lg+, card list below) with cardRender callback 2026-04-29 14:27:17 +02:00
Matt Ciaccio
41ae8a328f feat(mobile): add DetailPageShell with sticky header + mobile sticky-action shelf 2026-04-29 14:25:45 +02:00
Matt Ciaccio
1ff3160eac feat(mobile): add ActionRow with horizontal-scroll-snap on mobile, wrap on desktop 2026-04-29 14:24:43 +02:00
Matt Ciaccio
5698d742d3 feat(mobile): make PageHeader mobile-aware (stack below sm, hide description when actions present) 2026-04-29 14:23:40 +02:00
Matt Ciaccio
e6ce265be0 fix(mobile): drop positive display rule that overrode desktop shell's flex layout 2026-04-29 14:20:11 +02:00
Matt Ciaccio
19bc2f2a54 feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer 2026-04-29 14:18:28 +02:00
Matt Ciaccio
b0a11f1785 feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet 2026-04-29 14:16:30 +02:00
Matt Ciaccio
3cbf2444fe feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer) 2026-04-29 14:15:25 +02:00
Matt Ciaccio
0330be1312 feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets 2026-04-29 14:14:18 +02:00
Matt Ciaccio
210360738d feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More) 2026-04-29 14:13:09 +02:00
Matt Ciaccio
4df04e1a58 feat(mobile): add MobileTopbar with title, back-button, and primary-action slots 2026-04-29 14:12:15 +02:00
Matt Ciaccio
0c3baf04c5 feat(mobile): add MobileLayoutProvider context + useMobileChrome hook 2026-04-29 14:11:27 +02:00
Matt Ciaccio
79667b24da chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon) 2026-04-29 14:10:14 +02:00
Matt Ciaccio
c4fdb29bbe feat(mobile): render Dialog full-screen below sm, centered modal at sm+ 2026-04-29 14:08:14 +02:00
Matt Ciaccio
38527d71fc feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom 2026-04-29 14:06:59 +02:00
Matt Ciaccio
3fbfba6598 chore(deps): add vaul for native-feel bottom sheets 2026-04-29 14:05:10 +02:00
Matt Ciaccio
e3a835675b feat(mobile): add useIsMobile() hook backed by matchMedia (visual-test-only) 2026-04-29 14:04:02 +02:00
Matt Ciaccio
1b085f81ed feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport 2026-04-29 14:00:49 +02:00
Matt Ciaccio
9f786fbcf3 feat(mobile): set data-form-factor body attr from User-Agent in root layout 2026-04-29 13:59:03 +02:00
Matt Ciaccio
906127a292 feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.) 2026-04-29 13:56:53 +02:00
Matt Ciaccio
737b43589b feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout 2026-04-29 13:55:37 +02:00
Matt Ciaccio
fbb1f1f366 scaffold(mobile): branch setup — audit harness, spec, plan, gitignore + client-portal cleanup
Pre-execution baseline for the mobile foundation PR:

- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:38 +02:00
Matt Ciaccio
ba89b61b3f fix(security): port-scope clientId/berthId/yachtId on interests + clientRelationships
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m17s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Pass-6 findings — both MEDIUM cross-tenant FK injection.

- interests.service: createInterest/updateInterest/linkBerth accepted
  clientId/berthId/yachtId from the request body without verifying the
  referenced row belongs to the caller's port. getInterestById joins
  clients/berths/yachtTags on these FKs without a port filter, so a
  port-A caller could splice a foreign-port id and surface that
  tenant's clientName, mooringNumber, or yacht ownership on read.
  New assertInterestFksInPort helper guards all three surfaces.

- clients.service.createRelationship: accepted clientBId from the
  body without a port check; the relationship list endpoint joins
  clients without filtering by port, so the foreign client's name
  + email would render in the relationships tab. Now verifies
  clientBId belongs to portId and rejects self-relationships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 04:14:09 +02:00
Matt Ciaccio
4eea19a85b sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
   berthId from the body and persisted them with no port check; getReminder
   then hydrated the row via Drizzle relations (no port filter on the
   join), so a port-A user with reminders:create could exfiltrate any
   port-B client/interest/berth row by guessing its UUID. New
   assertReminderFksInPort gates create + update.

2. HIGH — listRecommendations(interestId, _portId) discarded portId
   entirely; the route GET /api/v1/interests/[id]/recommendations
   forwarded the URL id straight through. A port-A user with
   interests:view could read any other tenant's recommended berths
   (mooring numbers, dimensions, status). Service now verifies the
   interest belongs to portId and joins berths filtered by port.

3. HIGH — Berth waiting list. The PATCH route did not pre-check that
   the berth belonged to ctx.portId — a port-A user with
   manage_waiting_list could reorder a port-B berth's queue. Separately,
   updateWaitingList accepted arbitrary entries[].clientId and inserted
   them without verifying tenancy, polluting the table with foreign-port
   FKs. Both gaps closed.

4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
   accepted any tagId and inserted into the join table. The tags table
   is per-port but the join only carries a single-column FK. The
   downstream getById join `tags ON join.tag_id = tags.id` has no port
   filter, so a foreign tag's name + color render in the requesting port.
   Helper now batch-validates tagIds belong to portId before insert.

5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
   gate (any role, including viewer, could write) and didn't validate
   that the URL entityId pointed at a port-scoped entity of the field
   definition's entityType. Route now uses
   withPermission('clients','view'/'edit',…); service validates the
   entityId per resolved entityType (client/interest/berth/yacht/company)
   against portId.

Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
Matt Ciaccio
47a1a51832 sec: webhook SSRF guard, IMAP-sync owner check, watcher port membership
Three findings from a fourth-pass review:

1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL
   parse; it accepted private/loopback/link-local/.internal hosts. The
   delivery worker fetched arbitrary URLs and persisted up to 1KB of
   response body into webhook_deliveries.response_body, which is then
   surfaced via the deliveries listing endpoint — a port admin could
   register a webhook to an internal HTTPS endpoint, hit the test
   endpoint to force immediate dispatch, and read the response back.
   Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs
   (v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp
   suffixes; the worker re-resolves the hostname at dispatch time and
   blocks before fetch (DNS rebinding defense). 21-case unit test
   covers the matrix.

2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check.
   Any user with email:view could enqueue an inbox-sync job for any
   accountId, which the worker would honour using the foreign user's
   decrypted IMAP credentials and advance the account's lastSyncAt
   (data-loss risk on the legitimate owner's next sync). Route now
   asserts account.userId === ctx.userId before enqueueing, matching
   the toggle/disconnect endpoints.

3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher
   inserts) didn't validate the watcher's userId belonged to the
   document's port. notifyDocumentEvent then emitted a real-time
   socket toast + email containing the document title to the foreign
   user. New assertWatchersInPort helper verifies each candidate has
   a userPortRoles row for the port (super-admin bypass).

818 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:15:39 +02:00
Matt Ciaccio
9a5479c2c7 sec: lock down socket.io room subscription + crm-invite cross-tenant ops
1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the
   handshake without verifying the user actually held a role in that
   port, then unconditionally joined the socket to `port:${portId}`.
   The `join:entity` handler also skipped authorization. This let any
   authenticated CRM user receive realtime events from any other
   tenant: invoice numbers + totals + client names, document signer
   emails, registration events with full client name + berth, file
   uploads, etc. Auth middleware now resolves the user's
   userPortRoles (or isSuperAdmin) before honouring portId, and
   join:entity verifies the entity's port matches a port the user
   has access to. Pre-existing pre-branch issue but fixed here given
   the explicit "all data is extremely sensitive" directive.

2. MEDIUM — listCrmInvites issued a global SELECT with no port
   scope. The crm_user_invites table has no portId column (invites
   mint global better-auth users, then port roles are assigned
   later). The previous gating on per-port admin.manage_users let
   any director enumerate every other tenant's pending invitee
   emails + isSuperAdmin flags — a phishing target list and a
   super-admin onboarding timing oracle. Restrict GET (list),
   DELETE (revoke), and POST resend to ctx.isSuperAdmin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:00:55 +02:00
Matt Ciaccio
e06fb9545b sec: lock down 5 cross-tenant IDORs uncovered in second-pass review
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
   (manage_settings) mutate any other tenant's port row by passing the
   foreign id in the path. Now non-super-admins must target their own
   ctx.portId; listPorts and createPort are super-admin only.

2. HIGH — Invoice create/update accepted arbitrary expenseIds and
   linked them into invoice_expenses with no port check; the GET
   response then re-emitted those foreign expense rows via the
   linkedExpenses join. assertExpensesInPort now validates each id
   belongs to the caller's portId before insert; getInvoiceById's
   join filters by expenses.portId as defense-in-depth.

3. HIGH — Document creation paths (createDocument, createFromWizard,
   createFromUpload) persisted user-supplied clientId/interestId/
   companyId/yachtId/reservationId without verifying those FKs were
   in-port. sendForSigning then loaded the foreign client/interest by
   id alone and pushed their PII into the Documenso payload. New
   assertSubjectFksInPort helper rejects out-of-port FKs at create
   time; sendForSigning's interest+client lookups now also filter by
   portId.

4. MEDIUM — calculateInterestScore read its redis cache before
   verifying portId, and the cache key was interestId-only — a
   foreign-port caller could observe a cached score breakdown.
   Cache key now includes portId, and the port-scope DB lookup runs
   before any cache.get.

5. MEDIUM — AI email-draft job results were retrievable by anyone who
   could guess the BullMQ jobId (default sequential integers). Job
   ids are now random UUIDs, requestEmailDraft validates interestId/
   clientId belong to ctx.portId before enqueueing, the worker's
   client lookup is port-scoped, and getEmailDraftResult requires
   the caller to match the original requester's userId+portId before
   returning the drafted subject/body.

The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
Matt Ciaccio
4c5334d471 sec: gate super-admin invite minting, OCR settings, and alert mutations
Three findings from the branch security review:

1. HIGH — Privilege escalation via super-admin invite. POST
   /api/v1/admin/invitations was gated only by manage_users (held by the
   port-scoped director role). The body schema accepted isSuperAdmin
   from the request, createCrmInvite persisted it verbatim, and
   consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
   the new account cross-tenant access. Now the route rejects
   isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
   requires invitedBy.isSuperAdmin as defense-in-depth.

2. HIGH — Receipt-image exfiltration via OCR settings. The route
   /api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
   in withAuth — any port role including viewer could PUT a swapped
   provider apiKey + flip aiEnabled, redirecting every subsequent
   receipt scan to attacker infrastructure. Both are now wrapped in
   withPermission('admin','manage_settings',…) matching the sibling
   admin routes (ai-budget, settings).

3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
   issued UPDATE … WHERE id=? with no portId predicate. Any
   authenticated user with a foreign alert UUID could mutate it. Both
   service functions now require portId and add it to the WHERE; the
   route handlers pass ctx.portId.

The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.

The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).

Two new regression test files cover the fixes (6 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:27:01 +02:00
Matt Ciaccio
61e40b5e76 chore(ops): split /api/health (liveness) from /api/ready (readiness)
Previously /api/health did deep dependency probes (postgres + redis +
minio) and 503'd on any failure. That's readiness behavior, not
liveness — a transient Redis/MinIO blip would tell the orchestrator to
restart the pod when it should only be dropped from the load balancer.

Make /api/health a thin liveness check (returns 200 unconditionally if
the process is responding) and move the deep checks to a new
/api/ready endpoint with the canonical Kubernetes-style 200/503
contract. Docker-compose healthchecks keep pointing at /api/health,
which is now more conservative (no false-positive container restarts).

Documenso/SMTP are intentionally not probed in /api/ready: each tenant
configures its own credentials and a tenant misconfiguration shouldn't
deadline the entire shared CRM.

Also tighten the gdpr-bundle-builder casts: replace the scattered
`as unknown as Record<string, unknown>` double-casts with a small
`toJsonRow<T>()` helper that does the widen narrow→wide in one place
with one cast hop instead of two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:03:10 +02:00
Matt Ciaccio
7f9d90ad05 fix(gdpr): cap export-bundle size at 50MB before upload
Article-15 bundles are JSON+HTML only (no receipts/contracts), so even
heavy clients land at <1 MB. Anything larger almost certainly indicates
an unbounded relation we forgot to cap. Fail the worker job before
uploading rather than push a runaway blob to MinIO + email the client a
download link of mystery size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:00:16 +02:00
Matt Ciaccio
5d29bfc153 refactor(services): centralize AuditMeta + transactional setEntityTags helper
The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.

Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).

Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.

The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:58:42 +02:00