Skip to content

User Enrollment ​

Before users can create Stratos records they must enroll with the Stratos service via OAuth.

Enrollment Record Schema ​

A user's Stratos enrollments are published as zone.stratos.actor.enrollment records on their PDS, created during the enrollment process. Each enrollment record represents a connection to a different Stratos service — a user can be enrolled in multiple Stratos services simultaneously, with each enrollment stored as a separate record using the service's DID as the record key.

Each enrollment record is stored at at://<did>/zone.stratos.actor.enrollment/<service-did>:

json
{
  "service": "https://stratos.example.com",
  "boundaries": [
    { "value": "did:web:stratos.example.com/WestCoastBestCoast" },
    { "value": "did:web:stratos.example.com/TeaDrinkers" }
  ],
  "signingKey": "did:key:zDnae...",
  "attestation": {
    "sig": { "$bytes": "base64..." },
    "signingKey": "did:key:zDnae..."
  },
  "createdAt": "2025-01-15T00:00:00.000Z"
}

A user enrolled in two Stratos services would have two records:

text
at://did:plc:abc123/zone.stratos.actor.enrollment/did:web:service-a.example.com  → Service A
at://did:plc:abc123/zone.stratos.actor.enrollment/did:web:service-b.example.com  → Service B
FieldTypeDescription
servicestring (URI)Stratos service endpoint where user's private data lives
boundariesArray<{ value: string }>Service-DID-qualified boundaries the user can access, each in {serviceDid}/{domainName} format
signingKeystring (did:key)User's P-256 public key, generated at enrollment and used to sign record commits
attestationServiceAttestationService attestation vouching for enrollment, boundaries, and signing key
attestation.sigbytesSignature over DAG-CBOR encoded {boundaries, did, signingKey} (sorted keys), signed by service key
attestation.signingKeystring (did:key)The Stratos service's public key used to verify the attestation
createdAtstring (datetime)When the enrollment was created

The enrollment process initializes the user's Stratos repository with an empty signed commit, so the repo is immediately valid for reads and writes. A P-256 signing key is generated and stored by the Stratos service — this key signs record commits, making them verifiable against the signingKey published in the enrollment record. If the per-user key is unavailable, the service falls back to its own Secp256k1 key.

Checking Enrollment Status ​

Discovery functions ​

getEnrollmentByServiceDid uses com.atproto.repo.getRecord with the service DID as the rkey for a direct O(1) lookup. This is more efficient than listing all records when targeting a specific service.

typescript
import { getEnrollmentByServiceDid } from '@northskysocial/stratos-client'
import type { StratosEnrollment } from '@northskysocial/stratos-client'

// Direct lookup by service DID (recommended)
const enrollment: StratosEnrollment | null = await getEnrollmentByServiceDid(
  did,
  pdsUrl,
  'did:web:stratos.example.com',
)

if (enrollment) {
  console.log(`Service: ${enrollment.service}, rkey: ${enrollment.rkey}`)
}

// With an existing FetchHandler (e.g. from an authenticated agent)
import type { FetchHandler } from '@atcute/client'

const target = await getEnrollmentByServiceDid(did, agent.handle, serviceDid)

Direct lookup by service DID ​

When you already know which Stratos service you're looking for, use getEnrollmentByServiceDid for a direct O(1) lookup instead of listing all records:

typescript
import {
  getEnrollmentByServiceDid,
  serviceDIDToRkey,
} from '@northskysocial/stratos-client'

const enrollment = await getEnrollmentByServiceDid(
  'did:plc:test123',
  'https://pds.example.com',
  'did:web:stratos.example.com',
)
if (enrollment) {
  console.log(`Enrolled in ${enrollment.service}`)
}

This calls com.atproto.repo.getRecord with the service DID as the rkey, which is more efficient than listRecords when targeting a specific service.

Service DID to rkey conversion ​

AT Protocol rkeys cannot contain % characters, but did:web DIDs with ports use %3A encoding ( e.g., did:web:localhost%3A3100). The serviceDIDToRkey helper handles this:

typescript
import { serviceDIDToRkey } from '@northskysocial/stratos-client'

serviceDIDToRkey('did:web:stratos.example.com') // => 'did:web:stratos.example.com'
serviceDIDToRkey('did:web:localhost%3A3100') // => 'did:web:localhost:3100'

Enrollment selection ​

When a user has multiple enrollments, you can find the right one by iterating or using direct lookups if you have the service DIDs. getEnrollmentByServiceDid is the preferred way to retrieve a specific enrollment.

Using raw XRPC ​

Check enrollment status via the Stratos service endpoint:

typescript
async function isUserEnrolled(
  stratosEndpoint: string,
  did: string,
): Promise<boolean> {
  const response = await fetch(
    `${stratosEndpoint}/xrpc/zone.stratos.enrollment.status?did=${encodeURIComponent(did)}`,
  )
  const data = await response.json()
  return data.enrolled === true
}

Boundaries are service-DID-qualified: each boundary value is stored in {serviceDid}/{domainName} format (e.g., did:web:stratos.example.com/animal-lovers). This makes every boundary globally addressable — the same domain name on two different Stratos services produces two distinct boundary values, so cross-enrollment conflicts are impossible by design.

Discovery should happen at session establishment (login/resume) and the result cached for the session lifetime. Reset enrollment state on account switch or logout.

Verifying the Attestation ​

Verify the signature and its service identity together. Pass the expected service DID from trusted configuration:

typescript
import { verifyEnrollmentAttestation } from '@northskysocial/stratos-client'

const result = await verifyEnrollmentAttestation(enrollment, userDid, {
  serviceDid: 'did:web:stratos.example.com',
})
if (!result.valid) throw new Error(result.error ?? 'Invalid attestation')

