|null $providedPlcOp caller-supplied plcOp * * @return array{result: AccountCreationResult, tokens: AuthTokenPair} */ public function create( string $handle, string $email, string $password, ?string $inviteCode, ?string $providedDid = null, ?array $providedPlcOp = null, ?string $recoveryKey = null, ): array { $handle = $this->handleValidator->validateForRegistration($handle); $email = $this->normalizeAndValidateEmail($email); $this->ensureEmailFree($email); $invite = $this->verifyInvite($inviteCode); [$did, $signingKey] = $this->establishIdentity( $handle, $providedDid, $providedPlcOp, $recoveryKey, ); $this->ensureDidFree($did); $now = new DateTimeImmutable(); $this->actors->save(new Actor( did: $did, handle: $handle, createdAt: $now, )); $this->accounts->save(new Account( did: $did, email: $email, passwordScrypt: $this->passwordHasher->hash($password), emailConfirmedAt: null, invitesDisabled: false, )); if ($invite !== null) { $this->inviteCodes->recordUse(new InviteCodeUse( code: $invite, usedBy: $did, usedAt: $now, )); } $store = $this->actorStores->get($did); $store->getSigningKeys()->save(new StoredSigningKey( curve: $signingKey->getCurveName(), privateKey: $signingKey->export(), didKey: $signingKey->getDidKey(), createdAt: $now, )); $init = $this->repoInitializer->initialize($did, $store, $signingKey); $this->sequenceEvents($did, $handle, $init, $now); $tokens = $this->tokens->issue($did, AuthTokenIssuer::SCOPE_ACCESS, null); $this->refreshTokens->save(new RefreshToken( id: $tokens->getRefreshJti(), did: $did, expiresAt: $tokens->getRefreshExpiresAt()->format(DATE_ATOM), appPasswordName: null, nextId: null, )); $didDoc = $this->didResolver->resolve($did); return [ 'result' => new AccountCreationResult($did, $handle, $didDoc), 'tokens' => $tokens, ]; } private function normalizeAndValidateEmail(string $email): string { $normalizedEmail = StringNormalizer::normalizeEmail($email); if (!filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { throw new InvalidEmailException('Invalid email address'); } return $normalizedEmail; } private function verifyInvite(?string $inviteCode): ?string { if (!$this->inviteRequired) { return $inviteCode; } if ($inviteCode === null || $inviteCode === '') { throw new InvalidInviteCodeException('Invite code required'); } try { $code = $this->inviteCodes->findByCode($inviteCode); } catch (InviteCodeNotFoundException) { throw new InvalidInviteCodeException('Invite code not found'); } if ($code->isDisabled()) { throw new InvalidInviteCodeException('Invite code is disabled'); } $uses = count($this->inviteCodes->findUsesForCode($code->getCode())); if ($uses >= $code->getAvailableUses()) { throw new InvalidInviteCodeException('Invite code has no remaining uses'); } return $code->getCode(); } private function ensureEmailFree(string $email): void { try { $this->accounts->findAccountByEmail($email); } catch (AccountNotFoundException) { return; } throw new EmailAlreadyTakenException('Email already in use'); } private function ensureDidFree(string $did): void { try { $this->accounts->findAccountByDid($did); } catch (AccountNotFoundException) { return; } throw new AccountAlreadyExistsException("Account already exists for {$did}"); } /** * @param array|null $providedPlcOp * @return array{0: string, 1: Keypair} [did, signingKey] */ private function establishIdentity( string $handle, ?string $providedDid, ?array $providedPlcOp, ?string $recoveryKey, ): array { if ($providedDid !== null) { if (!Did::isValid($providedDid)) { throw new \InvalidArgumentException("Invalid DID: {$providedDid}"); } if (str_starts_with($providedDid, 'did:plc:')) { if ($providedPlcOp !== null) { try { $this->plc->submit($providedDid, $providedPlcOp); } catch (PlcDirectoryClientException $e) { throw new \RuntimeException( "Failed to submit caller-supplied plcOp: " . $e->getMessage(), 0, $e, ); } } $signingKey = $this->extractSigningKeyForExistingDid($providedDid); return [$providedDid, $signingKey]; } if (str_starts_with($providedDid, 'did:web:')) { $signingKey = $this->extractSigningKeyForExistingDid($providedDid); return [$providedDid, $signingKey]; } throw new \InvalidArgumentException("Unsupported DID method: {$providedDid}"); } $signingKey = $this->keypairs->generate(); $rotationKeys = [$this->plcRotationKey->getDidKey()]; if ($recoveryKey !== null && $recoveryKey !== '') { array_unshift($rotationKeys, $recoveryKey); } $op = $this->plc->buildAndSignGenesisOp( rotationKeys: $rotationKeys, signingKey: $signingKey->getDidKey(), handle: $handle, pdsEndpoint: 'https://' . $this->hostname, signer: $this->plcRotationKey, ); $did = $this->plc->didForOp($op); try { $this->plc->submit($did, $op); } catch (PlcDirectoryClientException $e) { throw new \RuntimeException( 'Failed to register new did:plc with directory: ' . $e->getMessage(), 0, $e, ); } return [$did, $signingKey]; } /** * When the caller brings their own DID, we don't generate a new signing * key; we expect the caller's DID document to already declare one * matching a key we hold. We provision a fresh signing key * (the BYO-did caller must have rotated their plcOp to include it * before calling). */ private function extractSigningKeyForExistingDid(string $did): Keypair { // Resolve doc to surface a clear error early if the DID isn't usable. $doc = $this->didResolver->resolve($did); if ($doc === null) { throw new \RuntimeException("Could not resolve provided DID: {$did}"); } return $this->keypairs->generate(); } /** * @param array{commitCid: string, commitBytes: string, mstCid: string, mstBytes: string, rev: string} $init */ private function sequenceEvents(string $did, string $handle, array $init, DateTimeImmutable $time): void { $blocks = [ $init['commitCid'] => $init['commitBytes'], $init['mstCid'] => $init['mstBytes'], ]; $commitPayload = $this->events->genesisCommit( did: $did, commitCid: $init['commitCid'], rev: $init['rev'], blocks: $blocks, time: $time, ); $this->sequencer->append($did, RepoSeqEvent::TYPE_APPEND, $commitPayload); $identityPayload = $this->events->identity($did, $handle, $time); $this->sequencer->append($did, RepoSeqEvent::TYPE_IDENTITY, $identityPayload); $accountPayload = $this->events->account($did, true, $time, null); $this->sequencer->append($did, RepoSeqEvent::TYPE_ACCOUNT, $accountPayload); } }