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:

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

discoverEnrollments uses com.atproto.repo.listRecords to fetch all enrollment records from the collection, validates each record's shape, and includes the rkey from the record URI. discoverEnrollment is a convenience wrapper that returns the first enrollment or null.

typescript
import {
  discoverEnrollments,
  discoverEnrollment,
} from '@northskysocial/stratos-client'
import type { StratosEnrollment } from '@northskysocial/stratos-client'

// Discover all enrollments (recommended for multi-service support)
const enrollments: StratosEnrollment[] = await discoverEnrollments(did, pdsUrl)

enrollments.forEach((e) => {
  console.log(`Service: ${e.service}, rkey: ${e.rkey}`)
})

// Convenience: discover the first/only enrollment
const enrollment: StratosEnrollment | null = await discoverEnrollment(
  did,
  pdsUrl,
)

// With an existing FetchHandler (e.g. from an authenticated agent)
import type { FetchHandler } from '@atcute/client'
const all = await discoverEnrollments(did, agent.handle)

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, select the right one by service URL:

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

const enrollments = await discoverEnrollments(did, pdsUrl)
const target = findEnrollmentByService(
  enrollments,
  'https://stratos.example.com',
)
if (target) {
  // Route requests to this enrollment's service
}

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
}

Boundary Addressability

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.

typescript
const enrollments = await discoverEnrollments(did, pdsUrl)

// Boundaries from different services are distinct by construction
enrollments.forEach((e) => {
  e.boundaries.forEach((b) => {
    // e.g. 'did:web:service-a.example.com/animal-lovers'
    //      'did:web:service-b.example.com/animal-lovers'
    console.log(b.value)
  })
})

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 = `${stratosEndpoint}/oauth/authorize?handle=${encodeURIComponent(handle)}`
  window.location.href = 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

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']

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.