Presence Proof Specification
Version: 1.0
Status: Draft
This specification defines two independent layers:
- Part 1: Core Service (Sections 1–11) — the presence verification and service discovery protocol. RFC-style, normative.
- Part 2: App Platform (Sections 12–16) — an optional convenience layer for building apps on top of the core. Developer-focused, non-normative.
Apps MAY use the App Platform for fast development, or integrate directly with the Core Service for full control. Service registration with the Core Service is optional — proof issuance does not require registration.
Part 1: Core Service
The Core Service verifies physical taps, issues signed presence proofs, and hosts service discovery. Identity is verified on each request using AT Protocol service auth.
1. Introduction
A presence proof is a signed attestation binding an identity (DID) to a location (AT-URI on the platform repo) at a point in time. Proofs are issued by a Core Service server after verifying a tap.
Two tap mechanisms are supported, with different trust properties:
- NFC tap (NTAG 424 DNA SUN): the tag computes an AES-CMAC over a hardware-monotonic counter using a key embedded in tamper-resistant silicon. Forging a tap requires extracting the AES key from the tag.
- QR tap: the Core Service generates a single-use SUN URL, displays it as a QR code, and verifies the same URL on submission. The cryptographic check runs, but the secret holder is the server itself — a QR tap proves visual proximity to the display, not possession of a secret. See Appendix A.4 and C.1 for the trust differences.
In both cases the resulting proof is bound to the visitor's DID via AT Protocol service auth (Section 3.4).
Proofs are ES256-signed JWTs. Verification is stateless — any party can verify a proof using the platform's published public key. No trust relationship, API access, or shared secret is required beyond key discovery.
2. Terminology
- Core Service: The server that verifies taps, issues proofs, and hosts service discovery. It knows about tags, locations, and registered service endpoints. It does not know about OAuth sessions, user preferences, or application UX.
- Visitor: The person tapping at a location, identified by a DID.
- Location: A physical place, identified by an AT-URI pointing to a
dev.atlocally.location.profilerecord on the platform's repo. Each location has exactly one tap source: either an NFC tag or a QR display (Section 9.1). - Tap: An interaction proving presence at a location, via either an NFC tag (NTAG 424 DNA SUN — hardware-authenticated AES-CMAC) or a server-generated QR code (single-use, server-signed and server-verified). The two modes have different trust properties; see Section 1 and Appendix C.1.
- Proof: The signed JWT described in this specification.
- Presence record: An AT Protocol record, recommended lexicon
dev.atlocally.presence, written to the visitor's PDS by the app after a successful tap. Distinct from the proof; see Section 8.
3. Proof format
3.1 Header
{
"alg": "ES256",
"typ": "JWT",
"kid": "presence-proof-key"
}
The kid MUST match a key in the platform's JWKS (Section 5).
3.2 Payload
{
"iss": "did:web:platform.example.com",
"sub": "did:plc:abc123xyz",
"loc": "at://did:web:platform.example.com/dev.atlocally.location.profile/park",
"jti": "3kpqhzxfj24n2",
"tapped_at": 1775846115,
"iat": 1775846115
}
3.3 Claims
| Claim | Type | Description |
|---|---|---|
iss |
DID (string) | Platform DID. Identifies the issuer. Verifiers use this to discover the JWKS. |
sub |
DID (string) | Visitor DID. The identity bound to this tap. |
loc |
AT-URI (string) | Location identifier. Points to the dev.atlocally.location.profile record on the platform's repo. Resolved from the tag UID at tap time. |
jti |
TID (string) | Unique proof identifier. Format is a 13-character base32-sortable TID. Used as the rkey when writing the presence record to the visitor's PDS, enabling single-record-per-tap semantics (Section 8.1). |
tapped_at |
Unix timestamp (integer) | Server-set time of the tap. |
iat |
Unix timestamp (integer) | JWT issued-at. Equal to tapped_at. |
The proof does not include an exp claim. Presence proofs are archival attestations of past events, not session tokens — they are meant to remain verifiable indefinitely. Apps that need freshness (e.g., a write window) apply their own policy against tapped_at (Section 6.3).
The proof intentionally excludes location metadata (name, description). Consumers resolve this from the location's profile record (Section 9).
The proof excludes application-specific time windows. Applications enforce their own policies against tapped_at (Section 6.3).
3.4 Identity binding
The sub claim identifies who tapped. The Core Service verifies this identity using AT Protocol service auth — a short-lived JWT issued by the visitor's PDS, signed with the PDS's signing key.
The Core Service does not implement OAuth, session management, or any identity protocol. Instead, it relies on the AT Protocol's native cross-service authentication mechanism: the caller obtains a service auth token from the visitor's PDS and presents it to the Core Service. The Core Service verifies the token by resolving the visitor's DID document and checking the signature against the PDS's public key.
This means any caller — an App Platform, an independent app, a native mobile app — can authenticate a visitor to the Core Service without the Core Service maintaining any session state. The PDS is the identity authority.
4. Signing
4.1 Algorithm
ES256 (ECDSA on NIST P-256 with SHA-256), per RFC 7518 Section 3.4.
4.2 Key management
The platform's proof signing key is a P-256 keypair. The public key is published in the platform's DID document (Section 5.1), JWKS endpoint (Section 5.2), and DID log (Section 5.3).
Each key has a unique kid. Implementations SHOULD encode the rotation date (e.g., presence-proof-key-2025-06) so retired keys are unambiguously distinguishable from successors.
4.3 Key rotation
Rotation is manual and expected to be infrequent. On rotation:
- The DID document (
/.well-known/did.json) is updated to reflect the new key only. The DID document represents current state and does not retain history. - A new entry is appended to the DID log (
/.well-known/did-log.json, Section 5.3), recording the new key'saddedAttimestamp and the previous key'sretiredAttimestamp. - The JWKS endpoint (
/.well-known/jwks.json) is updated to include the new key alongside all prior keys (Section 5.2). Retired keys are marked but never removed.
Verifiers select the appropriate key by matching the JWT's kid header against JWKS or the DID log. Presence proofs are archival — their value is attesting that a tap occurred at a point in time, meaningful indefinitely for downstream copies, archives, and after-the-fact audits.
5. Key discovery
The platform exposes three endpoints for key discovery, each serving a different purpose:
| Endpoint | Contains | Use for |
|---|---|---|
/.well-known/did.json |
Current key only | Standard did:web resolution; verifying recently-issued proofs |
/.well-known/jwks.json |
All keys (current + retired) | JWT-library key lookup by kid |
/.well-known/did-log.json |
Append-only key history with timestamps | Auditing rotations; reconstructing which key was active at a given time |
5.1 DID document (canonical, current)
The platform's DID document is the canonical did:web resolution per W3C did-core. It reflects the current state of the platform identity — current verification method, current service endpoints. Past keys are not present.
GET /.well-known/did.json
→ {
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:web:platform.example.com",
"verificationMethod": [{
"id": "did:web:platform.example.com#presence-proof-key-2025-06",
"type": "JsonWebKey2020",
"controller": "did:web:platform.example.com",
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}],
"service": [{
"id": "#presence",
"type": "PresenceVerificationService",
"serviceEndpoint": "https://platform.example.com"
}]
}
A verifier with only the iss claim can resolve the platform DID, extract the current verification method, and verify a recently-issued proof. For proofs whose kid does not match the current key (i.e., signed with a since-retired key), verifiers consult JWKS (Section 5.2) or the DID log (Section 5.3).
This is the same mechanism the Core Service uses to verify service auth tokens (Section 7.2) — key discovery via DID documents is the standard across the entire system.
5.2 JWKS endpoint (all keys)
The JWKS endpoint serves every signing key ever used by the platform, including retired ones. This enables verification of historical proofs via the standard JWT-ecosystem flow: the verifier matches the JWT's kid header against the entries in keys.
GET /.well-known/jwks.json
→ {
"keys": [
{
"kty": "EC",
"kid": "presence-proof-key-2025-06",
"crv": "P-256",
"x": "...",
"y": "...",
"use": "sig",
"alg": "ES256"
},
{
"kty": "EC",
"kid": "presence-proof-key-2024-01",
"crv": "P-256",
"x": "...",
"y": "...",
"alg": "ES256"
}
]
}
The current signing key carries use: "sig". Retired keys omit use, signaling that they are present for historical verification only and MUST NOT be used to validate newly-issued artifacts. Verifiers performing signature checks on a specific JWT SHOULD select the key whose kid matches the JWT header regardless of the use value.
The platform sets Cache-Control: public, max-age=3600. Verifiers SHOULD cache the JWKS for at least 1 hour and MUST NOT fetch it more than once per minute under normal operation.
5.3 DID log (key history)
The DID log is an append-only record of every signing key ever associated with the platform DID, with timestamps for when each key was added and retired. It is conceptually similar to the per-DID operation log maintained by did:plc registries (e.g., https://plc.directory/{did}/log), adapted for did:web and scoped to signing keys.
GET /.well-known/did-log.json
→ {
"did": "did:web:platform.example.com",
"entries": [
{
"kid": "presence-proof-key-2024-01",
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"addedAt": "2024-01-01T00:00:00Z",
"retiredAt": "2025-06-01T00:00:00Z"
},
{
"kid": "presence-proof-key-2025-06",
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"addedAt": "2025-06-01T00:00:00Z",
"retiredAt": null
}
]
}
Entries are ordered by addedAt ascending. The most recent entry with retiredAt: null is the current signing key. Entries are never removed.
The DID log is not a W3C did-core artifact — did:web does not define an audit log endpoint. It is an atlocally-specific extension that provides PLC-style key auditability for a did:web identifier. Verifiers that only need to validate an individual proof can rely on JWKS or the DID document; the DID log is intended for auditors, for verifiers that need to reconstruct historical state with timestamps, and as the source of truth for rebuilding /.well-known/jwks.json after operator-side changes.
6. Verification
6.1 Steps (via DID document)
- Extract the
issclaim from the JWT payload. - Resolve the platform's DID document (e.g.,
GET {iss-as-did-web-url}/.well-known/did.json). - Extract the public key from the
verificationMethodmatching the JWT'skidheader. - Verify the ES256 signature.
- Apply application-specific policy against
tapped_at(Section 6.3).
Alternatively, verifiers MAY use the JWKS endpoint (Section 5.2) for key discovery. The verification steps are the same, substituting step 2-3 with a JWKS fetch and kid lookup.
6.2 Convenience endpoint
Verifying a proof locally requires key discovery — either DID resolution (Section 6.1) or JWKS fetching (Section 5.2). For callers that don't want to implement either, the Core Service can verify proofs directly using the signing key it already holds:
POST /xrpc/dev.atlocally.verifyProof
Content-Type: application/json
{ "proof": "<JWT>" }
→ { "valid": true, "proof": { "iss": "...", "sub": "...", "loc": "...", "tapped_at": ... } }
→ { "valid": false }
This endpoint has no auth requirement. The result is identical to local verification — the platform checks the same signature with the same key. The tradeoff is a network round-trip to the platform instead of local crypto, and a trust dependency on the platform's honesty (see Appendix C.3).
6.3 Application time policies
The proof carries no expiry claim; apps define their own freshness policies against tapped_at:
const elapsed = Math.floor(Date.now() / 1000) - proof.tapped_at;
// Guestbook: 1-hour write window, 24-hour read window
if (mode === "write" && elapsed > 3600) reject();
if (mode === "read" && elapsed > 86400) reject();
// Scavenger hunt: valid only during event
if (proof.tapped_at < eventStart || proof.tapped_at > eventEnd) reject();
// Loyalty: tap must be from today
if (new Date(proof.tapped_at * 1000).toDateString() !== new Date().toDateString()) reject();
7. Issuance
7.1 Prerequisites
- A provisioned NFC tag (NTAG 424 DNA with SUN enabled) or a server-controlled QR display at the location.
- A service auth token for the visitor, obtained from the visitor's PDS (see Section 7.2).
7.2 Service auth
Before requesting a proof, the caller MUST obtain a service auth token from the visitor's PDS. This token is a JWT that the PDS signs with its signing key, attesting that the visitor is authenticated.
GET /xrpc/com.atproto.server.getServiceAuth
?aud=did:web:platform.example.com
&lxm=<method-nsid>
&exp=<unix timestamp, recommended: now + 60s>
→ { "token": "<service-auth-jwt>" }
| Parameter | Description |
|---|---|
aud |
The Core Service's DID. The token is bound to this audience. |
lxm |
Required. The lexicon method the token authorizes. Equals the NSID of the Core Service method being called (e.g., dev.atlocally.tap for a tap; dev.atlocally.createLocation for a location creation). A token with a different lxm MUST be rejected. |
exp |
Token expiry. SHOULD be short-lived (60 seconds recommended). |
Callers obtain a separate service auth token for each Core Service method they invoke. The tokens are cheap to mint (a single XRPC call to the PDS) and short-lived, so this is not a meaningful overhead.
The caller may be:
- The App Platform (which authenticated the visitor via OAuth, then requests a service auth token from the visitor's PDS)
- An independent app (which authenticated the visitor via its own OAuth client, then requests a service auth token)
- A native mobile app (which has its own AT Protocol session)
The Core Service does not care who the caller is. It verifies the service auth token.
7.3 Flow
Caller obtains a service auth token from the visitor's PDS (Section 7.2).
Caller submits tap parameters with the service auth token:
POST /xrpc/dev.atlocally.tap Authorization: Bearer <service-auth-token> Content-Type: application/json { "uid": "...", "ctr": "...", "cmac": "..." }Core Service verifies identity:
- Decodes the service auth token (without verifying signature yet) to extract the visitor's DID.
- Resolves the visitor's DID document to obtain the PDS's signing key.
- Verifies the service auth token signature against the PDS's signing key.
- Checks that the token's
audmatches the Core Service's DID. - Checks that the token's
lxmmatches the invoked method's NSID (for this flow,dev.atlocally.tap). Tokens lackinglxm, or carrying a differentlxm, MUST be rejected. - Checks that the token has not expired.
Core Service verifies the SUN message:
- Validates the AES-CMAC against the stored key for this tag UID.
- Checks the counter against a high-water mark (rejects if ≤ HWM −
counterWindow). - Records the counter value (rejects exact duplicates).
Core Service signs and returns the presence proof JWT.
The caller (optionally) writes a
dev.atlocally.presencerecord to the visitor's PDS (Section 8.1).
7.4 Why service auth
Service auth is AT Protocol's native mechanism for cross-service identity verification (used by feed generators, labelers, and other services in the AT Protocol ecosystem). It provides:
- Cryptographic identity proof: The PDS signs the token with its key, published in the DID document.
- Audience binding: The
audclaim prevents token reuse against a different service. - Method binding: The
lxmclaim prevents using a token intended for one operation to authorize a different one. - Short-lived: Tokens expire quickly (recommended 60 seconds), limiting the window for replay.
- Stateless verification: Each token is verified independently by resolving the issuer's DID document.
7.5 Counter window
The platform maintains a high-water mark (HWM) per tag UID. Counters are accepted if they fall within (HWM - counterWindow, ∞) and have not been seen before. The default counterWindow is 5. This accommodates concurrent taps while rejecting replays.
7.6 Rate limiting
The dev.atlocally.tap endpoint SHOULD enforce rate limits to mitigate abuse. Recommended limits:
- Per DID: no more than 10 taps per minute across all tags.
- Per tag UID: no more than 20 taps per minute (accommodates busy locations).
- Per source IP: no more than 30 taps per minute.
These limits are not enforced at the protocol level. Platform operators configure them based on deployment context.
8. Presence
The Core Service does not store presence records and does not retain visitor identity beyond the duration of a tap request. A successful tap yields a proof JWT (Section 3), returned to the caller. Records describing the tap, if any, are written to the visitor's PDS by the app.
Apps that need aggregate views ("recent check-ins at this location") maintain their own indexes of the dev.atlocally.presence records they observe — either from records they themselves brokered into the visitor's PDS, or by consuming the atproto firehose. The Core Service is not a shared aggregator.
8.1 Recommended lexicon
An app that publishes a presence record to the visitor's PDS SHOULD use the dev.atlocally.presence lexicon:
{
"$type": "dev.atlocally.presence",
"location": "at://did:web:platform.example.com/dev.atlocally.location.profile/park",
"locationName": "Riverside Park",
"visitDate": "2026-04-11",
"proof": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InByZXNlbmNlLXByb29mLWtleSJ9..."
}
| Field | Description |
|---|---|
location |
Location AT-URI (matches the proof's loc claim). |
locationName |
Human-readable name at time of visit. |
visitDate |
Date of visit in UTC (YYYY-MM-DD). Day-granularity default — see Appendix C.6. |
proof |
The presence proof JWT returned by the Core Service. Makes the record self-verifying. |
The record's rkey MUST equal the proof's jti claim. Because jti is unique per tap and putRecord on a PDS treats the same rkey as an update, this guarantees at most one presence record per tap on the visitor's PDS — a replay-writing attempt overwrites the existing record rather than creating a duplicate.
The app calls com.atproto.repo.putRecord against the visitor's PDS with rkey = proof.jti. Writing on behalf of the visitor requires an OAuth session; the App Platform (Section 13) brokers this for apps that don't manage OAuth themselves. Apps with their own OAuth client write directly.
The Core Service does not observe or require this record. An app that doesn't want to publish presence (e.g., a private check-in system) simply omits the write.
8.2 Record verification
A consumer reading a dev.atlocally.presence record from a visitor's PDS verifies it by:
- Decode the embedded
proofJWT (no signature check yet). - Resolve the platform DID from the JWT's
issclaim, fetch JWKS or DID document (Section 5). - Verify the ES256 signature using the public key matching the JWT's
kid. - Check claim consistency:
- JWT's
subequals the repo's DID (the repo that holds the record). - JWT's
locequals the record'slocationfield. - JWT's
jtiequals the record's rkey.
- JWT's
- If all match, the record is genuine — the Core Service issued a proof for this visitor at this location, and the record hasn't been fabricated or tampered with.
Consumers reading historical records verify via signature and claim consistency alone; the proof carries no expiry. Apps that need freshness (e.g., a write window) apply their own policy against tapped_at.
9. Location identity
A location is identified by the AT-URI of its profile record on the platform's repo:
at://{platform-did}/dev.atlocally.location.profile/{location-id}
This matches how atproto identifies non-actor entities (posts, feeds, lists). The platform is the only DID in the system; locations are records within its repo.
9.1 Location types
Each location has a type that determines its tap source:
| Type | Tap source | Counter | Created when |
|---|---|---|---|
qr |
Platform-generated QR codes | Software (in-memory, recorded on tap) | dev.atlocally.createLocation (default) |
nfc |
Physical NTAG 424 DNA tag | Hardware (tag manages counter) | dev.atlocally.setLocationTagUid |
Locations default to qr at creation. When a physical NFC tag is provisioned and its UID is registered via dev.atlocally.setLocationTagUid, the location type changes to nfc. The QR display endpoint (GET /qr/{id}) only serves QR-type locations.
9.2 Location profile records
Location metadata is stored as a record on the platform's repo. The record's AT-URI is the location's identifier.
{
"$type": "dev.atlocally.location.profile",
"name": "Riverside Park",
"description": "Community park by the river"
}
One record per location; rkey is the location's short ID (e.g. park). Queryable via standard atproto XRPC:
GET /xrpc/com.atproto.repo.getRecord
?repo={platform-did}
&collection=dev.atlocally.location.profile
&rkey={location-id}
Or via the dev.atlocally.getLocation method with the short ID.
9.3 Parsing the loc claim
An AT-URI has the form at://{did}/{collection}/{rkey}. Consumers parse the proof's loc claim to extract:
- The platform DID — verifies that the issuer (
iss) matches, rejecting proofs whose location points at a different platform. - The location ID (rkey) — passes to
dev.atlocally.getLocationto fetch name, description, and tap type.
An AT-URI with a collection other than dev.atlocally.location.profile is malformed and MUST be rejected.
9.4 Stability and migration
Location identifiers remain stable as long as the platform DID and the location's rkey don't change. Because the platform DID is a did:web, it is domain-dependent: renaming the platform's domain invalidates the AT-URI in every existing proof (the proofs remain cryptographically valid, but their loc claims point at an inaccessible platform).
Operators planning domain migrations SHOULD consider migrating to did:plc for the platform identity first, as did:plc is domain-independent. See Appendix E.
10. Core Service API
The Core Service is an XRPC service. Application-specific methods use the dev.atlocally.* lexicon namespace. Records use the standard com.atproto.repo.* surface, scoped to the platform's own repo. Well-known paths (DID documents, JWKS) and operational paths (tag URLs, QR displays) stay plain HTTP per atproto convention. Lexicon definitions are listed in Appendix F.
10.1 Public methods (no auth)
Queries (GET /xrpc/<method>):
| Method | Description |
|---|---|
dev.atlocally.listLocations |
List locations. Optional: tagUid, owner |
dev.atlocally.getLocation |
Get a single location |
dev.atlocally.listServices |
List services. Optional: location, owner |
com.atproto.repo.getRecord |
Get a record on the platform repo (location profiles) |
com.atproto.repo.listRecords |
List records on the platform repo |
com.atproto.repo.describeRepo |
Describe the platform repo |
Procedures (POST /xrpc/<method>):
| Method | Description |
|---|---|
dev.atlocally.verifyProof |
Verify a proof JWT |
10.2 Authenticated methods
All require an AT Protocol service auth token (Section 7.2) in the Authorization: Bearer header. The Core Service verifies the token by resolving the caller's DID document (see Section 7.3). The token's lxm claim MUST equal the invoked method's NSID; tokens with a missing or mismatched lxm are rejected.
| Method | Expected lxm |
Description |
|---|---|---|
dev.atlocally.tap |
dev.atlocally.tap |
Verify a tap, issue a proof |
dev.atlocally.createLocation |
dev.atlocally.createLocation |
Create a location (ID generated server-side) |
dev.atlocally.setLocationTagUid |
dev.atlocally.setLocationTagUid |
Set tag UID, change type to NFC, sync counter HWM |
dev.atlocally.registerService |
dev.atlocally.registerService |
Register or update a service (Section 11) |
dev.atlocally.deleteService |
dev.atlocally.deleteService |
Remove a service (owner only) |
The Core Service does not expose com.atproto.repo.putRecord or deleteRecord. Location profiles are managed via dev.atlocally.createLocation and related admin methods. Visitor records live on the visitor's PDS and are managed through that PDS's own atproto API.
10.3 Error responses
XRPC methods return errors using the standard atproto shape: an HTTP status code plus a JSON body.
{
"error": "ErrorName",
"message": "Optional human-readable detail."
}
The error field is a short CamelCase identifier. It MUST equal one of the names listed in the method's lexicon errors array, or one of the cross-cutting errors below. Clients SHOULD dispatch on error; the message field is free-form and intended for logs and developer tooling, not for parsing.
Cross-cutting errors, available to any authenticated method:
| HTTP | error |
Cause |
|---|---|---|
| 400 | InvalidRequest |
Request shape does not match the method's input schema. |
| 401 | AuthRequired |
No Authorization header, or header not in Bearer <token> form. |
| 401 | InvalidToken |
Token signature invalid, aud mismatch, or lxm missing or mismatched. |
| 401 | TokenExpired |
Service auth token's exp has passed. |
| 403 | NotAuthorized |
Token is valid but the verified DID is not permitted for this operation (e.g., trying to delete a service owned by another DID). |
| 429 | RateLimitExceeded |
Request exceeded a rate limit. Response SHOULD include a Retry-After header. |
| 500 | InternalServerError |
Unexpected server failure. |
Method-specific errors are declared in each lexicon's errors array (see Appendix F). For example, dev.atlocally.tap defines InvalidCmac, UnknownTag, StaleCounter, and Replay alongside the cross-cutting set.
Non-XRPC endpoints (Section 10.6) use plain HTTP status codes. Well-known JSON paths return a plain HTTP error with no body; /t/{data} and /qr/{id} return HTML error pages appropriate to their contexts.
10.4 Cross-origin access
The Core Service is a public protocol surface designed for cross-origin browser access. All XRPC methods and well-known paths respond with permissive CORS headers:
Access-Control-Allow-Origin: *Access-Control-Allow-Methods: GET, POST, OPTIONSAccess-Control-Allow-Headers: Authorization, Content-TypeAccess-Control-Expose-Headers: Retry-AfterAccess-Control-Max-Age: 3600
The Core Service does not support credentialed requests. Access-Control-Allow-Credentials is never set; browsers invoking fetch with credentials: "include" will fail preflight.
Authentication is always via the Authorization: Bearer <service-auth-token> header, which browser clients attach explicitly per request. There are no session cookies on the Core Service to protect. The App Platform (Part 2), which does use cookies for OAuth sessions, maintains its own origin allowlist (Section 13.2) and is not governed by this section.
10.5 Caching
Cache behavior by endpoint category.
Key-discovery endpoints. The Core Service sends Cache-Control: public, max-age=3600:
| Path | Rationale |
|---|---|
/.well-known/did.json |
DID document is stable between key rotations. |
/.well-known/jwks.json |
See Section 5.2. |
/.well-known/did-log.json |
Append-only history; entries only change on rotation. |
Verifiers caching these SHOULD observe the max-age and MUST NOT refetch more than once per minute under normal operation.
Authenticated procedures (dev.atlocally.tap, createLocation, setLocationTagUid, registerService, deleteService) send Cache-Control: no-store. These endpoints mutate state or require fresh authentication per call.
Unauthenticated procedures (dev.atlocally.verifyProof) send Cache-Control: no-store. POST responses are not cacheable by intermediaries in the general case, and verifying a proof is cheap enough that caching offers no meaningful benefit.
Public query endpoints (dev.atlocally.listLocations, getLocation, listServices, and the com.atproto.repo.* surface) send Cache-Control: no-cache by default. Operators running a CDN or caching proxy MAY configure a short positive max-age for these; no value is normative.
Non-XRPC operational endpoints (/t/{data}, /qr/{id}, /qr/{id}/events) send Cache-Control: no-store. Tap URLs are single-use, QR pages render a fresh code per load, and SSE streams are live.
10.6 Non-XRPC endpoints
Plain HTTP paths for things where XRPC is not the right shape:
| Method | Path | Purpose |
|---|---|---|
GET |
/.well-known/did.json |
Platform DID document (W3C did:web resolution) |
GET |
/.well-known/jwks.json |
Proof signing keys (JWT ecosystem standard) |
GET |
/.well-known/did-log.json |
Platform key history (Section 5.3) |
GET |
/lexicons/{nsid}.json |
Lexicon JSON for any method or record under dev.atlocally.* (Appendix F) |
GET |
/t/{data} |
Compact tap URL, for NFC tag NDEF records (redirects to tap flow) |
GET |
/qr/{id} |
QR display page (HTML, for QR-type locations) |
GET |
/qr/{id}/events |
SSE stream — notifies displays when a QR has been scanned |
10.7 What the Core Service does NOT provide
- OAuth or session management
- Tap-to-app routing (App Platform concern — see Section 13)
- Bluesky posting on behalf of users
- Admin secrets or shared credentials
- Writes to foreign repos (visitor PDSes are the visitor's; the App Platform or app performs those writes)
These are the App Platform's domain (Part 2) or the application's own responsibility.
11. Service registration
Services register with the Core Service to be discoverable by tap routers and by other clients. Registration is optional — proof issuance does not require registration. A service that never registers can still verify taps and receive proofs.
11.1 Model
Any developer with an AT Protocol DID can register services. The model is open by default — there is no location-owner gatekeeping. Service registration is authenticated via AT Protocol service auth (the same mechanism used for location creation and tap verification).
11.2 Registering
POST /xrpc/dev.atlocally.registerService
Authorization: Bearer <service-auth-token>
Content-Type: application/json
{
"id": "guestbook",
"name": "Guestbook",
"callbackUrl": "https://guestbook.example.com",
"description": "Leave a message at this location.",
"scope": "all"
}
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique service identifier. |
name |
Yes | Human-readable name. |
callbackUrl |
Yes | Where the App Platform redirects users after a tap (Section 13). |
description |
No | One-line description. |
scope |
No | "all" (every location) or a JSON array of location IDs. Defaults to "all". |
Registration is idempotent — calling it again with the same id updates the existing entry. Only the original owner (the DID that first registered the service) can update it.
11.3 Scope
Services can target all locations or a specific set:
"all"— the service is available at every location.["park", "cafe"]— the service is only associated with the specified locations.
11.4 Querying
GET /xrpc/dev.atlocally.listServices → all registered services
GET /xrpc/dev.atlocally.listServices?location=<id> → services available at a specific location (filtered by scope)
GET /xrpc/dev.atlocally.listServices?owner=did:plc:X → services owned by a specific DID
No authentication required. Tap routers and apps use this method to discover which services apply at a given location — the Core Service is the source of truth for service discovery.
Results are returned in a stable, implementation-defined order. The response includes a cursor when more entries are available. Default limit is 50; maximum 100.
11.5 Removing
POST /xrpc/dev.atlocally.deleteService
Authorization: Bearer <service-auth-token>
Content-Type: application/json
{ "id": "guestbook" }
Only the owner (the DID that registered the service) can remove it.
11.6 Rate limiting
The dev.atlocally.registerService endpoint SHOULD enforce rate limits. Recommended: no more than 10 registrations per hour per DID.
Part 2: App Platform
The App Platform is an optional convenience layer that sits between the Core Service and applications. It handles the things most simple apps need but shouldn't have to build themselves: OAuth, tap routing, Bluesky integration, and app registration.
You don't need the App Platform. If your app handles its own OAuth and reads NFC tags directly (or accepts proofs from users), you can talk to the Core Service and skip everything in Part 2. The App Platform exists to make the common case easy.
12. What the App Platform does
The App Platform wraps the Core Service and adds:
| Capability | What it does | Why apps want it |
|---|---|---|
| AT Protocol OAuth | Handles the full OAuth flow with Bluesky PDS servers | Apps don't need their own OAuth client |
| Service auth brokering | Obtains service auth tokens from the visitor's PDS and forwards them to the Core Service | Apps don't need to implement DID resolution or service auth |
| Tap routing | Redirects NFC/QR taps to the right app based on registered services | Users scan once and land in the right place |
| Bluesky posting | Posts to Bluesky on behalf of authenticated users | Apps don't need PDS write access |
| Session management | Tracks active OAuth sessions | Apps can check if a user is logged in |
| App registry | Tracks which apps have OAuth/API key access | Enables CORS allowlisting and per-app credentials |
Note: service discovery is a Core Service concern (Section 11). The App Platform queries the Core Service's dev.atlocally.listServices for tap routing.
13. Registration
There are two separate registrations, serving different purposes:
13.1 Service registration (Core Service)
Service registration with the Core Service (Section 11) makes the app discoverable by tap routers and other clients. This uses AT Protocol service auth — no admin secret or shared credentials.
13.2 App Platform registration (optional)
Apps that use the App Platform's OAuth, session management, or Bluesky posting register separately with the App Platform. This provides an API key and adds the app's callback URL to the CORS allowlist.
POST /admin/apps
Authorization: Bearer <admin-secret>
Content-Type: application/json
{
"id": "guestbook",
"name": "Guestbook",
"description": "Leave a message at this location for other visitors to read.",
"callbackUrl": "https://guestbook.example.com"
}
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique app identifier. Lowercase, alphanumeric. |
name |
Yes | Human-readable name shown to users. |
callbackUrl |
Yes | Whitelisted for OAuth redirects and CORS. |
description |
No | One-line description. |
The App Platform returns a per-app API key on registration. The admin secret is an operator-only credential — apps use their API key for ongoing calls.
13.3 Listing apps
GET /apps → all registered apps
GET /apps?location=<id> → apps registered with the App Platform for a location
No authentication required. Returns id, name, description, and callbackUrl for each app.
14. Tap routing
When a user scans an NFC tag or QR code, the URL points to the platform's domain. The App Platform intercepts the tap URL and decides where to send the user, querying the Core Service's services API.
14.1 Flow
1. User scans: GET /t/{version}-{payload}
2. App Platform parses the tap URL to extract the tag UID.
3. App Platform looks up the location from the tag UID (via Core Service).
4. App Platform queries Core Service for services at that location
(`GET /xrpc/dev.atlocally.listServices?location=<id>`).
5. Routing:
- One service registered → redirect to that service's callbackUrl with tap params
- Multiple services → present a chooser to the visitor
- No services → redirect to the platform's default page
The tap parameters are passed to the app as a URL fragment:
https://guestbook.example.com#tap=1-{uid}{ctr}{cmac}
The app then obtains a service auth token (Section 15.2) and submits those parameters to POST /xrpc/dev.atlocally.tap on the Core Service to get a proof.
14.2 Apps that don't need tap routing
An app can bypass tap routing entirely by reading NFC tags directly:
- Web NFC API: A web page can read NDEF records from NFC tags without the browser opening the URL.
- Native apps: Android apps can register as NFC intent handlers.
- App-hosted QR codes: The app generates its own QR codes and parses the tap parameters itself.
In all cases, the app extracts uid, ctr, and cmac from the tag's URL, obtains a service auth token, and calls POST /xrpc/dev.atlocally.tap on the Core Service with the token in the Authorization header.
15. App Platform APIs
15.1 OAuth
GET /login?handle=<handle>&redirect=<app-callback-url>
Starts the AT Protocol OAuth flow. After authentication, redirects to the app's callback URL with #auth=<did> appended. The redirect URL MUST match a registered app's callbackUrl.
GET /oauth/callback
OAuth callback handler. Not called by apps directly.
GET /client-metadata.json
OAuth client metadata for PDS discovery.
15.2 Service auth brokering
The Core Service requires a service auth token for every authenticated request (Section 7.2). Obtaining one requires an OAuth session with the visitor's PDS. The App Platform bridges this gap: apps that use the App Platform's OAuth don't need to implement service auth themselves.
POST /session/service-token
Content-Type: application/json
{ "did": "did:plc:abc123", "lxm": "dev.atlocally.tap" }
→ { "token": "<service-auth-jwt>" }
The App Platform uses its stored OAuth session to call com.atproto.server.getServiceAuth on the visitor's PDS, with aud set to the Core Service's DID and a 60-second expiry. The returned token can be passed directly to the Core Service's POST /xrpc/dev.atlocally.tap in the Authorization: Bearer header.
| Field | Required | Description |
|---|---|---|
did |
Yes | The visitor's DID. Must have an active OAuth session on the App Platform. |
lxm |
No | The lexicon method to bind the token to. Defaults to dev.atlocally.tap. |
This endpoint requires a session cookie set during the OAuth callback. The cookie is an HMAC-signed token containing the visitor's DID, verified against the requested DID to prevent callers from minting tokens for other users. The cookie is HttpOnly, Secure (HTTPS), SameSite=None (HTTPS) or SameSite=Lax (HTTP), with a 7-day expiry. SameSite=None is required for cross-origin app frontends to send the cookie on fetch requests.
15.3 Session check
POST /session/check
Content-Type: application/json
{ "did": "did:plc:abc123", "proof": "<presence-proof-jwt>" }
→ 200 { "active": true, "did": "did:plc:abc123" }
→ 401 { "active": false, "did": "did:plc:abc123" }
Apps call this to verify a user has an active OAuth session before allowing writes. Requires a valid presence proof matching the requested DID.
Service discovery is a Core Service concern. Tap routing queries dev.atlocally.listServices?location={id} against the Core Service to find what apps are available at a location; the App Platform does not maintain its own service registry.
15.4 PDS record creation
POST /pds/create-record
Content-Type: application/json
{
"did": "did:plc:abc123",
"collection": "app.atlocally.guestbook.post",
"record": { ... },
"proof": "<presence-proof-jwt>"
}
→ 201 { "uri": "at://did:plc:abc123/app.atlocally.guestbook.post/rkey", "cid": "..." }
Creates a record on the visitor's PDS using the App Platform's stored OAuth session. This enables apps to write visitor-signed AT Protocol records without implementing their own OAuth client.
Requires a valid presence proof matching the requested DID. The App Platform verifies the proof against the Core Service's JWKS, then restores the visitor's OAuth session and calls com.atproto.repo.createRecord on the visitor's PDS. Rate limited to 10 writes per minute per DID.
Use cases:
- Public posts: the app writes the full record to the visitor's PDS (visitor-signed, publicly verifiable)
- Private post receipts: the app writes a content hash attestation to the visitor's PDS (proves authorship without revealing content)
- Bluesky sharing: the app writes an
app.bsky.feed.postrecord to share to the visitor's timeline
16. Integration patterns
16.1 Simple app (uses App Platform)
Best for: hackathon projects, community tools, single-developer apps.
┌──────────┐ ┌──────────────┐ ┌───────────────┐
│ Visitor │────▶│ App Platform │────▶│ Core Service │
│ (phone) │◀────│ + your app │◀────│ │
└──────────┘ └──────────────┘ └───────────────┘
Your app:
- Registers a service with the Core Service for discovery (Section 11).
- Optionally registers with the App Platform for OAuth/API keys (Section 13.2).
- Receives tap redirects with parameters in the URL fragment.
- Obtains a service auth token via the App Platform (Section 15.2) and calls
POST /xrpc/dev.atlocally.tapon the Core Service to get a proof. - Verifies proofs via JWKS (or the
dev.atlocally.verifyProofconvenience method). - Calls
POST /session/checkbefore allowing writes. - Calls
POST /pds/create-recordto write adev.atlocally.presencerecord (and/or app-specific records) to the visitor's PDS.
What you don't build: OAuth, service auth, DID resolution, NFC handling, Bluesky API integration.
16.2 Independent app (Core Service only)
Best for: businesses, apps with their own auth, apps that need full control.
┌──────────┐ ┌──────────┐ ┌───────────────┐
│ Visitor │────▶│ Your App │────▶│ Core Service │
│ (phone) │◀────│ │◀────│ │
└──────────┘ └──────────┘ └───────────────┘
Your app:
- Reads NFC tags directly (Web NFC, native app) or hosts its own QR codes.
- Authenticates the visitor via AT Protocol OAuth (your own client).
- Obtains a service auth token from the visitor's PDS (
com.atproto.server.getServiceAuthwithaudset to the Core Service's DID). - Calls
POST /xrpc/dev.atlocally.tapon the Core Service with the tap parameters and the service auth token. - Verifies proofs via JWKS.
- Writes
dev.atlocally.presence(and/or app-specific records) to the visitor's PDS viacom.atproto.repo.putRecord, using its own OAuth session.
What you build yourself: OAuth client, service auth token acquisition, NFC reading or QR hosting, record creation on the visitor's PDS. What you get from the Core Service: tap verification, cryptographic identity verification, proof issuance, JWKS.
Independent apps may optionally register their services with the Core Service (Section 11) for discovery purposes, but this is not required for proof issuance. An app that never registers can still verify taps and receive proofs.
16.3 Self-hosted platform
For operators who want full control over the infrastructure:
- Run your own Core Service instance.
- Optionally run your own App Platform instance.
- Provision your own NFC tags with your platform's URL and AES keys.
- Your proofs are signed with your keys and verifiable via your JWKS.
Multiple Core Services can coexist. A verifier only needs the issuer's JWKS URL (derived from the iss claim) to verify any proof, regardless of which platform issued it.
Appendices
Appendix A: Tap sources
The Core Service accepts taps from two sources: NFC tags and QR displays. Both produce the same SUN URL format. The proof issuance flow (Section 7) is the same regardless of source.
A.1 SUN URL format
The tap URL uses a compact, versioned format:
https://platform.com/t/{version}-{payload}
The version prefix determines how to parse the payload. The version and payload are separated by a - (hyphen), which cannot appear in the hex payload, making the split unambiguous.
Version table
| Version | Tag type | Payload format | Total payload | Description |
|---|---|---|---|---|
1 |
NTAG 424 DNA | uid(14) + ctr(6) + cmac(16) |
36 hex chars | Plain SDM with UID + counter + CMAC mirroring |
Future tag types or encoding changes use new version numbers. The server checks the version and parses accordingly. Existing tags and QR codes continue to work — the version is fixed at generation time.
Example
https://platform.com/t/1-04659B824F239000004C94F7221B6C1253B1
Parsed as version 1:
| Field | Hex offset | Size | Value | Description |
|---|---|---|---|---|
uid |
0–13 | 7 bytes (14 hex) | 04659B824F2390 |
Tag identifier |
ctr |
14–19 | 3 bytes (6 hex) | 00004C |
SDM read counter |
cmac |
20–35 | 8 bytes (16 hex) | 94F7221B6C1253B1 |
Truncated SDMMAC |
All fields are uppercase hex. The counter is displayed MSB-first in the URL. The UID and counter sizes are defined by the NTAG 424 DNA hardware. The CMAC size is defined by the truncation in the SDM MAC calculation (Section 9.1.3 of the NT4H2421Gx datasheet).
A.2 NFC tags (NTAG 424 DNA)
The NTAG 424 DNA chip computes the CMAC in a hardware secure element. The AES key is written during provisioning (Appendix B) and cannot be read back.
Tag identification: Each tag has a factory-assigned 7-byte UID. No two tags share a UID.
Counter: Hardware-monotonic, fused into the chip. Cannot be reset or decremented.
Proximity: NFC communication requires ~4cm between the phone and tag.
Cloning resistance: The AES key resides in tamper-resistant silicon. Software-based cloning is not possible.
A.3 QR displays
A QR display is a screen at a location that shows a QR code containing a SUN URL. The platform generates the URL server-side using the same AES key and counter logic, but in software.
Counter: Software-managed on the platform in memory, seeded from the database high-water mark on startup. The counter advances on each QR generation but is NOT recorded in the database — only verified taps are recorded, matching NFC behavior.
Refresh: QR codes are generated on demand (one per page load). Each generated QR is single-use — once scanned and verified, the display is notified via Server-Sent Events to reload with a fresh QR code. No periodic auto-refresh.
Multiple displays: A location can have multiple QR display screens simultaneously. Each page load generates a QR with a unique counter, allowing multiple people to verify at the same time.
Proximity: Camera range (meters). Weaker than NFC.
A.4 Security comparison
| Property | NFC (NTAG 424 DNA) | QR display |
|---|---|---|
| Key storage | Hardware secure element | Platform server |
| Proximity required | ~4cm | Camera range (meters) |
| Cloning resistance | Very high | N/A (server-generated) |
| Counter integrity | Hardware-monotonic | Software-managed |
| Offline operation | No power needed | Requires power + network |
| Deployment cost | $3–5 per tag | Requires display device |
Appendix B: Tag provisioning
B.1 Provisioning flow
- Create a location on the platform. The platform generates an AES-128 key and a tag UID placeholder.
- Read the tag's UID from the physical tag.
- Write the AES key to the tag's key slot.
- Set the NDEF URL template:
https://platform.com/tap?uid={UID}&ctr={CTR}&cmac={CMAC}. - Lock the AES key slot to prevent the key from being overwritten.
- Lock the NDEF file's write access to require the master key (or set to
0Ffor permanent read-only). - Change the master key to a value known only to the operator.
- Register the tag's UID on the platform.
Step 6 prevents unauthorized rewriting of the NDEF URL template. Without it, anyone with physical access and an NFC writing app could redirect the tag's URL to a different server. The AES key and CMAC would still be valid (the key is in the secure element), but taps would be sent to the wrong destination.
Operators choosing between master-key-locked and permanent read-only should consider the tradeoff: permanent locking prevents URL template updates if the platform domain changes, while master-key locking allows updates but requires physical access to each tag.
B.2 URL template
The NDEF URL template is separate from the AES key. If write access is locked to the master key (not permanent read-only), the operator can rewrite the URL template without re-provisioning the AES key. This is useful when changing domains — the tag's cryptographic identity (UID + AES key) is unaffected.
In deployments with an App Platform, the URL template SHOULD point to the App Platform's domain so that its GET /t/{data} tap-routing handler directs users to the appropriate app. Independent apps that read NFC directly do not depend on the URL template domain.
B.3 Tag lifecycle
- Counter endurance: 200,000 cycles minimum (NXP datasheet). At 50 taps/day, ~11 years.
- Key compromise: Deregister the tag UID and provision a replacement.
- Physical damage: No data loss — records and proofs are on the platform, not the tag.
Appendix C: Security considerations
C.1 Trust model
The system has three credentials in sequence: the SUN URL (from a tag or QR display), the service auth token (from the PDS), and the proof JWT (from the platform).
What the SUN URL attests depends on which tap mode produced it:
- NFC SUN URL: proves "someone with physical access to this tag generated this URL." The AES key resides in tamper-resistant silicon on the tag; the platform never holds a copy used for emission, only verification.
- QR SUN URL: proves "someone observed a freshly-issued QR code on this location's display." The platform generates the URL itself, so possession of a URL only implies the holder (or an observer) was within camera range of the display while it was active. Single-use counter prevents replay after the first scan, but anyone who photographs or shoulder-surfs the QR before the legitimate user can claim the tap.
The service auth token proves "this DID is authenticated by their PDS." The proof JWT combines the relevant tap evidence with the verified identity: "this authenticated DID submitted a valid tap at this location at this time."
The SUN URL and proof JWT are bearer credentials — whoever holds them can use them. The service auth token is audience-bound (to the Core Service's DID) and short-lived (recommended 60 seconds), limiting its reuse.
C.2 Properties
- Tap authenticity: A valid proof implies a valid SUN URL was submitted. For NFC, generating that URL requires the tag's AES key. For QR, generating it requires the platform — possession of a URL only implies the holder observed an active display.
- Identity binding: The
subDID is cryptographically verified via AT Protocol service auth. The visitor's PDS signs a token attesting their identity, and the Core Service verifies the signature against the DID document's public key. The caller cannot claim an arbitrary DID. - Temporal integrity:
tapped_atis server-set. The client cannot influence the timestamp. - Signature integrity: Modifying any claim invalidates the ES256 signature.
- Counter uniqueness: Each SUN URL is single-use.
C.3 Non-properties
- Current presence: The proof attests that a tap occurred, not that the holder is currently at the location.
- Exclusive possession: Both the SUN URL and proof JWT are bearer credentials.
- Content authorship: The proof establishes presence, not authorship. Applications implement authorship proofs at their own layer.
- Platform honesty: The platform could issue proofs without a real tap. The proof is as trustworthy as the issuing platform.
C.4 Bearer credential sharing
Sharing can occur at two points:
- SUN URL sharing: A visitor forwards the URL. The recipient submits it with their own DID.
- Proof sharing: A visitor forwards the JWT. The
substill identifies the original visitor.
The jti-as-rkey binding (Section 8.1) blocks one class of replay: a single proof cannot produce multiple presence records on the visitor's PDS — duplicate writes collide on the rkey and the PDS keeps only the most recent. A consumer listing dev.atlocally.presence records sees at most one per tap.
Further mitigations are application-layer concerns:
- Require active auth in addition to a valid proof for writes.
- Monitor counter velocity for bulk harvesting.
C.5 Revocation
Individual proof revocation is not supported. Proofs do not expire. Applications requiring revocation (e.g., to invalidate proofs from a compromised tap key) MUST implement their own revocation lists. Key rotation (Section 4.3) invalidates only future proofs signed by the retired key; previously-issued proofs remain verifiable via the DID log.
C.6 Visitor data
The Core Service records no visitor-identifying data beyond the duration of a tap request. Presence records — if any — live on the visitor's PDS (Section 8); the Core Service neither stores them nor exposes any aggregation over taps.
Consequences:
- Visitor control: Whether a tap becomes visible to the world is the visitor's (and their app's) choice. Apps that don't call
com.atproto.repo.putRecordafter tap verification leave no public trace of the tap beyond the proof JWT held by the caller. - No scraping surface: There is no endpoint on the Core Service that reveals who tapped at a given location.
- Aggregation is app-layer: Apps that want "who checked in here" views maintain their own indexes of
dev.atlocally.presencerecords they have observed — either ones they brokered, or records consumed from the atproto firehose. - Ownership and deletion: Visitors delete records via their PDS using standard atproto tooling. No vendor-mediated deletion.
The visitDate day-granularity default in dev.atlocally.presence is a privacy default for published records. The full-precision tapped_at lives in the proof, which is ephemeral and held only by the caller. Apps that legitimately need higher precision (e.g., event check-ins) MAY include a tappedAt field, subject to the visitor's PDS privacy model.
Appendix D: AT Protocol conformance
The Core Service hosts a single repo under its own platform DID. That repo contains location profile records (keyed by location ID as the rkey) and is queryable via com.atproto.repo.listRecords, getRecord, and describeRepo for client compatibility. The platform does not expose XRPC repos for any other DID: locations are records within the platform repo, not separate actors, and visitor records live on the visitor's own PDS.
The platform's repo is a subset of a full atproto repo:
- Records are returned in the expected XRPC response format.
- CIDs are computed correctly (DAG-CBOR, SHA-256, CID v1) and can be used for content verification.
- Merkle Search Trees, repository signing, commit chains, and
com.atproto.sync.*endpoints are not implemented.
The dev.atlocally namespace (platform protocol) and app.atlocally namespace (apps) follow AT Protocol reverse-DNS convention, derived from atlocally.dev and atlocally.app respectively.
Appendix E: Future considerations
E.1 Tag-signed proofs
Current proofs are platform-signed. A stronger model would have the NFC tag itself sign the proof. Current NFC hardware does not support arbitrary signing — only AES-CMAC over a fixed message format. Future chips with asymmetric signing could enable fully decentralized presence proofs.
E.2 Per-app credentials
The App Platform issues per-app API keys on registration. The admin secret is an operator-only credential used for initial app registration. Core Service registration uses AT Protocol service auth — no shared secrets. A natural evolution is scoped permissions per API key (e.g., an app that can check sessions but not post to Bluesky).
Appendix F: Lexicons
The Core Service defines the following lexicons.
Records:
| NSID | Holder | Purpose |
|---|---|---|
dev.atlocally.location.profile |
Platform repo | Location metadata (Section 9.2) |
dev.atlocally.presence |
Visitor PDS | Recommended lexicon for presence records on the visitor's repo (Section 8.1) |
XRPC methods (all under the dev.atlocally.* namespace):
| NSID | Type | Auth | Section |
|---|---|---|---|
dev.atlocally.tap |
procedure | service auth | 7.3 |
dev.atlocally.verifyProof |
procedure | none | 6.2 |
dev.atlocally.listLocations |
query | none | 9 |
dev.atlocally.getLocation |
query | none | 9 |
dev.atlocally.createLocation |
procedure | service auth | 9 |
dev.atlocally.setLocationTagUid |
procedure | service auth | 9 |
dev.atlocally.listServices |
query | none | 11 |
dev.atlocally.registerService |
procedure | service auth | 11 |
dev.atlocally.deleteService |
procedure | service auth | 11 |
Full JSON schemas live in the implementation repo under /lexicons/ and are also served by the Core Service at /lexicons/{nsid}.json (e.g., /lexicons/dev.atlocally.tap.json). The served files are the authoritative source for request/response shapes. This appendix is a catalogue only.
The dev.atlocally namespace is derived from the atlocally.dev domain per AT Protocol reverse-DNS convention. Apps SHOULD use app.atlocally.* for app-specific records written to visitor PDSes (e.g., app.atlocally.guestbook.post).