Skip to content

security(persist): never eval persisted code + hardened CSP (TODO 10) - #102

Merged
lopugit merged 48 commits into
claude/todo9-date-reviver-strictfrom
claude/todo10-eval-csp-s3
Jul 29, 2026
Merged

security(persist): never eval persisted code + hardened CSP (TODO 10)#102
lopugit merged 48 commits into
claude/todo9-date-reviver-strictfrom
claude/todo10-eval-csp-s3

Conversation

@lopugit

@lopugit lopugit commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Fixes TODO 10 — 🔒 SECURITY: persisted-state eval = arbitrary code execution on load; add a CSP (spec: TODO/claude-todo/09-security-hardening.md §C).

Stacked on #94 (claude/todo9-date-reviver-strict) — both PRs rewrite the same reviver/replacer in ThingtimeProvider.tsx; stacking avoids a guaranteed conflict. Merge #94 first, then this.

The vulnerability

The localforage persist reviver ran eval(value.code) (plus a second scoped eval) for any parsed {ttype:'function'} value, and the whole thingtime tree revives on every load. Anything able to write same-origin storage — an XSS, a browser extension, another tab — could plant a payload giving persistent arbitrary code execution on every subsequent load. No CSP existed anywhere.

The fix

1. Persisted code never executes. {ttype:'function'} values now revive as inert warning stubs (no eval, no scoped re-eval), and the replacer drops functions from persist entirely — load falls back to code-defined defaults for those slots. Legacy blobs keep working; their function entries just stop being live code.

