From ef5f118c9319b6db7e03ae714c57c5f362cd5881 Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:53:37 -0400 Subject: [PATCH] Rewrite project and server documentation Turn the README into a public project page, add a docs index, reorganize the server operations runbook, and refresh architecture and format docs. Remove the obsolete AI runtime spike note. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- README.md | 129 +++++++-- SECURITY.md | 21 +- apps/server/OPERATIONS.md | 158 +++++------ apps/server/README.md | 355 ++++++------------------- docs/README.md | 37 +++ docs/architecture/ai-runtime-spike.md | 23 -- docs/architecture/ai-runtime.md | 11 +- docs/architecture/plugin-runtime.md | 35 ++- docs/architecture/sync-server.md | 81 +----- docs/security/threat-model.md | 12 +- docs/testing/local-alpha-acceptance.md | 4 +- docs/workspace-format/checkpoints.md | 10 +- docs/workspace-format/collaboration.md | 2 +- docs/workspace-format/sync-v1.md | 4 +- docs/workspace-format/v1.md | 2 +- 16 files changed, 382 insertions(+), 504 deletions(-) create mode 100644 docs/README.md delete mode 100644 docs/architecture/ai-runtime-spike.md diff --git a/AGENTS.md b/AGENTS.md index 0f7ac47..0a77a36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ After completing the code, ask the user if they want a playground link. Only cal - `crates/local-core` owns canonical parsing, deterministic serialization, filesystem safety, atomic writes, indexing, watching, reconciliation, credentials, and domain operations. - `crates/mcp-server` adapts MCP tools to local-core services and must not duplicate business logic. - `src-tauri` owns the desktop process and typed IPC/event bridge. -- A future `apps/server` may use Bun, TypeScript, and Hono. It is outside the Initial MVP and must not become a dependency of local features. +- `apps/server` contains the experimental encrypted sync service built with Bun, TypeScript, and Hono. It must not become a dependency of local features. `apps/server-web` contains its account pages and encrypted share viewer. ### Workspace schema ownership diff --git a/README.md b/README.md index 8aa3101..b75061f 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,132 @@ # Noura -Noura is the open workspace for humans and AI. The Local Alpha stores durable workspace state in ordinary Markdown and binary files. A local SQLite database supplies its disposable index, metadata cache, and full-text search. +The open workspace for humans and AI. -## Status +Noura is an open-source, local-first desktop workspace where everything is a plugin. Combine notes, tasks, projects, and AI chat in one workspace. Keep your work in ordinary files you can edit, back up, and use without Noura. -The repository contains the desktop Local Alpha with: +[![Status: Local Alpha](https://img.shields.io/badge/status-Local%20Alpha-8b5cf6)](#try-noura) +[![Continuous integration](https://github.com/lobbystack/noura/actions/workflows/ci.yml/badge.svg)](https://github.com/lobbystack/noura/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) -- File-backed notes, tasks, and projects -- Month Calendar projection -- External-edit reconciliation and conflict review -- Model Context Protocol (MCP) mutations -- Full-text search -- Index recovery from canonical workspace files +[Try Noura](#try-noura) · [Documentation](#documentation) · [Contribute](#contributing) -The repository also contains: +## A workspace built from plugins -- `apps/server` and `apps/server-web`: an experimental encrypted sync service, not a production release. See [`apps/server/README.md`](apps/server/README.md). -- `apps/website`: the marketing site. +Notes, tasks, projects, calendar, folders, and AI are first-party plugins. Choose the combination you need for each workspace in **Settings**. -See [`docs/architecture/`](docs/architecture/) for design notes and [`docs/workspace-format/`](docs/workspace-format/) for the workspace format. +Start with notes for writing, then add tasks and projects to organize the work around them. Turn plugins on or off without deleting their files. Plugins use [shared capabilities](docs/architecture/plugin-runtime.md#capabilities) to work with your files and contribute commands or AI context. -## Development +## Work in one workspace -Install Bun and Rust 1.91 or newer, then run: +Write project notes, track tasks, and give your AI assistant context from the same workspace: + +- **Notes**: write and edit Markdown +- **Tasks and projects**: track priorities, due dates, and progress on project boards +- **Calendar**: view scheduled work by month, week, or day +- **Search**: find content across your workspace with full-text search +- **AI chat**: connect your provider, choose workspace context, and approve tool actions +- **External AI tools**: read and update notes and tasks through [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) + +## Keep control of your work + +Use Noura alongside your existing tools: + +- **Open files**: keep notes, tasks, projects, and chat history as Markdown with structured metadata +- **Local use**: read and edit your workspace offline, without a Noura account or hosted service +- **External editing**: edit, move, and rename files with other tools; review conflicts when changes overlap +- **AI permissions**: choose your provider and authorize sending workspace content before an in-app AI request +- **Credentials**: Noura stores provider credentials in your operating system’s credential store + +## Try Noura + +Run the desktop app from source. Install these prerequisites: + +- [Bun](https://bun.sh/docs/installation) 1.3.14 +- [Rust](https://www.rust-lang.org/tools/install) 1.91 or newer +- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/) for your operating system ```sh +git clone https://github.com/lobbystack/noura.git +cd noura bun install +bun run tauri dev +``` + +Create a workspace, add a note, and open the note’s Markdown file in your editor. + +## Develop Noura + +Run the development checks from the repository root: + +```sh bun run check bun run test +bun run build +bun run format:check ``` -Run the desktop development host with `bun run tauri dev` after installing the Tauri platform prerequisites. +Follow the [desktop verification guide](docs/testing/local-alpha-acceptance.md) for the full checks and workspace workflows. -Run the marketing website from the repository root: +To work on the marketing website, start its development server: ```sh bun run dev:website ``` -The marketing site runs at `http://127.0.0.1:5174`. Its embedded product demo -uses the application development server at `http://127.0.0.1:5173` when that -server is running. +Open `http://127.0.0.1:5174` in your browser. + +## How Noura stores your workspace + +Your workspace is a folder with a [manifest](docs/workspace-format/v1.md#manifest) at `.noura/workspace.yaml`. Notes, tasks, projects, and chats use Markdown with structured metadata in [frontmatter](docs/workspace-format/v1.md#managed-markdown). You can move or rename a file without changing its stable identifier. + +Noura commits workspace files to disk before reporting a successful change. You can rebuild its SQLite search index and metadata cache from those files. The desktop app and MCP server use the same Rust services: + +```mermaid +flowchart LR + desktop["Noura desktop"] --> core["Local core"] + tools["MCP tools"] --> core + core <--> files["Workspace files"] + core --> index["Rebuildable SQLite index"] +``` + +Read the [workspace format](docs/workspace-format/v1.md) for file layouts and the [local-core architecture](docs/architecture/local-core.md) for write and recovery behavior. + +## Repository structure + +Start with the directory for the part you want to work on: + +| Directory | Purpose | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| [`apps/app`](apps/app) | SvelteKit and Svelte 5 application interface | +| [`apps/website`](apps/website) | Marketing website | +| [`apps/server`](apps/server) and [`apps/server-web`](apps/server-web) | Sync service, account pages, and share viewer | +| [`packages`](packages) | Typed workspace client, shared types, editor, AI runtime, and plugin contracts | +| [`crates/local-core`](crates/local-core) | Rust file operations, validation, indexing, watching, and credentials | +| [`crates/mcp-server`](crates/mcp-server) | MCP adapter over local-core services | +| [`plugins`](plugins) | First-party workspace modules | +| [`src-tauri`](src-tauri) | Tauri 2 desktop shell and native bridge | + +## Documentation + +Start with the [documentation guide](docs/README.md), or use these references for implementation details: + +- [Workspace format](docs/workspace-format/v1.md) +- [Local-core architecture](docs/architecture/local-core.md) +- [AI runtime and permissions](docs/architecture/ai-runtime.md) +- [Plugin capabilities](docs/architecture/plugin-runtime.md) +- [Desktop verification guide](docs/testing/local-alpha-acceptance.md) +- [Server setup](apps/server/README.md) + +## Contributing + +Read the [contributor conventions](AGENTS.md) before changing code. Keep pull requests focused, include tests for behavior changes, and run the development checks. + +For workspace-format changes, update the shared [conformance fixtures](docs/workspace-format/fixtures/) so Rust and TypeScript accept the same files. -## Storage contract +## Support and security -Workspace files are canonical. Paths identify current locations, while frontmatter IDs identify managed objects. Noura can rebuild a deleted local index without losing durable workspace data. See the [workspace format](docs/workspace-format/v1.md) for details. +Use [GitHub issues](https://github.com/lobbystack/noura/issues) for questions, bug reports, and feature requests. Follow the [security policy](SECURITY.md) to report a vulnerability. -## Security +## License -Report vulnerabilities privately. See [SECURITY.md](SECURITY.md). +Noura uses the [MIT license](LICENSE). See [third-party notices](THIRD_PARTY_NOTICES.md) for dependency attributions. diff --git a/SECURITY.md b/SECURITY.md index 2764e92..4e66ba7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,24 +4,18 @@ Report suspected vulnerabilities privately. Do not open a public issue. ## Report a vulnerability -Use GitHub's private vulnerability reporting: -[Report a vulnerability](https://github.com/lobbystack/noura/security/advisories/new). +Use [GitHub’s private vulnerability reporting](https://github.com/lobbystack/noura/security/advisories/new). -If you cannot use GitHub, email `hello@lobbystack.com`. Include a description, -reproduction steps, and the affected version or commit. +If you cannot use GitHub, email `hello@lobbystack.com`. Include a description, reproduction steps, and the affected version or commit. -We acknowledge reports within a few business days. Allow time for a fix before -public disclosure. +We acknowledge reports within a few business days. Allow time for a fix before public disclosure. ## In scope -Noura is local-first and stores durable workspace state in ordinary files. Review -these areas: +Noura is local-first and stores durable workspace state in ordinary files. Review these areas: -- Parsing, validation, normalization, and deterministic serialization of - workspace files -- Path handling across the Rust/TypeScript boundary: traversal, absolute paths, - symlink escapes, and non-UTF-8 paths +- Parsing, validation, normalization, and deterministic serialization of workspace files +- Path handling across the Rust/TypeScript boundary: traversal, absolute paths, symlink escapes, and non-UTF-8 paths - Credential storage and the encrypted synchronization service - Plugin input, workspace frontmatter, MCP arguments, and AI provider output @@ -37,8 +31,7 @@ Send these details: These findings are out of scope: -- A malicious local user with access to an unlocked device and its credential - store +- A malicious local user with access to an unlocked device and its credential store - Denial of service through resource exhaustion on a single-user local instance Test only infrastructure you own or have permission to test. diff --git a/apps/server/OPERATIONS.md b/apps/server/OPERATIONS.md index 211adf8..df145cf 100644 --- a/apps/server/OPERATIONS.md +++ b/apps/server/OPERATIONS.md @@ -1,41 +1,38 @@ # Operating the development server -The Docker image contains the Bun service, migrations, account pages, public -viewer, and a generated dependency-notice file. It runs as the unprivileged -`bun` user. Put HTTPS termination in front of it and configure `PUBLIC_ORIGIN` -to the exact external origin. Allowlisted email, mail delivery (`SMTP_URL` or -`RESEND_API_KEY`), and passkey origin configuration must agree with that origin. -Set `TRUSTED_IP_HEADER` to the header your reverse proxy overwrites with the -client address; authentication rate limiting keys on it. +Deploy, back up, and validate the Noura sync service with this runbook. The service remains an experimental collaboration implementation. Do not treat a configured GitHub Actions workflow or a local probe as release approval. Developers should use the [server README](README.md) for local setup and API contracts. + +## Prepare a deployment + +The image runs as the unprivileged `bun` user. Run it behind HTTPS termination and set `PUBLIC_ORIGIN` to that exact external origin. Passkey configuration, allowlisted email, and mail delivery must use the same origin. The supplied Compose configuration binds the service to `127.0.0.1:1900`; run Caddy or another TLS proxy on the host. + +Set these values in an operator secret store: + +- `DATABASE_URL` +- `AUTH_SECRET`, with at least 32 random characters +- `MAIL_FROM` +- One mail delivery setting: `SMTP_URL` or `RESEND_API_KEY` +- `POSTGRES_PASSWORD` when using Compose + +Set `ALLOWED_EMAILS` for every account that may sign in. An empty value admits no accounts. Set `TRUSTED_IP_HEADER` only to a header that the reverse proxy overwrites with the client address. Authentication rate limiting uses that header when configured. + +Keep `AUTH_SECRET`, mail credentials, and database credentials out of the repository. PostgreSQL stores session hashes, encrypted workspace operations, and account and authorization metadata. Restrict database access and protect backups. The service does not hold workspace content keys, so a database restore cannot recover lost device keys or recovery material. ## Startup and health -Run `bun migrate.js` from the image before starting `bun main.js`. The supplied -Compose file separates migration from the service and waits for PostgreSQL. -`/health` checks process liveness; `/ready` also checks database/schema readiness. -Neither endpoint establishes that mail delivery or passkey registration works. +Run migrations before starting the service. The supplied Compose file runs `bun migrate.js` after PostgreSQL passes its health check, then starts `bun main.js`. + +`/health` checks process liveness. `/ready` also checks database and schema readiness. Neither endpoint checks mail delivery or passkey registration. -Keep `AUTH_SECRET`, mail delivery credentials, and database credentials in the -operator's -secret store. The database stores session hashes and encrypted workspace -operations. It still contains account and authorization metadata, so restrict -database access and protect backups. The server never possesses workspace -content keys; losing users' device keys and recovery material cannot be repaired -by a database restore. +The server accepts a non-loopback `PUBLIC_ORIGIN` only over HTTPS. Its optional S3 endpoint also requires HTTPS except on loopback. The repository does not provision a domain or a managed production instance. ## Backup and restore -The implementation stores encrypted operations, public snapshots, key envelopes, -policies, blob metadata, and authentication records in PostgreSQL. Attachment -ciphertext lives in `BLOB_ROOT`; completed objects may also live in configured -S3-compatible storage. A backup must capture PostgreSQL and local staging at one -offline boundary and preserve every referenced S3 object. Preserve the matching -server image and operator configuration separately, including `AUTH_SECRET`. Do -not commit backups or secrets to Git. +Capture the database and local blob storage at one offline boundary. Stop every server writer, wait for requests and storage finalization to finish, then capture PostgreSQL and `BLOB_ROOT` together. Do not combine independently timed database and blob backups while writes are active. -Use PostgreSQL service definitions and a protected password file instead of -placing credentials directly in commands. With `noura` and `noura-restore` -service definitions configured by the operator: +PostgreSQL contains encrypted operations, public snapshots, key envelopes, policies, blob metadata, and authentication records. `BLOB_ROOT` contains attachment ciphertext and defaults to `./data/blobs`; Compose mounts it at `/data/blobs`. Preserve referenced S3 objects and the bucket configuration when S3 storage is enabled. Keep the matching server image, operator configuration, and authentication secrets in a separate protected backup. + +Use PostgreSQL service definitions and a protected password file instead of credentials in commands. With `noura` and `noura-restore` service definitions: ```sh umask 077 @@ -43,58 +40,71 @@ pg_dump --dbname=service=noura --format=custom --no-owner --no-acl --file=noura. pg_restore --exit-on-error --no-owner --no-acl --dbname=service=noura-restore noura.dump ``` -Restore into an empty, isolated database, never over the running service. Start -the matching image against it with outbound email disabled at the network -boundary. Compare table counts and deterministic row hashes with the backup's -recorded inventory. Then exercise readiness, a prearranged test account, an -authorized encrypted pull, and a revoked token/link. Only switch production -traffic after those checks succeed. Keep the old database isolated until the -operator's rollback window closes. - -A local PostgreSQL 16 rehearsal on 2026-09-05 restored the complete integration -test database into a new temporary database and compared every public table's -row count and sorted JSON row hash. It passed and the temporary database was -removed. This verifies the current database-only backup mechanics; it is not a -production disaster-recovery rehearsal, offsite retention policy, or measured -recovery-time guarantee. +Restore into an empty, isolated database, never over the running service. Start the matching image with outbound email blocked at the network boundary. Compare table counts and deterministic row hashes with the backup inventory. Then check readiness, a prearranged test account, an authorized encrypted pull, and a revoked token or public link. Switch production traffic only after these checks succeed. Keep the old database isolated until the rollback window closes. + +## Attachment storage and retention + +Keep `BLOB_ROOT` on durable storage. PostgreSQL holds quota reservations, TUS metadata, and completion records; attachment bytes remain separate ciphertext files. There is no storage garbage collection yet, so monitor database and blob growth. + +Set `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY` to store completed ciphertext with Bun’s S3 client. `S3_REGION` defaults to `us-east-1`. Use the optional `S3_ENDPOINT` for a custom endpoint; HTTPS is required outside loopback. + +Local staging remains durable after completion, so back up the bucket and local staging with PostgreSQL. Validate provider versioning, outage behavior, retention, bucket policies, and recovery time for the selected storage service. ## Limits and failure behavior -There is no history pruning or snapshot compaction yet. Monitor database growth. -The default workspace ciphertext quota is 1 GiB. Requests are limited to 2 MiB, -individual ciphertext envelopes to 1 MiB, and resumable attachment ciphertext to -1 GiB. Rate limits apply per account; this is not a substitute for perimeter -connection limits or a measured capacity plan. +Monitor capacity against these current limits: + +- 1 GiB ciphertext quota per workspace +- 2 MiB encoded request body +- 1 MiB ciphertext per operation +- 1 GiB resumable attachment ciphertext +- 120 requests per account per minute + +No history pruning or snapshot compaction runs yet. Account rate limits do not replace perimeter connection limits or a measured capacity plan. + +Native clients retain ordered encrypted outboxes during outages. Pull cursors advance only after canonical file application or durable conflict preservation. Missing content keys and untrusted senders stop a pull without advancing its cursor. Local files remain usable while the service is unavailable. + +Device revocation blocks future server access. Removing access requires a new object-key epoch before later updates; clients also need the matching rotation and approval workflows. Revocation cannot erase plaintext or keys that a recipient obtained earlier. Public-link revocation blocks later server fetches, but recipients can retain decrypted snapshots. + +## File-relay load probe -For a repeatable local transport probe, run from the repository root: +Run this transport probe from the repository root against a disposable database: ```sh NOURA_TEST_DATABASE_URL=postgres://user@localhost/noura_test bun apps/server/scripts/load-probe.ts ``` -It creates unique test fixtures and measures one signed opaque operation delivered -to 100 simultaneous HTTP readers. A local PostgreSQL 16 run on 2026-09-05 measured -42 ms p50, 54 ms p95, and 56 ms maximum from push start through response parsing, -with zero remaining subscriptions. These are local transport measurements, not -WAN performance, a soak test, or the planned 20-editor collaboration acceptance -test. The probe's payload is synthetic framing data; native integration tests -separately exercise authenticated encryption and file application. - -Native clients retain ordered encrypted outboxes through outages. Pull cursors -advance after canonical file application or durable conflict preservation. -Missing content keys and untrusted senders stop a pull without advancing its -cursor. Local files remain usable when the service is unavailable. - -Device revocation prevents future server access. Removing access requires a new -object-key epoch before further updates; clients still need the corresponding -rotation and approval workflows. Revocation cannot erase plaintext or keys a -recipient previously obtained. Public-link revocation prevents further server -fetches; a recipient can retain an already decrypted snapshot. - -## Release gates still open - -Invitation and permission-management UI, recovery re-invitation and rotation, -collaborative text editing and presence, compaction, attachment garbage collection, -and desktop public publishing remain unfinished. Sustained soak measurements, -multi-platform desktop UI runs, real SMTP/passkey deployment checks, and external -security review are also outstanding. This image is a development build. +The probe creates unique fixtures, opens 100 simultaneous HTTP pull clients, and submits one signed opaque operation from each of 20 concurrent writers. Each client must receive all 20 operations once. The probe fails when p95 delivery is at least 1,000 ms or subscriptions remain after cleanup. + +## Realtime collaboration load and soak probe + +Run the realtime probe against a disposable PostgreSQL database: + +```sh +NOURA_TEST_DATABASE_URL=postgres://user@localhost/noura_test bun run --cwd apps/server test:collaboration-load +NOURA_TEST_DATABASE_URL=postgres://user@localhost/noura_test NOURA_SOAK_SECONDS=3600 bun run --cwd apps/server test:collaboration-load +``` + +The default run connects 100 authenticated WebSockets. Twenty writers each submit one version-two text operation with 100 ms simulated round-trip time (RTT). Every client must retrieve every operation through the authoritative HTTP pull. The default p95 budget is below 1,000 ms. The regular server workflow sets `NOURA_LOAD_P95_BUDGET_MS=3000` for its shared runner; its configuration does not establish a passing CI outcome. + +The probe fails for p95 at or above the configured budget, duplicate or missing delivery, incomplete fanout, subscription leakage, or RSS growth above 256 MiB after connection warmup. A recovery pull runs after five seconds for missed notifications. The soak run repeats the burst once per second for one hour while retaining a fixed-size latency histogram. + +Bounded overrides include `NOURA_LOAD_CLIENTS`, `NOURA_LOAD_WRITERS`, `NOURA_LOAD_RTT_MS`, `NOURA_LOAD_INTERVAL_MS`, `NOURA_MAX_RSS_GROWTH_MIB`, and `NOURA_LOAD_P95_BUDGET_MS`. Set `NOURA_LOAD_FORCE_RECONNECT_BATCH=1` to close one socket after the first committed burst and verify an authoritative pull before reconnect. Set `NOURA_LOAD_FORCE_RECOVERY_BATCH=1` to suppress one client's first-batch notification and verify the five-second recovery pull. The regular server workflow configures both faults. The scheduled soak workflow configures 100 clients, 20 writers, 100 ms RTT, and a one-hour duration; configuration is not execution evidence. + +These probes exercise WebSocket invalidation and signed operation pulls. They do not replace multi-process native CRDT application, cross-platform desktop acceptance, or the 20-editor latency measurement required for release. + +## Offline restore rehearsal + +Run `bun run --cwd apps/server test:restore` only with a disposable `NOURA_TEST_DATABASE_URL` that permits `CREATE DATABASE`. The rehearsal creates two unique databases and never restores over an existing database. Install PostgreSQL 16 `pg_dump` and `pg_restore` on `PATH`, or set `NOURA_TEST_PG_BIN` to their directory. + +The rehearsal waits for synthetic requests to finish, closes its only writer, dumps PostgreSQL, and copies the local blob directory. It restores both to fresh locations and verifies sessions, signed operations, cursors, quotas, complete ciphertext, a partial-upload continuation, staged and committed transitions, checkpoints, generations, and blob manifests. It removes the original blob directory before verification, then removes temporary databases and files. + +Set `NOURA_TEST_S3_ENDPOINT`, `NOURA_TEST_S3_BUCKET`, `NOURA_TEST_S3_ACCESS_KEY`, and `NOURA_TEST_S3_SECRET_KEY` to include an S3-compatible test server. This mode backs up a completed synthetic object, deletes it from the test bucket, restores it, removes its completed local cache, and checks the read. It uses unique object keys and removes only its own objects. It does not create a bucket or test provider versioning, regional outages, bucket policies, or production recovery time. + +The repository configures this rehearsal in the regular server workflow. Verify the workflow's current GitHub result before relying on it as CI evidence. + +## Public service scope and security caveats + +This service stores and routes signed encrypted records. Native clients retain content keys and canonical workspace files. The service remains experimental, and standard startup disables collaboration capabilities. Do not represent it as a production Noura collaboration service. + +Collaboration notifications and transient presence do not replace durable HTTP pull and acknowledgment. Follow the protocol contracts and validation procedures before enabling experimental routes. diff --git a/apps/server/README.md b/apps/server/README.md index ec90aec..8b1c5ed 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -1,57 +1,16 @@ # Noura sync service (development) -An original Bun/Hono/PostgreSQL service for signed encrypted operations. This -is the server foundation and an experimental collaboration implementation, -**not a production Noura collaboration release**. Production startup deliberately -withholds collaboration capabilities until the remaining release gates below -pass. - -Once enabled, rollout admission and protocol continuity are separate. Setting -`collaborationRollout: false` while retaining `checkpointTransitions: true` -blocks new workspace capabilities while continuing to serve existing checkpoints, -transitions, live-text generations, and capability-bound object activations. - -## Implemented - -- Better Auth passkeys and emailed magic links, with an operator email allowlist. -- Account-authenticated, single-use device challenges and Ed25519 possession proofs. -- Seven-day opaque device sessions, stored as hashes; renewal invalidates the - previous session, and revoked device IDs cannot enroll again. -- Workspace and object creation, device listing/revocation, and workspace listing. -- Atomic signed operation batches, durable retry IDs, increasing decimal-string - cursors, payload quotas, per-account rate limits, and object-filtered downloads. -- Bounded notification-driven pulls using one shared PostgreSQL listener, with - authentication and access rechecked after waiting. -- Real PostgreSQL integration tests, including account login with captured mail. -- Browser-approved native device sign-in with OS-held credentials, a desktop - Settings account panel, and passkey/magic-link browser account pages. -- Signed access-policy revisions, member/object permissions, device-wrapped - content keys, epoch rotation enforcement, chained policy-history verification, - per-revision writer authorization, and permission-filtered key retrieval. -- Revocable encrypted public snapshots and a read-only browser viewer. Decryption - keys and the pinned signer travel in the URL fragment, never the HTTP request. -- Native AES-GCM/Ed25519 file transport, age key wrapping, encrypted key-envelope - persistence, a file-backed crash-replay journal, and conflict preservation. -- Native background capture and restart, pause/resume, owner replica joining, - verified device approval, encrypted key backup before upload, and explicit - local/remote resolution of same-path conflicts without overwriting later edits. -- Owner-signed workspace capabilities, automatic invitation activation, atomic - checkpoint/key rotations, isolated fresh-recipient history, and resumable - transition-bound checkpoint blobs. -- Writer-authorized object activation plus native create, update, move, delete, - external-change, MCP, managed metadata/body, and plain-text collaboration paths. -- Yjs/Yrs text generations with acknowledged baselines, durable drafts, restart - recovery, generation rebase/review, and an isolated bounded native decoder worker. -- Authenticated realtime notifications and encrypted transient presence, with - durable HTTP pull/acknowledgment remaining authoritative. +Develop signed, encrypted sync operations with this Bun, Hono, and PostgreSQL service. It is an experimental collaboration implementation, not a production Noura collaboration release. Production startup does not enable collaboration capabilities. See [operating procedures](OPERATIONS.md) for deployment requirements, backups, and validation procedures. + +Once enabled, rollout admission and protocol continuity remain separate. Setting `collaborationRollout: false` with `checkpointTransitions: true` blocks new workspace capabilities while the service continues existing checkpoints, transitions, live-text generations, and capability-bound object activations. + +## Experimental service capabilities + +The experimental service supports approved-device authentication, signed encrypted operation exchange, signed access policies, device-wrapped content keys, and revocable encrypted public snapshots. Native clients retain canonical file application, durable journals, conflict preservation, and local key material. Experimental invitation, checkpoint, text-generation, notification, and presence features retain durable HTTP pull and acknowledgment as their authoritative transport. ## Run locally -From this directory, copy `.env.example` to `.env`. Set the database connection, -a random `AUTH_SECRET`, account email delivery, sender address, and -`ALLOWED_EMAILS`. Choose one delivery path: `SMTP_URL` or `RESEND_API_KEY`. -An empty allowlist admits nobody. No messages are printed to logs in lieu of a -mail transport. +From this directory, copy `.env.example` to `.env`. Set the database connection, a random `AUTH_SECRET`, account email delivery, sender address, and `ALLOWED_EMAILS`. Choose one delivery path: `SMTP_URL` or `RESEND_API_KEY`. An empty allowlist admits nobody. No messages are printed to logs in lieu of a mail transport. ```sh bun install @@ -59,42 +18,31 @@ bun run migrate bun run start ``` -To include the account pages and public viewer, run `bun run build:release`, then -`bun dist/main.js`. Docker packages this combined release automatically. +To include the account pages and public viewer, run `bun run build:release`, then `bun dist/main.js`. Docker packages this combined release automatically. -Use `/health` for liveness and `/ready` for database/schema readiness. Production -requires an HTTPS `PUBLIC_ORIGIN`; plain HTTP is allowed only for loopback. +Use `/health` for liveness and `/ready` for database/schema readiness. Production requires an HTTPS `PUBLIC_ORIGIN`; plain HTTP is allowed only for loopback. -`docker compose --env-file .env -f compose.yaml up --build` starts PostgreSQL, -runs migrations, then starts the service. Also set `POSTGRES_PASSWORD` for -Compose, using a URL-safe random value. The application listens only on host -loopback; use the example Caddy configuration for external TLS termination. -Neither a domain nor a managed production instance is provisioned by this code. +`docker compose --env-file .env -f compose.yaml up --build` starts PostgreSQL, runs migrations, then starts the service. Also set `POSTGRES_PASSWORD` for Compose, using a URL-safe random value. The application listens only on host loopback; use the example Caddy configuration for external TLS termination. Neither a domain nor a managed production instance is provisioned by this code. ## API -Account routes live under `/api/auth/*` and use Better Auth cookies. Create a -device challenge with `POST /v1/device-challenges` using the authenticated cookie -and same-origin `Origin` header. The response contains `challenge`, `accountId`, -and a five-minute lifetime. +Account routes live under `/api/auth/*` and use Better Auth cookies. Create a device challenge with `POST /v1/device-challenges` using the authenticated cookie and same-origin `Origin` header. The response contains `challenge`, `accountId`, and a five-minute lifetime. -Sign the UTF-8 JSON tuple below with the device's Ed25519 private key: +Sign the canonical UTF-8 bytes of this JSON tuple with the device's Ed25519 private key. Serialize the array with `JSON.stringify` and no added whitespace or fields: ```text -["noura.device.enroll", serverOrigin, accountId, deviceId, publicKeyBase64, challenge] +["noura.device.enroll",serverOrigin,accountId,deviceId,publicKeyBase64,challenge] ``` -Send `{deviceId, publicKey, challenge, proof}` to `POST /v1/devices` using the -same cookie and origin. Public keys are 32 raw bytes in canonical base64; proof -signatures are 64 bytes in canonical base64. The response supplies a bearer -token. A fresh challenge/proof renews an existing non-revoked device session. -This authenticates a device; it does **not** distribute content keys. +Send `{deviceId, publicKey, challenge, proof}` to `POST /v1/devices` using the same cookie and origin. Public keys are 32 raw bytes in canonical base64; proof signatures are 64 bytes in canonical base64. The response supplies a bearer token. A fresh challenge/proof renews an existing non-revoked device session. This authenticates a device; it does **not** distribute content keys. + +Native clients use Better Auth's device-authorization flow and include `encryptionRecipient`. Their v2 proof signs these canonical UTF-8 bytes: + +```text +["noura.device.enroll.v2",serverOrigin,accountId,deviceId,publicKeyBase64,encryptionRecipient,challenge] +``` -Native clients use Better Auth's device-authorization flow and enroll an age -recipient with `{encryptionRecipient}` included. Their v2 proof signs -`["noura.device.enroll.v2",serverOrigin,accountId,deviceId,publicKeyBase64,encryptionRecipient,challenge]`. -The temporary account bearer session is exchanged for a Noura device session -inside native code and then signed out. +The temporary account bearer session is exchanged for a Noura device session inside native code and then signed out. All remaining `/v1` routes require `Authorization: Bearer `: @@ -103,6 +51,8 @@ All remaining `/v1` routes require `Authorization: Bearer `: | GET | `/v1/devices` | List your devices | | DELETE | `/v1/devices/:id` | Revoke your device and its sessions | | GET/POST | `/v1/workspaces` | List/create (`{id}`) workspaces | +| POST/GET | `/v1/workspaces/:id/invitations` | Create/list workspace invitations | +| DELETE | `/v1/workspaces/:id/invitations/:id` | Revoke a pending workspace invitation | | POST | `/v1/workspaces/:id/objects` | Create an opaque object (`{id}`) | | POST | `/v1/workspaces/:id/operations` | Upload `{operations: [...]}` | | GET | `/v1/workspaces/:id/operations?after=0` | Fetch an authorized page | @@ -113,223 +63,85 @@ All remaining `/v1` routes require `Authorization: Bearer `: | POST | `/v1/workspaces/:id/links` | Create an encrypted public snapshot | | PUT/DELETE | `/v1/workspaces/:id/links/:link` | Update/revoke a public snapshot | -`GET /public/:token` returns an active encrypted public snapshot anonymously. -`/share/:token` is its browser viewer, `/account` handles sign-in, and -`/account/device` explicitly approves or denies desktop device codes. - -The native file and key formats are documented in -[`sync-v1.md`](../../docs/workspace-format/sync-v1.md). - -The operation contract lives in `packages/shared/src/sync.ts`. Sign the fixed -tuple defined in `src/protocol.ts`. Operation payloads are client-encrypted -AES-256-GCM ciphertext including the tag, with a fresh 12-byte nonce. The server -validates framing and signatures; it cannot prove a malicious client actually -encrypted its submitted bytes. Clients must validate/decrypt incoming envelopes. - -Limits: 100 operations per batch, 1 MiB ciphertext per operation, 2 MiB encoded -request body, 1 GiB ciphertext quota per workspace, and 120 requests per account -per minute. The first limit reached applies. No history is automatically pruned. -Matching retries return the original sequence; changed bytes under an existing -operation ID return a conflict. A failed operation rolls back the whole batch. - -An idle client can add `wait=25&accessRevision=` to its pull. -The server subscribes before reading, waits at most 25 seconds if caught up, and -then rechecks the session and permissions. Notifications carry only workspace -IDs; all returned operations still pass the normal authorization filter. At most -1000 concurrent waiters are retained per process. This long-poll path is an -authoritative fallback for the experimental native collaboration client; -WebSocket notifications only prompt durable pulls. +`GET /public/:token` returns an active encrypted public snapshot anonymously. `/share/:token` is its browser viewer, `/account` handles sign-in, and `/account/device` explicitly approves or denies desktop device codes. + +## Invitation and access semantics + +Workspace owners create invitations for the `admin`, `editor`, or `viewer` role. An invitation expires after seven days, and a workspace can have at most 100 active invitations. A link can be accepted by one account and can be revoked while pending. + +Accepting an invitation records the prospective account. It does not grant membership or distribute content keys. Membership begins only after the owner signs an access policy and wraps object keys for approved recipient devices. The native coordinator rotates each checkpoint-enrolled object when effective recipient devices change. + +The native file and key formats are documented in [`sync-v1.md`](../../docs/workspace-format/sync-v1.md). + +The operation contract lives in `packages/shared/src/sync.ts`. Sign the fixed tuple defined in `src/protocol.ts`: serialize the array with `JSON.stringify`, encode it as UTF-8, and sign those bytes. Omit `generation` and `kind` for version 1 operations. + +```text +["noura.sync.operation",version,workspaceId,objectId,deviceId,operationId,epoch,policyRevision,nonceBase64,ciphertextBase64,generation,kind] +``` + +Operation payloads are client-encrypted AES-256-GCM ciphertext including the tag, with a fresh 12-byte nonce. The server validates framing and signatures; it cannot prove a malicious client encrypted submitted bytes. Clients must validate and decrypt incoming envelopes. + +Limits: 100 operations per batch, 1 MiB ciphertext per operation, 2 MiB encoded request body, 1 GiB ciphertext quota per workspace, and 120 requests per account per minute. The first limit reached applies. No history is automatically pruned. Matching retries return the original sequence; changed bytes under an existing operation ID return a conflict. A failed operation rolls back the whole batch. + +An idle client can add `wait=25&accessRevision=` to its pull. The server subscribes before reading, waits at most 25 seconds if caught up, and then rechecks the session and permissions. Notifications carry only workspace IDs; all returned operations still pass the normal authorization filter. At most 1000 concurrent waiters are retained per process. This long-poll path is an authoritative fallback for the experimental native collaboration client; WebSocket notifications only prompt durable pulls. ## Verification +Run the focused TypeScript checks and protocol test from this directory: + ```sh bun run check bun run build bun test src/protocol.test.ts -# Set the dedicated test database URL and all three compiled native probe paths first. +``` + +Run native integration coverage only against a dedicated test database. The tests create unique fixtures and retain them for inspection; they never truncate existing tables. The command fails unless the database URL and every compiled native probe are present. Ordinary repository tests skip database scenarios without that URL; the server CI job requires them. + +Build the native probes from the repository root with `cargo build -p local-core --examples`. Set `NOURA_TEST_DATABASE_URL` and point `NOURA_NATIVE_PROBE`, `NOURA_NATIVE_SIGNIN_PROBE`, and `NOURA_NATIVE_KEYS_PROBE` to the absolute `target/debug/examples/sync_probe`, `signin_probe`, and `keys_probe` paths before running this command: + +```sh bun run test:integration ``` -For native integration coverage, first build `cargo build -p local-core --examples` -from the repository root. Set `NOURA_NATIVE_PROBE`, `NOURA_NATIVE_SIGNIN_PROBE`, -and `NOURA_NATIVE_KEYS_PROBE` to the absolute `target/debug/examples/sync_probe`, -`signin_probe`, and `keys_probe` paths. -These run real Rust clients against HTTP and PostgreSQL, including reconnection, -conflict preservation, browser approval, device revocation, signed access policies, -and native age key distribution that rejects unpinned signers. Coordinator probes -also exercise persisted pause/resume, stable moves, deletion, approved peer key -distribution, two-way edits, convergence after explicit conflict resolution, and -streaming encrypted attachment exchange through resumable uploads. They are not a -substitute for tests with three running desktop applications. - -`cargo run -p local-core --example relay_authorization_probe --locked` is a -separate malicious-relay regression. It gives a synthetic viewer the real object -key and its own approved signing identity, then serves a viewer-authored update -from a loopback relay. The gate passes only when the client rejects the update as -`sync_writer_not_authorized` and leaves the canonical file unchanged. - -Use a dedicated test database. The tests create uniquely named fixtures and -retain them for inspection; they never truncate existing tables. The integration -command fails if the database URL or any compiled native probe is absent. Ordinary repository tests skip -database scenarios without that variable; the server CI job requires them. - -To run the attachment and native recovery cases against an S3-compatible test -server, also set `NOURA_TEST_S3_ENDPOINT`, `NOURA_TEST_S3_BUCKET`, -`NOURA_TEST_S3_ACCESS_KEY`, and `NOURA_TEST_S3_SECRET_KEY`. These are separate from -production storage variables. The tests use unique workspace/object IDs. A local -MinIO run has verified resumed uploads, S3 range reads without a local cache, -native attachment exchange, and recovery. This does not certify a production -bucket's backup or retention configuration. +These run real Rust clients against HTTP and PostgreSQL, including reconnection, conflict preservation, browser approval, device revocation, signed access policies, and native age key distribution that rejects unpinned signers. Coordinator probes also exercise persisted pause/resume, stable moves, deletion, approved peer key distribution, two-way edits, convergence after explicit conflict resolution, and streaming encrypted attachment exchange through resumable uploads. They are not a substitute for tests with three running desktop applications. -## File-relay load probe +`cargo run -p local-core --example relay_authorization_probe --locked` is a separate malicious-relay regression. It gives a synthetic viewer the real object key and its own approved signing identity, then serves a viewer-authored update from a loopback relay. The gate passes only when the client rejects the update as `sync_writer_not_authorized` and leaves the canonical file unchanged. -With `NOURA_TEST_DATABASE_URL` set, run `bun scripts/load-probe.ts` for 100 -HTTP clients and 20 simultaneous writers. Every client must receive all 20 -signed operations once, with p95 delivery below one second and no remaining -subscriptions. A local macOS/PostgreSQL run delivered all 2,000 operations with -p50 42 ms, p95 54 ms, and maximum 56 ms, measured from each HTTP upload start. -This is one synthetic burst on loopback. It does not establish WAN performance, -CRDT convergence, collaborative editor latency, or sustained-load behavior. +To run the attachment and native recovery cases against an S3-compatible test server, also set `NOURA_TEST_S3_ENDPOINT`, `NOURA_TEST_S3_BUCKET`, `NOURA_TEST_S3_ACCESS_KEY`, and `NOURA_TEST_S3_SECRET_KEY`. These are separate from production storage variables. The tests use unique workspace/object IDs. Validate a production bucket's backup and retention configuration separately. -## Realtime collaboration load and soak probe +## File-relay load probe -With `NOURA_TEST_DATABASE_URL` set to a disposable PostgreSQL database, run: +Run the [file-relay probe procedure](OPERATIONS.md#file-relay-load-probe) against a disposable database. The runbook defines its client and writer counts, acceptance criteria, and scope limits. -```sh -bun run test:collaboration-load -NOURA_SOAK_SECONDS=3600 bun run test:collaboration-load -``` +## Realtime collaboration load and soak probe -The default run connects 100 authenticated WebSockets, submits one concurrent -version-two text operation from each of 20 writers, simulates 100 ms RTT, and -requires every client to retrieve every operation through the authoritative HTTP -pull. It fails at one-second p95, unexpected or duplicate delivery, incomplete -fanout, subscription leakage, or more than 256 MiB RSS growth after connection -warmup. A five-second authoritative recovery pull covers a notification that was -not processed and the final report exposes the recovery count. The soak form -repeats the burst once per second for one hour while keeping latency storage -fixed-size. `NOURA_LOAD_CLIENTS`, `NOURA_LOAD_WRITERS`, -`NOURA_LOAD_RTT_MS`, `NOURA_LOAD_INTERVAL_MS`, and -`NOURA_MAX_RSS_GROWTH_MIB` override the bounded defaults. Setting -`NOURA_LOAD_FORCE_RECONNECT_BATCH=1` closes one socket after the first committed -burst and verifies recovery through an authoritative pull before reconnect; the -ordinary server CI gate enables this fault. It also sets -`NOURA_LOAD_FORCE_RECOVERY_BATCH=1` to suppress one different client's first-batch -notification and prove the five-second recovery pull closes that gap. - -A local smoke run completed five batches and 10,000 deliveries with 100 clients, -20 writers, 100 ms simulated RTT, p95 202 ms, and 35.6 MiB RSS growth. This -exercises the real WebSocket invalidation and signed operation/pull path, but it -does not replace multi-process native CRDT application or cross-platform desktop -acceptance. - -The corrected one-hour local soak completed 3,596 batches and 7,192,000 exact -deliveries with the same 100 clients, 20 writers, and 100 ms simulated RTT. It -measured p50 172 ms, p95 204 ms, maximum 1.129 s, and 44.7 MiB RSS growth, with -zero reconnects, zero recovery pulls, and no leaked subscriptions. A separate -forced-fault run closed one socket and suppressed another client's notification; -both recovered through authoritative pulls (`reconnects: 1`, `recoveryPulls: 1`) -while p95 remained 211 ms. +Run the [collaboration probe procedure](OPERATIONS.md#realtime-collaboration-load-and-soak-probe) against a disposable database. It documents the p95 default and CI override, fault injections, soak configuration, and scope limits. ## Offline restore rehearsal -`bun run test:restore` creates two uniquely named disposable databases using -`NOURA_TEST_DATABASE_URL` (which must permit `CREATE DATABASE`). It needs -PostgreSQL 16 `pg_dump` and `pg_restore` on PATH, or their directory in -`NOURA_TEST_PG_BIN`. It never restores over an existing database. - -The drill waits for all synthetic requests to complete and closes the only -writer before dumping PostgreSQL and copying the local blob directory. It -restores both into fresh locations and verifies sessions, signed operations, -cursors, quotas, complete ciphertext, continuation of a partial upload, staged -and committed transitions, checkpoints, generations, and blob manifests. -The original blob directory is removed before verification. Temporary databases -and files are removed afterwards. This drill has passed locally and is included -in server CI; CI execution remains to be verified on GitHub. - -To include S3, set the four `NOURA_TEST_S3_*` variables described above. The -rehearsal also backs up its completed synthetic object, deletes it from the test -bucket, restores it, and removes its completed local cache before checking the -read. This mode passed against local MinIO. It uses unique object keys and -cleans up only its own objects; it does not recreate the bucket or test provider -versioning, regional outages, bucket policies, or production recovery times. - -An operator backup needs the same offline boundary: stop every server writer, -wait for requests and storage finalization to finish, then capture PostgreSQL and -the durable blob directory together. Keep server configuration and authentication -secrets in a separate protected backup. With S3 enabled, also preserve the -referenced immutable objects and bucket configuration; this local-storage drill -does not test all production S3 failure modes. Validate a restore in an isolated deployment -before replacing a live service. Never combine independently timed database and -blob backups while writes are active. - -## Remaining release requirements - -Existing-member permission-management UI, folder sharing, attachment conflict -resolution and storage garbage collection, snapshot compaction, desktop public -publishing, and production backup/restore rehearsal remain unfinished or outside -this collaboration milestone. Release still requires the full desktop flow on -macOS, Windows/UTM, and Linux; a 20-editor/100 ms RTT latency measurement; the -Windows worker capability confinement and crash-durability fault tests; and an -independent security review. Backend access and public-link APIs are executable, -but do not yet form a complete user-facing sharing workflow. - -Workspace invitation creation, browser acceptance, desktop fingerprint approval, -signed membership activation, and pending-invitation revocation are implemented. -Acceptance does not grant membership until the owner signs the policy and wraps -the object keys for approved recipients. The native coordinator rotates every -checkpoint-enrolled object when effective recipient devices change. A user-facing -flow for arbitrary existing-member role changes/removal and folder sharing is -still absent. +Run the [restore rehearsal](OPERATIONS.md#offline-restore-rehearsal) before changing backup or storage operations. The runbook specifies its offline boundary, test database permissions, S3 mode, and exclusions. ## Attachment storage -`BLOB_ROOT` holds durable ciphertext uploads and defaults to `./data/blobs`. -The Docker image uses `/data/blobs`, backed by the Compose `blobs` volume. -Do not place this directory on ephemeral storage. PostgreSQL stores quota -reservations, TUS metadata, and completion records; attachment bytes remain -separate files. Backups must include both PostgreSQL and this directory. - -Setting `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY` enables -completed ciphertext storage through Bun's S3 client. Optional `S3_ENDPOINT` -must use HTTPS except on loopback; `S3_REGION` defaults to `us-east-1`. -Compose forwards these settings. Local staging remains durable and is currently -retained after completion. A deployment using S3 must back up its bucket as well -as PostgreSQL and local staging. S3 transfer and restore have passed against a -local MinIO endpoint; provider-specific versioning, outage, and retention behavior -remains an operator deployment gate. - -Workspace members can join as owner, admin, editor, or viewer. The native client -verifies the signed membership policy before choosing its transfer mode. Viewer -replicas pull remote updates and retain local edits without adding them to the -upload queue. Existing queued edits survive a downgrade but are not uploaded. - -`PUT /v1/keys/share` accepts a bounded array of signed recipient envelopes from -an existing object writer. Recipients must already have object access and be -active devices. This endpoint cannot grant access, change a role, rotate an epoch, -or replace an existing recipient ciphertext. It lets editors supply keys for -newly created objects without permission-management rights. Native clients still -require explicit recipient fingerprint approval before calling it. Signed access -policies remain required for membership, grants, and epoch changes. - -Desktop recovery kits are workspace-bound JSON backups containing an age identity, -public trust pins, and encrypted key envelopes. Export/import use native file -dialogs; secret bytes never enter IPC. Import into the matching joined workspace -restores keys to the newly signed-in device, retrieves authorized later envelopes, -and downloads content while leaving synchronization paused. Tests recover files -and attachments with a clean credential store that lacks the original device's -signing key, and preserve external edits on retry. Re-invitation after membership -revocation and the key-rotation UI remain release work. +See [attachment storage and retention](OPERATIONS.md#attachment-storage-and-retention) for durable storage locations, S3 requirements, and backup scope. + +## Member roles and replica behavior + +The native client verifies the signed membership policy before selecting a transfer mode. Workspace members can join as owner, admin, editor, or viewer. Viewer replicas pull remote updates and retain local edits without adding them to the upload queue. Existing queued edits survive a downgrade but are not uploaded. + +## Recipient key distribution + +`PUT /v1/keys/share` accepts a bounded array of signed recipient envelopes from an existing object writer. Recipients must already have object access and be active devices. This endpoint cannot grant access, change a role, rotate an epoch, or replace an existing recipient ciphertext. It lets editors supply keys for newly created objects without permission-management rights. Native clients still require explicit recipient fingerprint approval before calling it. Signed access policies remain required for membership, grants, and epoch changes. + +## Desktop recovery kits + +Recovery kits let a newly signed-in device restore workspace keys without sending secret bytes through IPC. + +Desktop recovery kits are workspace-bound JSON backups containing an age identity, public trust pins, and encrypted key envelopes. Export/import use native file dialogs; secret bytes never enter IPC. Import into the matching joined workspace restores keys to the newly signed-in device, retrieves authorized later envelopes, and downloads content while leaving synchronization paused. Tests recover files and attachments with a clean credential store that lacks the original device's signing key, and preserve external edits on retry. Re-invitation after membership revocation and the key-rotation UI remain release work. ## Desktop login during development -Desktop debug builds use `http://localhost:1900` for Noura Sync. Run this -server with the account web assets available, apply its migrations, and configure -`ALLOWED_EMAILS` and local email delivery as described above. **Log in** opens the -system browser; **Sign up** opens account creation with the same pending device -request. After authentication, compare the device code and approve the desktop. -The website opens Noura again, where **Enable sync** authorizes synchronization -of the current workspace. Logging in alone does not upload workspace files. +Desktop debug builds use `http://localhost:1900` for Noura Sync. Run this server with the account web assets available, apply its migrations, and configure `ALLOWED_EMAILS` and local email delivery as described above. **Log in** opens the system browser; **Sign up** opens account creation with the same pending device request. After authentication, compare the device code and approve the desktop. The website opens Noura again, where **Enable sync** authorizes synchronization of the current workspace. Logging in alone does not upload workspace files. Set `NOURA_SYNC_ORIGIN` when building the desktop to use another service: @@ -337,13 +149,6 @@ Set `NOURA_SYNC_ORIGIN` when building the desktop to use another service: NOURA_SYNC_ORIGIN=http://localhost:1909 bun run tauri build --debug --bundles app ``` -Release builds have no implicit service address. Supply `NOURA_SYNC_ORIGIN` for -managed sync; custom servers remain available under **Use a self-hosted server…**. -HTTPS is required except for loopback development addresses. +Release builds have no implicit service address. Supply `NOURA_SYNC_ORIGIN` for managed sync; custom servers remain available under **Use a self-hosted server…**. HTTPS is required except for loopback development addresses. -The `noura://auth/complete` return link only focuses Noura and refreshes its native -sign-in flow. It carries no credentials and cannot authorize sync. If the browser -blocks automatic opening, use **Return to Noura**. On macOS, test with the generated -`.app` bundle: URL-scheme registration is part of the bundle, not plain `tauri dev`. -Closing settings does not cancel login; cancelling explicitly, expiration, or -quitting the application ends a pending attempt. +The `noura://auth/complete` return link only focuses Noura and refreshes its native sign-in flow. It carries no credentials and cannot authorize sync. If the browser blocks automatic opening, use **Return to Noura**. On macOS, test with the generated `.app` bundle: URL-scheme registration is part of the bundle, not plain `tauri dev`. Closing settings does not cancel login; cancelling explicitly, expiration, or quitting the application ends a pending attempt. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8d9618c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Noura documentation + +Use these guides to run Noura, understand its file-backed workspace, and contribute to its plugins and services. Start with the guide for your task: + +- **Run Noura**: follow the [desktop setup instructions](../README.md#try-noura) +- **Contribute code**: read the [contributor conventions](../AGENTS.md) and [desktop verification guide](testing/local-alpha-acceptance.md) +- **Work with plugins**: learn the [activation lifecycle and capability contracts](architecture/plugin-runtime.md) +- **Understand workspace files**: read the [workspace format](workspace-format/v1.md) and [local-core architecture](architecture/local-core.md) +- **Understand AI access**: read about [provider consent, tool approval, and chat persistence](architecture/ai-runtime.md) +- **Run the sync service**: follow [server setup](../apps/server/README.md), then the [operations guide](../apps/server/OPERATIONS.md) + +## Workspace format references + +Treat these specifications as the public contract for durable workspace data. Rust owns parsing and deterministic serialization; TypeScript validators use the same [conformance fixtures](workspace-format/fixtures/): + +- [Workspace format v1](workspace-format/v1.md): manifests, managed Markdown, stable identifiers, and chats +- [Encrypted sync protocol](workspace-format/sync-v1.md): signed operations, key envelopes, file changes, and attachments +- [Collaboration protocol](workspace-format/collaboration.md): text generations, updates, and recovery +- [Encrypted checkpoints](workspace-format/checkpoints.md): snapshots and access transitions + +## Architecture references + +Use these documents to understand component responsibilities and the boundaries a change must preserve: + +- [Local core](architecture/local-core.md) +- [Plugin runtime](architecture/plugin-runtime.md) +- [AI runtime](architecture/ai-runtime.md) +- [Encrypted synchronization service](architecture/sync-server.md) + +## Security and provenance + +Follow the [security policy](../SECURITY.md) to report a vulnerability. Review the relevant boundaries and dependency requirements before changing them: + +- [Threat model](security/threat-model.md) +- [Dependency audit](security/dependency-audit.md) +- [Source provenance requirements](provenance/README.md) +- [Third-party notices](../THIRD_PARTY_NOTICES.md) diff --git a/docs/architecture/ai-runtime-spike.md b/docs/architecture/ai-runtime-spike.md deleted file mode 100644 index 0975f54..0000000 --- a/docs/architecture/ai-runtime-spike.md +++ /dev/null @@ -1,23 +0,0 @@ -# Pi runtime spike - -This temporary Phase 0 slice proves that Pi Agent Core can run inside Noura's WebView without access to a provider credential or direct network transport. - -## Flow - -1. The AI route creates a Pi `Agent` with a custom stream function. -2. The stream function creates a UUID operation ID and invokes a native Tauri Channel transport. -3. The native host sends ordered synthetic `delta`, `done`, or `aborted` frames. -4. The adapter rejects a mismatched operation ID or sequence and converts terminal native frames into Pi assistant messages. -5. Pi cancellation calls the native cancellation command. The native operation sends an `aborted` frame and removes its operation registry entry. - -The probe sends neither workspace content nor provider credentials. It uses no provider request, filesystem tool, shell tool, or persistent agent state. - -## Verification - -Run `bun run verify:pi-runtime` to build a standalone browser bundle and reject Node-only runtime imports. With Pi `0.84.4`, the standalone bundle is 285.97 kB uncompressed and 62.85 kB gzip. - -The application production build loads the probe only from the AI route. The current route chunk contribution is approximately 106.48 kB gzip; reassess this cost before making the production agent surface generally available. - -## Mobile status - -The iOS and Android configuration overrides use `org.noura.app`. The generated iOS project is available locally, but a full iOS compile requires Xcode and the iPhoneOS SDK. Android generation and compilation require an installed Android SDK and NDK. Those toolchains are not currently available, so mobile WebView execution remains a release gate rather than a verified claim. diff --git a/docs/architecture/ai-runtime.md b/docs/architecture/ai-runtime.md index 85a50a5..9c8e3e6 100644 --- a/docs/architecture/ai-runtime.md +++ b/docs/architecture/ai-runtime.md @@ -51,10 +51,13 @@ All mutation requests use expected revisions. External edits win and surface a c ## Web access -Web search and URL fetch are later, separate native capabilities. They accept only validated HTTPS destinations, reject private/local addresses and unsafe redirects, enforce size/type/timeout limits, return text-oriented extracted content with source attribution, and have their own one-time disclosure. They never use browser cookies, browser history, local URL schemes, or credential discovery. +Web search and URL fetch have a separate native authorization boundary. Access requires its own one-time disclosure and consent, in addition to AI provider consent. The native service applies these restrictions: -Exa is not enabled automatically. A September 2026 review found that its API requires an account and API key, grants use only to authorized users under its Terms, is subject to documented usage limits, and has usage-based pricing. Zero-configuration redistribution is therefore not established. Noura's native web service fails closed unless a compliant, explicitly configured provider and the separate web disclosure are present. +- Accept only validated HTTPS destinations +- Reject private or local addresses and unsafe redirects +- Enforce size, type, and timeout limits +- Return text-oriented content with source attribution -## Superseded sidecar direction +Web access never uses browser cookies, browser history, local URL schemes, or credential discovery. -The former sidecar approach is superseded for the Noura AI runtime. Pi is bundled as a browser-safe first-party dependency in the WebView; native provider access is reached only through typed IPC. The Phase 0 compatibility record remains in [Pi runtime spike](./ai-runtime-spike.md). +Noura’s native web service fails closed until you configure a compliant provider and grant separate web-access consent. Provider configuration does not authorize a request on its own. diff --git a/docs/architecture/plugin-runtime.md b/docs/architecture/plugin-runtime.md index 652ba94..9e7a8cb 100644 --- a/docs/architecture/plugin-runtime.md +++ b/docs/architecture/plugin-runtime.md @@ -1,6 +1,6 @@ # Plugin runtime architecture -The plugin platform layers the workspace on one kernel: `packages/plugin-sdk` defines capability contracts, `packages/workspace` adapts them to the typed client, and `apps/app` constructs the runtime inside the webview. First-party domains (`plugins/notes`, `tasks`, `calendar`, `projects`, `folders`) dogfood the same public surface a future ecosystem plugin receives. +Noura’s first-party notes, tasks, calendar, projects, folders, and AI plugins use shared capability contracts. `packages/plugin-sdk` defines those contracts, `packages/workspace` adapts them to the typed client, and `apps/app` constructs the runtime in the WebView: ```text apps/app state @@ -14,14 +14,28 @@ PluginContext ── capability-gated facade over NouraClient ## Activation lifecycle -- Similar to any other file, `.noura/workspace.yaml` is authoritative. The runtime never caches plugin state; `syncWithManifest` re-reads the manifest and reconciles the active set. -- A plugin activates only when its manifest id appears in `enabled_plugins`. Unknown ids in the manifest are ignored, so a workspace carrying ecosystem plugins opens on older builds. -- Plugin definitions may implement `deactivate(context)`. The host passes the same context instance the plugin saw during activation, so handlers and disposers captured then stay valid. Deactivation runs commands unregistering, AI tool/context removal, and event unsubscription through the disposers the capabilities already return. -- The desktop app re-syncs on startup, after `workspace:ready`, after `workspace:manifest-updated`, and on `file:changed`. The engine observes external edits to `.noura/workspace.yaml` through its watcher: the atomic-write journal suppresses the engine's own writes, and a genuine external change adopts the file into the engine snapshot and emits `workspace:manifest-updated` with source `external`, so plugin state follows the file even while the app is open. The Settings panel writes `enabled_plugins` through the engine's `manifest_update` (atomic, revision-checked against `updated`), so live deactivation and activation run through the same durable file mutation. Closing a workspace deactivates every plugin instead of leaving commands registered against a workspace that is gone. Navigation and route guards derive from the reconciled state: modules that are off genuinely simplify the workspace. +`syncWithManifest` treats `.noura/workspace.yaml` as authoritative and reconciles the active host set in this order: + +1. Read `enabled_plugins` from the durable manifest. Preserve unknown IDs in the manifest without activating them. +2. Deactivate active plugins whose IDs are absent from `enabled_plugins`. +3. Activate enabled first-party plugins that are not active. + +The desktop runtime reconciles at startup and after `workspace:ready`, `workspace:manifest-updated`, or `file:changed` events. The engine adopts external manifest edits and emits `workspace:manifest-updated` with source `external`. Its atomic-write journal suppresses notifications for its own writes. + +Settings updates `enabled_plugins` through the engine’s atomic, revision-checked `manifest_update` operation. Navigation and route guards follow the reconciled plugin state. + +Plugin definitions may implement `deactivate(context)`. The host supplies the activation context, and capability disposers unregister commands, remove AI tools and context, and unsubscribe events. Closing a workspace deactivates its plugins and removes their registrations. ## Capabilities -`PluginContext` exposes only what the plugin manifest declares: `workspace.files`, `workspace.objects`, `workspace.search`, `workspace.commands`, `workspace.events`, `workspace.storage`, `ai.tools`, and `ai.context`. Every call guards against undeclared capabilities before it reaches a service. Plugins never see transport internals, SQLite, or Rust types. +`PluginContext` exposes the capabilities declared by the plugin manifest: + +- `workspace.files` and `workspace.objects` +- `workspace.search` and `workspace.commands` +- `workspace.events` and `workspace.storage` +- `ai.tools` and `ai.context` + +Every call checks the declared capability before it reaches a service. Plugins never see transport internals, SQLite, or Rust types. ## Storage contract @@ -33,6 +47,13 @@ PluginContext ── capability-gated facade over NouraClient ## Boundaries +Keep dependency and transport responsibilities separate: + - `packages/workspace` may import first-party plugins; plugins never import `packages/workspace`. - The MCP server does not construct a plugin runtime. Plugin-contributed MCP tools are future work behind the same capability contracts. -- Rich UI contributions from plugins (a tree, boards, custom panes) do not exist yet. Shell-level surfaces are organized behind the **per-module sidebar registry** (`apps/app/src/lib/sidebar-modules.ts`): each module decides what lives in the sidebar for its routes — or opts out entirely, in which case those routes render full-width. Contributions are gated by the module's plugin being enabled, and sections read module-owned stores (the file browser reads the folders-gated tree, the tasks views read the shared tasks projection). A `workspace.views.register`-style capability is the intended upgrade path so plugins can own their own UI; the registry shape is designed so those entries can replace the hardcoded ones without changing the renderer. + +## UI contributions + +Plugins do not yet contribute rich UI such as trees, boards, or custom panes. The shell uses the per-module sidebar registry in `apps/app/src/lib/sidebar-modules.ts`; each module defines sidebar content for its routes or opts out so its routes render full width. + +The registry includes a module only when its plugin is enabled. Sidebar sections read module-owned stores: the file browser reads the folders-gated tree, while task views read the shared tasks projection. A future `workspace.views.register` capability can replace these hardcoded entries without changing the renderer. diff --git a/docs/architecture/sync-server.md b/docs/architecture/sync-server.md index 492a8df..484c777 100644 --- a/docs/architecture/sync-server.md +++ b/docs/architecture/sync-server.md @@ -1,82 +1,25 @@ # Encrypted synchronization service -## Dependency decision (2026-09-05) - -Syncular was inspected at commit `e4ae85f33617909ad41a2af1a9c7f5296fa77b00` -in https://github.com/syncular/syncular (Apache-2.0). No donor source was copied. - -The native client owns a rusqlite connection. Its private `apply_section_body` -and `apply_commit_changes` methods apply remote changes inside the client's -SQLite observation transaction. There is no public file-commit acknowledgment -hook. Its server `pruneCommitLog` considers cursor age and a retained-count -floor; it has no client-created encrypted snapshot coverage predicate. - -These are static compatibility findings, not results of a working Syncular -prototype. Adopting the complete native client would require modifying its -persistence boundary. Using only the server would still require a separate -Noura client and snapshot retention protocol. We therefore use the approved -fallback: Bun/Hono, PostgreSQL, and a Noura-owned opaque-operation protocol. -This supersedes the provisional Syncular recommendation, not the file-first -or encryption invariants. SuperSync and Secsync remain research references; -their source has not been copied or adapted. +Noura’s sync service uses Bun, Hono, and PostgreSQL to route signed, encrypted operations between authorized clients. Clients own workspace content keys and canonical file writes. Read the [server setup guide](../../apps/server/README.md) to run the service. ## Trust boundary -The service receives signed ciphertext envelopes, never workspace plaintext. -Authentication and authorization are separate from possession of content keys. -An operation's signature binds all routing fields and ciphertext. Database -transactions serialize workspace changes, including revocation, before an -acknowledgment is returned. Operation IDs are durable retry identities. +Clients send signed ciphertext envelopes rather than plaintext workspace content. Authentication and authorization are separate from possession of content keys. An operation’s signature binds its routing fields and ciphertext. -Client keys do not belong on this server. Native credentials belong in the OS -credential store. The server can see opaque workspace, object and device IDs, -authorization relationships, ciphertext length, and traffic timing. +Database transactions serialize workspace changes, including revocation, before the server returns an acknowledgment. Operation IDs are durable retry identities. Native credentials remain in the operating system’s credential store. -## Release status +The server can observe this metadata: -This service is under implementation. It must not be described as production -ready or as integrated desktop synchronization until native durable application, -identity/recovery, collaborative editing, granular sharing, public viewing, -and the complete acceptance gates are verified. Read the service README for -the currently executable surface and verification commands. +- Opaque workspace, object, and device IDs +- Authorization relationships +- Ciphertext length and traffic timing -The current implementation includes browser-approved native account sign-in, -signed access policies, encrypted public snapshots and viewing, native file -transport, age key envelopes, and a durable file journal. See -[`sync-v1.md`](../workspace-format/sync-v1.md) for the file and key protocol. -Desktop Settings now exposes background workspace sync, pause/resume, joining an -existing member workspace in an empty local folder, explicit device-fingerprint approval, -and same-path conflict review. The native coordinator retains consent, sender -pins, encrypted keys, and its outbox across restarts. It never acquires trust from -an unverified server key. Fingerprints bind the enrolled account ID, device ID, -Ed25519 key, and age recipient together. Device account labels cannot change an -existing approval's scope. +The server does not hold client content keys. Clients must authenticate and decrypt incoming content; the server cannot prove that a malicious client encrypted submitted bytes. -A writer can back up its own signed recipient envelope before uploading its -first operation. This does not grant another account access or rotate an epoch; -authorization and epoch changes still require a complete signed access policy. -Shared coordinator passes use that policy to distribute keys only to explicitly -approved recipients. The key-delivery endpoint now permits an existing object -writer to supply immutable envelopes to already-authorized devices. This closes -the editor-created-object gap without granting editors permission-management -rights. Viewers use a receive-only transport after verifying their signed role. -Revoked recipients stop further shared uploads until keys are rotated. +## Service scope -File payload version 2 adds explicit conflict resolution over reviewed content -revisions. Version 1 remains unchanged. This is necessary because a single-base -file update cannot converge the two reviewed branches after an offline conflict. -It does not permit overwriting an unrelated later revision and is not a substitute -for the planned collaborative text protocol. +The service supports the encrypted transport described in [`sync-v1.md`](../workspace-format/sync-v1.md): signed opaque operations, access policies, encrypted key envelopes, attachment ciphertext, and public encrypted snapshots. The native client owns canonical file application, local consent, trust pins, credentials, and durable journal state. -Payload version 3 adds encrypted attachment descriptors and age streaming files. -The server reuses the MIT-licensed Tus server and file store for resumable transfer; -PostgreSQL authorization and durable-file acknowledgments wrap that dependency. -See the workspace-format specification for exact descriptor and encryption rules. +The service verifies authentication and authorization, serializes workspace changes, and stores opaque protocol records. It does not own workspace content keys or canonical workspace files. Experimental collaboration capability routes remain disabled by standard service startup; durable HTTP pull and acknowledgment remain authoritative when those routes are enabled for testing. -Workspace owners can create seven-day invitation links in desktop Settings. -Recipients accept through an authenticated browser session, then connect a desktop -device. Acceptance alone creates no membership or key access. The owner compares -the recipient's device fingerprints, approves them locally, and grants access by -signing the next complete access policy with recipient key envelopes. Pending or -accepted invitations can be revoked. Each workspace permits at most 100 active -invitations; the review list prioritizes active invitations over history. +The service remains experimental. Follow the [operations guide](../../apps/server/OPERATIONS.md) for deployment, backup, and validation requirements. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index dc716d5..ffeda4e 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -4,6 +4,14 @@ Noura protects workspace bytes, provider credentials, stable identity, and the integrity of writes performed after an external edit. The Initial MVP assumes a trusted local user and trusted first-party bundled plugins. It does not claim isolation from malware running under the same operating system account. +## Local and network-enabled scope + +The local core runs workspace operations without a Noura account or network service. It reads and writes canonical workspace files locally, and the desktop host and standalone Model Context Protocol (MCP) server call the same services. + +Network-enabled features add separate boundaries. You authorize the client before it sends workspace content to an AI provider. The native web-access boundary requires explicit consent and validates public HTTPS destinations. + +Experimental account and sync services process authentication metadata and encrypted workspace content. Clients retain workspace content keys; managed sync must never require plaintext workspace bytes on the server. Local workspace operations remain independent of these services. + ## Trust boundaries - Workspace paths and file contents are untrusted input. @@ -20,6 +28,6 @@ Search escapes tokens before building an FTS expression. The UI receives text sn Provider settings contain opaque credential references. The OS credential store holds secret values. Noura does not write secrets to workspace files, SQLite, frontend storage, logs, or snapshots. -## Deferred risks +## Deferred and release-gated risks -The Initial MVP does not load community plugins, serve remote MCP transports, synchronize data, or expose accounts. Those features require separate authorization, sandboxing, network, and encryption reviews before implementation. +The Initial MVP does not load community plugins or serve remote MCP transports. Those features require separate authorization and sandboxing reviews before implementation. Sync, account, and collaboration paths remain experimental and require their documented network, encryption, cross-platform, and independent-review release gates before user-facing release. diff --git a/docs/testing/local-alpha-acceptance.md b/docs/testing/local-alpha-acceptance.md index fe901e6..7be28e2 100644 --- a/docs/testing/local-alpha-acceptance.md +++ b/docs/testing/local-alpha-acceptance.md @@ -73,12 +73,14 @@ Use this runbook for contributor and release review of the desktop Local Alpha. 1. In Settings, choose **Rebuild index**. Confirm the button stays disabled while running and a success toast appears. Confirm every projection refreshes. 2. Record each object's ID, path, body, and dated properties. 3. Quit Noura. -4. Find the workspace's local index by matching the ID from `.noura/workspace.yaml`: +4. Find the workspace’s local index by matching the ID from `.noura/workspace.yaml`. On macOS, search the application-data directory with: ```sh find "$HOME/Library/Application Support" -path "*/workspaces/*/index.sqlite" -print ``` + On other platforms, locate the index in Noura’s operating system application-data directory. Match the workspace ID before deleting any index. + 5. Delete only that workspace's `index.sqlite`, then reopen Noura and the disposable workspace. 6. Confirm notes, tasks, projects, IDs, paths, bodies, search results, Calendar results, and project boards match the recorded state. Calendar may return to Month because its view preference is disposable. diff --git a/docs/workspace-format/checkpoints.md b/docs/workspace-format/checkpoints.md index 55b41df..8af7b1d 100644 --- a/docs/workspace-format/checkpoints.md +++ b/docs/workspace-format/checkpoints.md @@ -145,10 +145,6 @@ public publishing are not implemented by this protocol. The experimental native realtime client supplies authenticated notifications and encrypted presence, while durable HTTP pulls and acknowledgements remain authoritative. The experimental text coordinator and editor adapter are described in -[collaboration.md](collaboration.md). The PostgreSQL 16 restore rehearsal has -passed with staged and committed transitions, checkpoint/blob manifests, -resumable uploads, and restored protocol state. Cross-platform -invitation-to-collaboration acceptance, the 20-editor WAN latency target, the -Windows worker capability confinement, native multi-process CRDT load, and an -independent security implementation review remain release gates. The -implementation and tests were author-performed, not an independent audit. +[collaboration.md](collaboration.md). Production startup keeps these routes +disabled. The implementation and tests were author-performed, not an +independent security audit. diff --git a/docs/workspace-format/collaboration.md b/docs/workspace-format/collaboration.md index 51c785c..eb055b6 100644 --- a/docs/workspace-format/collaboration.md +++ b/docs/workspace-format/collaboration.md @@ -66,7 +66,7 @@ No upstream implementation source was copied. The native coordinator validates a candidate document before writing. Text is limited to 8 MiB and encoded document history to 32 MiB. Restore and candidate application cross a dedicated native worker process that receives only CRDT bytes -and text—never credentials, network handles, workspace paths, or file writers. Its +and text, never credentials, network handles, workspace paths, or file writers. Its environment is cleared and its working directory is the filesystem root. Release workers enter Apple's pure-computation Seatbelt profile on macOS; Linux workers install a no-new-privileges seccomp filter that rejects networking, filesystem diff --git a/docs/workspace-format/sync-v1.md b/docs/workspace-format/sync-v1.md index 49e4da2..e13332a 100644 --- a/docs/workspace-format/sync-v1.md +++ b/docs/workspace-format/sync-v1.md @@ -202,7 +202,9 @@ serializer; TypeScript exposes validation only. Directory fsync is implemented on Unix. Equivalent crash-durability behavior on Windows still needs implementation and platform testing. Move/identity conflict -resolution, attachment conflict resolution, and collaborative text updates remain unfinished. +resolution and attachment conflict resolution remain unfinished. Collaborative +text updates are experimental, disabled by default, and remain release-gated; see +[the collaboration protocol](collaboration.md). ## Encrypted attachments diff --git a/docs/workspace-format/v1.md b/docs/workspace-format/v1.md index 2c88119..ea2e45e 100644 --- a/docs/workspace-format/v1.md +++ b/docs/workspace-format/v1.md @@ -28,7 +28,7 @@ The workspace ID identifies the workspace across moves. Device-local indexes and - Plugin identifiers are a lowercase letter followed by lowercase letters, digits, or hyphens, at most 64 characters total. - The list is deduplicated and order is not significant. Readers tolerate a hand edit's duplicates and unsorted entries by canonicalizing on read; writers serialize the deduplicated list in sorted order. - An empty list is valid and enables no domain modules. -- Unknown identifiers are preserved and tolerated so future ecosystem plugins do not break older builds; readers simply ignore ids they do not implement. +- Readers preserve unknown identifiers and ignore IDs they do not implement, so future ecosystem plugins do not break older builds. - Application plugin toggles rewrite this file atomically and refresh `updated`. ## Managed Markdown