Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,982 changes: 1,883 additions & 99 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
name = "pace_server"
version = "0.1.0"
edition = "2021"
rust-version = "1.97.1"

[dependencies]
axum = "0.8"
rusqlite = { version = "0.38", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
serde_json = "1"
pace_core = { git = "https://github.com/pace-kit/pace_core", rev = "1aa2b36270b68ea6fc04c268c3d5661ec09790aa" }
pace_editor_format = { git = "https://github.com/pace-kit/pace_app", rev = "1dd8f4d8fcfb84849164a6041895dd15a1e9ef19" }
sha2 = "0.10"
subtle = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] }
uuid = { version = "1", features = ["v4"] }
tower-http = { version = "0.6", features = ["cors"] }
url = "2"

[dev-dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
Expand Down
72 changes: 49 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,37 @@ Work locally, download a revision, edit, and explicitly upload against that revi

## Implementation sequence

1. Storage and revision API (this layer): SQLite persistence, unchanged project
1. Storage and revision API: SQLite persistence, unchanged project
bytes, bounded requests, strong revision ETags, transactional writes, access control.
2. Authentication: provision bearer identities and enable the server binary.
The bare router fails closed without an identity; the binary always uses auth.
3. Client integration: connect the local editor's serializer and expose conflicts
to the user after the local core/editor workflow is available.

## Scene compatibility prerequisite

The referenced `pace_core` main at `6ccfd6e` has no scene schema/serializer;
`pace_app` main at `b195b90` is a demo, without project save/load. The server
therefore validates **JSON syntax only**, up to 1 MiB (including whitespace),
and stores/returns the original UTF-8 bytes. Unknown fields, whitespace, key order,
and numeric spelling are preserved. The examples/tests are transport fixtures,
not a proposed Pace scene format. Structurally invalid scenes that are valid JSON
cannot yet be rejected: integrate the core's canonical validator when available.
The transport does not execute scripts, dereference asset URLs, or unpack archives.
External assets are not uploaded by this API.
3. Client integration: the `pace_app` browser editor uploads/downloads its existing
draft format and offers explicit conflict decisions. The server also accepts
core scenes through the same revision API. See the app's project-sharing guide.

## Supported project formats

Uploads are limited to **64 MiB**, including whitespace and embedded images,
matching the editor's project-file limit. Validation uses immutable dependencies:

- **PACE core scene v1** (`format_version: 1`): `Scene::from_json` from
`pace_core` at `1aa2b36270b68ea6fc04c268c3d5661ec09790aa` checks shape, version,
stable IDs, references, geometry and dialogue exits. Unknown fields are rejected.
- **Editor draft v1** (`format: "pace-editor-draft", version: 1`):
`pace_editor_format::Project::from_json` from `pace_app` at
`1dd8f4d8fcfb84849164a6041895dd15a1e9ef19` is the editor's own loader, extracted
without schema changes. It rejects malformed structures, unsupported versions,
unknown fields and duplicate IDs. Incomplete drafts and unresolved references
remain editable, just as with local save/load; this does not certify playability.

The server stores/returns accepted UTF-8 bytes unchanged: no envelope, conversion,
rewritten IDs or asset extraction. Whitespace, field order and numeric spelling
are preserved. Embedded backgrounds travel inside the draft. Core scenes and
drafts remain distinct contracts; runtime export/conversion is not part of sharing.
Other JSON, runtime save slots and future versions are rejected. Previously stored
arbitrary JSON from the initial API remains downloadable, but new writes must use
a supported format. The transport never executes scripts or fetches asset URLs.
Rust 1.97.1+ is required by the pinned core dependency.

## Run the authenticated server

Expand Down Expand Up @@ -53,7 +66,19 @@ PACE_AUTH_FILE=./auth.json PACE_DATABASE=./pace-projects.sqlite3 cargo run --loc
`PACE_BIND` defaults to `127.0.0.1:3000`. TLS must be terminated by a trusted
reverse proxy before exposing the service beyond localhost; bearer tokens confer
access to anyone holding them. Do not log authorization headers. There is no
permissive CORS policy or browser login flow in this layer.
browser login flow in this layer. Cross-origin browser access is disabled unless
`PACE_ALLOWED_ORIGINS` lists exact origins separated by commas, for example:

```sh
PACE_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 \
PACE_AUTH_FILE=./auth.json PACE_DATABASE=./pace-projects.sqlite3 cargo run --locked
```

Paths, credentials and wildcard origins are rejected. Allowed preflight requests
do not require authentication; actual project requests always do. The policy
allows Authorization, Content-Type and If-Match, and exposes ETag and Location.
Use a same-origin reverse proxy instead if preferred. CORS is a browser policy;
authorization still governs all callers.

For token rotation, add a new digest with the **same principal**, distribute that
token securely, remove the old digest, and restart. Multiple distinct digests may
Expand All @@ -66,10 +91,9 @@ granting access. Do not commit token files or operator config.

## Two-client HTTP walkthrough

Use a JSON file produced by the local editor when its serializer is available.
For a transport-only smoke test, `printf '{}\n' > project.json` is sufficient;
this does not establish scene-format compatibility. These commands use `curl`
and `jq`, with the local token files created above:
Use a JSON file saved by the editor, or copy
`tests/fixtures/core-scene-v1.json` to `project.json` to exercise the canonical
core fixture. These commands use `curl` and `jq`, with the tokens created above:

```sh
BASE=http://127.0.0.1:3000
Expand Down Expand Up @@ -139,7 +163,7 @@ and [§15.5.13](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.13),
with a deliberately restricted condition syntax to require an explicit revision.

Missing/invalid credentials return `401`; unknown and inaccessible projects
both return `404`. Readers attempting writes receive `403`. Invalid JSON returns
both return `404`. Readers attempting writes receive `403`. Invalid JSON/schema returns
`400`, unsupported media/encoding `415`, and oversized projects `413`.
Revision conflicts never change the stored bytes or revision.
ACL changes are owner-only, do not change the content ETag, and are serialized
Expand All @@ -163,5 +187,7 @@ Integration tests use real HTTP sockets and temporary SQLite files, including
independent database connections racing the same revision, two-client exchange,
process kill/restart persistence of contents/revisions/ACLs, denied access,
reader/writer/owner permissions, malformed/oversized (including chunked) payloads,
and required preconditions. These are transport tests; the editor's actual
save/load round trip remains a prerequisite for client integration.
required preconditions, canonical core/draft fixtures, schema errors and CORS.
The app's browser suite starts this real server and verifies local save → upload
→ download → save equality for the complete three-room draft, including images,
as well as two-user conflicts, explicit reconciliation and denied access.
26 changes: 26 additions & 0 deletions src/cors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use axum::http::{header, HeaderValue, Method};
use tower_http::cors::CorsLayer;

/// Explicit browser origin allowlist. Authentication remains mandatory on requests.
pub fn cors(origins: &str) -> Result<CorsLayer, &'static str> {
let mut allowed = Vec::new();
for value in origins.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let url = url::Url::parse(value).map_err(|_| "Invalid PACE_ALLOWED_ORIGINS origin")?;
if !matches!(url.scheme(), "http" | "https") || url.origin().ascii_serialization() != value
{
return Err(
"PACE_ALLOWED_ORIGINS requires exact http(s) origins without paths or wildcards",
);
}
allowed.push(HeaderValue::from_str(value).map_err(|_| "Invalid origin header")?);
}
Ok(CorsLayer::new()
.allow_origin(allowed)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::IF_MATCH,
])
.expose_headers([header::ETAG, header::LOCATION]))
}
2 changes: 1 addition & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ impl ApiError {
Self::new(
StatusCode::PAYLOAD_TOO_LARGE,
"project_too_large",
"Project exceeds the 1 MiB limit",
"Project exceeds the 64 MiB limit",
)
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Durable, explicitly versioned project snapshots. This is not a co-editing service.
pub mod api;
pub mod auth;
pub mod cors;
pub mod error;
pub mod project;
pub mod storage;
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let config_path = env::var("PACE_AUTH_FILE").map_err(|_| "PACE_AUTH_FILE is required")?;
let auth_bytes = std::fs::read(config_path)?;
let auth = Auth::from_json(&auth_bytes)?;
let cors = pace_server::cors::cors(&env::var("PACE_ALLOWED_ORIGINS").unwrap_or_default())?;
let database = env::var("PACE_DATABASE").unwrap_or_else(|_| "pace-projects.sqlite3".into());
if database.is_empty() || database == ":memory:" {
return Err("PACE_DATABASE must name a persistent SQLite file".into());
Expand All @@ -17,7 +18,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let bind = env::var("PACE_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
let listener = TcpListener::bind(bind).await?;
println!("Listening on http://{}", listener.local_addr()?);
axum::serve(listener, authenticated_router(store, auth))
axum::serve(listener, authenticated_router(store, auth).layer(cors))
.with_graceful_shutdown(shutdown())
.await?;
Ok(())
Expand Down
45 changes: 40 additions & 5 deletions src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,51 @@ use serde::{Deserialize, Serialize};
use crate::error::ApiError;

/// Limit applies to the complete uncompressed project, including whitespace.
pub const MAX_PROJECT_BYTES: usize = 1024 * 1024;
pub const MAX_PROJECT_BYTES: usize = pace_editor_format::MAX_PROJECT_BYTES;

/// Transport validation only: the upstream core does not yet define a scene schema.
/// RawValue validates JSON without interpreting or normalizing numbers/extension fields.
/// Validate with the pinned upstream loaders, without reserializing stored bytes.
/// Drafts remain editable (including unresolved references); core scenes must be runnable.
pub fn validate_project(bytes: &[u8]) -> Result<(), ApiError> {
if bytes.len() > MAX_PROJECT_BYTES {
return Err(ApiError::too_large());
}
serde_json::from_slice::<&serde_json::value::RawValue>(bytes)
.map_err(|_| ApiError::bad_request("invalid_project", "Project must be valid JSON"))?;
let text = std::str::from_utf8(bytes)
.map_err(|_| ApiError::bad_request("invalid_project", "Project must be UTF-8 JSON"))?;
#[derive(Deserialize)]
struct Format {
format: Option<String>,
format_version: Option<u32>,
}
let header: Format = serde_json::from_str(text).map_err(|_| {
ApiError::bad_request(
"invalid_project",
"Project must be a supported JSON document",
)
})?;
match (header.format.as_deref(), header.format_version) {
(Some(pace_editor_format::FORMAT), None) => {
pace_editor_format::Project::from_json(text).map_err(|_| {
ApiError::bad_request(
"invalid_project",
"Invalid editor draft: check its version, structure and object IDs",
)
})?;
}
(None, Some(_)) => {
pace_core::scene::Scene::from_json(text).map_err(|_| {
ApiError::bad_request(
"invalid_project",
"Invalid core scene: check its version, structure, IDs, references and bounds",
)
})?;
}
_ => {
return Err(ApiError::bad_request(
"unsupported_format",
"Expected a pace-editor-draft v1 or PACE core scene v1",
))
}
}
Ok(())
}

Expand Down
4 changes: 2 additions & 2 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ impl Store {
}

pub async fn create(&self, principal: Principal, body: Vec<u8>) -> Result<Snapshot, ApiError> {
validate_project(&body)?;
self.run(move |connection| {
validate_project(&body)?;
let id = Uuid::new_v4().to_string();
connection.execute(
"INSERT INTO projects (id, owner, revision, body) VALUES (?1, ?2, 1, ?3)",
Expand Down Expand Up @@ -99,8 +99,8 @@ impl Store {
expected: i64,
body: Vec<u8>,
) -> Result<i64, ApiError> {
validate_project(&body)?;
self.run(move |connection| {
validate_project(&body)?;
let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let (owner, revision): (String, i64) = tx
.query_row(
Expand Down
6 changes: 3 additions & 3 deletions tests/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async fn owner_grants_reader_writer_access_and_revokes_it() {
.status(),
204
);
let edit = b"{\"edited_by\":\"bob\",\"scene\":{}}";
let edit = common::edited("Edited by Bob");
let response = bob
.put(&url)
.header("If-Match", "\"1\"")
Expand Down Expand Up @@ -233,7 +233,7 @@ async fn each_creator_owns_only_their_own_projects() {
let created = bob
.post(format!("{}/v1/projects", server.base))
.header("Content-Type", "application/json")
.body("{\"owner\":\"alice\"}")
.body(common::edited("Alice's title does not confer ownership"))
.send()
.await
.unwrap();
Expand Down Expand Up @@ -296,7 +296,7 @@ async fn chunked_uploads_cannot_bypass_the_size_limit() {
let mut chunk = b"4000\r\n".to_vec();
chunk.extend(vec![b' '; 16 * 1024]);
chunk.extend_from_slice(b"\r\n");
for _ in 0..65 { if socket.write_all(&chunk).is_err() { break; } }
for _ in 0..=pace_server::project::MAX_PROJECT_BYTES / (16 * 1024) { if socket.write_all(&chunk).is_err() { break; } }
let _ = socket.write_all(b"2\r\n{}\r\n0\r\n\r\n");
let mut response = [0; 2048];
let size = socket.read(&mut response).unwrap();
Expand Down
12 changes: 9 additions & 3 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ use reqwest::{Client, Response};
use sha2::{Digest, Sha256};
use tokio::{net::TcpListener, task::JoinHandle};

pub const PROJECT: &[u8] =
b"{ \"scene\": {\"nodes\":[]}, \"extension\": {\"large\":123456789012345678901234567890} }\n";
pub const PROJECT: &[u8] = include_bytes!("../fixtures/core-scene-v1.json");

pub fn edited(title: &str) -> Vec<u8> {
let mut value: serde_json::Value = serde_json::from_slice(PROJECT).unwrap();
value["title"] = title.into();
serde_json::to_vec_pretty(&value).unwrap()
}

pub const ALICE: &str = "alice-test-token-00000000000000000000000000000000";
pub const BOB: &str = "bob-test-token-0000000000000000000000000000000000";
Expand Down Expand Up @@ -43,7 +48,8 @@ impl Server {
let app = authenticated_router(
Store::open(path).unwrap(),
Auth::from_json(&auth_json()).unwrap(),
);
)
.layer(pace_server::cors::cors("https://editor.example").unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Fixture asset license

The Last Light's story, dialogue, room layouts, palette and fixture data in
`game.json` were created for this repository. These original demo assets are
dedicated to the public domain under CC0 1.0:
https://creativecommons.org/publicdomain/zero/1.0/

The demo uses geometric shapes rather than external artwork. The desktop viewer
uses Bevy's bundled Fira Mono font, distributed by Bevy under the SIL Open Font
License 1.1. No PACE mascot, reference-site artwork, music or third-party game
assets are included.
Loading
Loading