Glymonir HTTP API
Living reference for third-party / cron-job consumers of the Glymonir backend. Every endpoint that carries
@RequireScopeon the server should have a matching section here. When you add a new scoped endpoint, add it in the right section with a request shape, a response shape, and a curl example. If a scope is added or renamed, update the Scopes table.
Base URL https://<host>/api · local dev: http://localhost:8123/api
Status: V1 — covers picture ingestion. Future revisions add picture read APIs, search, and notifications. Scope strings are immutable once shipped — see Stability guarantees below.
Quick start with the CLI
For most "upload a picture from a script" use cases, the official Node CLI is the fastest path — one command, no preprocessing code to write:
export GLYMONIR_API_KEY=gly_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Zero-install:
npx glymonir-cli upload photo.jpg
# To a specific library:
npx glymonir-cli upload --library 1234 photo.jpg
# With metadata + batch:
npx glymonir-cli upload \
--name "Mt. Fuji at sunrise" --tags mountain,sunrise \
*.jpgThe CLI does locally what the web app's imagePreprocess.ts does (thumb + preview WebP, 512×512 embedding JPEG, SHA-256 dedup) and drives the same /picture/upload/r2/* endpoints documented below. Source + docs at tools/glymonir-cli/.
If you need finer control or you're not on Node, the raw HTTP API is documented end-to-end below.
Table of contents
- Quick start with the CLI
- Authentication
- Scopes
- Response envelope
- Error codes
- API key management
- Gallery read (
gallery:read) - Picture upload (
gallery:upload) - Stability guarantees
- Roadmap
Authentication
There are two paths into the API. Endpoints don't care which one a request took as long as the resolved user has the required permissions.
| Path | Where it comes from | Lifetime | Use it for |
|---|---|---|---|
| Supabase JWT | supabase.auth browser session | 1 hour, refreshable | Browser UI, anything interactive |
| API key | POST /user/api-keys (this doc) | Until revoked / expired | Cron jobs, Workers, server-to-server |
Both go in the same header:
Authorization: Bearer <token>The server's ApiKeyAuthFilter runs first and recognises the API key prefix gly_live_ — anything else is handed off to JwtAuthFilter. So mixing both kinds of clients against the same endpoint just works.
API key acts as the user who created it
A key's effective permissions = (its owner's role permissions) ∩ (its granted scopes). A key cannot reach an admin-only endpoint even if the scope string somehow matched — every endpoint additionally checks the underlying user's role / library membership. The admin:* and gallery:upload scopes are admin-grantable only.
Token format
gly_live_<32 random chars>The 32-char body is drawn from a 56-symbol alphabet that excludes 0, O, 1, l, I (so a copy-paste error doesn't silently produce a different valid token). Roughly 186 bits of entropy.
Only SHA-256(token) is stored — there's no recovery flow. If you lose a token, revoke it and create a new one. The full plaintext is returned exactly once, in the response to POST /user/api-keys. After that the API will only ever return the 13-char display prefix (e.g. gly_live_a8K9x).
Scopes
| Scope | Who can grant | What it covers |
|---|---|---|
gallery:read | Any logged-in user (subject to issuance gate) | Bulk-read the public gallery — list / single-picture detail with full metadata (name, description, tags, colors, dimensions, R2 URLs) and 1024-d CLIP embedding vectors. See /api/gallery/* below. |
gallery:upload | Admin only | Upload pictures to the public gallery via the R2 two-stage flow + admin batch URL ingestion. |
admin:* | Admin only | Resource-prefix wildcard — every admin:xxx endpoint (admin trash purge, admin batch import diagnostics, admin user ban, etc.). Not a super wildcard; does not cover gallery:upload or gallery:read. |
Issuance gate: POST /user/api-keys requires the calling user to be on a Pro plan or to have the admin role. Free / Starter users get 40101 with an upgrade hint. Admin role auto-bypasses the plan gate ("effective plan = UNLIMITED" treatment).
Wildcard rule at runtime: a granted scope of a:b:* matches any required a:b:<anything>; granted * matches everything. So if you ever need a true super-key, grant * (currently not in the public catalog — gate it carefully).
Legacy alias: keys issued before 2026-05-25 may still carry the deprecated picture:upload scope. The server treats picture:upload as equivalent to gallery:upload so old keys keep working; new keys can no longer request picture:upload.
The set of scopes a user can grant on POST /user/api-keys is filtered by role AND plan. Hit GET /user/api-keys/available-scopes to find out what a given caller may request — an empty response means the caller isn't eligible to create keys (likely on a Free / Starter plan).
Response envelope
Every JSON endpoint wraps its payload in:
{
"code": 0,
"data": { /* endpoint-specific */ },
"message": "ok"
}code: 0 means success. Any non-zero code is a business error and data is null. See Error codes below. SSE endpoints (/picture/upload/batch/selected) are the exception — they stream events directly, not the envelope.
Error codes
| Code | Meaning | Typical trigger |
|---|---|---|
0 | OK | — |
40000 | Invalid request parameters | Missing/malformed field, body validation failure |
40100 | Not logged in | Missing/wrong/revoked/expired API key. All four cases return the same error so a caller can't enumerate which one happened. |
40101 | No permission / missing scope | Authenticated, but the key doesn't carry the scope the endpoint requires. Example: "API key missing required scope: gallery:read" |
40300 | Access forbidden | User-level permission check failed (e.g. PICTURE_UPLOAD permission on a library the caller doesn't belong to). |
40400 | Not found | Resource missing OR not in the public gallery OR not yet review-approved (the API surface collapses all three into a single 404 so it can't be used to enumerate). |
42301 | Embedding not ready (similar) | Picture too new for /similar/list. Frontend falls back to tag-based recommendation. |
42500 | Embedding not ready (gallery API) | /api/gallery/picture/{id}/embedding — the picture exists but the CLIP vector hasn't been computed yet. Retry after a few minutes. |
42900 | Rate limit exceeded | Per-API-key throttle hit. Response carries an HTTP Retry-After header with seconds until the next token. Defaults: Pro/user = 60 req/min steady-state with 100-token burst; admin = unlimited. |
50000 | System internal exception | Server bug or downstream outage. |
HTTP status is the standard 200 for success and 4xx/5xx for the matching error class. Always check code in the body — a 200 with code != 0 is a business error.
API key management
These five endpoints live under /user/api-keys and only require a logged-in user (Supabase JWT works; an API key with the right scope would too, but there's no scope for self-management in V1 so use JWT).
POST /user/api-keys — create
Request:
{
"name": "ingest-worker-2026-05",
"scopes": ["gallery:read"],
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest",
"expiresInDays": 365
}| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–255 chars, used in UI listing |
scopes | string[] | yes | Each must appear in the catalog AND be grantable by the caller |
description | string | no | Free-form admin note |
expiresInDays | int | no | Omit / 0 = never expires |
Response (code: 0):
{
"data": {
"plaintext": "gly_live_...",
"key": {
"id": "1234567890",
"name": "ingest-worker-2026-05",
"prefix": "gly_live_a8K9",
"scopes": ["gallery:read"],
"expiresAt": "2027-05-04T00:00:00Z",
"revokedAt": null,
"lastUsedAt": null,
"lastUsedIp": null,
"totalRequests": 0,
"createTime": "2026-05-04T13:02:11Z",
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest"
}
}
}plaintext is shown once. Save it now.
curl:
curl -X POST https://<host>/api/user/api-keys \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"name":"ingest-worker-2026-05","scopes":["gallery:read"]}'GET /user/api-keys — list
Lists the caller's own keys (revoked ones included, with revokedAt set). Paginated.
GET /user/api-keys?current=1&pageSize=20Response shape: standard MyBatis-Plus Page<ApiKeyVO> — records[], total, current, size. keyHash is never returned.
POST /user/api-keys/{id}/revoke — revoke
Idempotent. A second call against an already-revoked key still returns true; the server doesn't differentiate "already revoked" from "just revoked now" so a curious caller can't probe state. A revoked key is rejected by the next request that uses it (returns 40100).
curl -X POST https://<host>/api/user/api-keys/1234567890/revoke \
-H "Authorization: Bearer <jwt>"POST /user/api-keys/update — update metadata
Only name and description are mutable. scopes is deliberately immutable — if you need a different scope set, create a new key and revoke the old one. This prevents silent permission creep.
{
"id": "1234567890",
"name": "ingest-worker-rotated",
"description": "rotated 2026-05-04, original token compromised"
}GET /user/api-keys/available-scopes — catalog
Returns the scopes the caller may grant. Frontend uses this to render the create-key dialog. Use it in scripts when you need to discover the current set programmatically — the catalog is intended to grow.
{
"data": [
{
"value": "gallery:read",
"label": "Read public gallery",
"description": "Bulk-read public gallery pictures + metadata + 1024-d CLIP embedding vectors.",
"requiredRole": "user"
}
]
}Gallery read (gallery:read)
The three endpoints below are designed for programmatic gallery consumers (Pro users + admins). They return only PUBLIC gallery pictures that have passed review — library pictures and REVIEWING / REJECTED rows are 404, regardless of the caller.
GET /api/gallery/pictures — cursor-paginated list
Bulk-list newest-first. Cursor-based pagination is stable across new uploads — page 2 of yesterday's snapshot remains page 2 even if 1,000 pictures land in between.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
cursor | string | — | Opaque page anchor. Omit for first page. Pass the previous response's nextCursor to continue. |
limit | int | 50 | Items per page. Clamped to [1, 200]. |
Response shape (data):
{
"items": [
{
"id": "1234567890",
"sha256": "abc...",
"name": "Mt. Fuji at sunrise",
"format": "jpg",
"width": 4032, "height": 3024,
"sizeBytes": 5242880,
"originalUrl": "https://cdn.example.com/photos/...jpg",
"thumbUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp",
"createTime": "2026-05-25T12:00:00Z"
}
],
"nextCursor": "MTcxNjY0MjQwMHwxMjM0NTY3ODkw",
"limit": 50
}nextCursor absent → end of results.
curl 'https://<host>/api/gallery/pictures?limit=50' \
-H 'Authorization: Bearer gly_live_...'GET /api/gallery/picture/{id} — single picture detail
Full record with all metadata + R2 URLs. The embedding vector is split into its own endpoint (next section) because the 4 KB payload is heavier than everything else combined.
Response shape (data):
{
"id": "1234567890",
"sha256": "abc...",
"name": "Mt. Fuji at sunrise",
"introduction": "Captured 2024-09-15 from Hakone.",
"tags": ["mountain","sunrise","japan"],
"userId": "987",
"createTime": "2026-05-25T12:00:00Z",
"format": "jpg", "width": 4032, "height": 3024, "sizeBytes": 5242880,
"picPalette": "[{\"hex\":\"#a36b3f\",\"ratio\":0.42,\"lab\":[...]}]",
"picMosaicLab": "[[L,a,b], [L,a,b], ... 25 entries]",
"originalUrl": "https://cdn.example.com/photos/...jpg",
"thumbUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp",
"previewUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp"
}404 (code 40400) when the id does not exist, the picture is in a library, or it has not yet passed review — the surface intentionally collapses these three so it cannot be used to enumerate private state.
GET /api/gallery/picture/{id}/embedding — 1024-d CLIP vector
{
"id": "1234567890",
"embeddingModel": "jina-clip-v2",
"dimensions": 1024,
"embedding": [0.0123, -0.0456, ...],
"computedAt": "2026-05-25T12:00:01Z"
}Status codes:
200 / code 0— vector ready indata.embedding200 / code 42500— picture exists but the CLIP vector hasn't been computed yet.data.embeddingisnull. Retry after a few minutes.200 / code 40400— same 404 collapse as above (missing / library / not PASS).
The vector is L2-normalised (norm 1.0) so dot-product = cosine similarity. Good for "find similar in my own dataset" workflows.
Picture upload (gallery:upload)
These three endpoints all require an API key that carries gallery:upload (admin-only scope), AND the resolved user must have PICTURE_UPLOAD permission on the target library (gallery target = no library check). The key clamps to the user, the user clamps to the library — both gates apply.
Upload contract (enforced by every endpoint below — no admin bypass):
| Rule | Value | Scope |
|---|---|---|
| Format whitelist | image/jpeg, image/png, image/webp | Every upload. GIF / TIFF / HEIC / SVG rejected. |
| Per-file maximum | ≤ 50 MB | Every upload, every destination. |
| Per-file minimum (public gallery) | ≥ 1 MB | When no library is targeted (libraryId omitted / null). Quality floor — keeps tiny low-resolution images out of the browse surface. |
| Per-file minimum (library) | none | When uploading to a library (libraryId set). User-private storage; a 50 KB icon is legitimate. |
| Admin batch URL ingest | ≥ 1 MB AND ≤ 50 MB | Same as gallery — /picture/upload/batch/selected always targets the public gallery. |
A violation returns code: 40000 (PARAMS_ERROR) with a message that names the rule it tripped ("File size exceeds 50 MB cap..." or "Public gallery uploads must be at least 1 MB..."). Constants are pinned by PictureUploadSizeIT — they don't drift without a deliberate code change.
POST /picture/upload/r2/check — stage 1
You hash the file locally, the server probes for dedupe, and either hands back an existing blobId (skip the upload) or a presigned R2 PUT URL.
Request:
{
"sha256": "<64-char hex of the original file>",
"size": 524288,
"ext": "jpg",
"contentType": "image/jpeg",
"libraryId": null // null = public gallery; set to a library id to upload to that library
}Response on dedupe hit:
{
"data": {
"duplicate": true,
"blobId": "987",
"stagingKey": null,
"putUrl": null,
"thumb": { "stagingKey": "...", "putUrl": "..." },
"preview": { "stagingKey": "...", "putUrl": "..." }
}
}Response on fresh upload:
{
"data": {
"duplicate": false,
"blobId": null,
"stagingKey": "staging/<uuid>.jpg",
"putUrl": "https://<r2-host>/staging/<uuid>.jpg?X-Amz-Signature=...",
"thumb": { "stagingKey": "...", "putUrl": "..." },
"preview": { "stagingKey": "...", "putUrl": "..." }
}
}Then PUT the bytes directly to the presigned URL(s) and call finalize.
POST /picture/upload/r2/finalize — stage 2
Promotes staging objects to permanent, creates the picture row, bumps ref_count on the blob, and returns the picture VO. Transactional.
Required fields when the original was a fresh upload:
{
"sha256": "...",
"stagingKey": "<from check response>",
"size": 524288,
"format": "JPEG",
"ext": "jpg",
"thumbKey": "<from check>",
"previewKey": "<from check>",
"embeddingKey": null,
"libraryId": null, // null = public gallery; set to a library id to upload to that library
"name": "Mt. Fuji at sunrise",
"introduction": "Wikimedia Commons, CC0",
"category": "landscape"
}On a dedupe hit, omit stagingKey / format / thumbKey etc. — the server reads them from the existing blob. Send pictureId instead if you're cloning into a new library.
POST /picture/upload/batch/selected — admin URL ingestion
Server-side fetcher: hand it a list of public URLs and it downloads, dedupes, uploads to R2, and persists each as a public-gallery picture. Only admins can call this. Streams SSE (one event per URL plus a final summary), not the JSON envelope.
Request:
{
"urlList": [
"https://upload.wikimedia.org/wikipedia/commons/.../foo.jpg",
"https://upload.wikimedia.org/wikipedia/commons/.../bar.jpg"
],
"namePrefix": "wikimedia-",
"tags": ["nature", "cc0"]
}Limit: 50 URLs per batch.
Per-URL event:
data: {"index":0,"url":"...","status":"success","message":"...","done":false}Final event:
data: {"done":true,"total":50,"successCount":48}curl example for a Worker / cron:
curl -N -X POST https://<host>/api/picture/upload/batch/selected \
-H "Authorization: Bearer gly_live_..." \
-H "Content-Type: application/json" \
-d '{"urlList":["https://...jpg"],"namePrefix":"cc0-","tags":["cc0"]}'For unattended ingestion this is the simplest path — you don't have to handle R2 presigned PUTs yourself.
Stability guarantees
- Scope strings are forever. Once a scope ships, it's never renamed. If a scope is deprecated, it's removed from the public catalog (so it can no longer be granted) but
ApiScopes.hasScopekeeps recognising it for keys that already hold it. - Token format
gly_live_*is stable. Future environments may introduce additional prefixes (e.g.iph_test_*), butgly_live_*always refers to production-grade keys. - Endpoint paths under
/api/...follow semver. Breaking changes to a shipped endpoint are announced and ride on a new major version in the path. Additive changes (new optional fields, new endpoints, new scopes) land any time. - Error code values are stable.
40100is forever "not logged in";40101is forever "no permission / missing scope".
Roadmap
The schema and filter chain were designed so the following land as pure additions, with no migration:
- Per-key rate limits —
rate_limit_rpmcolumn + Bucket4j filter. - IP allowlist —
ip_allowlist CIDR[]column + filter check. - Audit log —
api_key_audittable + async listener. - Token rotation —
rotated_from_key_idcolumn + rotate endpoint. - Publishable / restricted key types — already reserved via the
key_typecolumn on the existing schema. - OAuth-style third-party apps — separate flow, separate entity.
If you're building against this API and would benefit from any of the above sooner, open an issue with a concrete use case.
