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

The enrollment record's attestation field is signed by the Stratos service's private key. To verify the enrollment is authentic, resolve the service's public key from its DID document and check the signature over the DAG-CBOR encoded payload:

typescript
import { encode as cborEncode } from '@atcute/cbor'
import { getPublicKeyFromDidController } from '@atcute/crypto'
import { getAtprotoVerificationMaterial } from '@atcute/identity'
import { WebDidDocumentResolver } from '@atcute/identity-resolver'
import type { StratosEnrollment } from '@northskysocial/stratos-client'

async function verifyEnrollmentAttestation(
  enrollment: StratosEnrollment,
  did: string,
): Promise<boolean> {
  const serviceDid = new URL(enrollment.service).hostname
    .replaceAll('.', ':')
    .replace(/^/, 'did:web:')

  const resolver = new WebDidDocumentResolver()
  const doc = await resolver.resolve(serviceDid as `did:web:${string}`)

  const material = getAtprotoVerificationMaterial(doc)
  if (!material) return false

  const { publicKeyBytes } = getPublicKeyFromDidController(material)

  // attestation payload is DAG-CBOR with sorted keys: {boundaries, did, signingKey}
  const boundaries = enrollment.boundaries.map((b) => b.value).sort()
  const payload = cborEncode({
    boundaries,
    did,
    signingKey: enrollment.signingKey,
  })

  const key = await crypto.subtle.importKey(
    'raw',
    publicKeyBytes,
    { name: 'ECDSA', namedCurve: 'K-256' },
    false,
    ['verify'],
  )

  return crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' },
    key,
    enrollment.attestation.sig,
    payload,
  )
}

This confirms the Stratos service vouches for the user's DID, boundaries, and signing key binding. The service's did:web DID document is the root of trust — cache the resolved key to avoid repeated lookups.

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.

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.