2. Hardened CSP on the built shell. A build-only Vite plugin (cspMetaPlugin in remix/vite.config.ts) injects a CSP <meta> tag into dist/index.html:

  • script-src 'self' 'unsafe-eval' 'sha256-…' 'sha256-…' — the two hashes are computed at build time from the shell's own inline scripts (theme pre-paint + title prefix). No 'unsafe-inline' for scripts, so injected inline/remote scripts are blocked.
  • style/font allowances for Emotion/Chakra inline styles and Google Fonts; object-src 'none'; base-uri 'self'.
  • Delivered as a meta tag deliberately: Vercel serves index.html via filesystem routing (bypassing Nitro), so a meta tag applies wherever the static shell is hosted. Dev is untouched (apply: 'build').
  • No frame-ancestors on purpose — embedding Thingtime is a feature (embed SDK, default-open SSO origins, PR Thingtime SSO: default-open origins — any URL (incl. preview deploys) can use Login with Thingtime #87).

Why 'unsafe-eval' stays (deviation from the TODO's ideal)

smarts/index.tsx and both Commanders evaluate user-typed JS by design (≈20 eval sites — command execution, $context scoping, property access). A CSP without 'unsafe-eval' bricks the Commander outright. This PR eliminates the actual persistence-RCE vector (storage-planted code) and blocks script injection; dropping 'unsafe-eval' is a separate migration (sandboxed evaluator for smarts), noted in TODO/TODO.md and the spec checklist.

Verification

Node suite (26 checks, reviver/replacer extracted verbatim from the shipped file, real flatted): an attack payload {ttype:'function', code:'(globalThis.__pwned = true, …)'} does not execute at parse time nor when the stub is called; functions are dropped from persist while sibling values survive; all TODO 9 date/string semantics unchanged.

Live against the built shell (build:client + vite preview, Browser pane):

  • CSP meta present with both hashes; in-browser crypto.subtle hash of the served inline scripts equals the meta hashes
  • app boots and renders fully under the CSP; zero console errors/violations from its own resources
  • a dynamically injected inline <script> is blocked (script-src-elem:inline violation — the only violation recorded)
  • eval still works in-page (Commander/smarts safe)
  • (The [LC] tab-title prefix disappearing under preview is pre-existing root.tsx:92 document.title = routeTitle behavior with no server loader data — unrelated to the CSP; hash-allowed scripts run.)

Also updates TODO/TODO.md item 10 and the §C/“Done when” status in claude-todo/09-security-hardening.md (A/B/D were already shipped).

Session 3 of 10 parallel todo sessions; claim branch pushed before work started.

🤖 Generated with Claude Code

lopugit and others added 3 commits July 19, 2026 04:45
Replaces the resolver from PR #81 (which granted the model unrestricted Bash(git:*)/Bash(gh:*)) with a defense-in-depth version: the model only edits+stages, enforcement is re-derived from git's object store (merge-tree) rather than tamperable files, git-grep pager breakout removed, global/system git config pinned to /dev/null, persist-credentials off with env-injected push auth, hook-proof git throughout, deterministic graphify-out one-side reset with subtree-OID verification, pre-AI-spend guard for workflow-file merges (optional CONFLICT_RESOLVER_PAT lifts it), repository_dispatch stacked-PR cascade with depth cap, and phased failure comments. Exactly one push-triggered resolver may exist; two race each other's pushes.

Adversarially reviewed across 4 multi-agent panel rounds (~100 agents); every sustained finding fixed and re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion cleanup

Every logical collection now lives in a versioned physical collection
(things -> things_v2, rosters -> rosters_v1), with the version coming from
COLLECTION_SCHEMA_VERSIONS - now the complete registry of all 20 collections.
Once a migration moves the database forward, every generation below the
current version can safely be deleted from the MongoDB backend.

- collectionNames.ts: pure single-source resolver (physicalCollectionName,
  classifyPhysicalCollections) + colocated node --test unit tests
- collections.ts: getCollection(logical) choke point, all named getters and
  ensureIndexes resolve through it; boot-time adoption renames unversioned
  legacy collections in place (instant, index-preserving) before any caller
  gets a handle, recording issues instead of throwing when rename is
  unavailable (Atlas M0) or both names exist
- migrations.ts: all migrations resolve through getCollection; new
  merge-legacy-collections copies leftover legacy docs forward by _id
  (insert-if-absent, unique-key conflicts skip + note, source never deleted);
  new drop-stale-collection-generations (destructive: true) is the only place
  old generations are removed - refuses legacy collections with unmerged docs
  and generations a pending migration still reads (sourcePhysicals), never
  touches unknown collections or generations above current
- getMigrationStatus: census gains physical names, a storage-generations
  listing, and live-derived adoption issues; run endpoint requires
  confirm: true for destructive migrations
- raw query API keeps logical names client-side; queryRunner resolves the
  collection, collStats, and every $lookup/$graphLookup/$unionWith target
  (resolvePipelineCollections) to physical names at execution
- MigrationsPanel: physical column, storage-generations table, adoption
  banner, destructive badge, confirm passthrough
- removed the dead legacy Mongo path (getUser, userCreateSession,
  userCheckExists, userValidatePassword, mongodb/{client,connection,db,
  collection}.ts) - the last unversioned-access bypass
- docs: FUNDAMENTALS section 3 physical-versioning contract, CLAUDE/AGENTS
  pointers, TESTING checklist, apiDocs migration endpoint updates

Verified live against local Mongo: adoption renamed all 20 collections in
place (sessions survived, unknown thingTypes untouched), merge copied 15
docs + skipped 1 unique-key conflict, cleanup 400s without confirm, dry-run
listed 20 candidates, confirmed run dropped 19 and kept the collection with
unmerged docs; census/generations/confirm flow exercised in the browser at
desktop + mobile widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DO 10)

The localforage reviver eval'd {ttype:'function'} payloads on every load,
so anything able to write same-origin storage (XSS, extension, another
tab) gained persistent arbitrary code execution. Persisted functions now
revive as inert warn stubs and functions are dropped from persist
entirely (load falls back to code-defined defaults).

A build-only Vite plugin injects a CSP meta tag into the static shell
(meta so it applies wherever Vercel serves index.html, bypassing Nitro):
script-src 'self' + sha256 hashes for the shell's two inline scripts --
no 'unsafe-inline' for scripts, so injected inline/remote scripts are
blocked. 'unsafe-eval' is retained deliberately: smarts and the
Commander evaluate user-typed JS by design; dropping it is blocked on
migrating them off eval. No frame-ancestors -- embedding is a feature.

Verified: 26-check node suite (attack payload never executes at parse or
stub-call; functions dropped; TODO 9 date semantics unchanged) and live
against the built shell via vite preview -- app boots and renders under
the CSP, zero violations from the shell's own scripts (hash equality
confirmed in-browser), an injected inline script IS blocked, and eval
still works for the Commander.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview, Comment Jul 26, 2026 12:48pm

Request Review

lopugit and others added 19 commits July 22, 2026 09:56
…/:id permalinks

