Document profile updates, reading users by ID and the user events

This commit is contained in:
envoyr committed 2026-11-07 00:34:40 +00:00
1 parent 93f6d79a0a
commit 5ecc535e07
2 files changed
+318 -14

No files matched your search

+161 -7
View File
@@ -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) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## 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`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. 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: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | 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 `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. 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 ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as 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 - `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`). `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 ```php
use Anikeen\Id\Exceptions\ApiException; 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. 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 ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they 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 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 `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 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 ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
@@ -1447,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): Result public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?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 findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result public function isEmailExisting(string $email): Result
@@ -1519,6 +1670,9 @@ public function getBillable(): Billable
```php ```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string 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 setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
+157 -7
View File
@@ -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) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## 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`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. 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: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | 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 `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. 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 ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as 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 - `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`). `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 ```php
use Anikeen\Id\Exceptions\ApiException; 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. 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 ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they 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 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 `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 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 ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),