feat(lexicon)!: lexicon resolution and publishing, permission sets in atproto-lexgen #169
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!169
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-130-lexicon"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #130.
Lexicon resolution and publication per the Lexicon spec, permission sets per the permission spec, and a reworked
atproto-lexgen.Runtime (
ATProtoNet)com.atproto.lexicon.*—LexiconSchemaRecord(com.atproto.lexicon.schema, rkey = NSID; defs kept as raw JSON, unknown fields round-trip) andResolvedLexicon({uri, cid, schema}, theresolveLexiconoutput) inLexicon/Com/AtProto/Lexicon/LexiconModels.cs;LexiconClient(client.Lexicon.ResolveLexiconAsync) inLexiconClient.cs, registered onAtProtoClient.XrpcErrors.LexiconNotFoundadded.ILexiconResolver/LexiconResolver— matches@atproto/lex-resolver0.3.0:_lexicon.+ the NSID authority reversed (name segment dropped), exactly onedid=TXT value (two identical ones fail too, as in the reference), no hierarchical walk;#atprotokey through the injectedIDidResolver;com.atproto.sync.getRecord+RecordProof.Verify(the reference verifies the commit proof, so this does too); then$type,lexicon == 1,id == nsid, non-emptydefs.HandleResolverinto internalIdentity/DnsTxtLookup.cs(HandleResolver now calls it; behaviour unchanged), theIdentityResolverOptionsDoH endpoint/timeout, and the SSRF-safe handler (IdentityNetworkPolicy); a non-HTTPS PDS is refused unlessAllowPrivateNetworks.ResolveAuthorityAsync(nsid)andResolveAsync(nsid, authority)(the reference'sdidAuthority) are public; errors areLexiconResolutionExceptionwith aKind(AuthorityNotFound,ResolutionFailed,NotFound,InvalidRecord,NotPermissionSet).CachingLexiconResolver— same model asCachingDidResolver(stale-while-revalidate, failure TTL, LRU capacity, shared in-flight fetch,InvalidateAsync). Defaults: stale 5 min (upstream oauth-provider'sLEXICON_REFRESH_FREQUENCY), expire 24 h (the permission spec's upper bound), failures 1 min; a failed refresh keeps serving the stale schema until expiry, as the permission spec asks.LexiconClientis also anILexiconResolver(explicit implementation) for the service path; it checksid == nsidand mapsLexiconNotFound.AddAtProtoIdentityalso registersILexiconResolver(a caching resolver over the sharedIDidResolver).AtProtoScopesbelongs to the parallel OAuth PR #167, so the check lives on the resolver side:ILexiconResolver.ResolvePermissionSetAsync(Nsid)andResolveIncludeScopeAsync(string scope)(parsesinclude:<nsid>/include?nsid=), returning the typedLexiconPermissionSet. Usable with the scopeAtProtoScopes.Include(...)builds;AtProtoScopesitself is untouched.atproto-lexgenLexiconSchemaparsespermission-set(title,title:lang,detail,detail:lang,permissions);csharpemitspublic static class <Name> { const string Nsid; static string Include(string? aud = null) }, forwarding toAtProtoScopes.Include(same signature onmainand in #167). Generated code was compiled against the SDK with warnings as errors.lint(new) —Validation/LexiconLinter.cs: permission sets (onlyrepo/rpc;blob/account/identityerror; no wildcards;collection/lxmmust be in the set's own NSID group or below, same check asIncludeScope.isParentAuthorityOf;action∈ create/update/delete, unique; rpcaudis*xorinheritAud: true, never a service DID; unknown resources/parameters warn since servers drop them), plus validid, non-emptydefs, primary types namedmain, the removednulltype anywhere, and*/non-NSID in spacecollections.--strictfails on warnings.publish(rewritten) — lints (errors stop it), signs in (--identifier/ATPROTO_IDENTIFIER; password only fromATPROTO_PASSWORDor a no-echo prompt,--passwordis rejected without echoing;--pds/ATPROTO_PDS_URL, else resolved from the handle/DID), diffs each schema against the published record (identical →SAME; any breaking change refuses the whole run, nothing written, unless--force),putRecordwith rkey = NSID andswapRecordon update, logs out, then checks each authority's_lexiconTXT record and printsOK/MISSING(with the record to create) /MISMATCH/UNKNOWN.resolve <nsid>...(new) — verified resolution through the SDK resolver; prints the Lexicon file (without$type) or writes--output <dir>/<nsid path>.json;--didskips DNS.migrateremoved — evaluated: the run mode applied add/remove/rename operations to local JSON files, and scaffold mode only printed stubs. The evolution rules forbid removing/renaming/retyping fields and require new NSIDs for breaking changes, so there is nothing for it to migrate. The command now prints why and points todiff --strict.TypeMapper.GetLexiconType/InferStringFormat/ToCamelCase/NsidToFilePath/ParseTypeValue(→SplitRef),CSharpEmitter.Emit(LexiconDocument),LexiconEmitter.JsonOptions, the wholeMigrations/folder; the three directory loaders, the two assembly→document loops and the duplicate options are nowSchema/LexiconLoader.cs+LexiconJson.nulldef type removed from the emitter's vocabulary (warns "removed from the Lexicon language"); space defs:*/non-NSIDcollectionsare left out of the emitted declaration with a warning (the emittedNsid.Parsewould throw);namelength follows the proposal (no limit; theSpaceTypeDeclaration.Namedoc's "1–64" was dropped).ATProtoNet(publish/resolve use it). To keeplexicon/diff --assemblybinding the inspected assembly to its own dependencies, it is loaded into a collectibleAssemblyLoadContext(InspectionLoadContext); framework assemblies stay shared soJsonPropertyNameAttributematching still works. The packed tool containsATProtoNet.dlland its 5 small dependencies.Program.cstestability —RunAsync(args, CommandContext)with substitutable output, environment, secret prompt and network (ILexgenNetwork/ILexiconRepository);Mainbinds the console.Breaking changes (migration)
atproto-lexgen publishno longer copies to a directory;--output/--baseline/--assembly/--no-bumpare gone and explain the new command. Migration: keep published copies in version control and usediff --strict; publish withpublish --input <dir> --identifier <handle>+ATPROTO_PASSWORD.atproto-lexgen migrateand theATProtoNet.LexiconGenerator.Migrationstypes are removed. Migration: none needed under the evolution rules; gate changes withdiff --strict.CSharpEmitter.Emit(doc)→EmitAll([doc]),LexiconEmitter.JsonOptions→LexiconJson.WriteOptions,TypeMapper.ParseTypeValue→SplitRef; the others had no callers.Verification
LexiconMigrationTestsandLexiconPublisherTestswere deleted with the code they tested). Full solution build: 0 warnings, 0 errors.tests/ATProtoNet.Tests/Lexicon/TestData/lexicon-resolution.json, generated with@atproto/repo0.10.14 /@atproto/crypto0.5.5 and checked with@atproto/lex-resolver0.3.0 (generator + README alongside, deterministic): a 44-record signed repo;postandauthBasicresolve (URI/CID match),mismatch(id ≠ rkey),badType(wrong$type) andmissing(absence proof) fail — the SDK agrees on all five, and rejects the proof under another signing key.LexiconResolverTests(vector, DNS name/spec examples, no hierarchical walk, exactly-one-DID, DoH failure/disabled/configured endpoint, DID/PDS/HTTPS failures, RecordNotFound, timeout, SSRF refusal, permission set + include scope parsing, DI),CachingLexiconResolverTests,LexiconClientTests,LexiconLinterTests(spec example set clean + every rule),CSharpEmitterTestsadditions, andLexiconGeneratorProgramTests(66 in-process tests: every command's help/unknown option/happy/error paths; publish create/same/update/breaking/force/non-lexicon record/DNS statuses/lint refusal/password handling; resolve print/write/--did/failure).atproto-lexgen resolve site.standard.document site.standard.authFull app.bsky.authFullApp chat.bsky.authFullChatClientresolved and verified all four from the real network, a non-existent authority failed withAuthorityNotFound, andlintpassed the four published sets with 0 errors/0 warnings.dotnet packof the tool succeeds.Notes / deferred
$type, version,id,defs); full lex-document schema validation (L4) is out of scope.LexiconDifferdoes not compare permission-set contents yet (added/removed permissions are not reported bydiff/publish); a small follow-up.CLAUDE.mdstill describes the tool as "…schema diffing, and migrations" — not edited here; worth updating.Identity/HandleResolver.cs(DoH code moved toDnsTxtLookup.cs),Http/XrpcErrors.cs(+1 constant),AtProtoClient.cs(+1 property),Server/Extensions/IdentityServiceCollectionExtensions.cs(+ILexiconResolver),Spaces/SpaceTypeDeclaration.cs(one doc line), and allow-list entries inConventions/*andLexiconDriftTests.Allowlists.cs.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T20:46:56.945Z) — nothing to re-trigger.
@claude /review — large; the description covers the CLI breaking changes and the pinned reference vector. The diff will be truncated, so please read these in full from the branch:
src/ATProtoNet/Lexicon/Com/AtProto/Lexicon/LexiconResolver.cs(DNS_lexiconlookup with no hierarchical walk, DID to PDS,sync.getRecordwithRecordProof.Verify, the NSID/idmatch, SSRF policy),CachingLexiconResolver.cs(TTL, negative caching, single-flight, bounds),LexiconModels.cs,src/ATProtoNet/Identity/DnsTxtLookup.csplus theHandleResolver.cschange (behaviour must be unchanged),tools/ATProtoNet.LexiconGenerator/Validation/LexiconLinter.cs(the permission-set rules from the Lexicon spec),Publishing/LexiconPublisher.csandPublishing/LexgenNetwork.cs(credential handling: env or no-echo prompt only, never echoed or logged), andProgram.cs. Compare with@atproto/lex-resolver. Focus on resolution correctness and spoofing (a record served from the wrong repo, or an id or NSID mismatch), cache poisoning, credential leaks in the CLI, and whether the removedmigrateor deleted helpers are still referenced by docs or code.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
837b3c0586b47454c53a✅ Verdict: approve — no blocking issues found.
Large, well-scoped PR implementing Lexicon resolution/publication and permission sets, plus a substantial
atproto-lexgenrework. I read the full diff (not just the truncated PR text) viagit diff main...origin/issue-130-lexicon, including the files cut off in the description:LexiconLinter.cs,LexiconPublisher.cs,LexgenNetwork.cs, and the relevant parts ofProgram.cs.What I checked closely:
LexiconResolver: DNS name derivation (_lexicon.<reversed-authority>), exactly-one-did=enforcement, PDS/signing-key lookup, HTTPS-unless-AllowPrivateNetworksgate,sync.getRecord+RecordProofverification, and theValidatechecks (lexicon==1,id==nsid, non-emptydefs). Matches the described spec behavior and the reference-vector tests (mismatch/badType/missing all map to the rightLexiconResolutionErrorKind).CachingLexiconResolver: fresh/stale/expired/failure-TTL state machine, shared in-flight fetch with detach-on-invalidate, LRU eviction, and the "failed refresh keeps serving stale until expiry" behavior — traced through the logic and it's internally consistent (theRunFetchAsyncfailure branch correctly distinguishes "still-fresh entry, keep serving" from "no usable entry, store fresh failure").LexiconClientas anILexiconResolver: correctly re-validates the service's answer (id == nsid) rather than trusting it blindly, and mapsLexiconNotFounddistinctly from other failures.LexiconLinter: permission-set namespace containment (CheckNsidsvia authority-prefix + dot boundary, avoiding thefeedvsfeed2false-positive), wildcard/action/audience checks, and thenull-type / space-collection warnings all look correct.LexiconPublisher/LexgenNetwork: the DNS authority check correctly translatesLexiconResolutionErrorKind.AuthorityNotFoundintonull("missing") rather than letting it propagate as a hard failure — that seam is exactly right forpublish's DNS-status report. The whole-run breaking-change refusal (nothing written unless--force) is applied before anyPutAsynccall, so there's no partial-write case.Program.cspublish path: passwords are never accepted as an argument, onlyATPROTO_PASSWORDor a non-echoing prompt — good security hygiene, matches the PR's own description.Minor, non-blocking observations (not filed as line findings since they don't rise to correctness bugs):
LexiconPublisher.PublishAsyncreads the currently-published record (GetAsync) and later writes withswapRecord: current?.Cid; for a brand-new NSIDcurrentis null so the write goes through as an unconditional create, meaning a genuinely concurrent first-publish from another process could be silently overwritten (noswapRecordguard against "must not exist"). This is a narrow race in a single-operator CLI tool and consistent with howputRecordis used elsewhere in the codebase, so I wouldn't block on it.atproto-lexgen publish --pdsaccepts a plainhttp://URL as well ashttps://, presumably for local/dev PDS instances; worth confirming that's intentional given the account password is sent to that endpoint duringSignInAsync.No correctness bugs found in the areas I was able to review in full. Given the scope (44 files, ~5300 insertions) I prioritized the security- and correctness-sensitive surfaces (resolver verification, caching semantics, permission-set validation, publish/sign-in flow) over the code-generation templating and the very large
LexiconGeneratorProgramTestsfile, which I skimmed rather than read line-by-line — the tests it adds appear commensurate with the described behavior. I did not have network access to independently re-verify the pinned@atproto/lex-resolverreference vector, but the resolver logic matches the algorithm as documented in the PR and indocs/did-resolution.md.⏱ 3m 4s · 23 turns · tokens: 120.2k in / 14.8k out (+2775.7k cache-read)
📜 Full session transcript
Addressed both review observations in 2c-commit
fix(lexgen)on top of the approved patch:publish --pds(and a PDS looked up from the DID document) must be HTTPS; plain HTTP is only allowed on loopback, so the password is never sent in plaintext off-machine (SdkLexgenNetwork.MaySignInAt, with tests).createRecord(which fails if the record appeared meanwhile) instead of an unguardedputRecord; replacements keepswapRecord.@claude /review — please review the new commit on top of the previously approved one (the earlier patch is unchanged apart from a CHANGELOG-only rebase onto main, which
verify-rebaseconfirmed byte-identical outside CHANGELOG.md).🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Re-review: one new commit (
e4863de) landed since my last pass, and it is a direct, well-targeted follow-up to both non-blocking notes I raised previously:publish --pds http://…used to send the account password to any host that accepted an http(s) URL. NowSdkLexgenNetwork.MaySignInAtrequires HTTPS, allowing plain HTTP only whenUri.IsLoopbackis true (a local dev PDS), and the same rule is applied to a PDS discovered via DID-document lookup (ResolveAsync+MaySignInAtcheck before signing in), not just the--pdsflag. New tests (Publish_PlainHttpPdsOffThisMachine_IsRefusedBeforeThePasswordIsSent,MaySignInAt_OnlyHttpsOrLoopbackHttp) cover the https/loopback-http/off-machine-http/ftp matrix, including verifying no sign-in attempt (_network.SignInsempty) occurs when refused.PutAsyncnow branches onreplacing:null(no previously published record) goes throughCreateRecordAsync, which fails if a record already exists, instead of the previous unconditionalPutRecordAsync/putRecordthat could silently overwrite a concurrent first publish; an existing record still goes throughPutRecordAsyncwithswapRecord, so replacement remains guarded.CHANGELOG and
docs/lexicon-codegen.mdwere updated in the same commit to describe both behaviors, consistent with the repo's changelog-in-same-commit convention. I diffed this commit in full and it's small, surgical, and doesn't touch any other resolver/linter/caching logic I reviewed previously (which I already verified carries over unchanged in this diff).No remaining issues. Approving.
⏱ 0m 39s · 10 turns · tokens: 87.9k in / 2.4k out (+980.1k cache-read)
📜 Full session transcript