- ReactionControl: controlled reaction picker opener — touch-and-hold opens
  the picker on mobile (Chakra trigger="hover" never fires on touch), hover
  still opens it on desktop, plain tap quick-reacts. Shared by post cards and
  comment rows.
- Comments share the post schema: rich comments are ["post","comment"]
  things (photos, marketplace listings, thingtime things) validated by the
  post crystal rules; the comment payload now carries the post vocabulary +
  reactions + reply counts; comments are reactable and replyable; comments
  always inherit the thread root's audience; feeds/profiles exclude them.
- Simple comment UI unchanged, with a context icon opening the full
  PostComposer in comment mode.
- Every post and comment gets a shareable /post/:id permalink page (comments
  include parent/root thread navigation) and every timestamp is a link to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chakra closeOnBlur treats a keyboard dismissal as leaving the popover, so
searching for a reaction and closing the keyboard nuked the picker mid-use.
New useOutsideTapClose hook closes on a real outside tap (capture-phase
pointerdown) instead of focus loss — applied to the reaction picker, the
share composer (same caption-loss bug), and the schemas-page picker. Escape
still closes via closeOnEsc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…env leak

- dev.mjs bootstraps missing dependencies (pnpm install --prefer-offline
  with CI=true so the modules-purge prompt can never abort without a TTY)
  before booting — a fresh worktree previously died with
  'sh: vite: command not found'.
- ensure-bcrypt + pre-dev.sh now run inside dev.mjs (after the bootstrap,
  with a scrubbed env) instead of as nested npm runs, and dev.mjs deletes
  the npm_config_verify_deps_before_run / npm_config__jsr_registry vars
  pnpm 11 leaks into the environment — killing the repeated
  'npm warn Unknown env config … will stop working' noise.
- pnpm-workspace.yaml sets confirmModulesPurge: false for every
  non-interactive install path (PM2, agent shells, worktree bootstraps).

Verified in a fresh worktree from origin/main: first run auto-installs in
~4s and boots vite+nitro on the worktree port trio; second run skips the
install; nested npm warnings are gone even with a polluted shell env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leanup

- Comment reactions repaint immediately (optimistic toggle + server
  reconcile + revert on error) — no more waiting a network roundtrip for
  the reaction to show as added/removed.
- Reply/replies on a comment open INLINE: a reply input plus the loaded
  thread render right on the current page (recursive CommentRow); nothing
  navigates away. The /post/:id permalink stays on the timestamp.
- Composer thingtime tab is a clean slate: the embedded editor drops its
  window toolbar (traffic lights + tmp.<session>.New Thing path) via a new
  EditorSplit chromeless mode, the tree hides its root key (hideRootPath —
  the hover/hold context trigger still renders), and the draft seeds as
  undefined so the editor opens on the bare "Imagine.." slate instead of
  an {} object. Popped-out floating windows keep their chrome.
- Converting an empty object/array value to an Editor.js document starts a
  BLANK doc (placeholder showing) instead of a paragraph containing the
  literal {} / [].
- ThingContextMenu's modal and context presentations portal to body:
  position:fixed inside the composer's transformed editor frame was
  trapped and clipped (the cut-off Convert-to/style panel + locked scroll
  on mobile). The popover presentation already portalled; now all three do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a text field inside a popover is focused (the on-screen keyboard is
up), an outside TOUCH tap now just blurs it — the picker/composer stays
open; the next outside tap closes it. Mouse clicks (desktop) still close
immediately. Applies to every useOutsideTapClose surface: the reaction
emoji picker, the share composer, and the schemas-page picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c sends, full picker on comments

- viewport meta gains maximum-scale=1: stops iOS Safari's input-focus
  auto-zoom (user pinch-zoom still works on iOS 10+).
- Only one EMPTY reply input is open at a time per card (ReplyFocusContext);
  rows with typed drafts always keep their input open.
- Comment/reply text autosaves as a per-user draft (useCommentDraft,
  localforage-backed like the thingtime editor drafts, debounced) — leave,
  reload, continue where you left off; a stored draft reopens its thread on
  mount. Device-local for now: server-side profile sync is its own follow-up.
- Comment and reply sends are optimistic: the comment renders instantly
  (pending row at 60% opacity), the server copy swaps in on success, and a
  failure removes it and gives the text back — no more send spinners.
