Content Management System with Substrate-based Access Control
Content is organized into series — named collections identified by a slug. Series names must match the pattern ^[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]$ (2–64 chars, lowercase alphanumeric, hyphens, and underscores).
Each upload may optionally have a lookup_path — a human-readable path within its series (e.g., images/logo.png). Lookup paths are unique within a series and match ^(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.[A-Za-z0-9]+$.
Every upload receives a globally unique UUID (auto-generated or provided in extra_metadata.uuid). UUIDs enable cross-series references and are the primary identifiers used in graph relationships.
Uploads can be linked using semantic predicates (e.g., skos:broader, rel:derivedFrom). Relationships are directional (source → target) and support soft-deletion. This forms a knowledge graph across all CMS content.
All API endpoints (except /docs, /_ah/health, and the auth endpoints themselves) require a valid Bearer token in the Authorization header.
Request a challenge to begin authentication. The address must be an authorized admin or scribe.
{
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}
{
"challenge": {
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"timestamp": 1700000000,
"nonce": "550e8400-e29b-41d4-a716-446655440000",
"message": "Sign this message to authenticate with Persistable CMS"
}
}
Submit the signed challenge to receive access and refresh tokens.
challenge JSON object from /api/auth/challenge.message_str = json.dumps(challenge, separators=(',', ':'))message_bytes = message_str.encode('utf-8')message_hex = message_bytes.hex()signature = keypair.sign(message_bytes)signature_hex = signature.hex(){
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"message": "7b2261646472657373223a2235...hex_encoded_json...",
"signature": "0xabc123...hex_encoded_signature..."
}
{
"access_token": "a1b2c3d4...blake3_hex...",
"refresh_token": "e5f6a7b8...blake3_hex...",
"expires_in": 3600,
"role": "admin",
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}
Exchange a valid refresh token for new access and refresh tokens.
{
"refresh_token": "e5f6a7b8...blake3_hex..."
}
{
"access_token": "new_access_token...",
"refresh_token": "new_refresh_token...",
"expires_in": 3600,
"role": "admin",
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}
Invalidate the current session tokens. Requires Authorization: Bearer <token>.
{
"message": "Logged out"
}
All admin endpoints require authentication and the caller must be the configured administrator.
Grant scribe access to a Substrate address.
{
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}
{
"message": "Scribe added",
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}
Revoke scribe access.
{
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}
{
"message": "Scribe removed",
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}
List all authorized scribes.
{
"scribes": [
{
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
"granted_by": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"created_at": "2024-01-15T10:30:00"
}
]
}
View the current CMS configuration.
{
"admin_address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"created_at": "2024-01-15T09:00:00"
}
All CMS endpoints require authentication. The base path is /api/cms.
Upload content to a series. Provide either base64-encoded binary (content) or plain text (content_text). Max binary size: 1 MB.
{
"content_text": "{\"name\": \"Example\"}",
"content_type": "application/json",
"lookup_path": "data/example.json",
"extra_metadata": {"tags": ["demo"]},
"signature": "optional_hex_sig"
}
{
"content": "iVBORw0KGgo...base64...",
"content_type": "image/png",
"lookup_path": "images/logo.png"
}
{
"message": "Upload successful",
"upload_id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"series": "my-series",
"lookup_path": "data/example.json"
}
Retrieve an upload by its numeric ID.
{
"data": {
"upload_id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"series": "my-series",
"lookup_path": "data/example.json",
"content_type": "application/json",
"content": "{\"name\": \"Example\"}",
"extra_metadata": {"tags": ["demo"]},
"source_ip": "127.0.0.1",
"user_agent": "python-requests/2.31.0",
"signature": null,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
"timestamp": "2024-01-15T10:30:00"
}
}
Retrieve an upload by its UUID. The upload must belong to the specified series.
Retrieve an upload by its lookup path within a series.
List uploads in a series with cursor-based pagination.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 100 | Results per page (1–200) |
cursor | string | — | Pagination cursor from previous response |
{
"uploads": [ ... ],
"series": "my-series",
"next_cursor": "CjsSNWo..."
}
Graph endpoints manage semantic relationships between uploads. All are under /api/cms and require authentication.
Create a directed relationship from the source upload to a target identified by UUID.
{
"predicate": "skos:broader",
"target_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
{
"message": "Relationship created",
"relationship": {
"relationship_id": 1,
"source_uuid": "550e8400-e29b-41d4-a716-446655440000",
"predicate": "skos:broader",
"target_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"target_series": "other-series",
"target_content_type": "application/json",
"target_lookup_path": "data/parent.json",
"status": "active",
"created_by": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"created_at": "2024-01-15T11:00:00",
"last_updated": "2024-01-15T11:00:00"
}
}
| Status | Condition | Body |
|---|---|---|
| 400 | Invalid predicate | {"error": "Invalid predicate", "allowed_predicates": {...}} |
| 400 | Self-link | {"error": "Cannot create relationship to self"} |
| 404 | Target not found | {"error": "Target UUID not found in CMS"} |
List all active relationships from a source upload.
{
"relationships": [ ... ],
"upload_id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000"
}
Soft-delete a relationship (sets status to removed).
{
"relationship_id": 1
}
{
"message": "Relationship removed",
"relationship": { ... }
}
List all soft-deleted relationships from a source upload.
List active relationships for an upload identified by UUID. The upload must belong to the specified series.
List soft-deleted relationships for an upload identified by UUID.
Convenience endpoint returning all allowed predicate strings, organized by namespace.
{
"predicates": {
"owl": ["owl:differentFrom", "owl:equivalentTo", "owl:sameAs"],
"rdfs": ["rdfs:isDefinedBy", "rdfs:seeAlso", "rdfs:subClassOf"],
"skos": ["skos:broadMatch", "skos:broader", "skos:closeMatch", ...],
...
}
}
| Namespace | Predicates |
|---|---|
owl |
owl:differentFrom, owl:equivalentTo, owl:sameAs |
rdfs |
rdfs:isDefinedBy, rdfs:seeAlso, rdfs:subClassOf |
skos |
skos:broadMatch, skos:broader, skos:closeMatch, skos:exactMatch, skos:narrowMatch, skos:narrower, skos:related, skos:relatedMatch |
cause |
cause:directCause, cause:enables, cause:indirectCause, cause:prerequisite, cause:prevents, cause:trigger |
intent |
intent:achieves, intent:aimsTo, intent:facilitates, intent:intendedFor, intent:motivates |
axiom |
axiom:contradicts, axiom:derivedFrom, axiom:implies, axiom:mutuallyExclusive, axiom:necessaryFor, axiom:sufficientFor |
spatial |
spatial:connects, spatial:contains, spatial:locatedIn, spatial:near, spatial:within |
temporal |
temporal:after, temporal:before, temporal:during, temporal:hasVersion, temporal:overlaps, temporal:versionOf |
part |
part:hasComponent, part:hasPart, part:isPartOf, part:partOf |
rel |
rel:archivedFrom, rel:creates, rel:dependsOn, rel:derivedFrom, rel:hasPart, rel:influences, rel:interactsWith, rel:linkedFrom, rel:linksTo, rel:modifies, rel:partOf, rel:referencedBy, rel:references, rel:relatedTo, rel:sourceOf, rel:usedDevice |
from substrateinterface import Keypair
# Generate new mnemonic and keypair
mnemonic = Keypair.generate_mnemonic()
keypair = Keypair.create_from_mnemonic(mnemonic)
print("Mnemonic:", mnemonic)
print("Address: ", keypair.ss58_address)
import json
import requests
from substrateinterface import Keypair
BASE_URL = "https://your-cms-instance.appspot.com"
keypair = Keypair.create_from_mnemonic("your twelve word mnemonic ...")
# Step 1: Request challenge
resp = requests.post(f"{BASE_URL}/api/auth/challenge", json={
"address": keypair.ss58_address
})
challenge = resp.json()["challenge"]
# Step 2: Sign the challenge
message_str = json.dumps(challenge, separators=(',', ':'))
message_bytes = message_str.encode('utf-8')
message_hex = message_bytes.hex()
signature = keypair.sign(message_bytes)
signature_hex = signature.hex()
# Step 3: Verify and get tokens
resp = requests.post(f"{BASE_URL}/api/auth/verify", json={
"address": keypair.ss58_address,
"message": message_hex,
"signature": signature_hex
})
tokens = resp.json()
access_token = tokens["access_token"]
# Step 4: Use the token
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(f"{BASE_URL}/api/cms/my-series/list", headers=headers)
print(resp.json())
# When access token expires (after 1 hour), use the refresh token:
resp = requests.post(f"{BASE_URL}/api/auth/refresh", json={
"refresh_token": tokens["refresh_token"]
})
new_tokens = resp.json()
# Refresh tokens last 30 days