Skip to content

feat(things,tokens): re-land hidden πŸ•΅οΈ links + PAT GET bridge + custom audiences 🎭 (#413/#431 never reached develop) - #613

Merged
lopugit merged 23 commits into
developfrom
claude/hidden-links-custom-audiences-reland
Sep 7, 2026
Merged

feat(things,tokens): re-land hidden πŸ•΅οΈ links + PAT GET bridge + custom audiences 🎭 (#413/#431 never reached develop)#613
lopugit merged 23 commits into
developfrom
claude/hidden-links-custom-audiences-reland

Conversation

@lopugit

@lopugit lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner

βœ… Develop S3 preview ready

The alias passed the develop bucket CORS preflight and a final live PR/SHA fence.

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

Why this PR exists

PR #413 (hidden πŸ•΅οΈ link-key visibility + the /api/v1/get PAT bridge) and PR #431 (custom audiences 🎭 with capability grants + groups) both show as merged, but they were merged into their stacked base branches a minute after PR #411 (that base) had already merged into develop. Neither merge commit is an ancestor of develop or main: develop has no /api/v1/get, no tt:custom, no /api/v1/groups, no CustomAudienceModal, and VISIBILITIES is still the four-circle list. This branch re-lands the published feature stack (origin/claude/hidden-links-get-bridge @ d826cf6, which contains #431) onto current develop.

What landed on the branch (unchanged from #413 / #431)

  • Hidden visibility ('hidden', acl ['tt:hidden','tt:user'] + rotating owner-only linkKey): ?key= URLs, anonymous keyed viewers, "Copy hidden link πŸ•΅οΈ", key rotation on every entry into hidden.
  • GET bridge /api/v1/get gated per token by meta.allowGet ("Works via GET links 🌍"): cookie-free, private, no-store, mirrors every op's scopes and accounting.
  • Custom audiences 🎭: tt:custom marker + baseline + tt:user/<name>[/comment|/write] and tt:group/<id>[…] capability grants (write βŠƒ comment βŠƒ read), the audience picker, groups managed via /api/v1/groups, grant-aware feed/search circles, "Hidden only πŸ•΅οΈ" token fence mode.
  • verify-pat-tokens.mjs sections G/H/I cover the three features.

Merge with develop (this PR's own work)

Eight files conflicted; resolutions:

  • things.ts: kept both import sets (groupIdsOf + emitMentionNotifications / NotificationActor), kept develop's exported patVisibilityBlocksAcl with the branch's bucket comment, kept REQUESTABLE_VISIBILITIES for circle filtering alongside develop's tag normalization, and kept the custom-audience shared-editing branch and develop's expectedUpdatedAt optimistic-concurrency check in updateThing.
  • patTokens.ts: allowGet and develop's createdVia: 'chatgpt-oauth' both survive in the mint input and the stored row.
  • PostCard.tsx: develop's onChanged(post.id, …) contract applied to the branch's audience/linkKey updates (the feed contract test counts every call).
  • PostComposer.tsx: custom acl on edit + develop's attachment-panel ids. share/_share.tsx: withLinkKeys viewer + develop's tags.
  • TESTING.md: both sides' checklist items (dropped the branch's stale five-circle duplicate). Raycast converter and the deprecated API CORS shim: develop's versions.
  • patScopes.test.ts: the visibility catalog test now expects the four modes (hidden is this stack's addition).

Verification

  • Unit groups green after the merge: acl, things, pat-scopes, api-capabilities, schemas, feed, feed-contract, hooks, nav, auth-introspection.
  • pnpm exec eslint clean on every conflict-resolved file.
  • Live checks against the worktree stack: see the PR note.

πŸ€– Generated with Claude Code

lopugit and others added 15 commits August 28, 2026 01:27
Two features, one stacked branch:

Hidden visibility β€” a fifth audience, 'hidden' (acl ['tt:hidden','tt:user']):
unlisted everywhere (feeds, profiles, listings, search β€” for everyone but the
owner) yet viewable by ANYONE presenting the thing's randomly generated
linkKey (?key= on GET /api/v1/things and the /post page; body.key admits
engagement). The server mints the 24-byte base64url key whenever a thing
ENTERS hidden β€” re-hiding rotates it so circulated links die β€” and projects
it to the owner only. canView honors the key only while the acl still says
hidden, so un-hiding retires links instantly. UI: πŸ•΅οΈ Hidden in the composer
circle select and the post-menu privacy submenu, a Hidden circle chip in feed
filters, and "Copy hidden link πŸ•΅οΈ" in the post menu.

GET bridge β€” per-token allowGet ("Works via GET links 🌍" tickbox) opens
GET /api/v1/get?token=…&op=…: the whole PAT things surface as plain GET URLs
(query-param token + args) for agents that can browse but cannot send
headers, bodies, or non-GET verbs. Cookies are never read (mutating GETs
cannot be CSRF'd β€” the unguessable token is the authorization); scope checks,
free-403 semantics, atomic use accounting, the tt:token sandbox, and the
visibility fence are byte-for-byte the Bearer path via a shared
resolvePatSessionActor core. op=self is free introspection. Responses carry
Cache-Control: private, no-store and Referrer-Policy: no-referrer. Registered
in the server import map + apiDocs (docs-derived route table).

verify-pat-tokens.mjs grows sections G (hidden, 17 checks) and H (bridge,
21 checks) β€” 105/105 pass live, including fence Γ— bridge and fence Γ— hidden
interplay. Browser-QA'd: UI mint with the tick badges 🌍 and drives the
bridge credential-less; composer-posted hidden post is invisible anonymous,
viewable via its secret URL, and offers Copy hidden link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ps, and the hidden-only token fence

Custom visibility: an acl carrying the tt:custom marker names exactly who
can do what. Baseline toggle (tt:all = everyone reads / tt:hidden = link-key
holders read / neither = only the picks), plus per-user grants
tt:user/<name>[/comment|/write] and per-group grants tt:group/<id>[…] with
write βŠƒ comment βŠƒ read. On custom things, general viewers get READ ONLY β€”
commenting/reacting/sharing needs the comment capability (inherit chains
judged on their terminal, saves exempt as private bookmarks), and write
grantees share-edit crystal/extended/tags through PATCH while audience,
folder, token grants, and deletion stay owner-only (storage keeps billing
the owner). Granted things land in grantees' FEEDS via a new
visibilityQueryFor grant clause. Capability grammar, evaluation
(aclCapabilityFor), specificity, and AclViewer.groupIds live in the registry;
viewer group memberships preload beside friendIds.

Groups: protected 'group' + 'group-member' things managed via /api/v1/groups
(list/create/replace-members/delete) + /api/v1/groups/audience-sources
(friends, connections, recents, groups β€” the picker's prefill). Member docs
mirror the friend-doc shape (targetId = member) so every query rides
existing indexes β€” no new MongoDB indexes (the 64-index local budget stays
untouched). Membership edits propagate live to every referencing acl;
member ids validate via findUserById (both user eras).

UI: CustomAudienceModal β€” baseline chips, people search
(/api/v1/users/search), prefilled+filterable Recents/Friends/Connections
sections, per-entry capability selects, save-selection-as-group and
existing-group chips β€” wired into the composer circle select (🎭 Custom +
Edit button; fixed a stale-closure fallback that reset the applied audience)
and the PostCard privacy menu (custom routes through the picker).

Token fence: visibility mode 'hidden' ("Hidden only πŸ•΅οΈ") β€” the token lives
entirely in hidden link-key things; its creates are born hidden and mint
their secret link.

verify-pat-tokens.mjs section I: 29 checks β€” capability matrix (read cannot
comment, write implies comment, writers never touch audience/delete),
baseline toggles (public baseline still gates comments; hidden baseline
mints a key that grants read only), live group grant/revoke, feed grant
clause, audience-sources, protected kinds, hidden-only fence. 134/134 pass.
Browser-QA'd desktop + mobile: full composer flow (search β†’ add β†’ capability
β†’ create group β†’ apply β†’ post) produced the exact acl and enforced it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s-custom-audiences-reland

# Conflicts:
#	Commander/extensions/raycast/src/commands/regexToReplacementConverter.tsx
#	TESTING.md
#	deprecated/api/src/index.js
#	remix/app/api/utils/auth/patTokens.ts
#	remix/app/api/utils/things/things.ts
#	remix/app/components/Feed/PostCard.tsx
#	remix/app/components/Feed/PostComposer.tsx
#	remix/app/routes/api/v1/things/share/_share.tsx
@github-actions github-actions Bot added lopu: mergeable The PR branches can currently be merged without conflicts lopu: overlapping files This PR changes files also changed by another open PR labels Sep 4, 2026
… keyed by id

The 105-check suite (now 149 checks on this stack) crashed in section F
because develop's search responses key posts by thing id; postRows()
accepts both shapes. Full run: 149 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lopugit

lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

πŸ§ͺ Develop S3 preview deploying

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

1 similar comment
@lopugit

lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

πŸ§ͺ Develop S3 preview deploying

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

@lopugit
lopugit temporarily deployed to develop-pr-613 September 4, 2026 13:53 Destroyed
@lopugit

lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

πŸ§ͺ Develop S3 preview deploying

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

@lopugit

lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

🧹 Develop S3 preview removed

The PR-specific alias and every workflow-created develop deployment were removed when this PR closed.

The ordinary generated Vercel Preview remains available on the shared development runtime.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review

Lopu reviewed this PR against develop as Thingtime's principal PR and repository manager. Using Claude Opus 5.

Lopu found no justified local change to publish from this review pass.

Lopu review β€” PR #613

feat(things,tokens): re-land hidden πŸ•΅οΈ links + PAT GET bridge + custom audiences 🎭

  • Head 2c9afc2d vs base develop @ 69f62ed6 (43 files, +3059/βˆ’161 excluding Graphify blobs)
  • Verdict: approve as-is β€” no code changes made. One hardening item and two
    notes raised for the owner's judgement; none block this PR.

What I compared

Full head-vs-base diff in the PR worktree, then read the surrounding
implementation rather than the diff alone for every security-load-bearing path:

  • ACL grammar β€” schemas/registry.ts (splitCapability, aclEntryMatches,
    aclSpecificity, aclCapabilityFor, sanitizeAcl), schemas/acl.test.ts.
  • Read gate β€” things.ts canView / canViewInherited / findViewableThing
    / findViewableThingAs, and the app-lens verdict path beside it.
  • Feed/search DB matching β€” visibilityQueryFor, circleClause,
    patVisibilityMatchClause, search.ts circle validation.
  • Write gates β€” createThing, updateThing, deleteThing, upsertThing,
    sharePost, addComment, toggleReaction.
  • PAT surface β€” patScopes.ts, patTokens.ts (resolvePatSessionActor,
    livePatSessionOf, resolveGetBridgeActor, resolveGetBridgeSelf) and the
    new /api/v1/get bridge route.
  • Groups β€” groups/groups.ts, users/social.ts groupIdsOf, both new
    routes.
  • Secret containment β€” queryContract.ts MONGO_PROTECTED_THING_FIELDS,
    querySafety.ts redaction/strip stages, the toPublicPosts /
    toPublicThings projections.
  • Repo conventions: AI_ALL.md (via CLAUDE.md), FUNDAMENTALS.md Β§3, plus
    the live PR check state and CodeQL snapshot.

Check state

All checks green on this exact head β€” nothing failing, cancelled, or timed out,
so there is no check-failure defect to diagnose. gh pr checks 613 reports
23 passing / 63 skipped, including Build + typecheck ratchet + unit tests,
API suite (headless /tests runner), Analyze (javascript-typescript),
Analyze (actions) and CodeQL. The skips are the control-plane's own
conditional jobs, not suppressed verification.

CodeQL: the trusted snapshot for this head is empty ([]) β€” no open alerts.
The disposition file is correctly left as []; there was nothing to fix or
dispose.

Key findings

Correct, and worth recording as such

These are the places where this kind of change usually goes wrong, and each is
handled:

  • linkKey is a bearer secret and is treated like one. Added to
    MONGO_PROTECTED_THING_FIELDS, so it is hard-stripped at every pipeline
    ingress (primary, $lookup, $unionWith) rather than relying on key-based
    response redaction that $objectToArray could route around. querySafety.ts
    now derives shouldRedactKey from that same list instead of respelling
    secure/uniquekeys inline β€” the drift that would otherwise reappear the
    next time a field joins the list is closed at the source.
  • The key only grants while the acl still says hidden (canView), and
    re-entering hidden mints a fresh key (updateThing), so links that
    circulated during an earlier hidden period cannot resurrect. Un-hiding
    retires every shared link instantly.
  • The token visibility fence outranks the link key. patVisibilityBlocksAcl
    is evaluated before the linkKeys branch in canView, so a fenced PAT
    cannot use a presented key to escape its audience.
  • withLinkKeys preserves the anonymous shell correctly. { id: '' } is
    falsy at every viewer?.id site, so an anonymous key-holder gets exactly one
    power β€” the key β€” and withFriendIds spreads rather than rebuilds the viewer,
    so linkKeys survives enrichment on every interaction path.
  • The circle-filter shortcut is sound. unfiltered requires no filter or
    all REQUESTABLE_VISIBILITIES, not a length comparison β€” so selecting
    "everything except πŸ•΅οΈ Hidden" really omits hidden instead of taking the
    unnarrowed path. The grants clause is gated on the 🎭 chip and then narrowed
    again, so a grant cannot smuggle a custom thing into a 🌐-only filter.
  • search.ts / getFeed validate against REQUESTABLE_VISIBILITIES. This
    is the right fix direction: a silently dropped circle reads downstream as "no
    circle filter", which widens the query β€” the opposite of what the caller
    asked for.
  • splitCapability is anchored. A bare endsWith('/write') would read
    tt:user/write (a legitimately registerable username) as base tt:user β€”
    the owner entry β€” silently giving the picked account nothing and the acl a
    phantom owner grant. Requiring a non-empty subject under a known prefix
    closes it, and registerUser.ts now rejects / in usernames from the other
    side. acl.test.ts pins both directions.
  • aclCapabilityFor floors on view. Returning none when aclAllows fails
    means a positive grant sitting beside a same-specificity exclusion can't hand
    out edit rights on a thing the same acl says the subject may not read. Every
    current caller proves view first, so this was unreachable β€” anchoring it in
    the function rather than leaving it a caller precondition is the right call.
  • Shared editing is scoped tightly. updateThing's lookup dropped
    ownerId from the filter, but the non-owner branch requires tt:custom +
    canViewInherited + capability write, returns a plain 404 (no existence
    oracle) otherwise, refuses acl/visibility/folderId/tokenAcl, and hard
    404s under an app lens. The write itself is keyed on _id, so the CAS still
    holds. deleteThing keeps ownerId in its atomic delete filter β€” write
    does not imply delete
    , which is the conservative and correct choice.
  • The GET bridge is opt-in and cookie-free. meta.allowGet is enforced on
    both the actor and the introspection path; cookies are never read, so a
    mutating GET cannot be forged cross-site with ambient credentials.
    Cache-Control: private, no-store + Referrer-Policy: no-referrer keep the
    ?token= out of caches and downstream Referer headers. Scope check precedes
    use consumption (403s stay free), and the refactor to resolvePatSessionActor
    means the Bearer and bridge paths cannot drift.
  • Prototype pollution is closed on both bridge inputs. Param names are
    attacker-chosen here; __proto__/constructor/prototype are dropped on the
    ?body= JSON and the query overlay, over a Object.create(null) base.
  • Groups can't be joined from the outside. group/group-member are
    PROTECTED_THINGTIME (generic CRUD refuses them), member docs are minted only
    by /api/v1/groups with ownerId = caller, and sanitizeMemberIds excludes
    the owner. There is no self-add path, so groupIdsOf can't be poisoned.
  • groupIdsOf reuses the existing (thingtime, targetId) access pattern, so
    the tt:group grant costs no new index β€” a deliberate nod to the 64-index
    budget noted in the module comment.

Hardening item (owner's call β€” not a live exploit)

A tt:group/<id> grant is not checked against the group's owner.
aclEntryMatches resolves a group entry purely through
viewer.groupIds.has(groupId), and groupIdsOf returns every group the viewer
is a member of regardless of who owns it. sanitizeAcl validates only the
entry's shape. So an acl of ['tt:custom','tt:user','tt:group/<G>'] where G
belongs to a different user hands that third party ongoing control over this
thing's audience: they can add members later and those members gain read (or,
with a /comment//write suffix, engagement/edit) on content they never had.

Why this is a hardening item and not a finding I'd block on: the exposure needs
the owner to voluntarily place a foreign group id in a hand-authored acl. The
picker only ever offers listGroups(userId) β€” the caller's own groups β€” and a
group shareId is an opaque randomUUID surfaced only to its owner via
GET /api/v1/groups. There is no path for an attacker to inject the entry into
someone else's acl (updateThing refuses input.acl from non-owners), and no
path to self-join a group they don't own.

If you want it closed, the natural chokepoint is the write path, not the
read path β€” validating group ownership in createThing/updateThing when the
acl carries tt:group/ entries keeps aclEntryMatches sync and pure (which is
the property the whole module is built around) and costs one indexed lookup only
on custom-audience writes. I deliberately did not implement this: it is a
product decision about whether cross-user group sharing is a feature or a
mistake, and guessing wrong would either add unjustified churn or quietly remove
a capability you may want.

Notes

  • asOwner fails closed, by luck rather than by construction.
    createThing's engage gate uses asOwner, which degrades to { id: ownerId }
    (no username, no groupIds) when viewer.id !== ownerId. Every real caller
    today passes a viewer whose id equals ownerId, so the full viewer is used;
    and an internal caller with viewer = null would be blocked, not admitted.
    Correct outcome either way β€” just worth knowing the degraded shape denies
    rather than grants, since a future caller could otherwise be puzzled by a 403.
  • op=self on the bridge is unrate-limited. It's free introspection by
    design (matching /api/v1/tokens/self) and each call still costs a JWT verify
    plus two DB reads. Tokens are unguessable so it isn't an oracle, but it is the
    one bridge op with no limiter if you ever want a cheap blanket one.

Changes made

None. Everything I checked was already correct, and the one item I'd
consider changing (group-owner validation, above) is a product decision rather
than a defect β€” making that call unilaterally on someone else's branch would be
exactly the speculative churn to avoid.

Validation run

Dependencies installed in the worktree (pnpm install --frozen-lockfile --ignore-scripts), then the suites covering this diff, run as CI runs them
(node --import tsx --test):

Suite Result
test:schemas (incl. new acl.test.ts) 124 pass / 0 fail
test:collections (incl. queryRunner.test.ts) 36 pass / 0 fail
test:feed (incl. hiddenLinkContract, audienceCancelContract) 40 pass / 0 fail
test:things 28 pass / 0 fail
test:hooks 35 pass / 0 fail
test:api-capabilities 10 pass / 0 fail
test:feed-contract 8 pass / 0 fail
test:pat-scopes 6 pass / 0 fail
test:acl (aclChainCore) 6 pass / 0 fail
test:nav 2 pass / 0 fail

295 passing, 0 failing. Lint and typecheck are already covered green on this
exact head by control-plane / Build + typecheck ratchet + unit tests.

One process note for the record: an initial run of queryRunner.test.ts under
bare node --test (before dependencies were installed) reported 4 failures.
Those were an artifact of Node's native type-stripping not resolving the
project's TS path aliases β€” under tsx, as CI runs it, the file is 11/11 green.
Not a defect in this PR.

Handover

A cooperative handover was requested during this session. PR #613 is complete β€”
reviewed, validated, reported. PR #295 was not started and has no report; it
remains for the queued continuation.

View Lopu workflow run

github-actions Bot added a commit that referenced this pull request Sep 4, 2026
…AT GET bridge + custom audiences 🎭 (#413/#431 never reached develop)
github-actions Bot added a commit that referenced this pull request Sep 4, 2026
…AT GET bridge + custom audiences 🎭 (#413/#431 never reached develop)
github-actions Bot added a commit that referenced this pull request Sep 4, 2026
…AT GET bridge + custom audiences 🎭 (#413/#431 never reached develop)
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 02:44 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 02:24 UTC (UTC+00:00) 2026-09-05 19:24 PDT (UTC-07:00) 2026-09-06 12:24 AEST (UTC+10:00)
Estimated finish 2026-09-06 02:44 UTC (UTC+00:00) 2026-09-05 19:44 PDT (UTC-07:00) 2026-09-06 12:44 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 27
Repository Conflicting 10
Repository Out-of-date with target 4
Repository GitHub state unknown 4
Repository Part of an open stack 4
Repository Touch files changed by another open PR 19
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 12
This resolver batch Currently resolving 0
This resolver batch Waiting 12
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 14 changed files are also touched by #10, #295, #564, #578, #595, #596, #602, #607, #610, #611, #612, #631, +6 more.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 02:24 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 03:31 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 03:11 UTC (UTC+00:00) 2026-09-05 20:11 PDT (UTC-07:00) 2026-09-06 13:11 AEST (UTC+10:00)
Estimated finish 2026-09-06 03:31 UTC (UTC+00:00) 2026-09-05 20:31 PDT (UTC-07:00) 2026-09-06 13:31 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 28
Repository Conflicting 12
Repository Out-of-date with target 3
Repository GitHub state unknown 4
Repository Part of an open stack 4
Repository Touch files changed by another open PR 19
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 13
This resolver batch Currently resolving 0
This resolver batch Waiting 13
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 14 changed files are also touched by #10, #295, #564, #578, #590, #595, #596, #602, #607, #610, #611, #612, +6 more.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 03:11 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ•΅οΈ Lopu review β€” #613

Read the full head (90b735ca) against develop (67ae2f2e), in dependency order rather than file order, concentrating on the paths where a mistake is a data leak. Verdict: sound. Two small contract divergences fixed in the worktree; no security defects found.

Checks are green (23 pass / 63 skipping / 0 failing), and the CodeQL snapshot for this exact head is empty β€” nothing to fix or dispose there.

What I specifically verified holds

This PR adds a bearer-secret read path, a cookie-free mutating GET surface, and cross-owner write grants β€” three things that each deserve their own paranoia. All of these check out:

  • The bridge can't be CSRF'd. Cookies are never read; ?token= is the sole authorization, and only meta.allowGet tokens resolve. Scope check runs before consumePatUse, so missing-scope 403s stay free β€” genuinely the same path as Bearer now that both share resolvePatSessionActor.
  • scopeForOp / rateLimitKeyForOp mirror patScopeFor / rateLimitKeyFor exactly, including the reaction/comment-thingtime special cases and the service-account ceiling. The bridge isn't a way around either.
  • Prototype pollution is closed on both ingress paths β€” null-prototype base plus the unsafe-key drop on parsed-body keys and attacker-chosen param names.
  • linkKey is a protected field, not merely a redacted one. The hard ingress strip is the right call β€” key-based response redaction alone can't survive $objectToArray-over-$$ROOT renaming. Deriving shouldRedactKey's set from MONGO_PROTECTED_THING_FIELDS also kills the drift risk the old hand-spelled list carried.
  • Keys only grant while the acl still says hidden, and re-hiding mints a fresh one β€” circulated links die on un-hide and can't resurrect.
  • The PAT fence still runs ahead of the key grant in canView, so a fenced token can't use a key to escape its audience. And coarse βŠ‡ exact holds for the new hidden mode, so no rows are silently lost.
  • updateThing dropping ownerId from its findOne doesn't open an oracle β€” non-owners get a plain 404 unless custom + viewable + write, apps 404 unconditionally, and sandbox / visibility / protected-thingtime checks all still run on the resolved doc afterwards.
  • group / group-member being PROTECTED_THINGTIME is what makes groupIdsOf safe to trust in acl evaluation β€” nobody can forge themselves into a group via generic CRUD. Good.
  • visibilityQueryFor's unfiltered shortcut is right: "everything except πŸ•΅οΈ Hidden" really omits hidden, and circles=hidden returns only the owner's hidden things, so a key-holder still can't enumerate someone else's unlisted content through the feed.

Two details I want to call out as genuinely nice: aclCapabilityFor flooring on view (so a grant beside a same-specificity exclusion yields none, not write) makes the function correct standalone instead of relying on every future caller rediscovering the precondition β€” and anchoring splitCapability to a non-empty grant prefix, backed by the /-in-username rejection in registerUser.ts, closes the "account named write" ambiguity from both sides.

Changes I pushed to the worktree

Both are in this PR's new code, and both are divergences from what _get.tsx's own docstring promises ("each the exact behaviour of its normal endpoint"):

  1. op=share silently dropped tags. POST /api/v1/things/share forwards body.tags and sharePost consumes it β€” deliberately ordering the quoter's caption tags ahead of the original's so they survive the MAX_TAGS cap. The bridge passed only text/acl/visibility, so a bridge share lost the quoter's hashtags entirely.
  2. A bare ?tags=a,b hard-400'd every write. sanitizeTags returns 400 tags must be a list for any non-array. On a URL-only surface that's the natural spelling, and the bridge already csv-normalizes thingtime for exactly this reason β€” tags was the one inconsistent field, failing create, update and share. Same normalization applied next to thingtime; a JSON ?tags=["a","b"] still passes through untouched. apiDocs.ts updated to match.

Validation

test:schemas 124/124 (incl. the new acl.test.ts) Β· test:acl 6/6 Β· test:pat-scopes 6/6 Β· test:collections 36/36 Β· test:things 28/28 Β· test:api-capabilities 10/10 Β· test:feed 33/33 Β· test:feed-contract 8/8 Β· test:hooks 35/35 Β· test:typecheck-ratchet 3/3 Β· eslint clean on both touched files. The last four were re-run after my edits.

The live-stack verify-pat-tokens.mjs sections G/H/I need a running app and a database, so they stay your manual gate β€” they're the right place to prove the three features end to end.

Non-blocking observations

  • Pre-existing on develop, not yours: apiDocs.ts declares headers?: Record<string, string> twice in ApiRequestExample (lines 13/15) β€” tsc reports TS2300. I confirmed the identical text at base 67ae2f2e, so feat(things,tokens): re-land hidden πŸ•΅οΈ links + PAT GET bridge + custom audiences 🎭 (#413/#431 never reached develop)Β #613 neither introduces nor worsens it and the ratchet absorbs it. Better as a one-line follow-up on develop than noise on this branch.
  • groupIdsOf caps at .limit(2000) with no signal when it truncates. Unreachable today (64 groups/owner), but a silent cap in an authorization preload is the kind of thing that ages badly.
  • Group ids are learnable β€” toPublicThings returns acl to any viewer who can see the thing, so a tt:group/<id> entry discloses the id. Only lets another owner address that group with their own content, so not an escalation, but worth knowing the id is an identifier and not a secret.
  • createThing's asOwner loses username when viewer.id !== ownerId, which would make a tt:user/<name> engagement grant miss. I traced every call site β€” all pass a viewer whose id equals ownerId, so it's latent, not live.

Happy to approve once you're comfortable with the two bridge fixes. 🎭

β€” Lopu

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 03:47 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 03:27 UTC (UTC+00:00) 2026-09-05 20:27 PDT (UTC-07:00) 2026-09-06 13:27 AEST (UTC+10:00)
Estimated finish 2026-09-06 03:47 UTC (UTC+00:00) 2026-09-05 20:47 PDT (UTC-07:00) 2026-09-06 13:47 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 22
Repository Conflicting 9
Repository Out-of-date with target 2
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 1
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 14 changed files are also touched by #10, #295, #564, #595, #602, #607, #610, #611, #612, #631, #638, #649, +3 more.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 03:27 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 04:00 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 03:40 UTC (UTC+00:00) 2026-09-05 20:40 PDT (UTC-07:00) 2026-09-06 13:40 AEST (UTC+10:00)
Estimated finish 2026-09-06 04:00 UTC (UTC+00:00) 2026-09-05 21:00 PDT (UTC-07:00) 2026-09-06 14:00 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 22
Repository Conflicting 9
Repository Out-of-date with target 2
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 1
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 14 changed files are also touched by #10, #295, #564, #595, #602, #607, #610, #611, #612, #631, #638, #649, +3 more.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 03:40 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ•΅οΈ Lopu review β€” #613

Read the full head (bdbe02a0) against develop @ b1f8e212, in dependency order rather than file order β€” acl grammar β†’ groups model β†’ the enforcement points in things.ts β†’ the two new HTTP surfaces β†’ the token layer β†’ the admin Mongo guard. I also read the two earlier Lopu passes on this PR so this one covered new ground instead of re-treading theirs.

Checks are green (23 pass / 63 skipping / 0 failing), and I confirmed the API-suite / build+unit-test run 34009044941 is against exactly this head rather than a stale one. The trusted CodeQL snapshot for bdbe02a0 is empty β€” nothing to fix, nothing to dispose.

Verdict: sound. One real defect found and fixed in the worktree.

The defect πŸ”

op=update and op=delete on the GET bridge silently dropped expectedUpdatedAt.

It isn't a hint on the normal routes β€” it's a compare-and-swap. updateThing and deleteThing both validate the ISO string, compare it against the stored updatedAt, and anchor updatedAt into the write filter itself (anchoredDeleteFilter, and the updateOne filter), so a losing racer gets a 409 instead of clobbering. _things.tsx threads it through on PATCH and DELETE. _get.tsx passed neither β€” so a caller who explicitly asked for a conditional write got an unguarded one, and a 200 telling them it was fine.

What makes this worth fixing rather than noting is what this branch adds. Before custom audiences, a thing had exactly one writer and the guard was near-decorative on a bridge aimed at browse-only agents. tt:user/<name>/write gives one thing genuinely concurrent writers for the first time β€” which is precisely the situation the guard exists for. It's also the same shape as the dropped-tags bug the previous pass fixed two commits ago, one surface over.

  • _get.tsx β€” thread it into both ops. An ISO stamp rides the URL untouched (overlayValue only parses {/[/"-prefixed values), and both callees already 400 a malformed one, so a mis-encoded timestamp fails loudly, never open.
  • apiDocs.ts β€” document the guard on the bridge endpoint.
  • verify-pat-tokens.mjs β€” five new section-H checks. The nice one: after the guarded update lands, the original stamp is replayed against op=delete and must 409 β€” which also proves the update genuinely moved updatedAt rather than quietly no-oping. They spend 5 of the 12 uses minted has left, so the existing "use accounting matches the call count" assertion is untouched.

Left alone deliberately: op=upsert needs no guard (upsertThing keeps existing.ownerId !== ownerId β†’ 404, so shared editing is PATCH-only and no racing writer can reach it), and replaceCrystal isn't worth adding since op=upsert already is whole-crystal replacement.

What I chased to its enforcement point

Rather than trusting the (genuinely excellent) comments, I went after the places this stack would fail silently and badly:

  • Group membership can't be self-granted. groupIdsOf matches any group-member doc naming the viewer in targetId, with no owner constraint β€” so the entire model rests on those docs being unforgeable. They are: both group and group-member are in PROTECTED_THINGTIME, and groups.ts only ever writes crystal.groupId from an owner-fenced findOwnedGroup. Worth keeping that coupling in mind if either kind ever leaves the protected list.
  • The removed ownership filter in updateThing is contained. Every downstream consumer keys off doc.ownerId, not the writer β€” storage ledger, boundAttachmentPresence, namespace stamp β€” and refusing acl/visibility/folderId/tokenAcl for non-owners keeps the folder-assignment and cycle paths (which do key off viewer.id) unreachable for them. The writer's own sandbox and visibility fences still run.
  • linkKey is treated as a credential, not a field β€” owner-only in projections and gated on the acl still saying hidden, re-minted on every entry into hidden, and in MONGO_PROTECTED_THING_FIELDS so the admin Mongo surface hard-strips it at every pipeline ingress instead of relying on key-name redaction a $rename could dodge. That last one is the detail I'd have expected to be missed.
  • splitCapability's anchoring, which keeps tt:user/write (the account named "write") from parsing as base tt:user β€” i.e. the owner entry. Unanchored, the picker's own output would have minted a phantom owner grant while giving that account nothing.

Validation

acl.test.ts 12/12 green locally; node --check clean on all three edited files. The five new verify-pat-tokens.mjs checks are not executed here β€” that script needs a live stack and database, which the review runner doesn't have β€” so they want a run on the next QA pass alongside G–I. I confirmed their shape assumptions statically: PublicThing.updatedAt projects as new Date(doc.updatedAt).toISOString() and both CAS comparisons are ms-precision getTime() equality, so the round-trip is exact.

Fix is uncommitted in the review worktree for the trusted publisher to push to this PR's head.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ•΅οΈπŸŽ­ Lopu review β€” #613: the picker offered a secret link the owner could never get

Read the full head (9194b96e) against develop @ b1f8e212 in dependency order β€” acl grammar β†’ groups model β†’ the enforcement points in things.ts β†’ the two new HTTP surfaces β†’ the token layer β†’ the admin Mongo guard β†’ the client. I read the five earlier Lopu passes first; they covered the GET bridge as an attack surface, the tt:group ownership question, the sanitizeAcl username-case fix and the expectedUpdatedAt compare-and-swap. None of them had reached the client half, so that's where this pass went β€” and that's where the bug was.

Checks are green on this exact head (47 success / 145 skipping / 1 neutral / zero failing), including API suite, Build + typecheck ratchet + unit tests, CodeQL and both Analyze jobs; I confirmed run 34011548523 is against 9194b96e, not a stale head. The trusted CodeQL snapshot is empty β€” nothing to fix, nothing to dispose.

Verdict: sound. One real defect, found and fixed.

The defect

The picker's middle baseline is "πŸ•΅οΈ + secret link", hinted "Anyone holding its hidden link can also view". composeCustomAcl implements it by emitting tt:hidden beside the tt:custom marker, and the whole server honours that acl β€” every one of these keys off acl.includes(ACL_HIDDEN):

Server behaviour Keyed on
createThing mints the linkKey acl
updateThing re-mints on entry into hidden acl
toPublicPosts projects it to the owner acl
canView admits key holders acl

But PostCard's copy-link item β€” the only UI anywhere that surfaces the key β€” keyed off the derived circle name:

const hiddenLink = post.visibility === 'hidden' && post.linkKey ? … : null;

visibilityFromAcl ranks tt:custom above tt:hidden on purpose, so a custom audience with a hidden baseline always reports visibility: 'custom' β€” that gate could never fire for it. The owner held a real, freshly minted, correctly projected key in their own payload and nothing would show it. handleCustomApply even merges resp.post.linkKey into card state right after applying an audience, which is the clearest evidence the menu was meant to show it. A baseline the picker advertises in its own hint text was unusable.

The fix β€” derive from the key, not the name

const hiddenLink = post.linkKey ? `${permalinkPath}?key=${encodeURIComponent(post.linkKey)}` : null;

post.linkKey is the condition the name gate was approximating: the server emits it only to the owner, and only while the acl still says hidden. Narrower than it looks β€” a plain πŸ•΅οΈ post is unchanged, and everywhere else linkKey is absent so the item stays "Copy link πŸ”—". No new data reaches the client, and the existing toast wording stays accurate: canView's key branch grants view only, because aclCapabilityFor ignores link keys, so a key holder still can't comment on or edit a tt:custom thing.

I added hiddenLinkContract.test.ts rather than just the one-liner. tsc can't see this β€” both spellings typecheck and the wrong one reads as the more explicit of the two β€” which is exactly why this directory already pins the onChanged contract as a source-text test. It sits beside it and pins three facts: the derivation uses post.linkKey and not post.visibility, the πŸ•΅οΈ baseline really composes tt:hidden, and visibilityFromAcl really ranks custom ahead of hidden.

What I chased to its enforcement point and left alone

  • key never reaches persisted state. body.key rides the create/update payloads on _things.tsx and _comment.tsx alongside attaching to the viewer. Safe by construction, not by luck: createThing/updateThing build the crystal from input.crystal alone, and addComment assembles an explicit whitelisted createInput. A bearer secret can't be echoed into a comment's public crystal.
  • Link keys can't escalate past view. An anonymous holder rides { id: '' } β€” falsy β€” so updateThing 401s, withFriendIds short-circuits without a query, and visibilityQueryFor adds neither the own-things nor the grants clause. A logged-in holder reaches aclCapabilityFor, which consults aclAllows only, so they land on none β†’ 403 for both shared editing and engagement.
  • upsertThing needs nothing precisely because the server keys on the acl: it delegates to createThing (mints) or updateThing (re-mints on entry). The client was the only place that had drifted onto the name.
  • updateGroup's final updateOne({ shareId }) omits the ownerId/thingtime anchor its sibling deleteGroup carries. Not a defect β€” doc came from the owner-fenced findOwnedGroup and shareId is a randomUUID β€” so I left it rather than manufacture churn. Flagging only because the asymmetry invites a second look later.

Validation

  • New contract test 3/3; postCardChangeContract.test.ts 8/8 (the edit touches no onChanged call site); acl.test.ts 12/12.
  • Negative control: restoring the old post.visibility === 'hidden' && gate fails test 1 and only test 1, then passes again once reverted β€” so the test genuinely catches the regression rather than restating the code.
  • feedTypes.test.ts did not run here: it imports './feedTypes' extensionless, which bare Node can't resolve without tsx (this runner has no node_modules). An import failure, not an assertion failure, predating this change β€” and CI ran the full unit suite green on this head.

Fix is uncommitted in the review worktree for the trusted publisher to push to this PR's exact head branch.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🎭 Lopu review β€” #613: cancelling the audience picker published private posts

Read the full head (53cf2820) against develop @ b1f8e212. I read the six earlier
Lopu passes first and deliberately routed around them β€” they covered the GET bridge as
an attack surface, the tt:group ownership question, sanitizeAcl's username case, the
dropped tags on op=share, the dropped expectedUpdatedAt on the bridge, and
PostCard's hidden-link derivation. This pass took what was left: the groups model,
the acl grammar end to end, the feed grant clause, and the audience picker.

Checks are green on this exact head (23 pass / zero failing, rest skipping),
including the API suite, build + typecheck ratchet + unit tests and both CodeQL Analyze
jobs. The trusted CodeQL snapshot is empty β€” nothing to fix, nothing to dispose.

Verdict: sound. One real defect, found and fixed.

The defect πŸ”’ β†’ 🌐

The composer flips visibility to 'custom' the instant the circle <Select> changes,
because that is what opens the picker. So the picker's onClose owns the undo β€” and the
undo was a fixed 'public':

if (!audienceAppliedRef.current) setVisibility((c) => (c === 'custom' ? 'public' : c));

For a new post that's correct β€” 'public' is the composer's own seed. For an edit
it silently publishes:

  1. Owner opens the edit composer on an existing πŸ”’ Private post.
  2. They pick 🎭 Custom β€” setVisibility('custom') runs, the picker opens.
  3. They change their mind and cancel (button, Esc, or overlay β€” Chakra routes all
    three to onClose).
  4. The fallback fires and the composer now reads 🌐 Public.
  5. The next Save ✨ hands a previously private post to everyone.

Same for πŸ‘₯ Friends, πŸ‘¨β€πŸ‘©β€πŸ‘§ Family and πŸ•΅οΈ Hidden. Cancel is the one gesture that should
change nothing, and this one moved the audience in exactly one direction β€” outward.

What convinced me it's an oversight rather than a decision: PostCard's sibling path is
already right.
handleVisibilityChange returns early on 'custom', opens the picker
without touching post.visibility, and commits only after the PATCH returns. Same
feature, safe. The composer was the odd one out.

The fix β€” restore, don't default

const audiencePreviousVisibilityRef = React.useRef<PostVisibility>(editPost?.visibility || 'public');

Captured on the way into the picker, restored on abandon. Narrower than it looks: the ref
seeds to the same expression the visibility state does, so a brand-new post still falls
back to 🌐 Public and that behaviour is unchanged β€” only the edit path stops widening.
Applying still wins, because audienceAppliedRef is set synchronously in onApply before
onClose runs (which is why that ref exists at all). No server change: the acl grammar,
the grant clause and every enforcement point were already correct. This was purely the
client committing a circle the user never confirmed.

Added audienceCancelContract.test.ts rather than just the fix. tsc can't see this β€”
'public' is a perfectly good PostVisibility β€” the same reason this directory already
pins the onChanged and hidden-link contracts as source-text tests. Negative control:
reverting both halves fails exactly tests 1 and 2 and leaves 3 and 4 passing, then returns
to 4/4 once restored, so it catches the regression rather than restating the code.

What I chased and left alone

  • Capability suffixes don't break feed delivery. This was my main hypothesis going in β€”
    same shape as the case-folding bug an earlier pass fixed β€” and it's already closed:
    visibilityQueryFor enumerates base, base/comment and base/write for both
    tt:user/<name> and every tt:group/<id>, so a grant carrying a capability still
    matches the exact-string $in.
  • Group membership is unforgeable, which is what makes groupIdsOf's owner-free match
    safe: group/group-member are PROTECTED_THINGTIME, and crystal.groupId is only
    ever written from an owner-fenced findOwnedGroup.
  • updateGroup's unanchored updateOne({ shareId }) (flagged previously) β€” still fine,
    now for a checked reason: group being protected means nobody can mint a second doc with
    a chosen shareId to collide with. Left rather than manufacture churn.
  • All three new routes are registered in server/routes/api/[...].ts, the only registration
    surface β€” no second manifest to drift from.

One thing I'd like your call on βš–οΈ

The picker is lossy on acl entries it doesn't model. parseCustomAcl skips every -
entry and composeCustomAcl rebuilds from scratch. So a tt:custom thing hand-authored
through the API with an exclusion β€” ['tt:custom','tt:all','tt:user','-tt:user/bob'], which
apiDocs.ts documents as supported β€” loses -tt:user/bob if the owner opens the audience
editor and clicks Use this audience 🎭 without changing anything. Bob can then see it.

I didn't fix it, because the naive fix is worse: preserving those entries reintroduces them
as conflicts the moment the same subject is picked in the UI (exclusions win ties, so adding
bob back would silently grant him nothing). Choosing between "the picker is the whole
statement of the audience" and "the picker preserves what it can't show" is a product call,
not a reviewer's. Worth deciding before 🎭 reaches people who also use the API.

Smaller notes, none blocking: tt:userFriends/write parses as a dead entry that matches
nobody (fails closed, but sanitizeAcl accepts it silently); createGroup's cap is
check-then-insert so it can race past 64; and GET /api/v1/groups sets no Cache-Control
while its audience-sources sibling correctly sets private, no-store.

Fix is uncommitted in the review worktree for the trusted publisher to push to this PR's exact head branch. A Lopu handover was requested mid-review β€” this PR was finished, validated and reported in full before ending.

β€” Lopu

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review β€” head 2c9afc2d vs develop @ b1f8e212 (43 files, ~3,059 insertions).

No blocking findings, and no changes made β€” I didn't find a defect that justified an edit, and I'd rather leave the branch alone than manufacture a commit.

Check / CodeQL state

gh pr checks shows one pass (Find merge-conflicting PRs); everything else is a conditional control-plane job reporting skipping. No failure, cancellation or timeout to diagnose. The trusted CodeQL snapshot for this head is empty, so there was nothing to fix or dispose.

What I actually tried to break

The audience model is the part of this stack that has to be right, so I read the enforcement paths rather than the diff:

  • updateThing dropping the ownerId filter is the load-bearing change here. It re-establishes ownership correctly: non-owners need tt:custom and canViewInherited and write; app lenses are refused; acl/visibility/folderId/tokenAcl are 403'd; the write filter anchors on _id so the shared write lands; and every storage-ledger call bills doc.ownerId. resolveFolderAssignment(viewer.id, …) sits behind input.folderId !== undefined, which the non-owner guard already rejects β€” so no cross-owner folder move.
  • Key leakage into stored data. I traced ?key=/body.key on every route that now takes it. addComment builds its crystal from an explicit field list and createThing/updateThing read only input.crystal, so a presented key can't be persisted into a child comment (which would have disclosed the parent's secret to that thread). The bridge also reserves key, so it can't ride into an op's args.
  • linkKey in MONGO_PROTECTED_THING_FIELDS is the right call β€” key-based response redaction alone wouldn't survive an $objectToArray rename, and deriving querySafety's set from that one list is what keeps the strip, the probe guard and the redactor from drifting.
  • aclCapabilityFor floors on aclAllows, so a grant the same acl also excludes from view yields none, not write. Edit rights can't outlive the view decision.

Two notes, not defects

  1. aclEntryMatches accepts tt:group/<id> on viewer.groupIds.has(id) alone β€” it never checks the group belongs to the thing's owner. Not reachable today: group ids are server-minted randomUUID()s, group/group-member are PROTECTED_THINGTIME so generic CRUD can't mint a chosen id, and group lists are owner-only. Worth recording as an invariant the design leans on β€” it becomes load-bearing the day group ids are ever caller-supplied or imported.
  2. groupIdsOf caps at 2000 membership docs. Truncation there only ever denies a grant, never grants one, so it's fail-closed and fine as-is.

Validation

Installed deps in the worktree and ran every suite covering the touched areas (the new test files are all picked up by existing globs β€” acl.test.ts by test:schemas, hiddenLinkContract/audienceCancelContract by test:feed, queryRunner.test.ts by test:collections):

test:schemas 124 Β· test:feed 40 Β· test:collections 36 Β· test:things 28 Β· test:api-capabilities 10 Β· test:feed-contract 8 Β· test:pat-scopes 6 Β· test:acl 6 β€” 258 passing, 0 failing.

npx eslint over all 43 changed source files: 0 errors (1 pre-existing no-script-url warning at registry.ts:4543, outside every hunk in this diff).

πŸ€– Posted by Lopu, Thingtime's review agent.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 11:08 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 10:48 UTC (UTC+00:00) 2026-09-06 03:48 PDT (UTC-07:00) 2026-09-06 20:48 AEST (UTC+10:00)
Estimated finish 2026-09-06 11:08 UTC (UTC+00:00) 2026-09-06 04:08 PDT (UTC-07:00) 2026-09-06 21:08 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 22
Repository Conflicting 0
Repository Out-of-date with target 0
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 15
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 10 changed files are also touched by #10, #295, #564, #590, #595, #607, #611, #638, #662, #665.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 10:48 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 11:17 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 10:57 UTC (UTC+00:00) 2026-09-06 03:57 PDT (UTC-07:00) 2026-09-06 20:57 AEST (UTC+10:00)
Estimated finish 2026-09-06 11:17 UTC (UTC+00:00) 2026-09-06 04:17 PDT (UTC-07:00) 2026-09-06 21:17 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 22
Repository Conflicting 0
Repository Out-of-date with target 7
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 14
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 10 changed files are also touched by #10, #295, #564, #595, #607, #611, #638, #662, #665.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 10:57 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 12:58 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 12:38 UTC (UTC+00:00) 2026-09-06 05:38 PDT (UTC-07:00) 2026-09-06 22:38 AEST (UTC+10:00)
Estimated finish 2026-09-06 12:58 UTC (UTC+00:00) 2026-09-06 05:58 PDT (UTC-07:00) 2026-09-06 22:58 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 23
Repository Conflicting 1
Repository Out-of-date with target 6
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 10 changed files are also touched by #10, #295, #564, #595, #607, #611, #638, #662, #665.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 12:38 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu detected an out-of-date PR branch

Status: Work detected β€” Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 13:15 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 12:55 UTC (UTC+00:00) 2026-09-06 05:55 PDT (UTC-07:00) 2026-09-06 22:55 AEST (UTC+10:00)
Estimated finish 2026-09-06 13:15 UTC (UTC+00:00) 2026-09-06 06:15 PDT (UTC-07:00) 2026-09-06 23:15 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 23
Repository Conflicting 1
Repository Out-of-date with target 6
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 10 changed files are also touched by #10, #295, #564, #595, #607, #611, #638, #662, #665.

Exact branch pair: develop β†’ claude/hidden-links-custom-audiences-reland.

Timeline

  • 12:55 UTC β€” Detected that develop needs to be merged into claude/hidden-links-custom-audiences-reland; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu review β€” PR #613 (head 2c9afc2, base develop @ 69f62ed)

I compared the full head against its base (43 files, +3059/βˆ’161) and concentrated on the surfaces where this PR can actually cause harm rather than on style.

Verdict: no blocking defect. I made no code changes β€” nothing justified one.

What holds up

  • The GET bridge's central risk is gated correctly. A credential in a query string is only allowed for tokens minted with meta.allowGet === true; both resolveGetBridgeActor and resolveGetBridgeSelf hard-fail otherwise. Cookies are never read, so a mutating GET can't be CSRF-forged with ambient credentials, and responses carry Cache-Control: private, no-store + Referrer-Policy: no-referrer.
  • The PAT refactor is behaviour-preserving. resolvePatSessionActor keeps scope-check-before-use-consumption ordering, atomic use accounting, and the sandbox/visibility fences identical across the Bearer and bridge paths. The one divergence is right: a dead account degrades to anonymous on Bearer but 401s on the bridge, which has no cookie fallback.
  • Prototype pollution is closed on both ingress paths β€” Object.create(null) base plus __proto__/constructor/prototype dropped from the parsed ?body= and the param overlay. Param names are attacker-chosen here, so this mattered.
  • scopeForOp is a faithful mirror of patScopeFor, and thingtime is CSV-normalized before the scope is computed β€” so ?thingtime=reaction is charged things.react, not things.create.
  • The ACL grammar is sound. aclAllows strips the - prefix before aclSpecificity/aclEntryMatches, so the new splitCapability only ever sees positive entries and exclusions keep their correct specificity. aclCapabilityFor floors on view first, so a grant can't confer write on a thing the same ACL denies.
  • Hidden-link secrets are strong and correctly scoped. 192 bits of CSPRNG (randomBytes(24)), owner-only on read, and hidden β†’ public β†’ hidden rotates the key while an ordinary edit of a still-hidden thing preserves it.
  • Deriving the querySafety probe set from MONGO_PROTECTED_THING_FIELDS is the right call β€” the ingress strip, aliasing guard, and response redactor can no longer drift apart when a field joins the list.
  • REQUESTABLE_VISIBILITIES in search.ts is a real fix, not cosmetic: a dropped circle silently reads as "no circle filter", which widens rather than narrows.

Validation

This worktree has no node_modules, so I ran the changed/added pure-logic suites under Node 22 native type stripping:

Suite Result
app/schemas/acl.test.ts (new) 12/12 pass
app/components/Feed/hiddenLinkContract.test.ts (new) 3/3 pass
app/components/Feed/audienceCancelContract.test.ts (new) 4/4 pass
app/api/utils/auth/patScopes.test.ts (changed) 6/6 pass
app/api/utils/mongodb/queryRunner.test.ts (changed) 4 failures β€” environment artifact, not this PR

To be explicit about that last row so nobody chases it: deserializeExtendedJson does await import('mongodb'), and require.resolve('mongodb')/require.resolve('bson') both report MODULE_NOT_FOUND in a dependency-free worktree. The enclosing bare catch turns that module-load failure into 400 One or more typed BSON values are invalid β€” exactly the assertion message those four tests fail on. They cover the protected-field strip this PR extends, so they're worth re-confirming in dependency-installed CI.

Non-blocking notes (deliberately not changed here)

  1. _get.tsx:162 compares bodyParam.length (UTF-16 code units) against a budget named in bytes. Cosmetic β€” practical URL length limits dominate.
  2. Pre-existing: that bare catch in querySafety.ts:269-272 maps an infrastructure failure onto a 400 client error. Worth its own PR.
  3. Pre-existing, shared with the normal route: a create whose thingtime holds both reaction and another kind is charged only things.react. The bridge mirrors patScopeFor faithfully, so this isn't a regression β€” but it may deserve a look at both call sites at once.

CodeQL is green and the trusted alert snapshot for this head is empty, so its disposition file stays [].

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ¦„ Lopu review β€” PR #613 (head 2c9afc2d, base develop @ 69f62ed6)

Reviewed for correctness, security, crashes, data/API compatibility and test
coverage before style, per AI_ALL.md. No change requested β€” recommending
approval.

Security fences I probed, and what closes each

Hazard Guard
CSRF on a mutating GET resolveGetBridgeActor never reads cookies; resolveThingsActor still refuses non-Bearer PATs, so query-param delivery stays confined to /api/v1/get
Token in a URL per-token opt-in meta.allowGet (default off), plus Cache-Control: private, no-store + Referrer-Policy: no-referrer
Scope escalation via the bridge scopeForOp matches patScopeFor in _things.tsx exactly; scope is checked before a use is consumed
Prototype pollution (param names are attacker-chosen here) Object.create(null) base + __proto__/constructor/prototype dropped on both the ?body= and overlay paths
Mass assignment UpdateThingInput is a closed field list β€” ?ownerId=/?linkKey= are inert
A custom-audience writer widening its own grant non-owners are refused acl/visibility/folderId/tokenAcl (things.ts:4237)
Hidden-link secret leaking linkKey joins MONGO_PROTECTED_THING_FIELDS, which the ingress strip, probe guard and redactor all now derive from β€” they can't drift

I also checked the two documented link-lifecycle promises against the code:
un-hiding retires circulating links instantly (canView honours a key only
while the acl still says hidden) and re-hiding rotates to a fresh key, so links
from an earlier hidden period never resurrect. Both hold.

Registration verified in all three required places, so the new endpoints won't
Nitro-404, and the three new suites are picked up by the existing
test:schemas / test:feed globs rather than sitting dead in the tree.

⚠️ One note for anyone running the tests locally

app/api/utils/mongodb/queryRunner.test.ts reports 4 failures in a checkout
with no installed dependencies
β€” this is not a regression in this PR. All
four fail inside normalizeMongoQueryRequest, whose deserializeExtendedJson
does await import('mongodb'); with the module absent the try falls through
to fail(400, 'One or more typed BSON values are invalid'). The identical
dynamic import is present verbatim on the base commit, so the same four fail on
base. They pass in CI, and this head is fully green.

Everything else I could run here passes: acl 12/12, hiddenLinkContract 3/3,
audienceCancelContract 4/4, patScopes 6/6.

CodeQL: the trusted alert snapshot for this exact head is empty, so there was
nothing to fix and nothing to dispose of.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Lopu review β€” #613 (hidden πŸ•΅οΈ links + GET bridge + custom audiences 🎭)

Compared 2c9afc2d against develop @ 69f62ed6. Read the surrounding implementation rather than just the diff for every security-load-bearing path: the acl grammar, canView/canViewInherited, visibilityQueryFor, the four write gates, the PAT/bridge resolvers, groups, and the protected-field strip.

Verdict: approve as-is. No code changes made. Checks are fully green on this head (23 pass / 63 skipped β€” the skips are the control-plane's own conditional jobs, not suppressed verification), and the CodeQL snapshot for this head is empty, so there was nothing to fix or dispose.

Things I specifically tried to break, and couldn't

  • linkKey containment. Putting it in MONGO_PROTECTED_THING_FIELDS gets the hard ingress strip at primary/$lookup/$unionWith rather than key-based redaction an $objectToArray rename could route around. Deriving shouldRedactKey from that same list instead of respelling secure/uniquekeys inline closes the drift at the source β€” that's the detail I'd have flagged if it were missing.
  • Link-key lifetime. The key only grants while the acl still says hidden, and re-entering hidden mints a fresh one, so links from an earlier hidden period can't resurrect. Un-hiding retires everything instantly.
  • Fence ordering. patVisibilityBlocksAcl runs before the linkKeys branch in canView, so a fenced PAT can't present a key to escape its audience.
  • The anonymous viewer shell. { id: '' } is falsy at every viewer?.id site, and withFriendIds spreads rather than rebuilds β€” so linkKeys survives enrichment on every interaction path. Easy field to drop; it isn't dropped.
  • The circle-filter shortcut. unfiltered requires no filter or all REQUESTABLE_VISIBILITIES β€” so "everything except πŸ•΅οΈ Hidden" really omits hidden. Validating circles against the requestable set is the right direction too: a silently dropped circle reads downstream as no filter, which widens.
  • splitCapability anchoring. A bare endsWith('/write') would read tt:user/write as base tt:user β€” the owner entry β€” giving the picked account nothing and the acl a phantom owner grant. Closed here and from the other side in registerUser.ts; both pinned in acl.test.ts.
  • Shared editing. Dropping ownerId from updateThing's lookup is fenced by tt:custom + canViewInherited + write, plain-404s otherwise (no existence oracle), refuses audience/folder/token-grant edits, hard-404s under an app lens, and keys the write on _id so the CAS holds. deleteThing keeps ownerId in its atomic filter β€” write does not imply delete, which is the right conservative call.
  • The GET bridge. allowGet enforced on both actor and introspection paths, cookie-free (so no ambient-credential CSRF on mutating GETs), no-store + no-referrer, scope check before use consumption, and prototype pollution closed on both inputs over an Object.create(null) base β€” param names are attacker-chosen here, so that mattered.
  • Group membership. group/group-member are PROTECTED_THINGTIME, member docs are minted only by /api/v1/groups with ownerId = caller, and sanitizeMemberIds excludes the owner. No self-add path, so groupIdsOf can't be poisoned.

One hardening item for your call (not a live exploit)

tt:group/<id> grants aren't checked against the group's owner. aclEntryMatches resolves group entries purely via viewer.groupIds.has(groupId), groupIdsOf returns memberships regardless of group owner, and sanitizeAcl validates only shape. So ['tt:custom','tt:user','tt:group/<G>'] where G belongs to someone else hands that third party ongoing control of this thing's audience β€” they can add members later and those members gain read (or engage/edit with a suffix).

I did not change this, because the exposure requires the owner to voluntarily paste a foreign group id into a hand-authored acl: the picker only offers listGroups(userId), group ids are opaque randomUUIDs surfaced only to their owner, non-owners can't set acl at all, and nobody can self-join a group. So it's defence-in-depth, and whether cross-user group sharing is a feature or a mistake is your product call, not mine to guess on your branch.

If you do want it closed, the write path is the right chokepoint β€” validating group ownership in createThing/updateThing when the acl carries tt:group/ keeps aclEntryMatches sync and pure (the property the module is built around) and costs one indexed lookup only on custom-audience writes.

Two smaller notes: createThing's engage gate uses asOwner, which degrades to { id: ownerId } when viewer.id !== ownerId β€” it denies rather than grants, so the outcome is right, just worth knowing the degraded shape is fail-closed. And op=self is the one bridge op with no rate limiter (free introspection by design; not an oracle since tokens are unguessable).

Validation

Installed deps in the worktree and ran the suites covering this diff as CI runs them (node --import tsx --test): schemas 124, feed 40, collections 36, hooks 35, things 28, api-capabilities 10, feed-contract 8, pat-scopes 6, acl 6, nav 2 β€” 295 passing, 0 failing. Lint/typecheck are already green on this head via Build + typecheck ratchet + unit tests.

For the record: a first pass at queryRunner.test.ts under bare node --test (pre-install) showed 4 failures β€” that was Node's native type-stripping not resolving the project's TS path aliases, not a defect. Under tsx it's 11/11.


A cooperative handover was requested mid-session, so #613 is the only PR reviewed here β€” #295 is left untouched for the queued continuation.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

βœ… Promoted to main in #680.

An earlier run stood aside on this PR; that verdict no longer applies.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

βœ… Promotion #680 has verified source lineage at current develop tip ce2274e9d20e6a887f6104bf02eec2dd9c84617e.

@lopugit

lopugit commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

πŸš€ Promotion PR for main opened: #680

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

last preview built 06/09 16:39 AEST #613 tt-pv1:613:develop:2c9afc2d06649bbafb823c74d9ecdb37a7381dd4:1788676767642 preview: develop removed tt-preview-state:v1:develop:removed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant