Repository navigation
Enforce authenticated identity on every public Convex entry point - #288
Conversation
Inventory every public query, mutation and action and enforce the classification with an exhaustive boundary test over the real registrations: protected functions must reject anonymous, unknown-key and asserted current/legacy identities before any read or write. Close the remaining identity gaps found by the audit: - DID re-mint/updateDID require a did:webvh at the caller's own path, and re-mint refuses a DID held by another account (account takeover). - publishList binds webvhDid to the publisher's own resource DID; the /d/* fallback requires the list owner to control the publication DID. - Anchor record writes, list-wide push, activity writes and the Sites/DID resolver lookups are no longer publicly callable. - Lists can only be filed in the actor's own categories; only owners can publish a list as a public template; deleteUserData declares its account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🚅 Deployed to the boop-pr-288 environment in Friends
|
There was a problem hiding this comment.
ℹ️ No critical issues. One of the new identity rules can be bypassed in two calls; the other two suggestions are small.
Reviewed changes
I reviewed the full diff at 8ec21e7. The touched suites pass locally: identity-binding, public-function-boundary, auth-boundary, private-sharing and remint-did, 122/122. I grepped src/ and convex/ and found no remaining callers of any name this PR removes or makes internal.
- DID re-mint and
updateDIDbinding: both endpoints now require adid:webvhat the caller's ownuser-<sub-org>path.applyRemintrefuses a DID that another account already holds, and only rewrites whole{oldDid}/…prefixes. - Publication DID binding:
publishListonly accepts the publisher's own{did}/resources/list-{id}. Clients already send this, built from the server-canonicaluseCurrentUser().did. /d/*resolver fallback: the new internalgetPublishedListForPathrequires the publication's controller DID to be the list owner's current or legacy DID.- Public surface reduced: anchor record writes,
recordActivity,sendListNotification, the Sites lookups and the DID-log/resource lookups are now removed or internal. The generated client registry is updated to match. - Cross-account references:
createListandupdateListCategorynow only accept the actor's own categories.createFromListblocks public templates for non-owners.deleteUserDatanow declares itsaccountsresource. - Enforcement: a new exhaustive registry test classifies every public registration. New identity-binding regressions cover the closed gaps. The inventory and runbook docs are updated.
claude-opus-5-5 | 𝕏
| const value = row[field]; | ||
| if (typeof value === "string" && value.startsWith(args.oldDid)) { | ||
| // Whole-DID prefix only: `{oldDid}/...`, never a longer DID sharing its characters. | ||
| if (typeof value === "string" && value.startsWith(`${args.oldDid}/`)) { |
There was a problem hiding this comment.
Small consistency gap: previewRemint still uses bare startsWith(args.oldDid) for prefix fields. The operator dry run will therefore count rows that applyRemint no longer rewrites, such as a longer DID that shares the prefix. Use the same `${args.oldDid}/` check there.
…P routes and private reads - Activity rows record the session or specific API key that acted (optional activities.credential), read from the actor context so PR #277's items.ts call sites are untouched. - Boundary test now synthesizes validator-shaped arguments, fails on any non-credential read before rejection, and drives every non-public HTTP route anonymously and with forged credentials. - Category and billing routes return 401/403 for credential failures instead of 500. - Revert the createFromList public-template restriction: it was bypassable (private save then updateTemplate, or createTemplate) and is a product decision; recorded as a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…edes the auth check Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues. One small exposure is noted inline: the new credential IDs are readable by anyone who can read the activity feed.
Reviewed changes
I reviewed the changes since 8ec21e7. At a5830c6 the public-function-boundary, identity-binding, auth-boundary and private-sharing suites pass, 115/115.
- Activity credential attribution: added an optional
activities.credentialfield. Assignment, unassignment, inherited-assignment and presence rows now record the acting session or specific API key, throughactingCredential(ctx)orctx.actor.credential. - HTTP auth status: the
billingHttpandcategoriesHttpcatch blocks now sendAuthErrors throughhandlerErrorResponse, so credential failures return 401/403 instead of 500. - Boundary test hardened: protected registrations are now called with business arguments sampled from their validators and naming real fixture rows. Any read outside the credential tables before rejection fails the test. A new pass checks every non-public HTTP route anonymously, with a forged key and with a forged bearer token.
- Template rule withdrawn: dropped the partial owner-only
isPubliccheck increateFromListand its "closed gap" claim. Template publication is now listed as a product-decision follow-up, which resolves the earlier thread.
The earlier previewRemint and recursive-discovery threads are still open. The new commit doesn't touch them.
claude-opus-5-5 | 𝕏
…k HTTP reads Round 2 review: comments now persist the session/API-key credential, and deleting one leaves a comment_deleted activity row (ID and author, no text) with the deleting credential. The HTTP boundary pass also fails on non-credential reads before rejection. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues. The new comment attribution also exposes credential IDs to readers; see the inline note.
Reviewed changes
I reviewed the changes since 7ab6552. At da78255, the identity-binding, public-function-boundary, auth-boundary and private-sharing suites pass 115/115, and tsc -p convex/tsconfig.json is clean.
- Comment credential attribution:
addCommentnow storesctx.actor.credentialon the comment row. The schema validator is now a sharedactingCredential, used by bothactivitiesandcomments. - Comment deletion audit:
deleteCommentnow writes acomment_deletedactivity row. The row records the acting credential, plus the comment's ID, author and creation time. It does not keep the comment text. - HTTP boundary pass: non-public routes now fail the test if they read anything beyond the credential tables before rejecting.
/d/*may also readdidLogs,listsandpublications. - Docs: updated the inventory and runbook to describe the comment attribution and the new activity type.
The earlier previewRemint, recursive-discovery and getListActivity credential-exposure threads are still open. These commits don't touch them.
claude-opus-5-5 | 𝕏
… the comment ID Round 3 review: published lists are readable by any signed-in account, so getListActivity/getItemComments (and their HTTP routes) now strip the stored credential, and comment_deleted rows no longer retain the deleted comment's author or timestamp (which also avoids stale DIDs after re-mint). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues. This commit fixes both credential-exposure threads, and I've resolved them. The earlier
previewRemintand recursive-discovery suggestions are still open.
Reviewed changes
I reviewed the changes since da78255. At 99b3e2a, the identity-binding, public-function-boundary, auth-boundary and private-sharing suites pass 115/115, and tsc -p convex/tsconfig.json is clean.
- Credential read projection: added
withoutCredentialinconvex/lib/actor.ts.getListActivityandgetItemCommentsnow return rows withoutcredential./api/activity/listcalls the same handler internally, so it gets the projection too. The other reads of these tables aregetCommentCountand the ExplorercreatedAtlookup inoriginals.ts. Neither returns the field. - Smaller deletion audit row: a
comment_deletedrow now records only{ commentId }. It no longer stores the deleted comment's author or creation time. - Regression coverage:
identity-binding.test.mjsnow checks that an editor reading activity and comments never receivescredential, and that the deletion note has exactly the comment ID. - Docs: the inventory and runbook now say that credentials are stored for audit and stripped from reads. They list an owner-facing audit view as a possible follow-up.
claude-opus-5-5 | 𝕏
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t DID drift - verifyAuthToken accepts only canonical base64url segments. jose decodes the signature's unused trailing bits leniently, so a re-encoded copy of a logged-out/revoked token hashed differently from its tombstone and could establish a fresh session (pre-existing since #241). - Clients adopt a newly minted did:webvh only when updateDID succeeds, so they never act under a DID the server refused. - createList leaves a list uncategorized when its category was deleted meanwhile; foreign categories are still rejected. - Boundary test scans convex subdirectories (migrations/) too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in the fixes themselves. There is one small doc-placement nit inline. The earlier
previewRemintsuggestion is still open.
Reviewed changes
I reviewed the changes since b35d90b. At d78e17d, the identity-binding, public-function-boundary, auth-provider, auth-boundary and private-sharing suites pass 135/135, and tsc -p convex/tsconfig.json is clean.
- JWT canonical encoding:
verifyAuthTokennow rejects any segment whose base64url decode→encode round-trip differs from the input. This closes the case where flipping a signature's trailing bits revived a revoked token. Every JWT check inconvex/goes throughverifyAuthToken:actorSession.establish/revokeandrequireSession. No path skips the new check. - Stale categories on create:
createListnow stores no category when the category no longer exists, so queued offline creates don't fail. A foreign category still getsresourceUnavailable, andupdateListCategorystill rejects a missing one. - Client DID adoption: on both session restore and OTP login, the client adopts a minted
did:webvhonly when/api/user/updateDIDreturns ok. On restore it no longer stores a DID log for a refused DID. - Recursive boundary discovery: the registry scan now includes
convex/subdirectories such asmigrations/, withoutbase: 'convex'. I resolved the earlier thread about this. - Docs: the runbook now covers both fixes, plus a pre-deploy check for
/d/*publications whose owner matches no account.
claude-opus-5-5 | 𝕏
| /** | ||
| * jose decodes base64url leniently: the unused low bits of a segment's final | ||
| * character may vary, so one signature has several valid token strings. Sessions and | ||
| * revocation tombstones are keyed by the token string's hash, so a re-encoded copy of a | ||
| * logged-out token would otherwise establish a fresh session. Accept only the single | ||
| * canonical encoding, which is what jose's signer produces. | ||
| */ |
There was a problem hiding this comment.
Nit: this helper was inserted between verifyAuthToken's JSDoc (L35–41) and the function itself. The @param/@returns/@throws block now documents isCanonicalJwt, and verifyAuthToken has none. Move isCanonicalJwt and its comment above the original JSDoc, so that block sits directly on verifyAuthToken again.

