feat(share-web): /<handle> profile + /me dashboard + sign-in#365
Merged
Conversation
- 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.
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theirprofile-listedshares. The/<handle>route is whatunlistedshares 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'sfetchMe,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
Profilepage 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" .-> MProfile page rules
profile-listedshares 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 = 100on 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 NULLon the handle query — released handles 404 immediately. Ifalexreleases their handle and someone else claims it, the new account's profile is what/alexshows, not a frozen snapshot of the previous owner's shares.users.deleted_at IS NULLjoined 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)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"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/checkboth return identical error shapes for "invalid format" and "not found":/api/profiles/!@#(invalid format)404/api/profiles/ghostuser(valid but unclaimed)404If we returned
422for invalid and404for 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 onname)pnpm --filter @spool/share-web test— api.test.ts (request shape), route.test.ts (handle vs slug ambiguity)/me, hit/<my handle>in another tab, publish aprofile-listedshare, hit/<my handle>again; release handle,/<handle>404s; schedule delete on desktop, refresh/mein web → pending banner with executeAtRisk
/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 NULLfilters are the trust boundary.tests/_helpers/fakes.tsadds the two new SQL matchers needed by profiles tests without touching the existing ones.Submitted by @graydawnc.