- Comment rows get the + → full EmojiPicker (recents recorded), same as
  posts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…replies, DevKit reset

- The "N comments" counter now counts the WHOLE thread — every descendant
  via one $graphLookup per page (plus legacy-era entries the graph can't
  see) — so a post with replies-to-replies reports the real total.
- The counter is clickable and toggles the comments section.
- Threads render two levels deep by default: resolveRelated ships the
  latest 5 replies per comment (with their own reactions + reply counts),
  and every level pages 5 at a time — "Show more comments/replies" reveals
  5 more per click (fetching the full reply list when the preload is short).
- Reply and thread controls split: "Reply 💬" toggles the input (still one
  empty input at a time), "N replies 🧵" toggles the thread.
- Reply inputs get the ⤢ full composer (photos/listing/thing rich replies),
  same as the comment input.
- DevKit panel gains a 🎯 reset-position button: clears the dragged spot,
  closes the panel, and the trigger returns to its bottom-right corner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cross reopen

Clicking "N comments" opens the section on its first page (5 comments,
Show-more paging beyond). The revealed count is component state, so closing
and reopening keeps everything you'd already expanded — it resets only when
the page is left or refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the placeholder gradient AppIcon with a pixel-crisp 1024x1024
render of the Thingtime tree t (remix/public/favicon-tree.ico), drawn as
exact colour blocks (#A8E61D canopy, #9C5A3C trunk) on an opaque white
background per App Store icon rules. Bump CURRENT_PROJECT_VERSION to 10
for the TestFlight upload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
npm run dev: self-bootstrap fresh worktrees + silence pnpm-11 env-leak warnings
Audit of every brand raster in the repo found the legacy favicon-generator
sets were smooth-scaled (blended edges, 11-88 unique colors instead of 2-3):
favicon.ico, favicon-16/32/96, all apple-icon-*, android-icon-*, ms-icon-*
in remix/public, and the favicon_io (2) set in resources/favicon.

Regenerate every one as an exact nearest-neighbour cell grid via the new
remix/scripts/gen-pixel-icon.sh (symmetric cell split, transparent bg,
k=2 colors). favicon-tree.ico moves 388->384px for uniform 128px cells and
adopts the Logo.tsx canonical green #A8E61D (was off-by-one #A8E61C).
favicon.ico is now a real 16+32+48 multi-size ICO.

iOS icon-1024 redrawn with power-of-2 geometry (256px cells, 128px margins)
so cell edges land on integer pixels at common rendered sizes; bump build
to 11 and re-upload to TestFlight.

Already crisp, untouched: the six 63px favicon variants (exact 21px cells)
and branding/thingtime-horizontal.svg (vector). Not the logo, untouched:
favicon_rainbow.ico, raycast/assets/command-icon.png.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The batch-6 refactor left thread fetching only on 'Show more replies', so
opening an UNLOADED thread (a level-2 comment's replies, which don't ship
in the payload) showed just the button and no replies. A shared openThread()
now fetches on every open path — the replies toggle, the reply input, and
the draft auto-open — so the first page appears immediately; optimistic
sends that race the fetch are kept and deduped. Loaded replies + reveal
depth persist across close/reopen and reset on refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… skeleton

- Every rendered comment row auto-fetches its missing reply depth on mount,
  so revealing a level is instant — and freshly revealed rows prefetch the
  NEXT depth themselves, cascading as you go deeper.
- Opening a thread / clicking Show more reveals cached replies immediately
  and still fires a background refetch, reconciling any live comments added
  in the meantime (optimistic sends that race the fetch are kept + deduped).
- The only loading state left is a left-to-right shimmer skeleton shaped
  like comment rows, shown solely on a cold open with nothing cached — and
  never rendered alongside the show-more button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A comment's things?id= read fetches its parent as a potential share
original, so the page comment surfaced BOTH as a page doc (pass 1 collected
its replies) AND as its parent's child (pass 2 re-fetched them) — doubling
every reply in commentsByTarget. The client's prefetch/reopen cycle then
painted the duplicates (the reported show → hide → show doubling).

resolveRelated now skips page ids in the pass-2 reply fetch (their children
and reactions were already collected against the page ids), pushComment
refuses same-id duplicates outright, and the client's fetchThread merge
dedupes by id as a last line of defense. Feed preloads verified unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The skeleton was appearing because only two levels shipped: a level-2 row's
replies (level 3) had to be fetched after mount, and a quick tap raced that
prefetch — on a cold serverless hop the skeleton lingered.

resolveRelated's per-level passes are now one loop shipping THREE levels of
replies (REPLIES_PER_LEVEL each, with reactions + direct counts per level;
the last processed level fetches counts only, and seenIds guards cycles).
Opening anything visible renders straight from the payload; level 4+ still
prefetches lazily on row mount — verified a 4-deep thread: level 3 renders
with zero interaction and level 4 reveals in one frame from its prefetch.

Client: the mount prefetch fires exactly once per row (the api identity is
render-unstable and permanently-short rows would have refetched forever),
and the skeleton shows 3 rows with roomier spacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHIPPED_REPLY_LEVELS returns to 1 (direct comments + one nested level per
payload — the default two-level view), and CommentRow gains a depth prop:
only depth-1 rows auto-open their preloaded thread, so each deeper tap
reveals exactly ONE more level. Every reveal stays instant — a row's mount
prefetch pulls its own two-level payload, putting the next depth in hand
before it can be tapped. All the dedupe/prefetch-once/skeleton fixes stay.

Verified on a 4-deep thread: default shows levels 1-2 only; tapping level
2 reveals level 3 alone; tapping level 3 reveals level 4 within 80ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed type tabs

- threadCache.ts: loaded reply threads live OUTSIDE component state (module
  map + localStorage tt-thread-cache, 5-day TTL, newest-400 cap, in-flight
  dedupe) — collapsing a parent no longer forgets fetched children, so
  re-expanding paints instantly with no skeleton, across reloads too.
  Reveals still background-refresh so live comments reconcile in.
- Always one depth ahead: the post card prefetches the first HIDDEN depth
  the moment it mounts (short level-1 threads + every shipped level-2 with
  replies), and every thread fetch prefetches the level below what arrived —
  the first taps can never skeleton. Avatar URLs warm into the image cache
  at every step so revealed rows never pop in placeholder avatars.
- The thingtime editor now opens in a bottom-sheet modal from a tappable
  preview (ThingView of the draft once it has content): flush left/right/
  bottom with a padded, rounded top on mobile; centered 680px sheet on
  desktop. Nested comment composers get a real editor instead of a cramped
  inline embed; Done returns to the composer with the draft preview.
- Post type tabs wrap so labels never bunch or overlap on narrow screens.

Verified at mobile viewport: collapse→re-expand→tap repro shows zero
skeletons, first level-3 tap instant from the post-mount prefetch, cache
survives full reloads, sheet geometry 0/0/0 with typing working inside,
tabs wrap cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eeded

Restructure the resolve job into tiers:

1. Conflicts confined to graphify-out/ trees resolve MECHANICALLY: take
   the base branch's side wholesale (repo rule for generated graph
   files), pipx-install graphifyy in CI and re-run `graphify update`
   (text/AST-only, no LLM), push the merge, and comment on the PR with
   a note that semantic AI extraction still needs a local run. Runs
   even when an AI credential exists — deterministic beats tokens for
   generated files.
