Skip to content

feat(share-web): /<handle> profile + /me dashboard + sign-in#365

Merged
graydawnc merged 11 commits into
mainfrom
feat/share-web-profile-me
Jun 5, 2026
Merged

feat(share-web): /<handle> profile + /me dashboard + sign-in#365
graydawnc merged 11 commits into
mainfrom
feat/share-web-profile-me

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

What

Three more public routes that turn a published share into an actual identity surface on spool.pro:

  • GET /<handle><Profile>: someone else's public profile page. Lists their profile-listed shares. The /<handle> route is what unlisted shares deliberately don't appear in — visibility opt-in stays per-share.
  • GET /me<Me>: the signed-in user's dashboard. Claim or change handle (live availability check), see your live shares (revoke from here, copy-link, view), schedule or cancel account deletion. The desktop Settings → Account screen is a mirror of this — same primitives, same wire shape.
  • GET /sign-in<SignIn>: provider list, currently just Google. Same identity-first redesign as Me / Profile — one consistent visual surface across the three identity routes.

Plus the backend half:

  • GET /api/profiles/<handle> — public endpoint that returns {name, avatar_url, shares: [...]}. Rate-limited 120/min/IP (anti-enumeration, but loose enough for legit reload). Returns 404 for both invalid-handle and missing-handle so a scanner can't tell the two apart.

Why one PR