Closes #236.
Summary
#241 already put an authenticated session/API-key boundary in front of most browser operations, so the issue's cited locations (
items.checkItemwithcheckedByDid,lists.getUserLists, unauthenticated list reads) were already fixed onmain. This PR finishes the job:Inventory
docs/authenticated-function-inventory.mdlists all 172 public registrations:actorSession.*,auth.*(compatibility names)scripts/public-function-boundary.test.mjsloads the real registrations and fails CI when:/d/*may also read its public resolution tables.I checked it against deliberately reintroduced gaps and it fails on them. Its HTTP pass found category and billing routes returning 500 for credential failures; those now return 401/403.
Gaps closed
POST /api/user/remintDidaccepted anydid:webvh, including another user's, andapplyRemintnever checked uniqueness. Ownership is DID-based, so the caller became owner of the victim's lists and keys.updateDIDnow require adid:webvhminted at the caller's own path,user-<sub-org>. That is what every client already sends.{oldDid}/….updateDIDno longer acceptsdid:key; only server-side login derives one.publishListrequireswebvhDidto be the publisher's own{did}/resources/list-{id}./d/*resolver fallback now requires the list owner to control the publication DID. Before, one account could serve its list under another account's path.confirmed), list-wide push, and activity writes.joseaccepts a JWT whose signature's last base64url character has its unused low bits flipped. Sessions and revocation tombstones are keyed by the token string's hash, so a re-encoded copy of a logged-out or revoked token could establish a fresh session, making logout and revocation ineffective.verifyAuthTokennow accepts only canonical encodings. Every token the signer issues is canonical, so existing sessions are unaffected.did:webvheven whenupdateDIDfailed. It now adopts it only on success, so it never publishes or acts under a DID the server refused.createListandupdateListCategoryaccept only the actor's own categories; on create, a category deleted meanwhile leaves the list uncategorized.deleteUserDatadeclares its account at the boundary.Auth integration choice
This keeps #241's session-record boundary rather than Convex
ctx.auth(auth.config.ts):accessSessionsis what makes logout, expiry, and persistent-mobile-session revocation invalidate reactive queries;ctx.authidentities are stateless.authenticate()inconvex/lib/actor.tsis the single place to add actx.authbranch later.Agent/API-key attribution and #277
Every call resolves
ctx.actor.credential: the session row or the specific API-key row, with scope and revocation checked in the same transaction.It is now persisted as an optional
credentialon:comment_deletedactivity row, which records only the comment ID.Two keys on one account leave distinguishable history, and a test confirms a credential named in arguments is never the one recorded. Credentials are stored for audit but stripped from every read response, since published lists are readable by any signed-in account.
The assignment helpers read the credential from the actor context the wrapper already provides, so Sign action records with owner Turnkey keys through one shared path #277's
items.tscall sites are untouched.Sign action records with owner Turnkey keys through one shared path #277 adds signed action records binding the same credential for item and list actions.
Merging this branch with Sign action records with owner Turnkey keys through one shared path #277 is conflict-free, and the combined tree passes 716/716 tests plus the registry check.
Rollout (coordinated with #262)
credentialonactivitiesandcomments, and thecomment_deletedactivity type, which agents reading/api/activity/listmay now see. There is no new public name, so no deploy order can widen access.did:key) get an error and nothing is written.#262's pending client-inventory and staging evidence is unchanged and still gates recipient grants.
docs/authentication-rollout.mdlists read-only data checks for prior misuse; I have not run them.Independent review
Cross-provider review (Codex GPT-6-Sol) round 1 raised three points, all addressed in a5830c6:
Round 2 confirmed those responses and found two remaining gaps, both now fixed: comment writes and deletions didn't record the acting key, and the HTTP pass didn't assert on reads.
Round 3 found three problems with that fix, all now resolved:
Adversarial review
Two independent attack passes ran after the review rounds: one hunting exploits with proof-of-concept tests, one hunting breaks in legitimate flows.
updateDID, offline or agent flows. It flagged two edge cases, both fixed above: client DID drift and stale categories./d/*publications whose list owner matches no account would now 404. I added a read-only check for these to the pre-deploy data checks; the fix is to repair the rows, not loosen the check.Verification
bun test: 702 pass, 0 fail (683 onmain+ 19 new: 6 boundary tests, 9 identity-binding regressions including token revival, 4 client DID-adoption tests). The boundary scan now includesconvex/subdirectories such asmigrations/. Againstmain's code, the binding and attribution regressions fail; the credential-resolution test documents existing Clarify unsigned provenance and preserve authenticated credential identity #273 behaviour.tsc -p convex/tsconfig.json,tsc -b,generate-auth-client --checkandvite buildpass. Lint on changed files: no new errors (4 pre-existingFunction-type errors indidResourcesHttp.ts, versus 5 onmain).Follow-ups (outside #236)
createFromList(isPublic), or by saving privately and thenupdateTemplate. Any reader can also retype the items intocreateTemplate, so no server rule alone prevents it. This is a product decision; I tried an owner-only rule in this PR, but review showed it was bypassable, so I removed it.registerPushTokenaccepting any web URL, which the server then POSTs to.getUsersByDidshas no input cap.items:writekeys can delete lists they own.🤖 Generated with Claude Code