2. Real code conflicts go to the Claude resolver (unchanged prompt).
3. Code conflicts with no ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN
   secret: abort the merge, leave one deduped PR comment explaining
   what to configure, and fail the job visibly.

Also handles the mergeability race: if GitHub reported CONFLICTING but
the merge applies cleanly, push it and note that on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y-fallback

ci: graphify-only conflict fallback — mechanical resolve + text-only graph refresh, no AI key needed 🔀🪄
lopugit and others added 26 commits July 25, 2026 14:11
Add Thingtime/Resources/AppIcon.icon — a hand-authored Icon Composer
package (white fill, canopy + trunk SVG layers with exact srgb brand
fills, glass/specular disabled) so iOS 26 renders the tree flat and
crisp instead of applying its auto-glass emboss to the legacy bitmap.
The appiconset remains as the pre-iOS-26 fallback.

Both flat and glass-chip variants were previewed on an iOS 26.5
simulator home screen; the flat look was chosen.

Add a fastlane upload lane for uploading an already-built IPA, bump
CURRENT_PROJECT_VERSION to 12, and document the .icon manifest gotchas
plus the beta-actool hybrid build workaround in iOS/AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… repost/share split

- Action row is icon + count only (X-style): comment, repost, share left;
  the merged react button pinned to the card's right edge so every react
  control — post and all comment depths — shares one right edge (IG-style).
