atwx / silverstripe-gate-api
A Silverstripe module adding a JWT authenticated content API to the SilverGate modules. Answers API calls where gate-client is installed, makes them where gate-manager is.
Package info
github.com/atwx/silverstripe-gate-api
Type:silverstripe-vendormodule
pkg:composer/atwx/silverstripe-gate-api
Requires
- php: ^8.3
- firebase/php-jwt: ^6.11
- guzzlehttp/guzzle: ^7.0
- silverstripe/framework: ^6.0
Requires (Dev)
- phpstan/extension-installer: ^1.3
- phpunit/phpunit: ^11.3
- silverstripe/standards: ^1
- squizlabs/php_codesniffer: ^3.7
Suggests
- atwx/silverstripe-gate-client: Enables the site role: answer API calls on a managed site.
- atwx/silverstripe-gate-manager: Enables the manager role: call the API on managed sites.
- lekoala/silverstripe-cms-actions: Adds a Test API button to ManagedSite in the CMS.
This package is auto-updated.
Last update: 2026-08-24 07:26:18 UTC
README
A JWT authenticated content API for the SilverGate modules.
It exists so a central system can read and write content on any managed site without each site needing its own credentials, its own API tokens, or its own integration code. The API is deliberately generic: it reflects over the ORM, so every DataObject a project defines is reachable without writing anything site specific.
Two roles, one package
The module adopts whichever role fits what it finds installed alongside it. Both can be active at once on an install that carries both siblings.
| Installed alongside | Role | What it does |
|---|---|---|
atwx/silverstripe-gate-client |
site | Answers API calls. Registers /_silvergateapi and validates tokens with the client's TokenService. |
atwx/silverstripe-gate-manager |
manager | Makes API calls, and serves them over MCP. Signs a token per ManagedSite with that site's own private key and calls its endpoint. |
| neither | none | The module is inert. |
Neither sibling is a hard dependency; the roles are switched on by Silverstripe's
moduleexists config rule. That is deliberate — neither gate-client nor
gate-manager has to change to gain the API. Both already provide everything
needed: the client validates tokens, and the manager's
CryptographyService::generateJwt() already accepts an arbitrary claim payload.
signs token, calls validates, executes
[ gate-manager ] ------------------------> [ gate-client + gate-api ]
[ + gate-api ] HTTPS + JWT on the managed site
Installation
composer require atwx/silverstripe-gate-api
Install it on the managed sites you want reachable, and on the manager instance you want to reach them from. It is the same package either way.
On a managed site
The site needs the SilverGate public key configured, exactly as for gate-client. If gate-client is already set up, there is nothing more to do — both modules read the same key, and the API is live as soon as the module is installed.
Atwx\SilverGateClient\Services\TokenService: public_key: | -----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----
or via .env, with escaped newlines:
SILVERGATECLIENT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
Note that the environment variable wins over the YAML config.
Until a key is configured every request is rejected, so installing the module does not by itself open anything.
On the manager
Nothing to configure. ManagedSite gains a callApi() method and a Test API
button in the CMS.
$site = ManagedSite::getByDomain('example.com'); $site->callApi('query', ['class' => 'ScheduleEntry', 'limit' => 5]); // or through the service, which has convenience wrappers SiteApiClient::singleton()->create( $site, 'ScheduleEntry', ['Title' => 'Welcome', 'Time' => '09:00:00', 'EventDayID' => 5, 'Speakers' => [7, 9]], ['sub' => 'content@example.com'] );
create() and update() add scope: write for you; everything else defaults to
read.
MCP server
Where the manager role is active, the module also serves a
Model Context Protocol endpoint at
/_silvergatemcp. One server covers every managed site: the tools take a
site argument instead of the server being installed per site, so a client
sees one small tool set rather than one set per site.
https://your-manager.example/_silvergatemcp
Clients discover everything else themselves. Registration is dynamic (RFC 7591), so there is nothing to configure by hand:
| Document | Path |
|---|---|
| Protected resource metadata (RFC 9728) | /.well-known/oauth-protected-resource |
| Authorization server metadata (RFC 8414) | /.well-known/oauth-authorization-server |
Tools
sites_list first — it returns only the sites the caller may reach, and whether
each is writable. Everything else mirrors one action of the site API:
site_classes, site_schema, site_query, site_get, site_create,
site_update, site_delete, site_publish, site_unpublish.
The write tools are hidden from tools/list unless the connection was
authorised for writing, so a read-only client is not tempted by them.
Authorisation
OAuth 2.1 with PKCE, public clients only. Two scopes:
| Scope | Grants |
|---|---|
mcp |
Read |
mcp:write |
Read, plus create, update, delete, publish |
The user authenticates with the manager's own login and is shown a consent
screen naming the client and spelling out whether write access was asked for.
The resulting token names a member, and every call downstream acts as that
member: the site sees sub set to their email, applies their canEdit(), and
records them in LastEdited.
Codes are single use, valid for two minutes, and bound to a PKCE S256 challenge. Codes and tokens are stored only as hashes.
Refresh tokens rotate: using one revokes it and issues a fresh pair. Three things end a session, so a leaked token cannot grant access forever:
| Setting | Default | Ends |
|---|---|---|
OAuthToken.lifetime |
1 hour | the access token; the client refreshes |
OAuthToken.refresh_idle_lifetime |
14 days | a refresh token nobody uses |
OAuthToken.refresh_absolute_lifetime |
30 days | the whole chain, counted from the original sign-in |
A rotation carries the chain's start forward, so refreshing often cannot push the absolute limit out.
Reuse detection. Because rotation revokes the old refresh token, a spent one turning up again means someone kept a copy. There is no way to tell the real client from an attacker at that point, so every token that client holds for that member is revoked and the user has to sign in again. The event is logged with the member and client involved.
Tokens are visible in the CMS with member, client, scope, when the session started and when it was last used, and can be revoked by deleting them.
Deciding who reaches which site
SitePolicy answers that. By default a member may reach any site they could log
into with SilverGate, on the grounds that the API grants nothing the browser
login would not. Installs with a finer grained model narrow it:
class MySitePolicyExtension extends Extension { public function updateSiteAccess(bool &$allowed, ManagedSite $site, Member $member, string $scope) { $allowed = $allowed && $this->myOwnRule($site, $member, $scope); } }
Atwx\SilverGateApi\Manager\Mcp\SitePolicy: extensions: - MySitePolicyExtension
Requests
All actions live under /_silvergateapi/<action>. Read actions accept GET or
POST, write actions require POST. Parameters go in a JSON body.
Authorization: Bearer <jwt>
Content-Type: application/json
| Action | Writes | Body |
|---|---|---|
ping |
— | |
classes |
search |
|
schema |
class |
|
query |
class, filter, sort, limit, offset, stage |
|
get |
class, id, stage |
|
create |
✓ | class, fields |
update |
✓ | class, id, fields |
delete |
✓ | class, id |
publish |
✓ | class, id |
unpublish |
✓ | class, id |
class accepts a fully qualified name or an unambiguous short name.
filter takes ORM search filters, e.g. {"Title:PartialMatch": "news"}.
Discovering a site
classes lists what is reachable; schema describes one class including field
types, the valid values of any Enum, and every relation. Call schema before
writing — it is what makes the API usable without knowing the project.
{
"class": "App\\Models\\ScheduleEntry",
"versioned": false,
"fields": [
{"name": "Title", "type": "Varchar(255)", "label": "Title"},
{"name": "Time", "type": "Time", "label": "Time"}
],
"relations": {
"hasOne": [{"name": "EventDay", "field": "EventDayID", "class": "App\\Models\\EventDay"}],
"manyMany": [{"name": "Speakers", "class": "App\\Models\\Speaker", "through": false}]
}
}
Writing relations
has_one is set through its ID field. has_many, many_many and
belongs_many_many take an array of IDs and replace the whole relation.
{
"class": "ScheduleEntry",
"fields": {
"Title": "Welcome",
"Time": "09:00:00",
"EventDayID": 5,
"Speakers": [7, 9]
}
}
Writing a field the class does not have is an error rather than a silent no-op, so a typo surfaces immediately.
Versioned records
Writes always go to the draft stage, whatever reading stage the request is
in, and publish is a separate call. Reads default to draft; pass
"stage": "live" for the published version.
Responses carry _published and _modified for versioned classes, _title for
everything, and _relations with the IDs of each multi-value relation on
get, create and update.
Token claims
The manager signs these alongside the standard iat / exp. All are optional.
| Claim | Meaning |
|---|---|
sub |
Email or ID of the member to act as. Defaults to gate-client's configured member. |
scope |
read (default) or write. |
classes |
Array of class names this token may touch. |
CryptographyService::generateJwt() in gate-manager already accepts a payload
array, so no change is needed there:
$jwt = CryptographyService::singleton()->generateJwt( privateKey: $site->PrivateKey, payload: ['sub' => 'content@example.com', 'scope' => 'write', 'classes' => ['ScheduleEntry', 'EventDay']], issuedAt: time() );
Restricting access
Four gates apply, narrowest wins.
denied_classes— never reachable. The security tables are denied by default; add your own.allowed_classes— if set, nothing outside the list is reachable.- The token's
classesclaim. - The model's own
canView()/canEdit()/canCreate()/canDelete(), evaluated against the member the token acts as.
Atwx\SilverGateApi\Site\Services\AccessPolicy: allowed_classes: - App\Models\ScheduleEntry - App\Models\EventDay - App\Models\Speaker
A class that is out of reach reports the same "unknown class" error as one that does not exist, so a caller cannot probe for what a site defines.
To refuse writes on a site entirely, regardless of what any token claims:
Atwx\SilverGateApi\Site\Services\AuthService: allow_writes: false
Notes on security
- The member is set for the request only, via
Security::setCurrentUser(). No session is created and nothing persists between calls. - Token lifetime is gate-client's
token_max_age_seconds(60 by default). Sign one token per call rather than reusing one. - gate-client's
login_as_default_admindefaults to true. If you rely on that fallback the API acts as the default admin — give each site a dedicated content member and name it in thesubclaim instead. - Internal errors return a generic message; the detail is included only on a dev site and always goes to the site's logger.
- Responses are sent with
Cache-Control: no-storeandX-Robots-Tag: noindex.