From a117a6a3c979b904cbbb17775f1330685d6fb2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:31:35 +0000 Subject: [PATCH 1/7] Add the preference.updated, user.updated and user.deleted event types --- src/Id/Webhooks/EventType.php | 28 ++++++++++++++++++++++++++++ tests/Id/WebhookTest.php | 10 ++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index a1c31a8..4806bf0 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -76,6 +76,34 @@ final class EventType */ public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available'; + /** + * A preference of a user was set, changed or deleted. Thin, never the value: `data.object` is + * `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: + * {type: "user"|"app", client_id}}`; fetch the preference again (or drop your copy when + * `deleted`) and ignore events with a lower version than the one you hold. Opt-in (not through + * `*`); only to trusted apps that may read the namespace, never to the app that made the change. + */ + public const PREFERENCE_UPDATED = 'preference.updated'; + + /** + * The profile of a user connected to your app changed: name, username, the verified email + * address, email_verified or the avatar. `data.object` is the full profile `{object: "user", id, + * name, username, email, email_verified, avatar, version}` (Resources\User), `data.changed` names + * the changed fields (never their old values). An email address appears only once verified + * (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL + * changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also + * sent to the app that made the change. + */ + public const USER_UPDATED = 'user.updated'; + + /** + * A user connected to your app deleted their account (or it was deleted): delete or anonymise + * it and treat it as final. `data.object` is `{object: "user", id, deleted: true, deleted_at}`. + * Sent once; no other event about the user follows. A later user.updated for the same ID means + * the account was restored. Opt-in (not through `*`). + */ + public const USER_DELETED = 'user.deleted'; + public const TEST = 'webhook.test'; /** diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php index 2c8b984..47657a4 100644 --- a/tests/Id/WebhookTest.php +++ b/tests/Id/WebhookTest.php @@ -84,6 +84,16 @@ class WebhookTest extends TestCase $this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all()); } + public function test_user_and_preference_events_have_their_own_types(): void + { + $this->assertSame('preference.updated', EventType::PREFERENCE_UPDATED); + $this->assertSame('user.updated', EventType::USER_UPDATED); + $this->assertSame('user.deleted', EventType::USER_DELETED); + $this->assertContains(EventType::PREFERENCE_UPDATED, EventType::all()); + $this->assertContains(EventType::USER_UPDATED, EventType::all()); + $this->assertContains(EventType::USER_DELETED, EventType::all()); + } + public function test_a_change_carries_the_previous_attributes(): void { $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [ From d458d5d7fc5460b217cfe85eea23a0db0eb70913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:33:38 +0000 Subject: [PATCH 2/7] Throw ValidationFailedException with per-field codes for 422 responses --- src/Id/Exceptions/ApiException.php | 5 ++ .../Exceptions/ValidationFailedException.php | 46 ++++++++++++++ src/Id/Support/AppRequest.php | 9 ++- tests/Id/ValidationFailedTest.php | 61 +++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/Id/Exceptions/ValidationFailedException.php create mode 100644 tests/Id/ValidationFailedTest.php diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index a6b2d0b..9b3e200 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -35,6 +35,11 @@ class ApiException extends ResourceException return PreconditionFailedException::fromResult($result); } + // refused input, with per-field codes on some endpoints + if ($status === 422 && static::class === self::class) { + return ValidationFailedException::fromResult($result); + } + return new static( rtrim((string) ($body->message ?? $result->error()), '.'), $status, diff --git a/src/Id/Exceptions/ValidationFailedException.php b/src/Id/Exceptions/ValidationFailedException.php new file mode 100644 index 0000000..f9ffd6a --- /dev/null +++ b/src/Id/Exceptions/ValidationFailedException.php @@ -0,0 +1,46 @@ + "username_taken"]`. + * Branch on the codes, the messages are for people and may change. + */ +class ValidationFailedException extends ApiException +{ + /** + * @return array field => code; empty when the endpoint sends none + */ + public function errorCodes(): array + { + $codes = $this->body()->codes ?? null; + + return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : []; + } + + public function errorCodeFor(string $field): ?string + { + return $this->errorCodes()[$field] ?? null; + } + + /** + * Whether any field failed with $code, or the response's `error` is $code. + */ + public function hasErrorCode(string $code): bool + { + return $this->error === $code || in_array($code, $this->errorCodes(), true); + } + + /** + * The messages per field (`errors`), for showing them to people. + * + * @return array> + */ + public function errors(): array + { + $errors = $this->body()->errors ?? null; + + return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : []; + } +} diff --git a/src/Id/Support/AppRequest.php b/src/Id/Support/AppRequest.php index 4fa2240..3b8182e 100644 --- a/src/Id/Support/AppRequest.php +++ b/src/Id/Support/AppRequest.php @@ -6,6 +6,7 @@ use Anikeen\Id\AnikeenId; use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\PreconditionFailedException; +use Anikeen\Id\Exceptions\ValidationFailedException; use Anikeen\Id\Result; use GuzzleHttp\Exception\GuzzleException; use Throwable; @@ -13,7 +14,7 @@ use Throwable; /** * Sends a request with the app's own token (client credentials). A rejected token is renewed * once; every failure ends in an ApiException with status and error code (a 412 in a - * PreconditionFailedException). + * PreconditionFailedException, a 422 in a ValidationFailedException). */ final class AppRequest { @@ -36,7 +37,11 @@ final class AppRequest if (!$result->success()) { $body = is_object($result->data) ? $result->data : null; - $exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; + $exception = match ($result->status) { + 412 => PreconditionFailedException::class, + 422 => ValidationFailedException::class, + default => ApiException::class, + }; throw new $exception( sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), diff --git a/tests/Id/ValidationFailedTest.php b/tests/Id/ValidationFailedTest.php new file mode 100644 index 0000000..1234306 --- /dev/null +++ b/tests/Id/ValidationFailedTest.php @@ -0,0 +1,61 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + public function test_422_of_app_requests_is_a_validation_exception(): void + { + $this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']); + + try { + app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10)); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('value_too_large', $e->error); + $this->assertSame([], $e->errorCodes()); + $this->assertTrue($e->hasErrorCode('value_too_large')); + } + } + + public function test_412_stays_a_precondition_failure(): void + { + $this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]); + + $this->expectException(PreconditionFailedException::class); + $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2); + } + + public function test_422_of_user_requests_is_a_validation_exception(): void + { + $this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']); + + try { + $this->owner()->setGlobalPreference('appearance', 'purple'); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame(422, $e->status); + $this->assertSame('invalid_shared_value', $e->error); + $this->assertSame([], $e->errorCodes()); + } + } +} From 2eceb34faa0016688fa81e5cef70b79925c8cb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:33:39 +0000 Subject: [PATCH 3/7] Read a connected user's profile by ID for trusted apps --- src/Id/Concerns/ManagesUsers.php | 25 +++++++ src/Id/Resources/User.php | 123 +++++++++++++++++++++++++++++++ tests/Id/UserByIdTest.php | 101 +++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 src/Id/Resources/User.php create mode 100644 tests/Id/UserByIdTest.php diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php index 26b07a7..2c2ed78 100644 --- a/src/Id/Concerns/ManagesUsers.php +++ b/src/Id/Concerns/ManagesUsers.php @@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns; use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Resources\User; use Anikeen\Id\Result; use Anikeen\Id\Support\AppRequest; use Throwable; @@ -95,6 +96,30 @@ trait ManagesUsers } } + /** + * The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token), + * e.g. to catch up on a missed user.updated webhook. The email address is returned even while + * it isn't verified (emailVerified() is false then). No scope needed. + * + * @return User|null null when the user is unknown, deleted or not connected to your app + * (404 "user_not_found"; the three look the same) + * @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests", + * or another failure + */ + public function findUserById(string $userId): ?User + { + try { + return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data); + } catch (ApiException $e) { + // only Anikeen ID's own answer means "no such user", not e.g. an unknown route + if ($e->isNotFound() && $e->error === 'user_not_found') { + return null; + } + + throw $e; + } + } + /** * Find the user of a social login or create it (trusted apps, app token). * diff --git a/src/Id/Resources/User.php b/src/Id/Resources/User.php new file mode 100644 index 0000000..c92e2fb --- /dev/null +++ b/src/Id/Resources/User.php @@ -0,0 +1,123 @@ +data = (object) $data; + } + + public function __get(string $name): mixed + { + return $this->data->{$name} ?? null; + } + + public function __isset(string $name): bool + { + return isset($this->data->{$name}); + } + + public function id(): string + { + return (string) ($this->data->id ?? ''); + } + + public function name(): ?string + { + return $this->data->name ?? null; + } + + public function username(): ?string + { + return $this->data->username ?? null; + } + + public function email(): ?string + { + return $this->data->email ?? null; + } + + /** + * From `email_verified` (webhooks) or `email_verified_at` (REST shapes). + */ + public function emailVerified(): bool + { + if (isset($this->data->email_verified)) { + return (bool) $this->data->email_verified; + } + + return !empty($this->data->email_verified_at); + } + + /** + * The URL of the profile picture. It stays the same when the picture changes (user.updated + * with `avatar` in `changed`): drop a cached copy then. + */ + public function avatar(): ?string + { + return $this->data->avatar ?? null; + } + + /** + * Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks): + * keep the highest one seen and ignore older data. + */ + public function version(): int + { + return (int) ($this->data->profile_version ?? $this->data->version ?? 0); + } + + /** + * A new email address waiting for the user's confirmation (profile updates only); `email` + * stays the current address until then. + */ + public function pendingEmail(): ?string + { + return $this->data->pending_email ?? null; + } + + public function pendingEmailExpiresAt(): ?DateTimeImmutable + { + return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null; + } + + public function toArray(): array + { + return (array) $this->data; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/tests/Id/UserByIdTest.php b/tests/Id/UserByIdTest.php new file mode 100644 index 0000000..e57feff --- /dev/null +++ b/tests/Id/UserByIdTest.php @@ -0,0 +1,101 @@ +sent[$index]['request']; + + return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); + } + + private function profile(array $overrides = []): array + { + return ['data' => [ + 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com', + 'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER', + 'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z', + ...$overrides, + ]]; + } + + public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void + { + $this->respond(200, $this->profile()); + + $user = app(AnikeenId::class)->findUserById('01JUSER'); + + $this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0)); + $this->assertInstanceOf(User::class, $user); + $this->assertSame('01JUSER', $user->id()); + $this->assertSame('Ada King', $user->name()); + $this->assertSame('ada', $user->username()); + $this->assertSame('ada@example.com', $user->email()); + $this->assertTrue($user->emailVerified()); + $this->assertSame('https://id.test/avatars/01JUSER', $user->avatar()); + $this->assertSame(8, $user->version()); + $this->assertSame('en', $user->locale); + $this->assertNull($user->pendingEmail()); + $this->assertSame('Ada King', $user->jsonSerialize()['name']); + } + + public function test_find_user_by_id_encodes_the_id(): void + { + $this->respond(200, $this->profile()); + + app(AnikeenId::class)->findUserById('a/b'); + + $this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath()); + } + + public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void + { + $this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']); + + $this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER')); + } + + public function test_find_user_by_id_throws_other_failures(): void + { + $this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']); + + try { + app(AnikeenId::class)->findUserById('01JUSER'); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(403, $e->status); + $this->assertSame('client_not_allowed', $e->error); + } + + // a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user" + $this->respond(404, ['message' => 'Not Found']); + $this->expectException(ApiException::class); + app(AnikeenId::class)->findUserById('01JUSER'); + } + + public function test_an_unverified_address_is_not_verified(): void + { + $this->respond(200, $this->profile(['email_verified_at' => null])); + + $this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified()); + } + + public function test_a_user_updated_webhook_object_is_a_user(): void + { + $user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', + 'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]); + + $this->assertSame(9, $user->version()); + $this->assertFalse($user->emailVerified()); + $this->assertNull($user->email()); + $this->assertSame('user', $user->object); + $this->assertSame('user.updated', EventType::USER_UPDATED); + } +} From 93f6d79a0ace29855d5dc69b49802b8d71f22a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:33:41 +0000 Subject: [PATCH 4/7] Let users change their profile and avatar with their token --- src/Id/AnikeenId.php | 26 ++++ src/Id/Concerns/ManagesProfile.php | 106 ++++++++++++++ tests/Id/UserProfileTest.php | 216 +++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 tests/Id/UserProfileTest.php diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index c1e2b77..5a92d18 100644 --- a/src/Id/AnikeenId.php +++ b/src/Id/AnikeenId.php @@ -397,6 +397,32 @@ class AnikeenId return $result; } + /** + * Send a multipart/form-data request (file uploads), with the same headers and error handling + * as request(), which stays JSON only. + * + * @param list}> $multipart + * Guzzle's multipart parts + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function requestMultipart(string $method, string $path, array $multipart): Result + { + try { + $response = $this->client->request($method, $path, [ + // no Content-Type: Guzzle sets it with the boundary + 'headers' => $this->buildHeaders(), + 'multipart' => $multipart, + ]); + + $result = new Result($response, null, $this); + } catch (RequestException $exception) { + $result = new Result($exception->getResponse(), $exception, $this); + } + + return $result; + } + /** * Build headers for request. * diff --git a/src/Id/Concerns/ManagesProfile.php b/src/Id/Concerns/ManagesProfile.php index 5ab113c..b871f44 100644 --- a/src/Id/Concerns/ManagesProfile.php +++ b/src/Id/Concerns/ManagesProfile.php @@ -2,8 +2,19 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Exceptions\ValidationFailedException; +use Anikeen\Id\Resources\User; +use Anikeen\Id\Result; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Utils; +use SplFileInfo; use Throwable; +/** + * The user's own profile, with the user's token. Changes need the scope `user` (`user:read` + * only reads; 403 "missing_scope"). + */ trait ManagesProfile { use HasBillable; @@ -23,4 +34,99 @@ trait ManagesProfile 'options' => $options, ])->data->url; } + + /** + * Change the user's profile (scope `user`). Only the fields sent change. A new email address + * doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes + * the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the + * result; user.updated follows then). Clears the cached getUserData(). + * + * @param array{ + * name?: string, + * username?: string, + * email?: string + * } $attributes The fields to change + * - name: The display name + * - username: The username (unique regardless of case) + * - email: A new email address (lower-cased; needs the user's confirmation) + * @return User the user as `GET v1/user` returns them, plus `pending_email` and + * `pending_email_expires_at` + * @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken", + * "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field)) + * @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required", + * 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour) + */ + public function updateProfile(array $attributes): User + { + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes)); + } + + /** + * Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to + * 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears + * the cached getUserData(). + * + * @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile + * @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the + * upright picture; the centre without one + * @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large", + * "crop_invalid" + * @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required", + * 429 "too_many_requests" (also more than 10 uploads an hour) + */ + public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User + { + $path = $file instanceof SplFileInfo ? $file->getPathname() : $file; + $filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName') + ? $file->getClientOriginalName() + : basename($path); + + $multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]]; + + foreach (['x', 'y', 'size'] as $key) { + if (isset($crop[$key])) { + $multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]]; + } + } + + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart)); + } + + /** + * Remove the uploaded profile picture (scope `user`); the initials are shown again when it + * was the one shown. Clears the cached getUserData(). + * + * @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests" + */ + public function removeAvatar(): User + { + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar')); + } + + /** + * @param callable(): Result $send + * @throws ApiException + */ + private function sendProfileRequest(callable $send): User + { + try { + $result = $send(); + } catch (GuzzleException $e) { + throw ApiException::unreachable($e); + } finally { + $this->userDataCache = null; + } + + if (!$result->success()) { + throw ApiException::fromResult($result); + } + + return new User($result->data); + } } \ No newline at end of file diff --git a/tests/Id/UserProfileTest.php b/tests/Id/UserProfileTest.php new file mode 100644 index 0000000..a7c94de --- /dev/null +++ b/tests/Id/UserProfileTest.php @@ -0,0 +1,216 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + private function sentCall(int $index): string + { + $request = $this->sent[$index]['request']; + + return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); + } + + private function profile(array $overrides = []): array + { + return ['data' => [ + 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com', + 'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER', + 'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z', + ...$overrides, + ]]; + } + + private function validationError(array $codes): array + { + return [ + 'message' => 'The given data was invalid.', + 'error' => array_values($codes)[0], + 'errors' => array_map(fn () => ['The value is invalid.'], $codes), + 'codes' => $codes, + ]; + } + + public function test_update_profile_patches_the_fields_with_the_user_token(): void + { + $this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9, + 'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z'])); + + $user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']); + + $this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0)); + $this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0)); + $this->assertSame('Ada Lovelace', $user->name()); + $this->assertSame('ada@example.com', $user->email()); + $this->assertSame('new@example.com', $user->pendingEmail()); + $this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt()); + $this->assertSame(9, $user->version()); + } + + public function test_update_profile_clears_the_cached_user_data(): void + { + $owner = $this->owner(); + $this->respond(200, $this->profile()); + $this->respond(200, $this->profile(['name' => 'Ada Lovelace'])); + $this->respond(200, $this->profile(['name' => 'Ada Lovelace'])); + + $this->assertSame('Ada King', $owner->getUserData()->name); + $owner->updateProfile(['name' => 'Ada Lovelace']); + + $this->assertSame('Ada Lovelace', $owner->getUserData()->name); + $this->assertCount(3, $this->sent); + $this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2)); + } + + public function test_refused_profile_changes_carry_codes_per_field(): void + { + $this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid'])); + + try { + $this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame(422, $e->status); + $this->assertSame('username_taken', $e->error); + $this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes()); + $this->assertSame('email_invalid', $e->errorCodeFor('email')); + $this->assertNull($e->errorCodeFor('name')); + $this->assertTrue($e->hasErrorCode('email_invalid')); + $this->assertFalse($e->hasErrorCode('email_taken')); + $this->assertSame(['The value is invalid.'], $e->errors()['username']); + $this->assertInstanceOf(ApiException::class, $e); + } + } + + public function test_a_client_credentials_token_is_refused(): void + { + $this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']); + + try { + $this->owner()->updateProfile(['name' => 'Ada']); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertNotInstanceOf(ValidationFailedException::class, $e); + $this->assertSame('user_token_required', $e->error); + } + } + + public function test_an_unreachable_service_is_an_api_exception(): void + { + $this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile'))); + + try { + $this->owner()->updateProfile(['name' => 'Ada']); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(0, $e->status); + $this->assertTrue($e->isTemporary()); + } + } + + public function test_upload_avatar_sends_the_picture_as_multipart(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'picture-bytes'); + $this->respond(200, $this->profile(['profile_version' => 10])); + + try { + $user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]); + } finally { + unlink($path); + } + + $request = $this->sent[0]['request']; + $body = (string) $request->getBody(); + + $this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0)); + $this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type')); + $this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body); + $this->assertStringContainsString('picture-bytes', $body); + $this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body); + $this->assertStringContainsString('name="crop[y]"', $body); + $this->assertStringContainsString('name="crop[size]"', $body); + $this->assertSame(10, $user->version()); + } + + public function test_upload_avatar_without_crop_sends_only_the_picture(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'picture-bytes'); + $this->respond(200, $this->profile()); + + try { + $this->owner()->uploadAvatar(new \SplFileInfo($path)); + } finally { + unlink($path); + } + + $body = (string) $this->sent[0]['request']->getBody(); + $this->assertStringContainsString('name="avatar"', $body); + $this->assertStringNotContainsString('crop', $body); + } + + public function test_a_refused_upload_carries_its_code(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'not a picture'); + $this->respond(422, $this->validationError(['avatar' => 'avatar_invalid'])); + $this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']); + + try { + try { + $this->owner()->uploadAvatar($path); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('avatar_invalid', $e->errorCodeFor('avatar')); + } + + try { + $this->owner()->uploadAvatar($path); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(413, $e->status); + $this->assertSame('avatar_too_large', $e->error); + } + } finally { + unlink($path); + } + } + + public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void + { + $owner = $this->owner(); + $this->respond(200, $this->profile()); + $this->respond(200, $this->profile(['profile_version' => 11])); + $this->respond(200, $this->profile(['profile_version' => 11])); + + $owner->getUserData(); + $user = $owner->removeAvatar(); + $owner->getUserData(); + + $this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1)); + $this->assertSame(11, $user->version()); + $this->assertCount(3, $this->sent); + } +} From 5ecc535e0796d431d05a96af139d7389e8867c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:34:40 +0000 Subject: [PATCH 5/7] Document profile updates, reading users by ID and the user events --- README.md | 168 +++++++++++++++++++++++++++++++++++++++++++++++++--- README.stub | 164 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 318 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0c9e84c..df5611e 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 4. [Payments](#payments) 5. [Trusted apps](#trusted-apps) -6. [Preferences](#preferences) -7. [API authentication](#api-authentication) -8. [General](#general) -9. [Examples](#examples) -10. [Documentation](#documentation) -11. [Development](#development) +6. [User profiles](#user-profiles) +7. [Preferences](#preferences) +8. [API authentication](#api-authentication) +9. [General](#general) +10. [Examples](#examples) +11. [Documentation](#documentation) +12. [Development](#development) ## Installation @@ -182,6 +183,8 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller token has to carry the reseller scope itself. For the product APIs there is only `products:read`; changing products needs `user`. +Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads. + Anikeen ID's [preferences](#preferences) use these scopes: | Scope | Constant | Grants | @@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the order of the two events. +#### User and preference events + +These events are about the user rather than your app's orders. They are **opt-in**: select them +explicitly for your endpoint, `*` doesn't include them. + +| Type | Constant | Sent to | `data` | +|------|----------|---------|--------| +| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) | +| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` | +| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value | + +```json +{"id": "01JEVT…", "type": "user.updated", "created": 1762500000, + "data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika", + "email": "erika@example.com", "email_verified": true, + "avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8}, + "changed": ["name", "avatar"]}} +``` + +- **`user.updated`** is sent for changes to `name`, `username`, the verified email address, + `email_verified` and the profile picture, from any source (the user's settings, the + [profile API](#changing-the-profile-user-token), support). **A new email address arrives only + once it is verified**: `email` is `null` while the address isn't, and a requested change sends + nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or + `["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed: + drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object. +- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes). + Events can arrive late or out of order: keep the highest version you have seen and ignore older + ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps). +- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and + what you keep only for it), and stop acting for the user. No other event about the user follows + it, also none still queued. If a `user.updated` arrives later for the same ID, support restored + the account: treat it as a returning user. +- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your + copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold + (versions are per user). Nothing is sent for a write that changes nothing, and none when the + account is deleted (`user.deleted` covers that). + +```php +use Anikeen\Id\Resources\User; + +$object = $event['data']['object']; + +if ($event['type'] === EventType::USER_UPDATED) { + $profile = new User($object); + $account = Account::firstWhere('anikeen_id', $profile->id()); + + if ($account && $profile->version() > $account->profile_version) { + $account->update([ + 'name' => $profile->name(), + 'email' => $profile->email() ?? $account->email, // null while unverified + 'profile_version' => $profile->version(), + ]); + } +} + +if ($event['type'] === EventType::USER_DELETED) { + Account::firstWhere('anikeen_id', $object['id'])?->anonymise(); +} + +if ($event['type'] === EventType::PREFERENCE_UPDATED) { + Cache::forget("preferences:{$object['user_id']}"); +} +``` + ### Errors Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as @@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries: - `body()`: the error response, for errors with details (e.g. `remaining` of `billing_limit_reached`). +Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints +with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()` +(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()` +has the messages per field. + ```php use Anikeen\Id\Exceptions\ApiException; @@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca other than an unknown address. +## User profiles + +### Changing the profile (user token) + +With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name, +username, email address and profile picture. Each call returns the updated profile +(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`. + +```php +use Anikeen\Id\Exceptions\ValidationFailedException; + +try { + $profile = $user->updateProfile([ // only the fields sent change + 'name' => 'Erika Mustermann', + 'username' => 'erika', + 'email' => 'erika.new@example.com', + ]); +} catch (ValidationFailedException $e) { + return back()->withErrors(match ($e->errorCodeFor('username')) { + 'username_taken' => ['username' => 'This username is taken.'], + default => $e->errors(), + }); +} + +$profile->email(); // still the current address +$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it +$profile->pendingEmailExpiresAt(); + +$user->uploadAvatar($request->file('avatar')); // a path or a file +$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels +$user->removeAvatar(); // back to the initials +``` + +- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new + address and a notice to the current one; the new address becomes the user's address only once + they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and + mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a + pending address is dropped after 7 days (`pendingEmailExpiresAt()`). +- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without + `crop` the centre is used. The `avatar` URL stays the same when the picture changes. +- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture + uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address + that is taken count too. + +| Status | `error` | +|--------|---------| +| 401 | `unauthenticated` | +| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | +| 413 | `avatar_too_large` (the upload is larger than the server accepts) | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 429 | `too_many_requests` | + +Branch on the codes, the messages are for people and may change. + +### Reading a user by ID (trusted apps) + +[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the +user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a +missed `user.updated`: + +```php +$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected + +$profile?->name(); +$profile?->email(); // returned even while unverified +$profile?->emailVerified(); +$profile?->avatar(); +$profile?->version(); // compare with the version of user.updated +$profile?->locale; // also username, created_at +``` + +It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429 +`too_many_requests` and other failures. The profile carries no billing or account data; read those +through their own endpoints. + ## Preferences Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they @@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference() on the model instance (one request per instance); `setGlobalPreference()` and `deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire -after about an hour, since other apps and the user can change shared preferences too: +after about an hour, since other apps and the user can change shared preferences too (trusted +apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it +sooner): ```php $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), @@ -1447,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys public function getAuthedUser(): Result public function createUser(array $attributes): Result public function findUserByEmail(string $email): ?Result +public function findUserById(string $userId): ?User public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function isEmailExisting(string $email): Result @@ -1519,6 +1670,9 @@ public function getBillable(): Billable ```php public function profilePortalUrl(?string $returnUrl = null, array $options = []): string +public function updateProfile(array $attributes): User +public function uploadAvatar(SplFileInfo|string $file, ?array $crop = null): User +public function removeAvatar(): User public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` diff --git a/README.stub b/README.stub index de76f6e..bc17847 100644 --- a/README.stub +++ b/README.stub @@ -16,12 +16,13 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 4. [Payments](#payments) 5. [Trusted apps](#trusted-apps) -6. [Preferences](#preferences) -7. [API authentication](#api-authentication) -8. [General](#general) -9. [Examples](#examples) -10. [Documentation](#documentation) -11. [Development](#development) +6. [User profiles](#user-profiles) +7. [Preferences](#preferences) +8. [API authentication](#api-authentication) +9. [General](#general) +10. [Examples](#examples) +11. [Documentation](#documentation) +12. [Development](#development) ## Installation @@ -182,6 +183,8 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller token has to carry the reseller scope itself. For the product APIs there is only `products:read`; changing products needs `user`. +Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads. + Anikeen ID's [preferences](#preferences) use these scopes: | Scope | Constant | Grants | @@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the order of the two events. +#### User and preference events + +These events are about the user rather than your app's orders. They are **opt-in**: select them +explicitly for your endpoint, `*` doesn't include them. + +| Type | Constant | Sent to | `data` | +|------|----------|---------|--------| +| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) | +| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` | +| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value | + +```json +{"id": "01JEVT…", "type": "user.updated", "created": 1762500000, + "data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika", + "email": "erika@example.com", "email_verified": true, + "avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8}, + "changed": ["name", "avatar"]}} +``` + +- **`user.updated`** is sent for changes to `name`, `username`, the verified email address, + `email_verified` and the profile picture, from any source (the user's settings, the + [profile API](#changing-the-profile-user-token), support). **A new email address arrives only + once it is verified**: `email` is `null` while the address isn't, and a requested change sends + nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or + `["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed: + drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object. +- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes). + Events can arrive late or out of order: keep the highest version you have seen and ignore older + ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps). +- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and + what you keep only for it), and stop acting for the user. No other event about the user follows + it, also none still queued. If a `user.updated` arrives later for the same ID, support restored + the account: treat it as a returning user. +- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your + copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold + (versions are per user). Nothing is sent for a write that changes nothing, and none when the + account is deleted (`user.deleted` covers that). + +```php +use Anikeen\Id\Resources\User; + +$object = $event['data']['object']; + +if ($event['type'] === EventType::USER_UPDATED) { + $profile = new User($object); + $account = Account::firstWhere('anikeen_id', $profile->id()); + + if ($account && $profile->version() > $account->profile_version) { + $account->update([ + 'name' => $profile->name(), + 'email' => $profile->email() ?? $account->email, // null while unverified + 'profile_version' => $profile->version(), + ]); + } +} + +if ($event['type'] === EventType::USER_DELETED) { + Account::firstWhere('anikeen_id', $object['id'])?->anonymise(); +} + +if ($event['type'] === EventType::PREFERENCE_UPDATED) { + Cache::forget("preferences:{$object['user_id']}"); +} +``` + ### Errors Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as @@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries: - `body()`: the error response, for errors with details (e.g. `remaining` of `billing_limit_reached`). +Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints +with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()` +(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()` +has the messages per field. + ```php use Anikeen\Id\Exceptions\ApiException; @@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca other than an unknown address. +## User profiles + +### Changing the profile (user token) + +With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name, +username, email address and profile picture. Each call returns the updated profile +(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`. + +```php +use Anikeen\Id\Exceptions\ValidationFailedException; + +try { + $profile = $user->updateProfile([ // only the fields sent change + 'name' => 'Erika Mustermann', + 'username' => 'erika', + 'email' => 'erika.new@example.com', + ]); +} catch (ValidationFailedException $e) { + return back()->withErrors(match ($e->errorCodeFor('username')) { + 'username_taken' => ['username' => 'This username is taken.'], + default => $e->errors(), + }); +} + +$profile->email(); // still the current address +$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it +$profile->pendingEmailExpiresAt(); + +$user->uploadAvatar($request->file('avatar')); // a path or a file +$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels +$user->removeAvatar(); // back to the initials +``` + +- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new + address and a notice to the current one; the new address becomes the user's address only once + they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and + mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a + pending address is dropped after 7 days (`pendingEmailExpiresAt()`). +- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without + `crop` the centre is used. The `avatar` URL stays the same when the picture changes. +- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture + uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address + that is taken count too. + +| Status | `error` | +|--------|---------| +| 401 | `unauthenticated` | +| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | +| 413 | `avatar_too_large` (the upload is larger than the server accepts) | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 429 | `too_many_requests` | + +Branch on the codes, the messages are for people and may change. + +### Reading a user by ID (trusted apps) + +[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the +user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a +missed `user.updated`: + +```php +$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected + +$profile?->name(); +$profile?->email(); // returned even while unverified +$profile?->emailVerified(); +$profile?->avatar(); +$profile?->version(); // compare with the version of user.updated +$profile?->locale; // also username, created_at +``` + +It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429 +`too_many_requests` and other failures. The profile carries no billing or account data; read those +through their own endpoints. + ## Preferences Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they @@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference() on the model instance (one request per instance); `setGlobalPreference()` and `deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire -after about an hour, since other apps and the user can change shared preferences too: +after about an hour, since other apps and the user can change shared preferences too (trusted +apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it +sooner): ```php $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), From 0ccf46d69a4499221e61f6de0f7b6168418fe664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:34:40 +0000 Subject: [PATCH 6/7] Add user profiles and user events to the changelog --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0020ee8..91a862b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +User profiles and the user and preference webhooks; they need an Anikeen ID release that supports +them. + +### Added + +- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email; + a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on + `Billable`. They return the updated profile and clear the cached `getUserData()`. +- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an + unknown, deleted or unconnected user). +- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's + `version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a + `user.updated` event. +- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with + `errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps + working. +- `AnikeenId::requestMultipart()` for file uploads. +- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated` + (`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`). + +## 3.4.0-beta.1 (2026-11-06) + Preferences, charges and the billing limit; they need an Anikeen ID release that supports them. ### Added From 57efac2bf5758554a40df719450f4fa31b068cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:36:52 +0000 Subject: [PATCH 7/7] Find crop codes under crop and document unreadable avatar files --- README.md | 2 +- README.stub | 2 +- src/Id/Concerns/ManagesProfile.php | 5 ++-- .../Exceptions/ValidationFailedException.php | 18 ++++++++++- tests/Id/UserProfileTest.php | 30 +++++++++++++++++++ 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index df5611e..8046300 100644 --- a/README.md +++ b/README.md @@ -1187,7 +1187,7 @@ $user->removeAvatar(); // back | 401 | `unauthenticated` | | 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | | 413 | `avatar_too_large` (the upload is larger than the server accepts) | -| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` | | 429 | `too_many_requests` | Branch on the codes, the messages are for people and may change. diff --git a/README.stub b/README.stub index bc17847..16dd144 100644 --- a/README.stub +++ b/README.stub @@ -1187,7 +1187,7 @@ $user->removeAvatar(); // back | 401 | `unauthenticated` | | 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | | 413 | `avatar_too_large` (the upload is larger than the server accepts) | -| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` | | 429 | `too_many_requests` | Branch on the codes, the messages are for people and may change. diff --git a/src/Id/Concerns/ManagesProfile.php b/src/Id/Concerns/ManagesProfile.php index b871f44..6ff2cdc 100644 --- a/src/Id/Concerns/ManagesProfile.php +++ b/src/Id/Concerns/ManagesProfile.php @@ -72,9 +72,10 @@ trait ManagesProfile * @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the * upright picture; the centre without one * @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large", - * "crop_invalid" + * "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them) * @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required", * 429 "too_many_requests" (also more than 10 uploads an hour) + * @throws \RuntimeException when the file can't be opened (nothing is sent then) */ public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User { @@ -129,4 +130,4 @@ trait ManagesProfile return new User($result->data); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Exceptions/ValidationFailedException.php b/src/Id/Exceptions/ValidationFailedException.php index f9ffd6a..7c6b925 100644 --- a/src/Id/Exceptions/ValidationFailedException.php +++ b/src/Id/Exceptions/ValidationFailedException.php @@ -19,9 +19,25 @@ class ValidationFailedException extends ApiException return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : []; } + /** + * The code of $field, or of its first failed sub-field when the codes are keyed by those + * (`crop` finds `crop.x`, `crop.y` or `crop.size`). + */ public function errorCodeFor(string $field): ?string { - return $this->errorCodes()[$field] ?? null; + $codes = $this->errorCodes(); + + if (isset($codes[$field])) { + return $codes[$field]; + } + + foreach ($codes as $key => $code) { + if (str_starts_with((string) $key, $field . '.')) { + return $code; + } + } + + return null; } /** diff --git a/tests/Id/UserProfileTest.php b/tests/Id/UserProfileTest.php index a7c94de..bea1366 100644 --- a/tests/Id/UserProfileTest.php +++ b/tests/Id/UserProfileTest.php @@ -198,6 +198,36 @@ class UserProfileTest extends TestCase } } + public function test_crop_codes_are_found_under_the_crop_field(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'a picture'); + $this->respond(422, $this->validationError(['crop.size' => 'crop_invalid'])); + + try { + $this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('crop_invalid', $e->errorCodeFor('crop')); + $this->assertSame('crop_invalid', $e->errorCodeFor('crop.size')); + $this->assertNull($e->errorCodeFor('cro')); + $this->assertNull($e->errorCodeFor('avatar')); + } finally { + unlink($path); + } + } + + public function test_an_unreadable_file_throws_before_anything_is_sent(): void + { + try { + $this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png'); + $this->fail('No exception thrown'); + } catch (\RuntimeException $e) { + $this->assertNotInstanceOf(ApiException::class, $e); + $this->assertCount(0, $this->sent); + } + } + public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void { $owner = $this->owner();