- Merged react button shows everyone's reactions (top token emojis + total,
  heart outline at zero, accent tint when the viewer reacted); click, hover,
  and touch-and-hold all open the quick-react popup (tapOpens mode on
  ReactionControl, with preventDefault so Chakra's trigger toggle can't
  close a hover-opened popup on click).
- Comment rows: icon-only reply toggle under the bubble, "View N replies /
  Hide replies" link below the comment (FB/IG), show-previous reveals below
  the lists, reply avatars (20px) smaller than parent comments (28px),
  indent capped past depth 4 so deep chains can't squeeze off 375px.
- Share split: repost menu (instant optimistic repost + quote composer with
  caption/circle, both inheriting the original post's visibility and an
  outward share icon (native sheet / copy link, link handed over in a toast
  when clipboard is unavailable).
- Guards: reacting on a pending (in-flight) comment no-ops; zero-width-space
  join keeps adjacent reaction glyphs from shaping into one emoji; shared
  AnchoredEmojiPicker replaces the duplicated popover blocks.
- TESTING.md: new checklist section for the engagement row + comment threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
)
- No max depth, no flattening: opening replies (or the reply input) at
  visual depth 4 REFOCUSES the comments panel on that comment — it slides
  in from the right as the new top-level row (28px avatar), a back arrow
  slides you back out one level per tap, and its replies restart at depth
  1 relative to it. Repeatable indefinitely; closing comments exits the
  drill-down back to the conversation root.
- Focus is a stack on PostCard (ThreadFocusContext hands focusThread to
  rows at the cap); slide-left/right keyframes pick direction from
  push vs pop; the focused root opens its replies on mount (defaultOpen),
  served instantly from the thread cache in the common case.
