Skip to content

Glymonir HTTP API

Living reference for third-party / cron-job consumers of the Glymonir backend. Every endpoint that carries @RequireScope on 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:

bash
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 \
  *.jpg

The 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


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.

PathWhere it comes fromLifetimeUse it for
Supabase JWTsupabase.auth browser session1 hour, refreshableBrowser UI, anything interactive
API keyPOST /user/api-keys (this doc)Until revoked / expiredCron 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

ScopeWho can grantWhat it covers
gallery:readAny 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:uploadAdmin onlyUpload pictures to the public gallery via the R2 two-stage flow + admin batch URL ingestion.
admin:*Admin onlyResource-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:

json
{
  "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

CodeMeaningTypical trigger
0OK
40000Invalid request parametersMissing/malformed field, body validation failure
40100Not logged inMissing/wrong/revoked/expired API key. All four cases return the same error so a caller can't enumerate which one happened.
40101No permission / missing scopeAuthenticated, but the key doesn't carry the scope the endpoint requires. Example: "API key missing required scope: gallery:read"
40300Access forbiddenUser-level permission check failed (e.g. PICTURE_UPLOAD permission on a library the caller doesn't belong to).
40400Not foundResource 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).
42301Embedding not ready (similar)Picture too new for /similar/list. Frontend falls back to tag-based recommendation.
42500Embedding 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.
42900Rate limit exceededPer-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.
50000System internal exceptionServer 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:

json
{
  "name": "ingest-worker-2026-05",
  "scopes": ["gallery:read"],
  "description": "Cloudflare Worker cron, Wikimedia CC0 ingest",
  "expiresInDays": 365
}
FieldTypeRequiredNotes
namestringyes1–255 chars, used in UI listing
scopesstring[]yesEach must appear in the catalog AND be grantable by the caller
descriptionstringnoFree-form admin note
expiresInDaysintnoOmit / 0 = never expires

Response (code: 0):

json
{
  "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:

bash
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=20

Response 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).

bash
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.

json
{
  "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.

json
{
  "data": [
    {
      "value": "gallery:read",
      "label": "Read public gallery",
      "description": "Bulk-read public gallery pictures + metadata + 1024-d CLIP embedding vectors.",
      "requiredRole": "user"
    }
  ]
}

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.

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:

NameTypeDefaultNotes
cursorstringOpaque page anchor. Omit for first page. Pass the previous response's nextCursor to continue.
limitint50Items per page. Clamped to [1, 200].

Response shape (data):

json
{
  "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.

bash
curl 'https://<host>/api/gallery/pictures?limit=50' \
  -H 'Authorization: Bearer gly_live_...'

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):

json
{
  "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.

json
{
  "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 in data.embedding
  • 200 / code 42500 — picture exists but the CLIP vector hasn't been computed yet. data.embedding is null. 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.


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):

RuleValueScope
Format whitelistimage/jpeg, image/png, image/webpEvery upload. GIF / TIFF / HEIC / SVG rejected.
Per-file maximum≤ 50 MBEvery upload, every destination.
Per-file minimum (public gallery)≥ 1 MBWhen no library is targeted (libraryId omitted / null). Quality floor — keeps tiny low-resolution images out of the browse surface.
Per-file minimum (library)noneWhen uploading to a library (libraryId set). User-private storage; a 50 KB icon is legitimate.
Admin batch URL ingest≥ 1 MB AND ≤ 50 MBSame 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:

json
{
  "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:

json
{
  "data": {
    "duplicate": true,
    "blobId": "987",
    "stagingKey": null,
    "putUrl": null,
    "thumb": { "stagingKey": "...", "putUrl": "..." },
    "preview": { "stagingKey": "...", "putUrl": "..." }
  }
}

Response on fresh upload:

json
{
  "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:

json
{
  "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:

json
{
  "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:

bash
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.hasScope keeps recognising it for keys that already hold it.
  • Token format gly_live_* is stable. Future environments may introduce additional prefixes (e.g. iph_test_*), but gly_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. 40100 is forever "not logged in"; 40101 is 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 limitsrate_limit_rpm column + Bucket4j filter.
  • IP allowlistip_allowlist CIDR[] column + filter check.
  • Audit logapi_key_audit table + async listener.
  • Token rotationrotated_from_key_id column + rotate endpoint.
  • Publishable / restricted key types — already reserved via the key_type column 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.

Released under the MIT License.