The client checks the current DID key first. For a retired key, it verifies the public zone.stratos.identity.getKeyHistory hash chain, both rotation signatures, and the signed issue-time window. Legacy attestations without issuedAt are accepted only against the current DID key. VerifyAttestationOptions.serviceDid is required; an arbitrary embedded key no longer establishes service identity. See the service key history design for storage, rotation, and trust limits.

See Attestation Verification for the full trust model and chained verification.

Initiating Enrollment ​

Enrollment uses OAuth. Redirect the user to start the flow:

typescript
function startEnrollment(stratosEndpoint: string, handle: string) {
  const url = new URL('/oauth/authorize', stratosEndpoint)
  url.searchParams.set('handle', handle)
  // Where Stratos returns the browser after enrollment.
  url.searchParams.set('redirect_uri', `${window.location.origin}/`)
  // Your client metadata document. Stratos reads it to confirm you own the
  // redirect target, so no operator configuration is needed.
  url.searchParams.set(
    'client_id',
    `${window.location.origin}/client-metadata.json`,
  )
  window.location.href = url.toString()
}

redirect_uri and client_id are optional. Send neither and the callback answers with JSON instead of returning the browser to your app.

To use redirect_uri, serve a client metadata document at your client_id URL. The client_id URL must:

  • use https
  • include a path — https://app.example is rejected, https://app.example/client-metadata.json is accepted
  • carry no fragment
  • name a hostname, not an IP address

The document must name itself in client_id and declare the redirect target's origin:

json
{
  "client_id": "https://app.example/client-metadata.json",
  "client_uri": "https://app.example",
  "redirect_uris": ["https://app.example/"],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "application_type": "web",
  "dpop_bound_access_tokens": true
}

Stratos reads only client_id and redirect_uris. AT Protocol requires the other fields of every OAuth client, so a document that omits them satisfies Stratos and is still rejected by the user's authorization server. token_endpoint_auth_method: "none" marks a public client; a confidential client declares its own method and keys.

This is the same document an AT Protocol OAuth client already publishes, so most apps have one. Stratos compares origins, so any path on a declared origin is accepted. After enrollment the browser returns to your redirect_uri with ?stratos_enrolled=true appended. No token, authorization code, or DID is ever placed on that URL.

Complete Flow ​

typescript
async function ensureEnrolled(
  stratosEndpoint: string,
  userHandle: string,
  userDid: string,
) {
  const enrolled = await isUserEnrolled(stratosEndpoint, userDid)
  if (enrolled) return true

  startEnrollment(stratosEndpoint, userHandle)
  return false
}

Handling the OAuth Callback ​

After enrollment completes the user is redirected back to your app. Handle the callback:

typescript
async function handleEnrollmentCallback() {
  const urlParams = new URLSearchParams(window.location.search)

  if (urlParams.get('error')) {
    console.error('Enrollment failed:', urlParams.get('error_description'))
    return { success: false, error: urlParams.get('error') }
  }

  return { success: true }
}

OAuth Scopes ​

Stratos records use AT Protocol auth scopes. Clients should declare the scopes they need in their OAuth metadata and scope selector UI.

Enrollment space grant and custody ​

The Stratos enrollment client requests atproto, the enrollment record write scope, and a space scope for its own authority. The space scope covers zone.stratos.space.feed, collection zone.stratos.feed.post, and both read and create. It omits skey so future boundary memberships remain covered. A scope permits operations; Stratos boundary membership still controls access.

Successful OAuth authorization does not establish spaces capability. The callback reads the granted scope with session.getTokenInfo(false) and checks the actual permissions. A grant for another authority, space type, collection, or only one action does not satisfy enrollment's request.

Granted scopeCapability verdictNew enrollment custody
Matching space read and create permissionscapablepds
Missing, partial, unrelated, or unreadable grantunknownstratos

An absent grant can mean an ordinary PDS ignored the space request or a user withheld consent. It does not prove the PDS lacks spaces support. Stratos records unknown and logs the incomplete grant, while ordinary PDS users can continue enrolling with Stratos custody.

Reauthorization preserves existing custody, signing keys, and records. Missing consent never moves a PDS-hosted repo to Stratos. Granting space access later never moves a Stratos-hosted repo to the PDS. Either custody change requires an explicit data migration. Stratos remains the space authority for both custody classes.

Required scopes ​

ScopeDescriptionDependency
repo:zone.stratos.actor.enrollmentRead/write enrollment recordsNone
repo:zone.stratos.feed.postRead/write Stratos postsRequires repo:zone.stratos.actor.enrollment
rpc:zone.stratos.feedgen.getFeed?aud=*Call any feed generator's getFeedNone

Scope utilities ​

typescript
import {
  STRATOS_SCOPES,
  buildCollectionScope,
  buildStratosScopes,
} from '@northskysocial/stratos-client'

// Individual scope construction
const enrollmentScope = buildCollectionScope(STRATOS_SCOPES.enrollment)
// => 'repo:zone.stratos.actor.enrollment'

// Full scope set for OAuth metadata
const scopes = buildStratosScopes()
// => ['atproto',
//     'repo:zone.stratos.actor.enrollment',
//     'repo:zone.stratos.feed.post?action=create&action=delete',
//     'rpc:zone.stratos.feedgen.getFeed?aud=*']

OAuth client metadata ​

Add scopes to your oauth-client-metadata.json:

json
{
  "scope": "atproto repo:zone.stratos.actor.enrollment repo:zone.stratos.feed.post"
}

Troubleshooting ​

"NotEnrolled" after completing OAuth — The service may have an allowlist. Contact the operator to request access.

OAuth redirect fails — Verify your app's callback URL matches the Stratos configuration and check for CORS issues in the browser console.