The three routes share the same client API surface (api.ts's fetchMe, claimHandle, checkHandle, revokeShare, scheduleAccountDeletion, etc.) and the same identity visual system. Splitting them would mean each gets approved in a half-formed state. The backend /api/profiles/<handle> only matters once the renderer can hit it, so it lands here too.

A Profile page that exists but can't be reached because there's no Me dashboard for the user to claim a handle on first would be a dead end. A SignIn page that exists but doesn't lead anywhere is just a button.

Routes + ownership

flowchart LR
    subgraph public["public routes"]
        S["/s/<slug>"]
        P["/<handle>"]
        T["Tombstone"]
    end
    subgraph authed["authed routes"]
        ME["/me"]
        SI["/sign-in"]
    end
    subgraph api["share-backend"]
        SN["/api/snapshots/:id"]
        PR["/api/profiles/:handle"]
        M["/api/me"]
        MS["/api/me/shares"]
        H["/api/handles/*"]
        D["/api/me/delete"]
        OUT["/api/auth/sign-out"]
    end

    S --> SN
    P --> PR
    ME --> M
    ME --> MS
    ME --> H
    ME --> D
    ME --> OUT
    SI -. "/api/auth/google/start" .-> M
Loading

Profile page rules

  • Lists profile-listed shares only. Unlisted shares are not enumerated under the handle even though the backend knows which handle owns them — the visibility opt-in is per-share, not per-account.
  • SHARE_LIMIT = 100 on the backend. A handle with thousands of profile-listed shares paginates server-side; the v0.5 cutoff keeps the page lean and uncomplicated.
  • released_at IS NULL on the handle query — released handles 404 immediately. If alex releases their handle and someone else claims it, the new account's profile is what /alex shows, not a frozen snapshot of the previous owner's shares.
  • users.deleted_at IS NULL joined in — a deletion-pending user is still reachable; a fully-deleted user 404s.

Me dashboard state machine

stateDiagram-v2
    [*] --> Loading
    Loading --> Anonymous: GET /api/me returns 401
    Anonymous --> SignIn: click "Sign in"
    Loading --> NoHandle: 200, handle is null
    Loading --> Ready: 200, handle claimed
    NoHandle --> Claiming: submit handle form
    Claiming --> NoHandle: 409 / 422
    Claiming --> Ready: 200
    Ready --> Renaming: edit handle in place
    Renaming --> Ready: 200 (old handle released, new claimed)
    Renaming --> Ready: 409 (status banner, handle unchanged)
    Ready --> Deleting: click "Delete account"
    Deleting --> Ready: cancel modal
    Deleting --> Pending: confirm (POST /api/me/delete)
    Pending --> Ready: click "Keep my account"<br/>(DELETE /api/me/delete)
    Pending --> Tombstoned: 24h grace expired,<br/>worker ran (PR #360)
Loading

Handle availability — debounced, race-safe

sequenceDiagram
    autonumber
    participant U as User
    participant H as <Me> handle input
    participant API as /api/handles/check

    U->>H: "a"
    H->>H: debounce 320ms
    U->>H: "al"
    Note over H: timer reset on keypress
    U->>H: "alex"
    H->>API: GET ?handle=alex (seq=42)
    U->>H: "alexn"
    H->>API: GET ?handle=alexn (seq=43)
    API-->>H: alex → taken (seq=42, late)
    Note over H: seq < latest, drop
    API-->>H: alexn → available (seq=43)
    H-->>U: green check, "available"
Loading

The seq counter is what keeps an out-of-order response from stamping a stale "available" over a fresh "taken" when the user is typing fast. The desktop Settings → Account uses the same pattern with the same 320ms debounce — they're peer renderers of the same identity surface.

Anti-enumeration

/api/profiles/<handle> and /api/handles/check both return identical error shapes for "invalid format" and "not found":

Input Response
/api/profiles/!@# (invalid format) 404
/api/profiles/ghostuser (valid but unclaimed) 404

If we returned 422 for invalid and 404 for missing, a scanner could trivially probe which handles are merely typoable vs which are real but unclaimed (interesting target for impersonation), or real and claimed (interesting target for harassment).

Verification

  • pnpm --filter @spool-lab/share-backend test — profiles.test.ts (28 cases: valid + invalid + missing handle, rate limit trip, deleted user 404, released handle 404, profile-listed only, 100 cap, OWASP escape on name)
  • pnpm --filter @spool/share-web test — api.test.ts (request shape), route.test.ts (handle vs slug ambiguity)
  • Manual: claim handle on /me, hit /<my handle> in another tab, publish a profile-listed share, hit /<my handle> again; release handle, /<handle> 404s; schedule delete on desktop, refresh /me in web → pending banner with executeAt

Risk

/api/profiles/<handle> is public — it's the first endpoint that exposes user info to an unauthenticated visitor. The rate limit + 404-on-invalid + released_at IS NULL filters are the trust boundary. tests/_helpers/fakes.ts adds the two new SQL matchers needed by profiles tests without touching the existing ones.

Submitted by @graydawnc.

graydawnc added 9 commits June 6, 2026 03:46
- Me page now fires fetchMe() and fetchMyShares() in parallel; the
  shares request still lands in flight before /me resolves, and the
  unauth/forbidden branches still redirect cleanly (the wasted shares
  call on a signed-out visitor is fine — they hit /me-as-unauth at most).
- Annotate route.ts nextSafe with a "keep at least as strict as the
  backend's safeNext" pointer so a future tightening on either side
  doesn't silently drift.
…r feedback, profile rate limit

Five concrete amends so /me / /sign-in / /@handle hold up under real
use:

- M1 (Me.tsx + api.ts) — surface deletion_pending_until that PR 3
  added to /api/me. The page boots straight into the 'scheduled +
  cancel' DeleteAccount state if the field is set, the banner above
  the header explains why share + handle actions are disabled, and a
  successful cancel triggers a re-fetch of /api/me/shares so the
  hidden list reappears. Without this, a pending user saw a normal
  /me page with the Delete button still active — clicking it 403'd
  silently against the backend's default requireUser policy.

- C2 (api.ts + Me.tsx) — fetchMyShares now returns a discriminated
  union (ok / unauthenticated / forbidden / error) instead of
  swallowing every non-200 as an empty array. The pending branch
  uses the 'forbidden' kind to render a dedicated copy instead of
  'You haven't published anything yet'.

- M3 (api.ts + Me.tsx) — revokeShare gains the same discriminated-
  union treatment. ShareRow accepts the result, surfaces an inline
  error under the share's meta (with role='alert'), and the
  forbidden / rate-limited / not-found branches each get distinct
  human copy.

- S1 (SignIn.tsx) — fetches /api/me on mount; an already-signed-in
  user is bounced straight to next instead of being shown the sign-
  in card. Prevents a re-OAuth from leaving an orphan KV session for
  its full 30-day TTL (web has no equivalent of PR 7's prior-token
  revoke).

- B2 (profiles/[handle].ts) — per-IP rate limit on the profile
  endpoint (120/min). Profiles are public-by-design but the limit
  caps the rate at which a script can brute-force the handle space
  looking for who-owns-what.

Plus styling for the new .me-banner-pending + .me-share-error and one
new profiles.test.ts case (429 when the limit is exceeded).
'Worker will hard-delete' bleeds implementation detail into user copy.
'Scheduled for' reads as natural English while still naming the exact
moment cancellation stops being possible.
…obile polish

Three polish items in one commit so PR 11 ships consistent:

- (M4) HandleClaim now does a debounced /api/handles/check on every
  keystroke and surfaces an inline status line ('Checking…' / 'Available.'
  / 'Taken.' / etc.). Submit stays disabled until status is 'available',
  so the network claim only fires on a value the server has already
  confirmed. Input normalises to lowercase + [a-z0-9_-] and caps at 32
  chars so obviously invalid inputs skip the round-trip. Matches the
  desktop SettingsAccount UX.

- profiles.test.ts moves to the typed `invoke()` helper that PR 2/3/4
  already migrated to — drops the file-local `ctxFor: any`, the dynamic
  per-test imports, and one eslint-disable. 8 tests still pass, no
  behaviour change.

- (CSS2) Narrow-viewport media query at ≤480px stacks the me/profile
  headers, collapses share-row actions under the title, lets the claim
  form's button take a row, and tightens outer padding. Cards (560px
  max) already fit phone widths; the header chrome around them was the
  weak spot. New .me-handle-status classes carry tone variants for the
  live availability indicator.
The new .me-handle-status node uses flex-basis: 100% to take a full
row beneath the input + Claim button. Without flex-wrap on the
parent, that forced the button off the right edge on desktop widths.
Profile + Me + SignIn now use the shared chrome (Page/Header/Footer/Avatar/Icon) introduced in feat/share-web-reader. Me's identity row drops the email line in favour of @handle as the public face; the email moves to a small 'Signed in as …' anchor at the bottom of the card (GitHub-style).

Other fixes: SignIn defaults to /me when next='/' (share-web doesn't own root); sign-out lands at /sign-in instead of '/'; HandleClaim status icons (check-circle/x-circle/alert/spin) and tone variants align with the design system; ShareRow swaps text buttons for compact icon buttons (View / Copy / Unpublish) and visibility pills.
Render the sign-in providers from a data-driven list rather than hard-coding a single Google button. v0.5 ships [google] only; adding GitHub or email is one entry here + a matching backend provider registration — no markup or CSS change. The OAuth start URL is now /api/auth/<provider>/start so the share-backend [provider] router routes the right verifier.
Comment thread packages/share-web/src/pages/SignIn.tsx Fixed
CodeQL flagged window.location.replace(dest) as js/client-side-
unvalidated-url-redirection because the static flow analysis can't see
through nextSafe(). nextSafe already strips //, /\, .., and the
javascript:/data:/vbscript: schemes, but a future maintainer loosening
nextSafe wouldn't break the call site's local guarantee. Re-asserting
the same-origin shape at the redirect site makes the safety locally
provable and avoids the medium-severity scanning alert blocking merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread packages/share-web/src/pages/SignIn.tsx Fixed
…gin guard

The previous belt-and-braces (string-prefix check) wasn't visible to
js/client-side-unvalidated-url-redirection — CodeQL re-flagged it on
the rerun. Using new URL(dest, origin) + origin equality is the
canonical pattern the rule does recognise, and we keep nextSafe() as
the primary upstream filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@graydawnc
graydawnc added this pull request to the merge queue Jun 5, 2026
Merged via the queue into main with commit 30cf2c8 Jun 5, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-web-profile-me branch June 5, 2026 20:56
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