Loreset Documentation
Loreset is a Headless CMS for hierarchical and polymorphic game content. This page collects the documentation for the current version of the platform: the LSL schema language specification, the export formats (bundle-v1 and versions.json), and descriptions of the implemented subsystems.
Platform overview
The platform follows an API-first design: the only interface is REST API v1 with JWT authentication. All responses use a single contract:
{ "success": true, "data": { … }, "message": null, "errors": null }
Component overview:
Frontend SPA ──JWT Bearer──> REST API (v1)
│
┌──────────────┼──────────────────┐
│ │ │
AclManager LslValidator ExportBuilder
│ │ │
└──────> ItemStorage Publisher (S3/local)
│ │
I18nIndexer CDN (S3)
│
i18n_registry
Data follows the Hybrid Storage principle: metadata, relations and versions live in relational tables (SQLite or MySQL), while the objects themselves, with arbitrary nesting, are stored as JSON documents. JSON columns are stored as TEXT for portability across databases.
Key concepts
| Concept | Description |
|---|---|
| Project | A game. Contains collections, members, assets and CDN settings. |
| Collection | A named set of records described by an LSL schema. Its code is unique within the project. The is_singleton flag limits the collection to a single record (e.g. global balance settings). |
| Schema | An immutable version of a collection's LSL definition. Publishing a new version moves the HEAD pointer current_schema_id; old versions remain available. |
| Item | Holds no data — only a status (draft → published → archived) and a HEAD pointer to the current revision. |
| Revision | An immutable JSON snapshot of an item's data. Every edit adds a revision with version + 1. History is never rewritten. |
| Publication | An upload of a versioned content bundle and the versions.json index to the project's CDN. |
Implemented features
The current version of the platform includes the following subsystems:
- Projects and members — multi-user projects with roles (
viewer,translator,content_manager,admin) and per-collection access overrides for individual users. - Collections and LSL schemas — creating collections together with the first schema version, immutable schema versioning, schema definition validation on save.
- Items and revisions — CRUD for records validated against the current schema, revision history, rollback without losing history, singletons, publication statuses.
- Localization — translations inside JSON documents (
i18n_string/i18n_text), automatic indexing into a flat registry, targeted translation edits, CSV export and import for Google Sheets. - Assets — uploading project files (icons, sounds) with arbitrary metadata; the asset id is used in
asset-typed fields. - Import and export — the self-contained
loreset/bundle-v1bundle; the import works as an idempotent upsert within a single transaction. - CDN publishing —
s3driver (any S3-compatible storage: AWS, MinIO, DO Spaces, Selectel) and alocaldriver for development; monotonic versions, checksums, publication history. - Developer tooling — an OpenAPI spec and a Postman collection generated from code; a demo data seeder; unit and functional tests.
.po / .xliff, refresh tokens.Loreset Schema Language (LSL)
LSL is the language for describing collection schemas. It's based on JSON Schema but extended with game-specific data types and UI hints that the admin UI uses to generate editing forms automatically. A schema is a plain JSON file that's easy to keep in Git (Schema-as-Code).
A minimal collection schema:
{
"type": "object",
"properties": {
"title": { "type": "i18n_string", "ui": { "label": "Title" } },
"trigger_event": {
"type": "enum",
"options": ["first_login", "loot_drop", "level_complete"]
},
"is_hidden": { "type": "boolean" }
},
"required": ["title", "trigger_event"]
}
Reusable blocks go into $defs and are referenced via {"$ref": "#/$defs/Name"}. Every field may carry a ui object with a caption (label) and a display component (component) — e.g. searchable_select, sortable_list, tabs.
LSL data types
| Type | Value in data |
|---|---|
integer, float, boolean | the corresponding scalars |
string, text, richtext | a string (text is multiline, richtext supports <ref/> tags linking to other records) |
timestamp | unix time (int) or a date string |
duration | number of seconds |
asset | an asset id (string) |
i18n_string, i18n_text | an object {"_i18n": true, "en": "...", "ru": "..."} |
enum | one of options (scalars or {value, label} objects) |
relation | the id of a record in the target collection (target); with value_field: "id" the target's existence is verified |
array | a list; every element is validated against items |
dictionary | a "key → value" object; values are validated against the values schema |
union | an object with a discriminator field; the variant is picked from variants |
object | a nested object with properties / required |
relation — links between collections
"hero": {
"type": "relation",
"target": "heroes",
"value_field": "id",
"display_template": "{{id}}. {{name}} ({{class}})",
"filters": { "is_playable": { "eq": true } },
"ui": { "label": "Hero", "component": "searchable_select" }
}
union — polymorphic structures
The key type for games: a field accepts one of several structures depending on the discriminator value.
// Schema
{
"type": "union",
"discriminator": "reward_type",
"variants": {
"currency": { "$ref": "#/$defs/RewardCurrency" },
"item": { "$ref": "#/$defs/RewardItem" }
}
}
// Valid data
{ "reward_type": "currency", "currency": "gold", "amount": 500, "func": "add" }
dictionary — "key → value" maps
"currency_wallet": {
"type": "dictionary",
"keys": { "type": "relation", "target": "currencies" },
"values": { "type": "integer" },
"ui": { "label": "Wallet (Currency → Amount)" }
}
Data validation rules
Item data is validated against the collection's current schema before every save; invalid data is never persisted.
- Unknown fields are rejected (
Unknown field). - Fields listed in
requiredmust be present and non-null. - Service fields prefixed with
_(_i18n,_legacy) are ignored. relationwithvalue_field: "id"verifies that the target record exists within the project.- Errors come back as a "path → message" map:
{
"success": false,
"message": "Item data does not match the collection schema.",
"errors": {
"$.trigger_event": "Value is not in the list of allowed options.",
"$.rewards[0].amount": "Expected an integer."
}
}
Content bundle (loreset/bundle-v1)
A single self-contained JSON for game engines: schemas, published records and a flat translation dictionary. Used both for exporting to the client and for moving content between environments (Dev → Prod).
{
"format": "loreset/bundle-v1",
"project": "heliostorm",
"version": 7,
"generated_at": "2026-07-08T12:00:00+00:00",
"checksum": "sha256:ab12…",
"schemas": {
"bonuses": { "version": 2, "schema": { … } }
},
"items": {
"bonuses": [
{
"id": "93a01703-…",
"data": {
"title": { "en": "Welcome Pack", "ru": "Приветственный набор" },
"trigger_event": "first_login"
}
}
]
},
"i18n": {
"bonuses.93a01703-….title": { "en": "Welcome Pack", "ru": "Приветственный набор" }
}
}
version— the project's monotonic publication number; the engine compares it with its local copy.checksum— sha256 of the payload (schemas+items+i18n).- Record data is "flattened": service fields (
_i18n,_legacyand anything prefixed with_) are removed; translations remain as{locale: value}objects. - Only records with the
publishedstatus are included.
Export — GET /v1/projects/{id}/export. Import — POST /v1/projects/{id}/import with the bundle as the body: missing collections are created, changed schemas are published as new versions, records are inserted or updated by id, and re-importing the same bundle is a no-op. Everything runs in a single transaction.
CDN version index (versions.json)
On every successful publication, a versions.json file — an index of all successful publications of the project — is rewritten next to the bundle-v{N}.json bundle. With a single static file request the game client learns the current content version and the bundle URL without calling the API:
<base_url>/<prefix>/versions.json
// e.g.: https://cdn.example.com/content/versions.json
File format
{
"format": "loreset/versions-v1",
"project": "my-rpg",
"generated_at": 1783508000,
"latest": 7,
"versions": [
{
"version": 7,
"file": "bundle-v7.json",
"url": "https://cdn.example.com/content/bundle-v7.json",
"checksum": "sha256:ab12…",
"items_count": 132,
"published_at": 1783508000
},
{
"version": 6,
"file": "bundle-v6.json",
"url": "https://cdn.example.com/content/bundle-v6.json",
"checksum": "sha256:cd34…",
"items_count": 130,
"published_at": 1783420000
}
]
}
Top-level fields
| Field | Type | Description |
|---|---|---|
format | string | Format marker, always loreset/versions-v1 |
project | string | Project code |
generated_at | int | Unix time when the index was generated |
latest | int | Number of the latest (current) bundle version |
versions | array | List of successful publications, newest first |
versions array element
| Field | Type | Description |
|---|---|---|
version | int | Bundle version number (grows monotonically within the project) |
file | string | Bundle file name; the public link is assembled as <base_url>/<prefix>/<file> |
url | string | The file URL captured at publication time |
checksum | string | Checksum (sha256:…), matches the checksum field inside the bundle |
items_count | int | Number of records in the bundle |
published_at | int | Unix time of the publication |
failed status never appear in the index. If uploading versions.json fails, the whole publication is marked failed.Recommended client update flow
- Download
versions.json. - Compare
latestwith the version of the local bundle copy. - If newer — download the file from the entry with
version == latestand verify thechecksum. - Treat any network error gracefully: keep running on the current version.
Items and revisions
An item stores only its status and a HEAD pointer; the data lives in immutable revisions. Any change — an edit, a translation, a rollback, an import — creates a new revision and moves HEAD.
# Create an item (validated against the current schema)
curl -X POST https://api.loreset.dev/v1/collections/<uuid>/items \
-H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
-d '{
"status": "draft",
"data": {
"title": { "_i18n": true, "en": "Welcome Pack", "ru": "Приветственный набор" },
"trigger_event": "first_login",
"rewards": [
{ "reward_type": "currency", "currency": "gold", "amount": 500, "func": "add" }
]
}
}'
Rollback never deletes history: the old revision's data is copied into a new revision and HEAD moves to it. With v1…v3 in history, a rollback creates v4 carrying the chosen revision's data:
curl -X POST https://api.loreset.dev/v1/items/<uuid>/rollback \
-H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
-d '{"revision_id": "<uuid-of-the-old-revision>"}'
Item statuses: draft → published → archived (transitions in any direction). Only published records make it into the export bundle. A singleton collection can hold only one record.
Localization (i18n)
Translations live inside the item's data — every i18n_string / i18n_text field is stored as an object with the _i18n flag:
"title": { "_i18n": true, "en": "Welcome Pack", "ru": "Приветственный набор" }
On every new revision the indexer walks the JSON and rebuilds a flat translation registry: item_id + field_path + locale → value. The path uses the rewards[0].title notation. Editing a translation is a versioned change: it creates a new revision of the item.
CSV pipeline for Google Sheets
The export produces a CSV with one row per translatable field and one column per locale:
collection,item_id,field_path,en,ru
bonuses,93a01703-…,title,Welcome Pack,Приветственный набор
bonuses,93a01703-…,rewards[0].title,Sword,
The file goes into Google Sheets, translators fill in the empty cells, then the CSV is imported back. Import rules:
- rows are matched by
item_id+field_path; - empty cells are skipped — existing translations are never wiped;
- each changed item gets exactly one new revision, no matter how many fields changed;
- rows without access rights or with unknown items land in
skipped; the import doesn't abort.
Access model (ACL)
Four access levels, each including the previous ones:
| Level | What it allows |
|---|---|
view | reading records, schemas, the translation registry, export |
translate | + editing translations (targeted and CSV import) |
edit | + creating/editing/deleting records, rollback, status changes, asset uploads |
manage | + structure (collections, schemas), members, ACL, project settings, import, publishing |
Permission sources (in priority order):
- Super admin — full access to everything.
- Project role — the base level:
viewer→ view,translator→ translate,content_manager→ edit,admin→ manage. - Per-collection override — overrides the project role for a specific collection. The
nonevalue hides the collection completely: it disappears from listings, and any request returns 403.
Example: a content manager has none on the "Quests" collection — they can edit everything except quests. A viewer has edit on "Bonuses" — they can edit bonuses only.
CDN publishing
Each project keeps its own CDN settings in the cdn_settings field:
{
"driver": "s3",
"bucket": "my-rpg-content",
"region": "eu-central-1",
"endpoint": "https://s3.example.com",
"base_url": "https://cdn.example.com",
"prefix": "content/"
}
The s3 driver works with any S3-compatible storage (AWS S3, MinIO, DO Spaces, Selectel, etc., including a custom endpoint and path-style URLs). The local driver writes files to local storage — handy for development. Secrets are masked in all API responses.
Publishing (requires the manage level):
curl -X POST https://api.loreset.dev/v1/projects/1/publish \
-H "Authorization: Bearer <token>"
What happens:
- The next monotonic
versionis allocated. - A
loreset/bundle-v1bundle is built from the published records. - The
bundle-v{N}.jsonfile is uploaded to the CDN via the project's driver. - The
versions.jsonindex is rewritten next to it. - A publication record with the URL, checksum and record count is stored in the history.
The publication history is available via GET /v1/projects/{id}/publications — sorted by version, newest first. If the upload fails, a record with the failed status is created, and such a publication never appears in versions.json.
versions.json and hot content updates from the CDN.