Related: CDN & Caching · Auth Tokens · Publish Flow
The public REST API is served by the publicv1 Firebase Function (Express app exported as v1 from functions/src/v1.ts; memory: '1GiB', maxInstances: 10, concurrency: 20) via the Hosting rewrite /api/v1/**. The region comes from the global options in functions/src/index.ts (process.env.REGION ?? 'europe-west6'). maxInstances and concurrency are set on the function itself, overriding the codebase-wide setGlobalOptions({ maxInstances: 1, concurrency: 600 }) — this is the only function serving public consumer traffic, so it must scale past a single instance. Three Express routers handle all routes: CDN, MANAGE, and DEV_TOOLS. JSON request bodies are limited to 5mb (express.json({ limit: '5mb' })) instead of the Express default 100kb, so a whole space's schemas fit in one push.
Content delivery with cache-busting and asset transformation. All content/translation endpoints follow the cv redirect pattern.
| Method | Path | Auth | Query params |
|---|---|---|---|
GET |
/api/v1/spaces/:spaceId/translations/:locale |
TRANSLATION_PUBLIC or TRANSLATION_DRAFT |
cv, version, token |
GET |
/api/v1/spaces/:spaceId/links |
CONTENT_PUBLIC, CONTENT_DRAFT, or DEV_TOOLS |
cv, kind, parentSlug, excludeChildren, token |
GET |
/api/v1/spaces/:spaceId/contents/slugs/*slug |
requireContentPermissions() |
cv, locale, version, resolveReference, resolveLink, resolveAsset, token |
GET |
/api/v1/spaces/:spaceId/contents/:contentId |
requireContentPermissions() |
cv, locale, version, resolveReference, resolveLink, resolveAsset, token |
GET |
/api/v1/spaces/:spaceId/assets/:assetId |
None (public) | w, h, q, f, fit, thumbnail |
GET |
/api/v1/spaces/:spaceId/assets/:assetId/original |
None (public) | (none — a transform param is rejected with 400) |
GET |
/api/v1/spaces/:spaceId/assets/:assetId/download |
None (public) | (none — a transform param is rejected with 400) |
Notable behaviors:
- Locale fallback — If the requested locale doesn't exist in the space, falls back to
space.localeFallback. resolveLink=true— Expands cross-content link IDs to fullContentLinkobjects.resolveReference=true— Inlines referenced content documents at the resolved locale.resolveAsset=true— Expands referenced asset IDs to full asset metadata viaresolveAssets()(functions/src/services/content.service.ts:327).- Asset transforms — Uses Sharp for images (
w/h/q/f/fitparams). Supported output formats (f):webp,jpeg,png,avif. SVG is passed through unsized; animated GIF/WebP are resized with all frames preserved. Video +w+thumbnailextracts a frame with FFmpeg then resizes with Sharp. - No implicit format conversion, but quality is normalised —
fis the only thing that changes an image format. A bare request keeps the stored format yet still re-encodes a still raster at that format default quality: a q95 upload measured 587KB and returned 219KB. GIF, SVG, video and animations are served as stored. Passingf=webporf=avifis the recommended way to cut transfer size further. - JPEG uses the mozjpeg encoder — trellis quantisation, overshoot deringing and optimised scans, which produce a measurably smaller file at the same quality value (~20% on a test source) for roughly 5x the encode time. Worth it here because encoding happens once per URL per cache miss under a 365-day TTL, while the saved bytes are paid on every hit.
qis not defaulted by the endpoint — it is rejected outside1–100rather than clamped, and when omitted nothing is passed to the encoder, so each format applies its own calibrated default: JPEG and WebP 80, AVIF 50, PNG lossless. A quality number is not portable between codecs, which is why one flat value is not imposed on all of them. An explicitqalways wins.fitparam —cover(default) ·contain·inside·outside·fill. Ignored unless bothwandhare present, since Sharp preserves aspect ratio with a single dimension.containpads: transparent forpng/webp/avif, opaque white otherwise (a transparent pad would flatten to black on a JPEG).- Invalid
forfit— returns400 invalid-argumentnaming the accepted values. An empty value (?f=) counts as absent, not invalid. The400is sent withCache-Control: public, max-age=3600so a bad URL is served from the CDN instead of re-entering the function; the TTL is deliberately short because the accepted value set can grow with a deploy. thumbnailparam — Collapses an animated WebP/GIF to its first frame, and extracts a video frame via FFmpeg (requiresw). Has no effect on other image types. Since animations now resize with every frame intact,thumbnailis how you ask for a still rather than how you make resizing work.- EXIF orientation is applied — Sharp strips the orientation tag on re-encode, so a rotated source is baked into the pixels. Without it a portrait phone photo returned landscape with its aspect ratio transposed.
- Embedded colour profiles are carried through — via
keepIccProfile(). Sources without one gain nothing; tagging every response as sRGB would add ~506 bytes each. /originaland/download— serve the stored bytes exactly as uploaded, inline and as anattachmentrespectively. Since a bare transform request re-encodes, these are the only way to retrieve the original file. Neither enters Sharp, and both rejectw/h/q/f/fit/thumbnail/downloadwith400rather than ignoring them.- Removed in v4 — the
?downloadflag andf=original. Both return400with a message naming the replacement route. Responses already cached under the old spellings keep serving for the remainder of their 365-day TTL.
Sharp is called as resize(width ?? null, height ?? null, { fit }), defaulting to cover when fit is not given.
w |
h |
Behavior |
|---|---|---|
| ✓ | — | Scale to width, height auto — aspect ratio preserved, no crop |
| — | ✓ | Scale to height, width auto — aspect ratio preserved, no crop |
| ✓ | ✓ | Controlled by fit, default cover crop — fills the exact box, excess edges cropped. See the fit table below |
| — | — | No resize — only format/quality re-encoding if f/q provided |
Only applied when both w and h are present. Examples are a 200×100 source into a 50×50 box.
fit |
Behavior | 200×100 → 50×50 |
|---|---|---|
cover (default) |
Fill the box, crop the overflow | 50×50, sides cropped |
contain |
Fit inside the box, pad to the exact box | 50×50, padded |
inside |
Shrink to fit inside the box, no pad, no crop | 50×25 |
outside |
Cover the box without cropping; may exceed it | 100×50 |
fill |
Stretch to the exact box, aspect ratio not preserved | 50×50, distorted |
cover remains the default deliberately: changing it would reshape every existing ?w=&h= URL and
invalidate the CDN. inside is usually what a CMS thumbnail wants — opt into it explicitly.
Breaking change — invalid f now returns 400. Previously an unrecognised f was silently
ignored and the untransformed image was returned; it now returns 400 invalid-argument, matching
fit. A typo in a format no longer fails quietly.
Special cases:
image/svg+xml— always passed through;w/h/fare ignored- Animated WebP or GIF on a bare request — served as stored; re-encoding every frame on each cache miss is the most expensive thing the endpoint could do, and the pixel cap would turn a plain
<img src>into a400 - Animated WebP or GIF with a transform — resized with all frames preserved;
f=webpconverts GIF to animated WebP. Rejected with400aboveMAX_ANIMATED_PIXELS(12 Mpx total) - Animated WebP or GIF with
thumbnail— first frame extracted, thenw/h/fapply normally
Admin bulk-write endpoints for translations and schemas. Uses X-API-KEY header auth (not query param).
| Method | Path | Auth | Body |
|---|---|---|---|
POST |
/api/v1/spaces/:spaceId/translations/:locale |
DEV_TOOLS (header) |
zTranslationUpdateSchema |
POST |
/api/v1/spaces/:spaceId/schemas |
DEV_TOOLS (header) |
zSchemaPushSchema |
Request body (zTranslationUpdateSchema):
{
type: 'add-missing' | 'update-existing' | 'delete-missing-key' | 'delete-missing-value';
dryRun?: boolean;
values: Record<string, string>; // translationId → value
}Operation types:
| Type | Behavior |
|---|---|
add-missing |
Creates new Translation docs for IDs that don't exist yet |
update-existing |
Updates locales.{locale} field for IDs that already exist |
delete-missing-key |
Deletes the whole translation doc — every locale's value — for each ID not in values |
delete-missing-value |
Removes only locales.{locale} for each ID not in values that has a value there; other locales keep theirs |
:locale scopes everything except delete-missing-key, which deletes keys across the space whatever locale is pushed —
run it with a complete file (normally the source locale). The former delete-missing was split into these two and is
no longer accepted.
Returns 400 invalid-argument (Locale not supported by this space) when :locale is not one of the space's locales.
Response:
{
message: string; // e.g. "Added 1 translation", "[DryRun] Would delete 3 translation keys", "Removed 2 locale values", "No translations to update"
ids: string[]; // only the IDs `type` wrote (or, on a dry run, would write)
dryRun?: true;
}update-existing only lists (and writes) IDs whose value for :locale actually differs; identical values are skipped.
Synchronous schema write used by @localess/cli's schema push. Requires DEV_TOOLS, same as translation updates.
Request body (zSchemaPushSchema):
{
type: 'upsert' | 'sync'; // sync = upsert + delete schemas absent from the payload
dryRun?: boolean;
schemas: SchemaExport[]; // same shape as the schema export/import zip format
}Unlike the import Task, push rejects (400 invalid-argument) a SCHEMA/SCHEMAS field whose schemas list is missing or empty — the editor could add no block to it. Imports still accept such fields, since existing spaces and their exports may hold them. Returns 404 not-found when the space does not exist. Upserts reuse the import Task's change detection (isSchemaChanged, key-order-insensitive), preserve createdAt, and clear absent optionals. sync mode refuses (400 failed-precondition, listing offenders) to delete a schema still referenced by a surviving schema's SCHEMA/SCHEMAS refs or OPTION/OPTIONS source.
Response:
{
message: string;
counts: { created: number; updated: number; deleted: number; unchanged: number };
ids: { created: string[]; updated: string[]; deleted: string[] };
dryRun?: true;
}The three translation operations write via Firestore WriteBatch in chunks of BATCH_MAX (500), committed sequentially by the commitInBatches() helper (functions/src/v1/manage.ts:38-56), then call generateTranslationsDraft() to update the Storage draft cache. Schema push instead writes through applySchemaPushPlan() and does no draft or cache work. With dryRun: true on either endpoint the write is skipped and only the affected IDs are returned.
Space introspection and OpenAPI generation. Uses token query param auth.
| Method | Path | Auth | Response |
|---|---|---|---|
GET |
/api/v1/spaces/:spaceId |
DEV_TOOLS |
{ id, name, locales, localeFallback, createdAt, updatedAt } |
GET |
/api/v1/spaces/:spaceId/open-api |
DEV_TOOLS |
OpenAPI 3.0 JSON spec generated from schemas |
GET |
/api/v1/spaces/:spaceId/schemas |
DEV_TOOLS |
SchemaExport[] (id + type-specific fields, no timestamps) |
GET |
/api/v1/spaces/:spaceId/translations/:locale/values |
DEV_TOOLS |
Record<string, string> — the values stored for locale, without fallback filling (keys with no or an empty value are absent); 400 for a locale not in the space. Used by localess translation pull --raw |
Breaking change (v3.3):
GET /schemaspreviously returnedRecord<schemaId, Schema>with raw Firestore timestamps. It now returns aSchemaExport[]array — the same shape the push endpoint accepts and the export zip contains. Upgrade@localess/clibefore upgrading Localess; the current CLI accepts both shapes.
validateIdParams(router) registers router.param checks for spaceId, contentId and assetId on CDN, DEV_TOOLS and MANAGE. A value that doesn't match ^[A-Za-z0-9_-]{1,128}$ (utils/id-param.ts) gets 400 invalid-argument, with Cache-Control: public, max-age=3600 (CACHE_BAD_REQUEST_MAX_AGE). router.param callbacks run before the route's own middleware, so a bad ID never reaches the permission checks or a path builder.
This matters because Express decodes %2F in route params, and these IDs are spliced into Firestore and Storage paths. Without the check, GET /contents/X%2Fdraft?cv=… builds spaces/{spaceId}/contents/X/draft/{locale}.json and serves the unpublished draft under a CONTENT_PUBLIC token: requireContentPermissions() only treats a request as a draft request when version is present. Hosting passes %2F through, so this worked through the app domain as well as the function URL.
Two more guards back it up:
contentLocaleCachePath()callsassertPathSegment()on both IDs.- Reference resolution skips a stored reference ID that isn't well-formed instead of failing the response.
Tokens are checked too: validateToken() accepts only 20 alphanumerics, and anything else gets the usual 401.
Token passed as ?token=<tokenId>. Results are cached in-memory per Function instance with a 5-minute TTL (key: ${spaceId}:${tokenId}) to reduce Firestore reads.
Conditional permission helpers:
requireContentPermissions()— requiresCONTENT_DRAFTorDEV_TOOLSwhenversionquery param is present; otherwise acceptsCONTENT_PUBLIC,CONTENT_DRAFT, orDEV_TOOLS.requireTranslationPermissions()— same logic withTRANSLATION_DRAFT/TRANSLATION_PUBLIC(plusDEV_TOOLS).
Token passed as X-API-KEY header. No caching — direct Firestore lookup on every request.
401 and 403 responses serialize via HttpsError's toJSON() as { message, status, details? }.
401 Unauthenticated — same generic body for a missing/malformed token and for a well-formed token that doesn't exist in Firestore, so the response never reveals which case occurred:
{ "message": "Missing or invalid API token", "status": "UNAUTHENTICATED" }403 Permission Denied — details names the permission(s) that would have satisfied the check and, where relevant, why:
{
"message": "Token is missing a required permission",
"status": "PERMISSION_DENIED",
"details": {
"requiredPermissions": ["CONTENT_DRAFT", "DEV_TOOLS"],
"reason": "This request includes a `version` query parameter, which requires access to draft content.",
"hint": "Add one of the required permissions to this token, or use a token that already has it."
}
}details.reason is omitted for fixed-permission checks (e.g. DEV_TOOLS-only endpoints, /links) — only requireContentPermissions()/requireTranslationPermissions() populate it, for both draft (version present) and published requests, explaining which permissions that kind of request needs.
| Permission | Grants access to |
|---|---|
CONTENT_PUBLIC |
Published content (no version param) |
CONTENT_DRAFT |
Draft content (version param required) |
TRANSLATION_PUBLIC |
Published translations |
TRANSLATION_DRAFT |
Draft translations (version param required) |
DEV_TOOLS |
MANAGE + DEV_TOOLS endpoints; also satisfies all PUBLIC/DRAFT checks |
TokenV1 (legacy, no version field) implicitly grants TRANSLATION_PUBLIC, TRANSLATION_DRAFT, CONTENT_PUBLIC, CONTENT_DRAFT — but not DEV_TOOLS.
TokenV2 (current, version: 2) has an explicit permissions: TokenPermission[] array.
| File | Purpose |
|---|---|
functions/src/v1/cdn.ts |
CDN router — all 7 delivery endpoints |
functions/src/v1/manage.ts |
MANAGE router — translation bulk-write and schema push |
functions/src/v1/dev-tools.ts |
DEV_TOOLS router — space metadata, OpenAPI, schemas, stored translation values |
functions/src/v1/middleware/query-auth.middleware.ts |
Query-param auth with 5-min token cache |
functions/src/v1/middleware/api-key-auth.middleware.ts |
Header-based auth (no cache) |
functions/src/config.ts |
Cache TTL constants, bucket, firestoreService |
functions/src/models/token.model.ts |
TokenPermission enum, TokenV1/TokenV2 types |
functions/src/utils/image-transform.ts |
applySharpTransforms, ImageFormat, isImageFormat |
functions/src/services/ |
generateTranslationsDraft, generateOpenApi, resolveLinks, resolveReferences, etc. |