- Server: canViewInherited and the /post parent-chain walk no longer cap
  legitimate comment chains at 4 hops (deep replies 404ed as "Post not
  found") — cycle safety now comes from a visited set, with a 64 rail for
  stack safety only, matching the crystal depth-rail convention.
- TESTING.md: drill-down checklist replaces the indent-cap expectation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion-versioning-7cbc86

# Conflicts:
#	TESTING.md
…ing-7cbc86

Version physical MongoDB collections as <name>_v<N> with safe generation cleanup
…chains

- schemas/registry.ts: sanitizeDataValue and checkExtendedKeys are now
  ITERATIVE (explicit work stack) with identity WeakSets — circular or
  repeated object references 400 loudly, nesting never touches the JS call
  stack, and the 64-level rails (MAX_DATA_CRYSTAL_DEPTH, MAX_EXTENDED_DEPTH)
  are gone entirely. The one depth-shaped bound left is the database's own:
  mongod physically refuses BSON nesting past 180 levels per doc, probed
  live on mongod 8.0.1 through the real create API (179 levels inside
  crystal/extended store; 180 is refused with "BSONObj exceeds maximum
  nested object depth"). MAX_STORABLE_NESTING=179 reports that as a precise
  400 naming MongoDB instead of a raw driver 500. crystal.thing payloads
  carry their one-level-deeper offset (base {path:'thing', depth:2}) —
  verified exact: 178-deep thing saves, 179 400s with the prefixed path.
- api/utils/things/things.ts: canViewInherited and the permalink
  parent-chain walk drop their 64-hop stack rails too. The visited set alone
  terminates both walks (finite db + no revisits), canViewInherited keys on
  shareId with an _id fallback so termination holds unconditionally, and
  each hop awaits a db read so chain length never grows the call stack.
  Verified by seeding an 85-deep reply chain through the real API
  (multi-user round-robin past the comment rate limit) and resolving the
  deepest comment's parent + root through GET /api/v1/things?id=.
- components/Feed/PostCard.tsx: the post's merged react button moves to sit
  DIRECTLY beside the comments icon (per feedback); comment rows keep their
  IG-style right-aligned react columns. Live-verified desktop + 375px
  mobile: popup placement (no clipping at the left position), optimistic
  toggle both directions, and the drill-down focus panel on the fresh
  85-deep chain (root -> depth 4 panel with back arrow).
- TESTING.md: engagement-row, thread-depth, and crystal checklists updated
  to the no-rail semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ight-0a4b22

iOS: use the Thingtime tree t icon as the app icon (TestFlight build 10)
…solver-action

# Conflicts:
#	.github/workflows/pr-conflict-resolver.yml
#	graphify-out/GRAPH_REPORT.md
#	graphify-out/manifest.json
…d pills

- CommentRow: the merged react control leaves the right-edge column and sits
  INLINE beside the reply icon under the bubble, mirroring the post's
  comments-then-react row. Restyled horizontal (emoji + count side by side,
  heart outline at zero) for the inline position.
- Single tap on a comment's react control now HEARTS the comment (default
  ReactionControl mode, onQuickTap ❤️ — optimistic, tap again to unheart);
  hover and touch-and-hold still open the quick-react popup, and the post's
  merged button keeps tapOpens (click opens the popup) per feedback.
- "Write a comment…" and "Reply to…" pills are subdued: house border grey,
  soft ink text, muted placeholder, muted hover/focus border — replacing
  Chakra's default near-black outline.
- Live-verified desktop + 375px mobile: inline controls render at every
  depth without wrap/overflow, single-tap heart applies instantly and no
  popup opens on click, hover still opens the popup, and the composer pill
  reads grey. TESTING.md checklist updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the graphify-only capability from PR #144 into the hardened resolver so superseding pr-conflict-resolver.yml does not regress graph freshness: after the resolution is verified and committed, graphify update runs AST/text-only (no LLM, no API key) on a pristine tree and is committed separately, before the push.

Ordering is load-bearing: the verify step asserts the staged graphify-out subtree still equals the base side, so refreshing any earlier would fail that check by design. The tree is reset --hard + cleaned first so stray model edits can never be indexed, and a guard refuses the refresh if graphify touches anything outside graphify-out/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
View/Hide replies now starts flush with the comment reply icon's glyph
(same paddingX ladder) instead of hanging off a 20px divider line; the
line denoting the linked parent is gone. Verified live at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review of the #144 port found four defects, all fixed and verified locally:

1. SECURITY: 'git clean -qfd' skips IGNORED files, so a model-planted graphify-out/.graphify_labels.json (read verbatim into the tracked GRAPH_REPORT.md) or a graphify-out/cache/ast/** entry (injecting nodes/edges into graph.json) survived the pristine-tree reset and would have been committed — smuggling model-authored content past the verify step. Now 'clean -qffdx' plus an outright rm+checkout of graphify-out. Verified: both planted files survive -qfd and are removed by -qffdx.

2. Any unguarded failure was fatal, which SKIPS the push step and discards the verified resolution after full AI spend (reachable via 'git add' on a graphify-out deleted by the base side: fatal rc=128). Now continue-on-error + a step timeout + an existence-guarded staging loop.

3. 'git add -A -- graphify-out/' swept in graphify's own leftovers (.graph.tmp.json, needs_update). Now only the tracked portable outputs are staged.

4. Unpinned graphifyy could rewrite the whole graph on version drift, manufacturing the very conflict the step exists to resolve. Pinned to 0.9.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

Replaces pr-conflict-resolver.yml with the hardened resolve-pr-conflicts.yml. The model gets no shell and no git access; all git is deterministic and enforced from git's object store. Ports #144's in-CI graphify refresh (AST-only, pinned) so retiring the incumbent loses no capability.
…evalidation fix

- reactionDisplayEmojis replaces topReactionEmojis: every token the viewer
  reacted with always shows (their full set, first), then the crowd's top
  remaining tokens by count (2 on comments, 3 on the post button). Verified
  live: a second user's 😮 renders AFTER the viewer's 😂🔥❤️ set despite
  equal counts.
- Stale-thread fix this surfaced: rows that mount with their thread OPEN
  (two-level ship, drill-panel roots) now force the background refetch —
  a cache-complete thread previously skipped it entirely (loaded >=
  commentCount bail), freezing reply reactions/edits at the cached snapshot
  until a manual close/reopen. Server-confirmed hearts now reconcile in
  after reload.
- TESTING.md: both behaviors added to the engagement checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the disappearing/reappearing/uncounted reactions on mobile (screen
recording): background fetches (thread revalidation, feed and profile
stale-while-revalidate, permalink loads) snapshot the db BEFORE the
viewer's tap but land AFTER its optimistic paint — ingesting those copies
wholesale wiped the reaction, which flickered back on ack or stayed wrong
when the fetch landed after the ack. Real network latency (Vercel preview)
made the windows wide and constant.

New merge layer, components/Feed/reactionOverlay.ts:
- every local reaction mutation (optimistic paint, server ack, failure
  revert) notes its resulting counts per thing id, timestamped;
- every ingestion of server copies merges through the overlay stamped with
  the fetch START time: fetches started before the last note keep the
  overlay's reaction fields (entry retained), fetches started after it
  carry the mutation server-side and retire the entry;
- recursive over shipped reply trees, referentially stable when nothing
  matches.

Wired at every ingestion point: fetchThreadInto (single choke point for
all thread paths, stamps + merges + caches merged copies), getCachedThread
(merges with the entry's write time), CommentRow payload seeds (epoch 0),
Feed + ProfilePage post pages (search and feed paths), and the /post
permalink load (post + parent + root). Both react handlers note
optimistic, ack, and revert states.

Deterministically verified in-browser by delaying thread GET responses 3s
and tapping inside the window: the stale response lands and the tap
survives; a clean reload converges on server truth and retires the
overlay; re-hearting sticks. TESTING.md gains the race checklist line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UserAvatarCircle (drawer footer, feed/comment composers) now renders the
  user's avatarUrl image when set — the rainbow initial circle is only the
  no-avatar fallback, matching AccountSwitcher's RowAvatar.
- Mobile drawer: clicking a NAVIGATING menu item (top-level or sub-item)
  now dismisses the drawer after navigation; items without a destination
  only select their submenu and keep it open. Desktop panel behavior is
  unchanged (DrawerSystem's onNavigate closes on mobile only).
- New per-item 'Close after click' setting (settings.drawer.closeOnClick,
  default ON) in Settings -> Drawer: every auth-visible menu item, children
  nested, each with a switch — off restores the old explore-the-submenu
  behavior for that item.
- Live-verified at 375px: Things click navigates + closes; toggling Feed
  off persists and keeps the drawer open on Feed clicks; avatar photo
  renders in the composer, drawer footer, and account switcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it chains

canViewInherited capped tt:inherit chain resolution at depth 4, so a comment
nested 5+ levels below its root post became invisible to every single-thing
path — react, comment, save, share, and the /post/:id permalink all returned
404 "Post not found" — while the feed (query-side match + owner shortcut)
still rendered it. Confirmed on live thingtime.com: depths 0-4 of a real
reply chain resolved, exactly the 5-deep replies 404ed.

The depth cap only ever existed to stop pathological cycles, so replace it
with real cycle detection: extract the walk into aclChainCore.ts (pure, DB-
free, quotaCore-style) bounded by a visited set plus a far-out-of-band
MAX_INHERIT_CHAIN=256 ceiling. Broken chains (deleted/missing targets) and
cycles still fail closed.

- node --test unit suite (npm run test:acl): deep-chain regression, ceiling
  edge, missing target, dangling inherit, cycle/self-cycle
- E2E via real API against the dev stack: 8-deep comment chain, react + GET
  succeed at every depth (previously failed from depth 5)
- TESTING.md: new checklist section for inherit-chain interactions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dismissAfterNavigate in DrawerContent runs onNavigate (popup dismiss +
mobile panel close) AND setOpen(false), so navigating top/sub items, the
brand link, and editor-section items now dismiss the pinned desktop panel
as well. The per-item 'Close after click' switches gate both viewports;
search is untouched (openSearch still honours the separate 'Search closes
drawer' setting, and mobile search still force-closes so the commander is
never buried).

Live-verified at 1280px: Docs/Account clicks navigate + close the panel;
Feed with its switch off navigates and the drawer stays open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ved conflicts)

Conflicted paths: remix/app/api/utils/things/things.ts

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/30194952822

Co-Authored-By: Claude <noreply@anthropic.com>
…ound-0630bb

Fix live "Post not found" on deep comment reactions (inherit-chain depth cap)
@lopugit
lopugit merged commit b81ea77 into claude/todo9-date-reviver-strict Jul 29, 2026
5 checks passed
@lopugit

lopugit commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Consolidated into #94 (session 3/10): the eval-removal + CSP commit has been merged into the date-reviver branch, so #94 now carries both TODO 9 and TODO 10. Closing this to leave a single PR. See #94.

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.

1 participant