Compare commits

..
8 Commits
Author SHA1 Message Date
envoyr fb71b172f2 Merge pull request #18 from anikeen-com/feature/reseller-scopes
Add product and reseller scopes (3.1.0)
2026-11-05 00:31:01 +01:00
Maurice Preuß 72f761b077 Release 3.1.0 2026-11-05 00:21:52 +01:00
Maurice Preuß e8432df997 Drop the full products scope the Console does not accept
The Console's product APIs accept user, user:read and products:read only
(decision 21 names products:read). A token with just "products" was
refused everywhere despite its consent text.
2026-11-05 00:11:35 +01:00
Maurice Preuß d99512a359 Add product and reseller scopes 2026-11-04 23:27:25 +01:00
envoyr 0ed6934da3 Merge pull request #16 from anikeen-com/fix/dependabot-alerts
Require patched Laravel and php-jwt versions
2026-11-04 22:09:39 +01:00
Maurice Preuß df7e506a5e Require patched Laravel and php-jwt versions
Raise the minimum versions so installs can no longer resolve releases with
known advisories (Dependabot alerts):

- laravel/framework / illuminate/*: ^12.69|^13.0 (TokenGuard auth bypass,
  CRLF injection in the email rule, signed URL path confusion, debug page XSS).
  Laravel 11 is end of life and gets no fixes, so support for it is dropped.
- firebase/php-jwt: ^7.0 (weak encryption in 6.x).
- orchestra/testbench: ^10.0|^11.0 to match.

Tests pass with both the lowest and the highest allowed versions.
2026-11-04 22:05:30 +01:00
envoyr 61385df92f Merge pull request #15 from anikeen-com/feature/user-api-exceptions
Throw ApiException for requests with the user's token too
2026-11-04 18:44:38 +01:00
Maurice PreußandClaude Opus 5.5 57ce0b4a79 Throw ApiException for requests with the user's token too
Requests with the user's token threw a bare ResourceException with a
message like "… for resource [Anikeen\Id\Resources\Order]" and no
error code, while app requests threw ApiException. Both throw
ApiException now: status (also getCode()), Anikeen ID's error code and
message, isNotFound(), isConflict(), isTemporary(). Failed lists throw
CollectionException, now a subclass of ApiException. A request without
a response (connection refused, timeout) throws ApiException with
status 0 instead of a Guzzle exception.

ApiException extends ResourceException, so existing catch blocks keep
working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:28:50 +01:00
12 changed files with 329 additions and 41 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Changelog
## 3.1.0 (2026-11-05)
- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs
of the Anikeen Console.
- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and
`reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`,
`Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console.
- Document the scope schema and the new scopes in the README.
Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags.
+5 -5
View File
@@ -30,13 +30,13 @@ CI (`.github/workflows/tests.yml`) runs PHPUnit on PHP 8.3 and 8.4.
attributes and break `save()`). Add a new cache property there when a concern caches.
- `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client;
traits on `Billable` act for one user.
- `src/Id/Resources/` – `BaseResource` (single object, throws `ResourceException`, or
`ConfirmationRequiredException` for `confirmation_required`) and `BaseCollection` (lists,
lazily built via `::builder()`).
- `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via
`::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for
lists), no response `::unreachable()`, `confirmation_required` a `ConfirmationRequiredException`.
All extend `ResourceException`.
- `src/Id/Resources/App*` – "app" variants that use the app's own client-credentials token
(`AnikeenId::asApp()`, `Support/AppRequest`) instead of the user's token, for trusted apps.
They throw `Exceptions/ApiException` with a stable `error` code and must not send any request
until a method is called.
They must not send any request until a method is called.
- `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen`
guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled
`oauth-public*.key`), scope middleware.
+45 -10
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+
PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php
public function boot(): void
@@ -160,6 +160,27 @@ if ($result->success()) {
}
```
### Scopes
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
`resource:client` access to what the app or token created itself. All of them are constants on
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
these scopes for its product and reseller APIs:
| Scope | Constant | Grants |
|-------|----------|--------|
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`.
### Change the default access token / refresh token field name
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
@@ -417,23 +438,37 @@ object through the API when the current state matters. The admin panel can send
### Errors
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
adds a stable `error` code. Catching `ResourceException` covers both:
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
well as with the app's (`app*()`). It carries:
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
```php
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Exceptions\ApiException;
try {
$order = $user->orders()->create($attributes);
} catch (ResourceException $e) {
} catch (ApiException $e) {
if ($e->isTemporary()) {
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
}
report($e);
return back()->with('error', 'The order could not be created.');
return back()->with('error', $e->getMessage());
}
```
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
keep working.
### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
@@ -700,7 +735,7 @@ $user = $result->shift(); // the object, or the first item of a list
```
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ResourceException` instead (see [Errors](#errors)).
`ApiException` instead (see [Errors](#errors)).
## Examples
+45 -10
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+
PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php
public function boot(): void
@@ -160,6 +160,27 @@ if ($result->success()) {
}
```
### Scopes
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
`resource:client` access to what the app or token created itself. All of them are constants on
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
these scopes for its product and reseller APIs:
| Scope | Constant | Grants |
|-------|----------|--------|
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`.
### Change the default access token / refresh token field name
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
@@ -417,23 +438,37 @@ object through the API when the current state matters. The admin panel can send
### Errors
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
adds a stable `error` code. Catching `ResourceException` covers both:
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
well as with the app's (`app*()`). It carries:
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
```php
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Exceptions\ApiException;
try {
$order = $user->orders()->create($attributes);
} catch (ResourceException $e) {
} catch (ApiException $e) {
if ($e->isTemporary()) {
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
}
report($e);
return back()->with('error', 'The order could not be created.');
return back()->with('error', $e->getMessage());
}
```
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
keep working.
### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
@@ -700,7 +735,7 @@ $user = $result->shift(); // the object, or the first item of a list
```
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ResourceException` instead (see [Errors](#errors)).
`ApiException` instead (see [Errors](#errors)).
## Examples
+5 -5
View File
@@ -15,15 +15,15 @@
"require": {
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/support": "^12.69|^13.0",
"illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0"
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"autoload": {
+11
View File
@@ -36,5 +36,16 @@ class Scope
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
// Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage),
// read-only. Changing products needs `user`; there is no full `products` scope.
const PRODUCTS_READ = 'products:read';
// Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not
// open these endpoints, a token must carry the reseller scope itself.
const RESELLER_CUSTOMERS = 'reseller-customers';
const RESELLER_CUSTOMERS_READ = 'reseller-customers:read';
const RESELLER_GRANTS = 'reseller-grants';
const RESELLER_ORDERS = 'reseller-orders';
const ADMIN = 'admin';
}
+25
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Throwable;
/**
@@ -19,6 +20,30 @@ class ApiException extends ResourceException
parent::__construct($message, $status, $previous);
}
/**
* The exception for a failed request: Anikeen ID's message and error code, the HTTP status
* (0 when there was no response).
*/
public static function fromResult(Result $result): static
{
$body = is_object($result->data) ? $result->data : null;
return new static(
rtrim((string) ($body->message ?? $result->error()), '.'),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
/**
* The exception for a request that got no response (network error, timeout).
*/
public static function unreachable(Throwable $previous): static
{
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
}
public function isNotFound(): bool
{
return $this->status === 404;
+5 -5
View File
@@ -2,10 +2,10 @@
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class CollectionException extends Exception
/**
* A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
* Anikeen ID's error code.
*/
class CollectionException extends ApiException
{
}
+8 -2
View File
@@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result;
use Closure;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Response;
use JsonSerializable;
use Throwable;
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
*/
protected function __construct(callable $callable)
{
$this->result = $callable();
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw CollectionException::unreachable($e);
}
if (!$this->result->success()) {
throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
throw CollectionException::fromResult($this->result);
}
}
+11 -4
View File
@@ -2,9 +2,10 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable;
#[\AllowDynamicProperties]
@@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable
public Result $result;
/**
* @throws ResourceException
* @throws ApiException the request failed (status, error code; a ResourceException)
* @throws ConfirmationRequiredException the user has to confirm it on Anikeen ID
*/
public function __construct(callable $callable)
{
$this->result = $callable();
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw ApiException::unreachable($e);
}
if (!$this->result->success()) {
if (($this->result->data->error ?? null) === 'confirmation_required') {
@@ -28,7 +35,7 @@ abstract class BaseResource implements JsonSerializable
);
}
throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
throw ApiException::fromResult($this->result);
}
foreach ($this->result->data as $key => $value) {
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Scope;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
class ScopeTest extends TestCase
{
public static function newScopes(): array
{
return [
'products:read' => [Scope::PRODUCTS_READ, 'products:read'],
'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'],
'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'],
'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'],
'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'],
];
}
#[DataProvider('newScopes')]
public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void
{
$this->assertSame($expected, $constant);
}
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
{
// the Console's product APIs accept `user`, `user:read` and `products:read` only
$this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants());
}
public function test_every_scope_follows_the_resource_and_level_schema(): void
{
$scopes = array_values((new ReflectionClass(Scope::class))->getConstants());
$this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique');
foreach ($scopes as $scope) {
// "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope);
}
}
public function test_each_reseller_resource_is_its_own_group(): void
{
$groups = collect((new ReflectionClass(Scope::class))->getConstants())
->filter(fn (string $scope) => str_starts_with($scope, 'reseller-'))
->groupBy(fn (string $scope) => explode(':', $scope)[0])
->map(fn ($scopes) => $scopes->sort()->values()->all())
->all();
$this->assertSame([
'reseller-customers' => ['reseller-customers', 'reseller-customers:read'],
'reseller-grants' => ['reseller-grants'],
'reseller-orders' => ['reseller-orders'],
], $groups);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserApiExceptionsTest extends TestCase
{
private function user(): Model
{
$user = new class extends Model {
use Billable;
};
$user->anikeen_id_access_token = 'user-token';
return $user;
}
public function test_a_refused_request_carries_status_and_error_code(): void
{
$this->respond(200, ['data' => []]); // orders() loads the list
$this->respond(422, ['message' => 'The billing address is missing.', 'error' => 'billing_address_missing']);
try {
$this->user()->orders()->create(['items' => []]);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertInstanceOf(ResourceException::class, $e, 'existing catch (ResourceException) blocks keep working');
$this->assertSame(422, $e->status);
$this->assertSame(422, $e->getCode());
$this->assertSame('billing_address_missing', $e->error);
$this->assertSame('The billing address is missing', $e->getMessage());
$this->assertFalse($e->isTemporary());
}
}
public function test_an_unknown_id_is_not_found(): void
{
$this->respond(200, ['data' => []]);
$this->respond(404, ['message' => 'Not found.']);
try {
$this->user()->orders()->find('01JMISSING');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertNull($e->error);
}
}
public function test_a_failed_list_is_an_api_exception_too(): void
{
$this->respond(503, ['message' => 'Maintenance.']);
try {
$this->user()->invoices();
$this->fail('Expected a CollectionException.');
} catch (CollectionException $e) {
$this->assertInstanceOf(ApiException::class, $e);
$this->assertSame(503, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_no_response_is_temporary_with_status_zero(): void
{
$this->respond(200, ['data' => []]);
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/orders/01J')));
try {
$this->user()->orders()->find('01J');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
$this->assertInstanceOf(ConnectException::class, $e->getPrevious());
}
}
public function test_confirmation_required_stays_its_own_exception(): void
{
$this->respond(200, ['data' => []]);
$this->respond(200, ['data' => ['id' => '01JORDER', 'status' => 'draft']]);
$this->respond(403, ['message' => 'Confirm it on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.test/confirm/01JORDER']);
$this->expectException(ConfirmationRequiredException::class);
$this->user()->orders()->find('01JORDER')->checkout();
}
}