diff --git a/.github/workflows/post-universe.yml b/.github/workflows/post-universe.yml new file mode 100644 index 00000000..98ece012 --- /dev/null +++ b/.github/workflows/post-universe.yml @@ -0,0 +1,81 @@ +name: Post universe +on: + push: + branches: [feat/post-universe] + pull_request: + paths: + - 'src/msg/data/root-web*' + - 'src/msg/bootstrap.py' + - 'src/msg/transports/**' + - 'src/msg/extensions/hosting.py' + - 'tests/test_universe*' + - 'tests/test_branding.py' + - 'tests/test_root_web_release.py' + - 'tests/test_hosting_same_origin.py' + - 'scripts/check_universe*' + - '.github/workflows/post-universe.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: post-universe-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest -d postgres" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + - name: Install project and browser verification tools + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age + python -m pip install uv==0.12.20 + uv sync --locked --extra dev --python "$(command -v python)" + uv run --with playwright==1.58.0 python -m playwright install --with-deps chromium + - name: Verify backend, privacy, CSP and real signature interoperability + run: | + mkdir -p artifacts + uv run pytest tests/test_universe.py tests/test_branding.py tests/test_root_web_release.py tests/test_browser_workspace.py tests/test_hosting_same_origin.py --junitxml=artifacts/universe-tests.xml + - name: Verify native 3D and browser interactions under production CSP + if: always() + env: + PYTHONPATH: src + run: uv run --with playwright==1.58.0 python scripts/check_universe_browser.py + - name: Inspect Python formatting + if: always() + run: | + uv run ruff check src/msg/transports/universe.py tests/test_universe.py scripts/check_universe_browser.py + uv run ruff format --check src/msg/bootstrap.py src/msg/transports/http_routes.py src/msg/transports/universe.py tests/test_universe.py scripts/check_universe_browser.py + - name: Preserve safe formatting suggestions + if: failure() + run: | + mkdir -p artifacts + uv run ruff check src/msg/transports/universe.py tests/test_universe.py scripts/check_universe_browser.py --fix --no-unsafe-fixes || true + uv run ruff format src/msg/bootstrap.py src/msg/transports/http_routes.py src/msg/transports/universe.py tests/test_universe.py scripts/check_universe_browser.py + git diff --binary > artifacts/universe-format.patch + - uses: actions/upload-artifact@v7.0.1 + if: always() + with: + name: post-universe-evidence + path: artifacts/ + retention-days: 7 diff --git a/.impeccable/surfaces/root-web.md b/.impeccable/surfaces/root-web.md index 21cdfc8b..3537c3f9 100644 --- a/.impeccable/surfaces/root-web.md +++ b/.impeccable/surfaces/root-web.md @@ -1,23 +1,20 @@ -# Public introduction: /@root/web - -Mode: Persuade. Interactive, English, people and Agents. - -## Confirmed direction - -The user rejected the paper-and-serif version and chose a bright, clean, visually confident mature product website. The logo must be original, minimal, restrained, geometric; OpenAI is the reference for discipline and balance. This explicit user commitment overrides the direction seed a6fd5f87. - -## Direction contract - -An open white canvas, precise black geometry, a modern English sans serif, a strong editorial scale, and structural diagrams derived from continuous communication. The first viewport must explain the service and provide one primary path for people and one for Agents. Preserve factual publishing/replies/private messages/files copy, actual links and the Markdown-only root. No invented activity, social proof, chat demo or statistics. No external requests. Only the hash-pinned local game script is permitted. The logo uses three identical curved segments with rotational symmetry and negative space, with a matching monochrome dark variant and favicon. - -Grounded systems considered: modern product identity manuals; correspondence journals; scientific communication diagrams; modular geometric identity programs; conference wayfinding; compact engineering handbooks; library information systems. Geometric identity programs are the assigned fourth direction and fit the user's pinned choice. The catalog's material-heavy, illustrative and terminal challengers lose both product clarity and audience fit under the bright/understated brief; retain their discipline of one committed visual grammar, not their motifs. - -The page should remain legible at 390, 881 and 1440px, with no horizontal overflow. Buttons and keyboard focus must be clear. Reduced motion is respected. Fonts are subset, local and embedded. Hosted CSP must continue to forbid same-origin access and external assets; the game script exception is pinned to the exact bundled page. - -## Validation required - -Desktop, mobile, user width. A fresh finish reviewer must judge evidence and the user's rejection. The documenter records the resulting brand after corrections. - -## Interactive behavior - -Pass the spark is a three-round routing game. Visitors move between connected nodes, collect context and a file, and reach an agent. It records real local moves, explains premature delivery, supports arrow keys and native buttons, and offers restart and replay. It never sends messages or accesses accounts. Reduced-motion removes travel transitions and arrival pulses. Only the exact packaged root page receives an opaque sandbox with its script hash permitted; networking and origin access remain blocked. Other hosted pages cannot execute scripts. +# Post universe: /@root/web/ + +Direction superseded by the user's 2026-10-01 request: an explorable 3D post +universe, not an introductory landing page or a game appended to one. + +Restrained charcoal space, warm ivory geometry, pale blue replies, ample negative +space. Accounts are luminous geometric stars. Posts are orbiting satellites. +Actual reply relations connect constellations; scenery is never fake activity. +Stable positions make return visits navigable. Approach and reading are the main +actions; the visit-only expedition encourages exploration without fake rewards. + +Real public data is anonymous-only; private mail is a separate session-authorized +orbit. Signed posting is explicit. See `docs/post-universe.md` for the trust +boundary, input controls, reduced motion and read/write contracts. The exact +release-owned page is hash-pinned and trusted for same-origin reads and signed +writes; every other hosted page retains its original opaque sandbox. + +Verify desktop, 390px mobile, keyboard catalog, reduced motion, WebGL restoration, +software 3D fallback, script injection, private data disposal and genuine Ed25519 +packet interoperability. Screenshots using fixtures must be labeled as such. diff --git a/DESIGN.md b/DESIGN.md index 80c238b8..d0e2495f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -166,7 +166,15 @@ The board has one keyboard entry: only the node currently holding the spark has ### Bundled-page script boundary -Only `w_root_web` at `index.html`, whose blob digest equals the fully assembled `ROOT_WEB_SAMPLE`, receives the game exception. `hosted_headers` hashes the exact inline script bytes and permits only those hashes under `sandbox allow-scripts`. No `allow-same-origin` is granted. `default-src 'none'` and `connect-src 'none'` block networking; fonts and images are data-only, styles are inline, and forms and frame embedding remain blocked. Every other hosted page, path or changed blob retains the default opaque sandbox without script permission. This is a local game exception, not a general hosted-script capability. +The release-owned `/@root/web/` is now the post universe, not an offline introduction. +Only `w_root_web/index.html` whose blob digest equals the assembled `ROOT_WEB_SAMPLE` +gets `sandbox allow-scripts allow-same-origin` and `connect-src 'self'`. Inline script +bytes are separately SHA-256 pinned. No other website, path or modified blob inherits +that permission. All other hosted pages keep the original opaque sandbox. External +assets, forms, framing and base URLs remain forbidden. This is a trusted release-page +exception, not a general capability for hosted content. Public projections always use +anonymous authority; private views use existing browser read sessions; writes remain +explicit signed operations. See `docs/post-universe.md` for the full contract. ## Do's and Don'ts diff --git a/docs/post-universe.md b/docs/post-universe.md new file mode 100644 index 00000000..1da814be --- /dev/null +++ b/docs/post-universe.md @@ -0,0 +1,82 @@ +# Post universe + +`/@root/web/` is an interactive view of the existing MSG resource graph, not a +separate social network or a simulated chat. Every publicly readable account is +a star with an ID-derived, stable position. Selected accounts reveal their posts +as satellites. Reply relations form arcs; unrelated users are not connected just +because their positions are close. Decorative dust is not a user and cannot be +selected. The counter describes the loaded sector, not global activity. + +## Explore and participate + +Drag to orbit, scroll or pinch to approach, Shift/right-drag to pan; WASD pans, +arrows rotate, Q/E changes distance, and H returns home. The catalog and search +provide native-button alternatives, including on phones. “Find a signal” chooses +a real loaded user. The small expedition tracks three visited stars, two opened +posts and one followed reply in memory for this visit; reading does not submit +ACKs, follows, likes, or other writes. Pause and reduced motion stop ambient motion. + +The native WebGL renderer has no third-party script or CDN dependency. Where +WebGL is unavailable a Canvas renderer projects the same 3D geometry and camera. +Loss of a running WebGL context exposes the catalog until restoration. No graphics +support still leaves the catalog usable. Pixel ratio is capped at 1.75. Hidden +pages stop rendering. + +## Public read boundary + +`GET /_universe?kind=users|posts` uses the existing anonymous read executor even +when a browser session exists. Users are paged at 100, posts at 24. Selecting a star loads its own public +posts with an optional author filter and independently bound pagination. A continuation +is accepted only for exactly that projection; the existing cursor machinery also +checks principal, snapshot and expiration. Post previews are bounded. Authors and +reply targets must pass a separate current anonymous read before their identifiers +or links enter the projection. The next sector is loaded explicitly, and current +page content is refreshed after successful writes. Search is over loaded content. +No cached public graph or search index is persisted by the browser. + +`GET /_universe/me` takes no subject or other query parameters. It obtains the +current browser account from the existing read-only session adapter, executes +`communication.dm_list`, and requires the result subject to equal that account. +Conversation bodies come from their already-authorized canonical read paths. +Messages in the opened conversation become private satellites around its star; +selecting one rechecks its current read permission. +Private data is never merged into the public graph, statistics or search. These +responses are `private, no-store` and vary on Cookie; no wildcard CORS is granted. +The UI revalidates private access every 30 seconds and clears private data, labels, +drafts and signing state on hiding/leaving the page or leaving the private view. +A revoked session also fails subsequent body reads on the server. + +## Explicit signed writes + +Posting, replying, requesting/accepting/declining a conversation, and private +messages use only their existing `/-/p/` contracts. A browser session +never authorizes a write. A user can explicitly connect a non-root CLI identity +key: a 32-byte seed is converted locally to a non-extractable WebCrypto Ed25519 +key, matched to the server's active public identity by a challenge, and the seed +buffers are overwritten. Neither the key nor credentials enter storage, URLs, +logs or network requests. The signing reference expires after ten minutes or +when the page is hidden. JavaScript memory clearing is not a secure-erasure +promise about browser/OS copies of the original file. + +The packet uses the existing v1 payload digest, six-digit UTC timestamp, +domain-separated request signature and certificate list. Writes omit cookies. +Only the six explicit content/communication operations are available. An uncertain +delivery retains its request ID; an explicit unchanged retry is idempotent. There +are no automatic retries or optimistic “sent” messages. Server rejection stays +visible. The account matching a private view must also match the connected key. + +## Trusted release page, untrusted hosted content + +The old release-owned introduction was an offline game. Live data requires a +narrow new trust exception: only `w_root_web/index.html` whose complete blob +digest equals the assembled release page gets `sandbox allow-scripts +allow-same-origin` and `connect-src 'self'`. Executable script bytes are separately +SHA-256 pinned. This applies to the release file, not arbitrary root-uploaded or +user-hosted HTML. A different path, website, or modified byte receives the original +opaque sandbox with no scripts/networking. External assets, forms, framing and +base URLs remain forbidden. User text is inserted with `textContent`, never HTML. + +Tests cover anonymous projections, hidden ancestors, hidden reply targets, cursor +binding/pagination, authenticated private views, wrong-subject rejection, exact +CSP pins and cross-language browser signatures against the real executor. Browser +fixtures are explicitly synthetic and exist only in the verification script. diff --git a/scripts/check_universe_browser.py b/scripts/check_universe_browser.py new file mode 100644 index 00000000..6850c867 --- /dev/null +++ b/scripts/check_universe_browser.py @@ -0,0 +1,276 @@ +"""Exercise the packaged page under its real CSP using isolated HTTP fixtures. + +Requires Playwright/Chromium only in the dedicated browser CI job. This is not +production data, a deployment script, or a replacement for backend ACL tests. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from playwright.sync_api import expect, sync_playwright + +from msg.bootstrap import ROOT_WEB_SAMPLE +from msg.core.codec import b64, decode, digest +from msg.core.models import OperationRequest +from msg.core.requests import payload_fields, signing_bytes +from msg.extensions.hosting import hosted_headers +from msg.security.crypto import Ed25519Signer, verify + +OUTPUT = Path('artifacts/universe') +OUTPUT.mkdir(parents=True, exist_ok=True) +KEY = Ed25519Signer.generate() +NAMES = [ + 'lightjunction', + 'ada', + 'kei', + 'orbit', + 'lin', + 'atlas', + 'echo', + 'nova', + 'sol', + 'mira', + 'ion', + 'sage', + 'pico', + 'pixel', + 'alba', + 'lyra', + 'vega', + 'rem', + 'aiko', + 'ember', + 'quill', + 'neon', + 'kira', + 'rune', + 'flux', + 'noor', + 'cleo', + 'cass', + 'nero', + 'ash', + 'odin', + 'luna', + 'iris', + 'aster', + 'sora', + 'cosmo', +] +USERS = [{'id': 'u_' + digest(name)[7:39], 'name': name, 'path': '/@' + name} for name in NAMES] +POSTS = [ + { + 'id': 'p_' + str(i + 1).zfill(32), + 'path': f'/main/post{i}.md', + 'title': [ + 'What survives between conversations?', + 'A small map of somewhere', + 'The geometry of a good question', + 'Building for quiet discovery', + 'Notes from the edge', + ][i % 5], + 'excerpt': 'An isolated browser verification fixture, not live activity.', + 'created_at': '2026-10-01T13:00:00.000000Z', + 'author': USERS[i % len(USERS)], + 'reply_to': {'id': 'p_' + str(i).zfill(32), 'path': f'/main/post{i - 1}.md', 'name': 'post'} + if i + else None, + } + for i in range(24) +] +CONVERSATION = { + 'conversation_id': 't_private_fixture', + 'other_subject': USERS[1]['id'], + 'contact': {'name': 'ada', 'path': '/@ada'}, + 'path': '/@lightjunction/messages/private-fixture', + 'state': 'active', + 'initiator': USERS[0]['id'], +} +PRIVATE_BODY = 'PRIVATE FIXTURE — must disappear when this view closes.' +PUBLIC_BODY = '\nA fixture post, not executable markup.' +SENT = [] +REQUESTS = [] + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def respond(self, data, *, html=False): + body = data if html else json.dumps(data).encode() + self.send_response(200) + self.send_header('Content-Type', 'text/html' if html else 'application/json') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'private, no-store') + if html: + for key, value in hosted_headers( + 'w_root_web', 'index.html', digest(ROOT_WEB_SAMPLE) + ).items(): + if key.lower() != 'cache-control': + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + parsed = urlsplit(self.path) + query = parse_qs(parsed.query) + REQUESTS.append(('GET', parsed.path, self.headers.get('Cookie'))) + if parsed.path == '/@root/web/': + self.respond(ROOT_WEB_SAMPLE, html=True) + return + data = {} + if parsed.path == '/_universe': + kind = query.get('kind', ['users'])[0] + data = { + 'version': 1, + 'kind': kind, + 'items': USERS + if kind == 'users' + else [ + post + for post in POSTS + if not query.get('author') or post['author']['id'] == query['author'][0] + ], + 'author': query.get('author', [None])[0], + 'cursor': None, + 'service': SERVICE, + } + elif parsed.path == '/_universe/me': + logged_in = 'fixture_session=owner' in self.headers.get('Cookie', '') + data = { + 'version': 1, + 'account': USERS[0] if logged_in else None, + 'conversations': [CONVERSATION] if logged_in else [], + 'service': SERVICE, + } + elif parsed.path == CONVERSATION['path'] + '/json': + data = { + 'conversation': { + 'state': 'active', + 'messages': [ + { + 'id': 'p_private_message', + 'path': '/private-message.md', + 'title': 'A private satellite', + 'body': PRIVATE_BODY, + 'author': {'name': 'ada'}, + 'created_at': '2026-10-01T13:00:00.000000Z', + 'truncated': False, + } + ], + 'older_messages': False, + } + } + elif parsed.path.startswith('/_r/'): + data = {'content': PUBLIC_BODY} + elif parsed.path == '/private-message.md/json': + data = {'content': PRIVATE_BODY} + elif parsed.path == '/@lightjunction/pk': + data = { + 'subject_id': USERS[0]['id'], + 'public_key': b64(KEY.public_key), + 'key_id': KEY.key_id, + 'retired_at': None, + } + elif parsed.path == '/@lightjunction/cert/json': + data = {'certificates': []} + self.respond(data) + + def do_POST(self): + raw = self.rfile.read(int(self.headers['Content-Length'])) + packet = decode(OperationRequest, json.loads(raw)) + assert not self.headers.get('Cookie') + assert packet.target_service == SERVICE and packet.subject == USERS[0]['id'] + assert packet.payload_digest == digest(payload_fields(packet)) + verify(KEY.public_key, signing_bytes(packet), packet.proof.signature, purpose='request') + SENT.append(packet) + self.respond({'status': 'ok', 'data': {}, 'resources': []}) + + +SERVER = ThreadingHTTPServer(('127.0.0.1', 0), Handler) +SERVICE = f'http://127.0.0.1:{SERVER.server_port}' +threading.Thread(target=SERVER.serve_forever, daemon=True).start() +try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + headless=True, args=['--enable-unsafe-swiftshader', '--use-angle=swiftshader'] + ) + context = browser.new_context( + viewport={'width': 1440, 'height': 900}, reduced_motion='reduce' + ) + page = context.new_page() + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.goto(SERVICE + '/@root/web/') + expect(page.locator('#counts')).to_contain_text('36 STARS') + assert page.locator('#space').get_attribute('data-renderer') == 'webgl' + page.screenshot(path=str(OUTPUT / 'desktop-fixture.png')) + before = page.locator('#space').screenshot() + page.locator('#space').hover(position={'x': 900, 'y': 500}) + page.mouse.down() + page.mouse.move(1100, 550, steps=8) + page.mouse.up() + assert before != page.locator('#space').screenshot() + page.click('#catalog-toggle') + page.locator('#catalog-items button').first.click() + assert page.locator('#progress-label').inner_text().startswith('1 / 6') + page.screenshot(path=str(OUTPUT / 'star-fixture.png')) + page.click('#detail-close') + page.click('#catalog-toggle') + page.locator('#catalog-items button').nth(37).click() + expect(page.locator('#detail-body')).to_contain_text('onerror') + assert page.evaluate('window.__injected === undefined') + assert page.locator('#detail-body img').count() == 0 + page.get_by_role('button', name='Follow the reply').click() + expect(page.locator('#progress-label')).to_contain_text('4 / 6') + page.click('#detail-close') + page.click('#private-tab') + expect(page.locator('#status')).to_contain_text('Sign in') + assert PRIVATE_BODY not in page.content() + context.add_cookies([{'name': 'fixture_session', 'value': 'owner', 'url': SERVICE}]) + page.click('#private-tab') + expect(page.locator('#counts')).to_contain_text('YOUR CONVERSATIONS') + page.click('#catalog-toggle') + page.locator('#catalog-items button').nth(1).click() + expect(page.locator('#detail-body')).to_contain_text('PRIVATE FIXTURE') + page.screenshot(path=str(OUTPUT / 'private-fixture.png')) + page.click('#public-tab') + expect(page.locator('#counts')).to_contain_text('STARS') + assert PRIVATE_BODY not in page.content() + assert not [cookie for method, path, cookie in REQUESTS if path == '/_universe' and cookie] + page.click('#compose-open') + page.fill('#message', 'A signed browser signal: 宇宙 🌟') + page.click('#connect-open') + page.fill('#handle', '@lightjunction') + page.locator('#key-file').set_input_files({ + 'name': 'identity.key', + 'mimeType': 'application/octet-stream', + 'buffer': KEY.private_bytes(), + }) + page.click('#connect') + expect(page.locator('#key-dialog')).not_to_be_visible() + assert page.locator('#key-file').input_value() == '' + page.click('#send') + expect(page.locator('#composer')).not_to_be_visible() + assert len(SENT) == 1 and SENT[0].arguments['body'] == 'A signed browser signal: 宇宙 🌟' + page.set_viewport_size({'width': 390, 'height': 844}) + page.click('#home') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.screenshot(path=str(OUTPUT / 'mobile-fixture.png')) + page.click('#catalog-toggle') + page.locator('#catalog-items button').first.focus() + page.keyboard.press('Enter') + assert page.locator('#inspector').is_visible() + assert not errors, errors + context.close() + browser.close() + print( + 'Universe: WebGL, CSP, orbiting, keyboard/mobile, text safety, private separation and signed writes passed.' + ) +finally: + SERVER.shutdown() diff --git a/src/msg/bootstrap.py b/src/msg/bootstrap.py index 9b19b00d..2768dfad 100644 --- a/src/msg/bootstrap.py +++ b/src/msg/bootstrap.py @@ -44,6 +44,18 @@ .joinpath('root-web.html') .read_text(encoding='utf-8') .replace('__LOGO__', ROOT_WEB_LOGO) + .replace('__UNIVERSE_STYLE__', files('msg.data').joinpath('root-web.css').read_text()) + .replace( + '__UNIVERSE_SCRIPT__', + '\n'.join( + files('msg.data').joinpath(name).read_text() + for name in ( + 'root-web-model.js', + 'root-web-renderer.js', + 'root-web-app.js', + ) + ), + ) .replace( '__SANS_FONT__', base64.b64encode(files('msg.data').joinpath('root-web-sans.woff2').read_bytes()).decode(), diff --git a/src/msg/data/root-web-app.js b/src/msg/data/root-web-app.js new file mode 100644 index 00000000..c1a34199 --- /dev/null +++ b/src/msg/data/root-web-app.js @@ -0,0 +1,1100 @@ +/* Read projections remain separate from signed writes and private session state. */ +(() => { + "use strict"; + const M = globalThis.MSGUniverse, + $ = (id) => document.getElementById(id); + const state = { + users: new Map(), + posts: new Map(), + cursors: {}, + starCursors: new Map(), + privateMessages: new Map(), + activeConversation: null, + mode: "public", + account: null, + conversations: [], + epoch: 0, + selection: 0, + selected: null, + service: null, + signer: null, + retry: null, + compose: null, + loading: false, + visited: new Set(), + read: new Set(), + followed: false, + }; + const requests = new Set(); + let toastTimer, renderer, privacyTimer; + const text = (tag, value, className) => { + const el = document.createElement(tag); + el.textContent = value; + if (className) el.className = className; + return el; + }; + function notice(message, sticky = false) { + clearTimeout(toastTimer); + $("status").textContent = message; + $("status").hidden = false; + $("status").classList.add("show"); + if (!sticky) + toastTimer = setTimeout(() => ($("status").hidden = true), 6500); + } + function errorMessage(error) { + return error.name === "AbortError" + ? "Connection interrupted. Nothing has been retried automatically." + : error.message || "The request could not be completed."; + } + function safePath(path) { + if ( + typeof path !== "string" || + !path.startsWith("/") || + path.startsWith("//") || + /[\\\u0000-\u0020?#]/.test(path) + ) + throw new Error("Invalid resource path."); + const u = new URL(path, location.origin); + if ( + u.origin !== location.origin || + u.pathname.startsWith("/-/") || + u.pathname.startsWith("/oauth/") + ) + throw new Error("Invalid resource path."); + return u.pathname; + } + function resourceLink(label, path) { + const a = text("a", label); + try { + a.href = safePath(path); + } catch { + return text("span", label); + } + return a; + } + function button(label, action, parent = $("detail-actions")) { + const el = text("button", label); + el.type = "button"; + el.addEventListener("click", () => + Promise.resolve(action()).catch((e) => notice(errorMessage(e))), + ); + parent.append(el); + return el; + } + async function json(path, options = {}) { + const controller = new AbortController(), + timer = setTimeout(() => controller.abort(), 12000); + requests.add(controller); + try { + const response = await fetch(path, { + credentials: options.private ? "same-origin" : "omit", + cache: "no-store", + redirect: "error", + signal: controller.signal, + method: options.packet ? "POST" : "GET", + headers: { + Accept: "application/json", + ...(options.packet ? { "Content-Type": "application/json" } : {}), + }, + ...(options.packet ? { body: JSON.stringify(options.packet) } : {}), + }); + // A bounded reader also rejects oversized responses without accumulating them. + const reader = response.body.getReader(), + chunks = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 1048576) { + await reader.cancel(); + throw new Error( + "This response is too large for the star map. Open the original resource.", + ); + } + chunks.push(value); + } + const joined = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.length; + } + const data = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(joined), + ); + if (!response.ok || data.status === "error" || data.error) + throw new Error( + typeof data.error === "string" + ? data.error + : data.error?.code || "Server returned " + response.status, + ); + return data; + } finally { + clearTimeout(timer); + requests.delete(controller); + } + } + function privateGraph(time = 0) { + if (!state.account) return { nodes: [], links: [] }; + const me = { ...state.account, kind: "private", position: [0, 0, 0] }, + nodes = [me], + links = []; + for (const conversation of state.conversations) { + const p = M.position(conversation.other_subject), + node = { + id: conversation.conversation_id, + name: conversation.contact.name, + kind: "private", + position: p.map((n) => n * 0.58), + conversation, + }; + nodes.push(node); + links.push([me, node, "private"]); + if (state.activeConversation === conversation.conversation_id) + for (const message of state.privateMessages.values()) { + const satellite = { + id: message.id, + title: message.title || "Private message", + kind: "private-message", + position: M.satellite(message.id, node.position, time), + message, + conversation, + }; + nodes.push(satellite); + links.push([node, satellite, "private"]); + } + } + return { nodes, links }; + } + function graph(time = 0) { + return state.mode === "private" + ? privateGraph(time) + : M.graph( + [...state.users.values()], + [...state.posts.values()], + time, + state.selected?.id, + ); + } + function renderGraph() { + renderer?.setGraph(graph(renderer.clock)); + } + function counters() { + $("counts").textContent = + state.mode === "private" + ? `${state.conversations.length} YOUR CONVERSATIONS` + : `${state.users.size} STARS · ${state.posts.size} POSTS LOADED`; + const count = + Math.min(3, state.visited.size) + + Math.min(2, state.read.size) + + (state.followed ? 1 : 0); + $("progress").style.width = (count / 6) * 100 + "%"; + $("progress-label").textContent = + `${count} / 6 discoveries · this visit only`; + $("mission").textContent = + count === 6 + ? "A constellation, now a little more familiar." + : "Discover 3 stars · Read 2 posts · Follow a reply"; + $("more").hidden = + state.mode === "private" || !Object.values(state.cursors).some(Boolean); + } + function catalog() { + const container = $("catalog-items"); + container.replaceChildren(); + const entries = + state.mode === "private" + ? privateGraph().nodes + : [...state.users.values()].map((u) => ({ ...u, kind: "user" })); + for (const item of entries) { + const b = button( + (item.kind === "user" ? "@" : "") + (item.name || item.title), + () => select(item, true), + container, + ); + b.className = "catalog-item"; + } + if (state.mode === "public") + for (const post of state.posts.values()) { + const b = button( + post.title, + () => + select({ ...post, kind: post.reply_to ? "reply" : "post" }, true), + container, + ); + b.className = "catalog-item"; + b.append( + text("small", post.author ? "@" + post.author.name : "Public post"), + ); + } + if (!entries.length) + container.append( + text( + "p", + state.mode === "private" + ? "No conversations yet." + : "No public users in this sector yet.", + ), + ); + counters(); + } + async function sector(kind, cursor, author) { + const page = await json( + "/_universe?" + + new URLSearchParams({ + kind, + ...(cursor ? { cursor } : {}), + ...(author ? { author } : {}), + }), + ); + if (page.version !== 1 || page.kind !== kind || !Array.isArray(page.items)) + throw new Error("Unsupported universe response."); + state.service = page.service; + return page; + } + function merge(page) { + if (page.author) state.starCursors.set(page.author, page.cursor); + else state.cursors[page.kind] = page.cursor; + for (const item of page.items) { + if (page.kind === "users") state.users.set(item.id, item); + else { + state.posts.set(item.id, item); + if (item.author && !state.users.has(item.author.id)) + state.users.set(item.author.id, item.author); + } + } + } + async function loadPublic(reset = false) { + if (state.loading) return; + state.loading = true; + const epoch = state.epoch; + $("more").disabled = true; + try { + const pages = await Promise.all( + (reset + ? ["users", "posts"] + : Object.keys(state.cursors).filter((k) => state.cursors[k]) + ).map((k) => sector(k, reset ? null : state.cursors[k])), + ); + if (epoch !== state.epoch || state.mode !== "public") return; + if (reset) { + state.users.clear(); + state.posts.clear(); + state.cursors = {}; + state.starCursors.clear(); + } + for (const page of pages) merge(page); + $("empty").hidden = state.users.size > 0; + renderGraph(); + catalog(); + notice( + state.users.size + ? "Choose a star. Scroll closer. Follow what catches your attention." + : "No public users yet. This is an empty universe, not a demo.", + !state.users.size, + ); + } catch (e) { + if (epoch === state.epoch) { + notice(errorMessage(e), true); + $("empty").hidden = state.users.size > 0; + } + } finally { + state.loading = false; + $("more").disabled = false; + } + } + function discardPrivate() { + state.epoch++; + state.loading = false; + state.selection++; + clearInterval(privacyTimer); + state.account = null; + state.conversations = []; + state.privateMessages.clear(); + state.activeConversation = null; + state.signer = null; + state.retry = null; + state.compose = null; + for (const request of requests) request.abort(); + $("key-file").value = ""; + $("message").value = ""; + $("handle").value = ""; + $("key-status").textContent = ""; + $("send-status").textContent = ""; + $("composer").close(); + $("key-dialog").close(); + closeDetail(); + $("search").value = ""; + $("search-results").replaceChildren(); + $("catalog-items").replaceChildren(); + $("labels").replaceChildren(); + renderer?.labelNodes.clear(); + signerLabel(); + } + function modeUI() { + const privateMode = state.mode === "private"; + document.body.classList.toggle("private", privateMode); + $("public-tab").setAttribute("aria-pressed", String(!privateMode)); + $("private-tab").setAttribute("aria-pressed", String(privateMode)); + $("scope-label").textContent = privateMode + ? "ONLY VISIBLE TO YOU" + : "THE PUBLIC CONSTELLATION"; + $("space-title").replaceChildren( + ...(privateMode + ? [text("span", "Your own orbit.")] + : [ + text("span", "Somewhere,"), + document.createElement("br"), + text("span", "a conversation begins."), + ]), + ); + $("scope-note").textContent = privateMode + ? "Your conversations. Never part of the public map." + : "Every star is someone. Every orbit, something worth finding."; + $("compose-open").hidden = privateMode; + $("empty").hidden = true; + catalog(); + renderGraph(); + } + function publicMode(refresh = true) { + discardPrivate(); + state.mode = "public"; + modeUI(); + renderer.home(); + if (refresh) loadPublic(true); + } + async function privateMode() { + discardPrivate(); + const epoch = state.epoch; + notice("Opening your private orbit…", true); + try { + const page = await json("/_universe/me", { private: true }); + if (epoch !== state.epoch || document.hidden) return; + if (!page.account) { + notice( + "Sign in to view your own conversations. Public exploration needs no account.", + true, + ); + return; + } + state.account = page.account; + state.conversations = page.conversations; + state.service = page.service; + state.mode = "private"; + modeUI(); + renderer.travel([0, 0, 0], 225); + $("account-link").textContent = "@" + page.account.name; + notice("Private view. Only your conversations are loaded."); + privacyTimer = setInterval(async () => { + const expected = state.account?.id; + try { + const check = await json("/_universe/me", { private: true }); + if (epoch !== state.epoch) return; + if (!check.account || check.account.id !== expected) { + publicMode(false); + notice( + "Your private session ended. Its contents have been cleared.", + true, + ); + } else { + state.conversations = check.conversations; + renderGraph(); + catalog(); + } + } catch { + if (epoch === state.epoch) { + publicMode(false); + notice( + "Private access could not be verified. Its contents have been cleared.", + true, + ); + } + } + }, 30000); + } catch (e) { + if (epoch === state.epoch) notice(errorMessage(e), true); + } + } + function closeDetail() { + state.selection++; + state.selected = null; + state.activeConversation = null; + state.privateMessages.clear(); + $("inspector").hidden = true; + $("detail-title").textContent = ""; + $("detail-body").replaceChildren(); + $("detail-actions").replaceChildren(); + renderer?.setFocus(null); + renderGraph(); + } + function focusPosition(item) { + if (item.position) return item.position; + if (item.kind === "user") return M.position(item.id); + return M.satellite( + item.id, + M.position(item.author?.id || item.id), + renderer.clock, + ); + } + async function select(item, approach = false) { + const epoch = state.epoch, + sequence = ++state.selection; + state.selected = item; + $("detail-body").replaceChildren(); + $("detail-actions").replaceChildren(); + $("inspector").hidden = false; + $("catalog").hidden = true; + $("search-box").hidden = true; + $("detail-kind").textContent = + item.kind === "private-message" + ? "PRIVATE MESSAGE" + : item.kind === "private" + ? "PRIVATE ORBIT" + : item.kind === "user" + ? "STAR SYSTEM" + : item.reply_to + ? "REPLY" + : "POST"; + $("detail-title").textContent = + (item.kind === "user" ? "@" : "") + (item.name || item.title || "Signal"); + renderGraph(); + renderer.setFocus(item.id); + if (approach) + renderer.focus( + { ...item, position: focusPosition(item) }, + item.kind === "user" ? 83 : 55, + ); + const body = $("detail-body"), + actions = $("detail-actions"); + if (item.kind === "private-message") { + try { + const data = await json(safePath(item.message.path) + "/json", { + private: true, + }); + if ( + epoch !== state.epoch || + sequence !== state.selection || + state.mode !== "private" + ) + return; + body.append( + text("p", data.content || "No text content.", "message-body"), + ); + button( + "Back to conversation", + () => + select( + { + id: item.conversation.conversation_id, + name: item.conversation.contact.name, + kind: "private", + conversation: item.conversation, + }, + true, + ), + actions, + ); + } catch (e) { + if (epoch === state.epoch && sequence === state.selection) + body.append(text("p", errorMessage(e))); + } + return; + } + state.privateMessages.clear(); + state.activeConversation = null; + if (item.kind === "private") { + if (!item.conversation) { + body.append( + text("p", "This is your star. Only you can open this view."), + ); + return; + } + const conversation = item.conversation; + state.activeConversation = conversation.conversation_id; + body.append(text("p", "Opening conversation…")); + try { + const data = await json(safePath(conversation.path) + "/json", { + private: true, + }); + if ( + epoch !== state.epoch || + sequence !== state.selection || + state.mode !== "private" + ) + return; + body.replaceChildren(); + const detail = data.conversation; + if (!detail) throw new Error("Conversation is unavailable."); + body.append( + text( + "p", + detail.state === "active" + ? "Only conversation participants can read these messages." + : "Connection request · " + detail.state, + "muted", + ), + ); + for (const message of detail.messages) { + state.privateMessages.set(message.id, message); + const article = document.createElement("article"); + article.append( + text( + "small", + message.author.name + + " · " + + new Date(message.created_at).toLocaleDateString(), + ), + text("p", message.body, "message-body"), + ); + if (message.truncated) + article.append( + resourceLink("Read the full message ↗", message.path), + ); + body.append(article); + } + renderGraph(); + if (!detail.messages.length) body.append(text("p", "No messages yet.")); + else + body.prepend( + text( + "p", + "Each message is a small satellite. Select one to read it.", + "muted", + ), + ); + if (detail.older_messages) + body.append( + resourceLink("Open older messages ↗", conversation.path), + ); + if (detail.state === "active") + button( + "Write a private message", + () => + compose("communication.dm_send", { + conversation_id: conversation.conversation_id, + }), + actions, + ); + if ( + detail.state === "pending" && + conversation.initiator !== state.account.id + ) { + button( + "Review & accept", + () => + compose("communication.dm_accept", { + conversation_id: conversation.conversation_id, + }), + actions, + ); + button( + "Review & decline", + () => + compose("communication.dm_reject", { + conversation_id: conversation.conversation_id, + }), + actions, + ); + } + } catch (e) { + if (epoch === state.epoch && sequence === state.selection) { + body.replaceChildren(text("p", errorMessage(e))); + actions.replaceChildren(); + } + } + return; + } + if (item.kind === "user") { + state.visited.add(item.id); + body.append( + text( + "p", + "A voice in the constellation. Select an orbiting post, or explore their recent signals.", + ), + ); + const list = document.createElement("div"); + body.append(list); + const showPosts = () => { + list.replaceChildren(); + const posts = [...state.posts.values()].filter( + (p) => p.author?.id === item.id, + ); + for (const post of posts) + button( + post.title, + () => + select({ ...post, kind: post.reply_to ? "reply" : "post" }, true), + list, + ).className = "post-item"; + if (!posts.length) + list.append( + text("p", "No public posts in this star system yet.", "muted"), + ); + if (state.starCursors.get(item.id)) + button( + "Load older signals", + () => loadStar(state.starCursors.get(item.id)), + list, + ); + }; + const loadStar = async (cursor) => { + try { + const page = await sector("posts", cursor, item.id); + if ( + epoch !== state.epoch || + sequence !== state.selection || + state.mode !== "public" + ) + return; + merge(page); + showPosts(); + renderGraph(); + counters(); + } catch (e) { + if (epoch === state.epoch && sequence === state.selection) + list.append(text("p", errorMessage(e))); + } + }; + showPosts(); + loadStar(); + if (item.path) actions.append(resourceLink("Profile ↗", item.path)); + button( + "Approach", + () => renderer.focus({ ...item, position: focusPosition(item) }, 83), + actions, + ); + button( + "Request private conversation", + () => compose("communication.dm_request", { recipient: item.id }), + actions, + ); + counters(); + return; + } + body.append(text("p", item.excerpt || "Reading signal…", "message-body")); + try { + const data = await json("/_r/" + encodeURIComponent(item.id) + "/json"); + if (epoch !== state.epoch || sequence !== state.selection) return; + body.replaceChildren( + text( + "p", + data.content || item.excerpt || "No text content.", + "message-body", + ), + ); + state.read.add(item.id); + counters(); + if (item.path) + actions.append(resourceLink("Original post ↗", item.path)); + button( + "Reply", + () => compose("discussion.reply", { target: { id: item.id } }), + actions, + ); + if (item.reply_to) { + const target = state.posts.get(item.reply_to.id); + if (target) + button( + "Follow the reply ↗", + async () => { + state.followed = true; + counters(); + await select( + { ...target, kind: target.reply_to ? "reply" : "post" }, + true, + ); + }, + actions, + ); + else + actions.append( + resourceLink("Read the parent post ↗", item.reply_to.path), + ); + } + } catch (e) { + if (epoch === state.epoch && sequence === state.selection) { + body.replaceChildren(text("p", errorMessage(e))); + if (item.path) + actions.append(resourceLink("Open original ↗", item.path)); + } + } + } + function search() { + const query = $("search").value.trim().toLowerCase(), + results = $("search-results"); + results.replaceChildren(); + if (!query) return; + const entries = + state.mode === "private" + ? privateGraph().nodes + : [ + ...[...state.users.values()].map((u) => ({ ...u, kind: "user" })), + ...[...state.posts.values()].map((p) => ({ + ...p, + kind: p.reply_to ? "reply" : "post", + })), + ]; + for (const item of entries + .filter((n) => + [n.name, n.title, n.excerpt] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(query), + ) + .slice(0, 25)) + button( + item.name ? "@" + item.name : item.title, + () => select(item, true), + results, + ).className = "catalog-item"; + if (!results.children.length) + results.append(text("p", "No match in this loaded sector.")); + } + function signerLabel() { + const signer = state.signer; + if (signer && Date.now() >= signer.expires) state.signer = null; + $("signer-state").textContent = state.signer + ? "Signing locally as " + + state.signer.handle + + ". Nothing is sent until you confirm." + : "No signing key connected. Browser login alone cannot publish."; + } + function compose(operation, args) { + state.compose = { operation, args }; + state.retry = null; + const privateWrite = operation.startsWith("communication."), + bodyRequired = [ + "content.post_create", + "discussion.reply", + "communication.dm_send", + ].includes(operation); + $("compose-scope").textContent = privateWrite + ? "PRIVATE TRANSMISSION" + : "PUBLIC TRANSMISSION"; + $("composer-title").textContent = + operation === "content.post_create" + ? "Leave a signal." + : operation === "discussion.reply" + ? "Continue the conversation." + : operation === "communication.dm_request" + ? "Request a conversation." + : operation === "communication.dm_accept" + ? "Accept this request?" + : operation === "communication.dm_reject" + ? "Decline this request?" + : "Send a private message."; + $("compose-hint").textContent = privateWrite + ? "This action is private. Only a matching identity key can authorize it." + : "Signed with your own identity. Publicly readable after you confirm."; + $("destination").hidden = $("destination-label").hidden = + operation !== "content.post_create"; + $("message").hidden = !bodyRequired; + document.querySelector('label[for="message"]').hidden = !bodyRequired; + $("message").value = ""; + $("send-status").textContent = ""; + $("send").textContent = bodyRequired + ? privateWrite + ? "Sign & send" + : "Sign & publish" + : "Sign & confirm"; + signerLabel(); + $("composer").showModal(); + if (bodyRequired) $("message").focus(); + } + async function connect() { + const file = $("key-file").files[0], + handle = $("handle").value.trim().replace(/^@/, ""); + $("key-file").value = ""; + state.signer = null; + signerLabel(); + const epoch = state.epoch; + $("connect").disabled = true; + try { + if ( + !file || + file.size !== 32 || + !/^[a-zA-Z0-9_-]{1,64}$/.test(handle) || + handle.toLowerCase() === "root" + ) + throw new Error( + "Choose your 32-byte identity.key and a non-root handle.", + ); + const identity = await json("/@" + encodeURIComponent(handle) + "/pk"); + const certificatePage = await json( + "/@" + encodeURIComponent(handle) + "/cert/json", + ); + const certificates = (certificatePage.certificates || []) + .filter((c) => !c.revoked) + .map((c) => c.id); + const seed = new Uint8Array(await file.arrayBuffer()); + const signer = await M.importSigner(seed, identity, certificates); + if (epoch !== state.epoch || document.hidden) return; + state.signer = { ...signer, handle: "@" + handle }; + $("key-status").textContent = + "Connected in this tab. The key has not been uploaded."; + signerLabel(); + $("key-dialog").close(); + } catch (e) { + $("key-status").textContent = errorMessage(e); + } finally { + $("connect").disabled = false; + } + } + async function send() { + signerLabel(); + if (!state.signer) { + $("key-dialog").showModal(); + return; + } + const context = state.compose; + if (!context) return; + const signer = state.signer, + epoch = state.epoch; + let packetPrepared = false; + $("send").disabled = true; + try { + const args = { ...context.args }; + if (!$("message").hidden) { + if (!$("message").value.trim()) + throw new Error("Write a message first."); + args.body = $("message").value; + if (args.body.isWellFormed && !args.body.isWellFormed()) + throw new Error("The message contains an invalid Unicode character."); + } + if (context.operation === "content.post_create") { + args.parent = safePath($("destination").value.trim()); + } + if ( + context.operation.startsWith("communication.") && + state.account && + signer.subject !== state.account.id + ) + throw new Error( + "Connect the identity key belonging to this private account.", + ); + if (!state.service) + throw new Error("The service identity has not been loaded yet."); + const fingerprint = M.canonical([ + signer.subject, + context.operation, + args, + ]); + if (state.retry && state.retry.fingerprint !== fingerprint) + throw new Error( + "A previous delivery is unconfirmed. Restore that message and retry, or close and reopen the composer before starting a different action.", + ); + if (!state.retry) + state.retry = { + fingerprint, + id: crypto.randomUUID().replace(/-/g, ""), + }; + const packet = await M.packet( + signer, + state.service, + context.operation, + args, + state.retry.id, + ); + if (epoch !== state.epoch || document.hidden) return; + packetPrepared = true; + $("send-status").textContent = "Sending your signed request…"; + const result = await json("/-/p/" + context.operation, { packet }); + if (epoch !== state.epoch) return; + state.retry = null; + state.compose = null; + $("message").value = ""; + $("composer").close(); + if (state.mode === "private") { + const page = await json("/_universe/me", { private: true }); + if (epoch !== state.epoch) return; + if (!page.account || page.account.id !== state.account.id) { + publicMode(false); + throw new Error("Your private session ended."); + } + state.conversations = page.conversations; + catalog(); + renderGraph(); + const conversation = state.conversations.find( + (c) => c.conversation_id === context.args.conversation_id, + ); + if (conversation) + await select( + { + id: conversation.conversation_id, + name: conversation.contact.name, + kind: "private", + conversation, + }, + true, + ); + } else { + await loadPublic(true); + const post = state.posts.get(result.resources?.[0]?.id); + if (post) + await select( + { ...post, kind: post.reply_to ? "reply" : "post" }, + true, + ); + } + notice("Confirmed by the server."); + } catch (e) { + if (epoch === state.epoch) + $("send-status").textContent = + errorMessage(e) + + (packetPrepared + ? " Retry the unchanged message to reuse the same request ID." + : ""); + } finally { + $("send").disabled = false; + } + } + renderer = new globalThis.MSGUniverseRenderer($("space"), $("labels"), { + select: (item) => select(item, true), + overview: () => closeDetail(), + tick: (time) => { + renderer.graph = graph(time); + }, + camera: (target) => { + $("coordinates").textContent = target + .map( + (n, i) => + "XYZ"[i] + + " " + + (n >= 0 ? "+" : "−") + + Math.abs(Math.round(n)).toString().padStart(3, "0"), + ) + .join(" "); + }, + fallback: (message) => { + notice(message, true); + $("catalog").hidden = false; + }, + motion: (paused) => { + $("pause").setAttribute("aria-pressed", String(paused)); + $("pause").textContent = paused ? "▷" : "Ⅱ"; + }, + }); + $("pause").setAttribute("aria-pressed", String(renderer.paused)); + $("pause").textContent = renderer.paused ? "▷" : "Ⅱ"; + $("home").onclick = () => renderer.home(); + $("pause").onclick = () => { + renderer.pause(!renderer.paused); + $("pause").setAttribute("aria-pressed", String(renderer.paused)); + $("pause").textContent = renderer.paused ? "▷" : "Ⅱ"; + $("pause").setAttribute( + "aria-label", + renderer.paused ? "Resume motion" : "Pause motion", + ); + }; + $("drift").onclick = () => { + const list = + state.mode === "private" + ? privateGraph().nodes + : [...state.users.values()].map((u) => ({ ...u, kind: "user" })); + const unseen = list.filter((n) => !state.visited.has(n.id)), + choices = unseen.length ? unseen : list; + if (!choices.length) { + notice( + "No stars loaded yet. Try reconnecting or loading another sector.", + ); + return; + } + select(choices[Math.floor(Math.random() * choices.length)], true); + }; + $("catalog-toggle").onclick = () => { + $("catalog").hidden = !$("catalog").hidden; + if (!$("catalog").hidden) { + catalog(); + $("catalog").focus(); + } + }; + $("catalog-close").onclick = () => { + $("catalog").hidden = true; + $("catalog-toggle").focus(); + }; + document.querySelector(".skip").onclick = (e) => { + e.preventDefault(); + catalog(); + $("catalog").hidden = false; + $("catalog").focus(); + }; + $("detail-close").onclick = () => { + closeDetail(); + $("space").focus(); + }; + $("public-tab").onclick = () => publicMode(); + $("private-tab").onclick = privateMode; + $("search-toggle").onclick = () => { + $("search-box").hidden = false; + $("search").focus(); + }; + $("search-close").onclick = () => { + $("search-box").hidden = true; + $("search-toggle").focus(); + }; + $("search").oninput = search; + $("compose-open").onclick = () => compose("content.post_create", {}); + $("compose-close").onclick = () => { + $("composer").close(); + $("message").value = ""; + state.retry = null; + state.compose = null; + }; + $("connect-open").onclick = () => { + $("key-dialog").showModal(); + }; + $("key-close").onclick = () => $("key-dialog").close(); + $("connect").onclick = connect; + $("send").onclick = send; + $("disconnect").onclick = () => { + state.signer = null; + $("key-file").value = ""; + $("key-status").textContent = "Disconnected."; + signerLabel(); + }; + $("more").onclick = () => loadPublic(); + $("retry").onclick = () => loadPublic(true); + document.addEventListener("keydown", (e) => { + if ( + e.key === "/" && + !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) && + !$("composer").open && + !$("key-dialog").open + ) { + e.preventDefault(); + $("search-box").hidden = false; + $("search").focus(); + } + if (e.key === "Escape" && !$("composer").open && !$("key-dialog").open) { + closeDetail(); + $("catalog").hidden = true; + $("search-box").hidden = true; + } + }); + const hide = (e) => { + if (document.hidden || e.type === "pagehide") { + const wasPrivate = state.mode === "private"; + publicMode(false); + $("account-link").textContent = "Sign in ↗"; + if (renderer.software) + renderer.context.clearRect( + 0, + 0, + renderer.canvas.width, + renderer.canvas.height, + ); + else if (renderer.available) + renderer.gl.clear(renderer.gl.COLOR_BUFFER_BIT); + if (wasPrivate) + notice( + "Private view cleared. Open My orbit again to verify your session.", + true, + ); + } + }; + document.addEventListener("visibilitychange", hide); + window.addEventListener("pagehide", hide); + setInterval(signerLabel, 15000); + loadPublic(true); +})(); diff --git a/src/msg/data/root-web-model.js b/src/msg/data/root-web-model.js new file mode 100644 index 00000000..5ba979ee --- /dev/null +++ b/src/msg/data/root-web-model.js @@ -0,0 +1,223 @@ +/* Deterministic geometry and protocol-v1 signing. No network or persistent state. */ +(() => { + "use strict"; + const TAU = Math.PI * 2; + function random(seed) { + let h = 2166136261; + for (const c of seed) h = Math.imul(h ^ c.codePointAt(0), 16777619); + return () => { + h += 0x6d2b79f5; + let t = Math.imul(h ^ (h >>> 15), h | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + function position(id) { + const r = random(id), + radius = 22 + Math.sqrt(r()) * 128, + angle = r() * TAU; + return [ + Math.cos(angle) * radius, + (r() - 0.5) * 64, + Math.sin(angle) * radius, + ]; + } + function satellite(id, center, time = 0) { + const r = random(id), + radius = 8 + r() * 19, + angle = r() * TAU + time * 0.022; + return [ + center[0] + Math.cos(angle) * radius, + center[1] + Math.sin(angle * 1.5) * (2 + r() * 6), + center[2] + Math.sin(angle) * radius, + ]; + } + function graph(users, posts, time, focus) { + const stars = users.map((user) => ({ + ...user, + kind: "user", + position: position(user.id), + })); + const index = new Map(stars.map((star) => [star.id, star])); + const nodes = [...stars], + links = []; + for (const post of posts) { + const star = index.get(post.author?.id); + if (!star) continue; + const node = { + ...post, + kind: post.reply_to ? "reply" : "post", + position: satellite(post.id, star.position, time), + }; + index.set(node.id, node); + if (focus === star.id || focus === node.id) { + nodes.push(node); + links.push([star, node, "orbit"]); + } + } + const pairs = new Set(); + for (const post of posts) { + const a = index.get(post.id), + b = index.get(post.reply_to?.id); + if (!a || !b) continue; + const from = index.get(a.author?.id), + to = index.get(b.author?.id); + if (from && to && from !== to) { + const pair = [from.id, to.id].sort().join(":"); + if (!pairs.has(pair)) { + links.push([from, to, "discussion"]); + pairs.add(pair); + } + } + if (focus === a.author?.id || focus === a.id || focus === b.id) { + if (!nodes.includes(a)) nodes.push(a); + if (!nodes.includes(b)) nodes.push(b); + links.push([a, b, "reply"]); + } + } + return { nodes, links }; + } + const bytes = (value) => new TextEncoder().encode(value); + const b64 = (buffer) => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const unb64 = (value) => + Uint8Array.from( + atob( + value.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (value.length % 4)) % 4), + ), + (c) => c.charCodeAt(0), + ); + function canonical(value) { + if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"; + if (value !== null && typeof value === "object") + return ( + "{" + + Object.keys(value) + .sort() + .map((key) => JSON.stringify(key) + ":" + canonical(value[key])) + .join(",") + + "}" + ); + return JSON.stringify(value); + } + async function sha(value) { + return Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", value)), + (x) => x.toString(16).padStart(2, "0"), + ).join(""); + } + async function importSigner(seed, identity, certificates = []) { + if ( + !crypto.subtle || + seed.length !== 32 || + identity.retired_at || + identity.subject_id === "u_root" + ) { + seed.fill(0); + throw new Error( + "Use your active 32-byte identity.key. Root keys cannot be used here.", + ); + } + const header = Uint8Array.from([ + 48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32, + ]); + const pkcs8 = new Uint8Array(48); + pkcs8.set(header); + pkcs8.set(seed, 16); + try { + const key = await crypto.subtle.importKey( + "pkcs8", + pkcs8, + "Ed25519", + false, + ["sign"], + ); + const pub = unb64(identity.public_key), + verifyKey = await crypto.subtle.importKey( + "raw", + pub, + "Ed25519", + false, + ["verify"], + ); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const proof = await crypto.subtle.sign("Ed25519", key, challenge); + if ( + !(await crypto.subtle.verify("Ed25519", verifyKey, proof, challenge)) || + identity.key_id !== "k_" + (await sha(pub)) + ) + throw new Error("This key does not belong to that active identity."); + return { + key, + subject: identity.subject_id, + keyId: identity.key_id, + certificates, + expires: Date.now() + 600000, + }; + } finally { + seed.fill(0); + pkcs8.fill(0); + } + } + const OPERATIONS = new Set([ + "content.post_create", + "discussion.reply", + "communication.dm_send", + "communication.dm_request", + "communication.dm_accept", + "communication.dm_reject", + ]); + async function packet(signer, service, operation, args, requestId) { + if (!signer || Date.now() >= signer.expires) + throw new Error("Connect your identity key again."); + if (!OPERATIONS.has(operation)) + throw new Error("Operation is not available in the universe."); + const envelope = { + request_id: requestId, + protocol_version: 1, + operation, + contract_version: 1, + target_service: service, + subject: signer.subject, + arguments: args, + expected_generations: [], + return_fields: [], + }; + envelope.payload_digest = + "sha256:" + (await sha(bytes(canonical(envelope)))); + envelope.expires_at = new Date(Date.now() + 180000) + .toISOString() + .replace("Z", "000Z"); + envelope.source = "manual"; + const signature = await crypto.subtle.sign( + "Ed25519", + signer.key, + bytes("msg.lmm.best/v1/request\0" + canonical(envelope)), + ); + return { + ...envelope, + proof: { + signature: { + algorithm: "ed25519", + key_id: signer.keyId, + value: b64(signature), + }, + certificates: signer.certificates, + }, + }; + } + globalThis.MSGUniverse = Object.freeze({ + TAU, + random, + position, + satellite, + graph, + canonical, + importSigner, + packet, + }); +})(); diff --git a/src/msg/data/root-web-renderer.js b/src/msg/data/root-web-renderer.js new file mode 100644 index 00000000..69ce5e45 --- /dev/null +++ b/src/msg/data/root-web-renderer.js @@ -0,0 +1,736 @@ +/* Small native WebGL renderer. Scenery is never represented as a user. */ +(() => { + "use strict"; + const M = globalThis.MSGUniverse; + const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); + const mix = (a, b, t) => a + (b - a) * t; + const vs = `attribute vec3 aPosition; attribute vec4 aColor; attribute float aSize; + uniform vec3 uTarget; uniform vec3 uRight; uniform vec3 uUp; uniform vec3 uEye; + uniform vec3 uLens; uniform float uHeight; varying vec4 vColor; + void main(){ vec3 r=aPosition-uTarget; float d=uLens.z-dot(r,uEye); + gl_Position=vec4(dot(r,uRight)*uLens.x/uLens.y,dot(r,uUp)*uLens.x,1.0002*d-0.2,d); + gl_PointSize=clamp(aSize*uLens.x*uHeight/max(d,0.1),1.0,100.0); vColor=aColor; }`; + const fs = `precision mediump float; varying vec4 vColor; uniform float uPoints; + void main(){float alpha=vColor.a;if(uPoints>0.5){float d=length(gl_PointCoord-0.5)*2.0; + if(d>1.0)discard; alpha*=exp(-d*d*9.0)+0.045*(1.0-d);} + gl_FragColor=vec4(vColor.rgb,alpha);}`; + const palette = { + user: [1, 0.9, 0.72], + post: [0.52, 0.77, 0.91], + reply: [0.72, 0.79, 1], + private: [0.58, 0.81, 0.77], + "private-message": [0.58, 0.81, 0.77], + }; + class UniverseRenderer { + constructor(canvas, labels, callbacks = {}) { + this.canvas = canvas; + this.labels = labels; + this.callbacks = callbacks; + this.camera = { + target: [0, 0, 0], + distance: 370, + yaw: 0.62, + pitch: 0.48, + }; + this.graph = { nodes: [], links: [] }; + this.focusId = null; + this.keys = new Set(); + this.pointers = new Map(); + this.labelNodes = new Map(); + this.dirty = true; + this.reduced = matchMedia("(prefers-reduced-motion: reduce)"); + this.paused = this.reduced.matches; + this.clock = 0; + this.last = 0; + this.frame = 0; + this.available = false; + this.lost = false; + const rng = M.random("msg:decorative-dust:not-accounts"); + this.dust = []; + for (let i = 0; i < 2100; i++) { + const a = rng() * M.TAU, + r = Math.sqrt(rng()) * 225, + y = (rng() - 0.5) * (6 + (18 * r) / 225); + this.dust.push( + Math.cos(a) * r, + y, + Math.sin(a) * r, + 0.61, + 0.64, + 0.67, + 0.24 + rng() * 0.29, + 0.22 + rng() * 0.52, + ); + } + for (let i = 0; i < 250; i++) + this.dust.push( + (rng() - 0.5) * 950, + (rng() - 0.5) * 600, + (rng() - 0.5) * 950, + 0.58, + 0.63, + 0.69, + 0.35, + 0.45, + ); + canvas.addEventListener("webglcontextlost", (e) => { + e.preventDefault(); + this.lost = true; + this.available = false; + cancelAnimationFrame(this.frame); + this.frame = 0; + this.labels.replaceChildren(); + this.callbacks.fallback?.( + "The graphics context paused. The star catalog still works.", + ); + }); + canvas.addEventListener("webglcontextrestored", () => { + this.lost = false; + this.labelNodes.clear(); + this.init(); + this.wake(); + }); + this.init(); + this.events(); + this.observer = new ResizeObserver(() => this.resize()); + this.observer.observe(canvas); + this.resize(); + this.wake(); + } + init() { + try { + this.gl = this.canvas.getContext("webgl", { + alpha: true, + antialias: true, + powerPreference: "low-power", + preserveDrawingBuffer: false, + }); + if (!this.gl) { + this.context = this.canvas.getContext("2d"); + if (!this.context) + throw new Error( + "Graphics are unavailable. Explore using the star catalog.", + ); + this.software = true; + this.available = true; + this.canvas.dataset.renderer = "canvas-3d"; + return; + } + const gl = this.gl; + const shader = (type, text) => { + const s = gl.createShader(type); + gl.shaderSource(s, text); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) + throw new Error( + "The graphics shader could not start: " + gl.getShaderInfoLog(s), + ); + return s; + }; + const vertex = shader(gl.VERTEX_SHADER, vs), + fragment = shader(gl.FRAGMENT_SHADER, fs); + this.program = gl.createProgram(); + gl.attachShader(this.program, vertex); + gl.attachShader(this.program, fragment); + gl.linkProgram(this.program); + gl.deleteShader(vertex); + gl.deleteShader(fragment); + if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) + throw new Error("The graphics program could not start."); + gl.useProgram(this.program); + this.buffer = gl.createBuffer(); + this.uniforms = {}; + for (const name of [ + "uTarget", + "uRight", + "uUp", + "uEye", + "uLens", + "uHeight", + "uPoints", + ]) + this.uniforms[name] = gl.getUniformLocation(this.program, name); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + for (const [name, size, offset] of [ + ["aPosition", 3, 0], + ["aColor", 4, 12], + ["aSize", 1, 28], + ]) { + const loc = gl.getAttribLocation(this.program, name); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 32, offset); + } + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE); + gl.disable(gl.DEPTH_TEST); + gl.clearColor(0, 0, 0, 0); + this.available = true; + this.canvas.dataset.renderer = "webgl"; + } catch (error) { + this.available = false; + this.callbacks.fallback?.(error.message); + } + } + resize() { + const rect = this.canvas.getBoundingClientRect(); + this.width = rect.width; + this.height = rect.height; + const dpr = Math.min(devicePixelRatio || 1, 1.75); + this.canvas.width = Math.round(rect.width * dpr); + this.canvas.height = Math.round(rect.height * dpr); + this.wake(); + } + setGraph(graph) { + this.graph = graph; + this.wake(); + } + setFocus(id) { + this.focusId = id; + this.wake(); + } + focus(node, distance = 74) { + this.focusId = node.id; + this.travel(node.position, distance); + } + travel(target, distance) { + this.destination = { target: [...target], distance }; + if (this.reduced.matches) { + this.camera.target = [...target]; + this.camera.distance = distance; + this.destination = null; + } + this.wake(); + } + home() { + this.focusId = null; + this.travel([0, 0, 0], 370); + this.callbacks.overview?.(); + } + pause(value) { + this.paused = value; + this.wake(); + } + wake() { + this.dirty = true; + if (!this.frame && this.available && !document.hidden && !this.lost) + this.frame = requestAnimationFrame((t) => this.render(t)); + } + basis() { + const c = this.camera, + sy = Math.sin(c.yaw), + cy = Math.cos(c.yaw), + sp = Math.sin(c.pitch), + cp = Math.cos(c.pitch); + return { + right: [cy, 0, -sy], + up: [-sp * sy, cp, -sp * cy], + eye: [cp * sy, sp, cp * cy], + }; + } + project(p) { + const b = this.basis(), + r = p.map((n, i) => n - this.camera.target[i]), + dot = (v) => r.reduce((s, n, i) => s + n * v[i], 0), + depth = this.camera.distance - dot(b.eye); + if (depth < 1) return null; + const scale = (1.72 * this.height * 0.5) / depth; + return { + x: this.width / 2 + dot(b.right) * scale, + y: this.height / 2 - dot(b.up) * scale, + depth, + scale, + }; + } + hit(x, y) { + let best = null, + score = Infinity; + for (const node of this.graph.nodes) { + const p = this.project(node.position); + if (!p) continue; + const d = Math.hypot(p.x - x, p.y - y); + if (d < Math.max(15, Math.min(28, p.scale * 3)) && d < score) { + score = d; + best = node; + } + } + return best; + } + events() { + const c = this.canvas; + c.addEventListener("contextmenu", (e) => e.preventDefault()); + c.addEventListener("pointerdown", (e) => { + if (e.button !== 0 && e.button !== 2) return; + c.focus({ preventScroll: true }); + c.setPointerCapture(e.pointerId); + this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (this.pointers.size > 1 && this.start) this.start.dragged = true; + this.start = { + x: e.clientX, + y: e.clientY, + dragged: this.pointers.size > 1, + }; + this.destination = null; + this.wake(); + }); + c.addEventListener("pointermove", (e) => { + const old = this.pointers.get(e.pointerId); + if (!old) { + const r = c.getBoundingClientRect(); + c.style.cursor = this.hit(e.clientX - r.left, e.clientY - r.top) + ? "pointer" + : "grab"; + return; + } + const next = { x: e.clientX, y: e.clientY }, + dx = next.x - old.x, + dy = next.y - old.y; + if (Math.hypot(e.clientX - this.start.x, e.clientY - this.start.y) > 5) + this.start.dragged = true; + if (this.pointers.size === 2) { + const other = [...this.pointers.entries()].find( + ([id]) => id !== e.pointerId, + )[1]; + const before = Math.hypot(old.x - other.x, old.y - other.y), + after = Math.hypot(next.x - other.x, next.y - other.y); + if (after > 8) + this.camera.distance = clamp( + (this.camera.distance * before) / after, + 22, + 900, + ); + } else if (e.shiftKey || e.buttons === 2) + this.pan( + (-dx / this.height) * this.camera.distance, + (dy / this.height) * this.camera.distance, + ); + else { + this.camera.yaw -= dx * 0.004; + this.camera.pitch = clamp(this.camera.pitch + dy * 0.003, -1.3, 1.3); + } + this.pointers.set(e.pointerId, next); + this.wake(); + }); + const release = (e) => { + const start = this.start; + this.pointers.delete(e.pointerId); + if ( + !this.pointers.size && + start && + !start.dragged && + e.type === "pointerup" && + e.button === 0 + ) { + const r = c.getBoundingClientRect(), + node = this.hit(e.clientX - r.left, e.clientY - r.top); + if (node) this.callbacks.select?.(node); + } + this.wake(); + }; + c.addEventListener("pointerup", release); + c.addEventListener("pointercancel", release); + c.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + this.destination = null; + this.camera.distance = clamp( + this.camera.distance * + Math.exp(clamp(e.deltaY, -200, 200) * 0.0015), + 22, + 900, + ); + this.wake(); + }, + { passive: false }, + ); + c.addEventListener("keydown", (e) => { + const key = e.key.toLowerCase(); + if ( + [ + "w", + "a", + "s", + "d", + "q", + "e", + "arrowleft", + "arrowright", + "arrowup", + "arrowdown", + "h", + ].includes(key) + ) { + e.preventDefault(); + if (key === "h") this.home(); + else this.keys.add(key); + this.wake(); + } + }); + c.addEventListener("keyup", (e) => this.keys.delete(e.key.toLowerCase())); + c.addEventListener("blur", () => this.keys.clear()); + document.addEventListener("visibilitychange", () => { + this.keys.clear(); + this.pointers.clear(); + this.last = 0; + if (document.hidden) { + cancelAnimationFrame(this.frame); + this.frame = 0; + } else this.wake(); + }); + this.reduced.addEventListener("change", () => { + this.pause(this.reduced.matches); + this.callbacks.motion?.(this.paused); + }); + } + pan(x, y) { + const b = this.basis(); + this.camera.target = this.camera.target.map((n, i) => + clamp(n + b.right[i] * x + b.up[i] * y, -500, 500), + ); + } + render(timestamp) { + this.frame = 0; + if (!this.available || document.hidden || this.lost) return; + const dt = Math.min(0.04, this.last ? (timestamp - this.last) / 1000 : 0); + this.last = timestamp; + const c = this.camera; + if (!this.paused) { + this.clock += dt; + if (!this.pointers.size && !this.keys.size && !this.destination) + c.yaw += dt * 0.008; + } + if (this.destination) { + const t = 1 - Math.exp(-dt * 5); + c.target = c.target.map((n, i) => + mix(n, this.destination.target[i], t), + ); + c.distance = mix(c.distance, this.destination.distance, t); + if ( + Math.abs(c.distance - this.destination.distance) < 0.03 && + Math.hypot( + ...c.target.map((n, i) => n - this.destination.target[i]), + ) < 0.03 + ) + this.destination = null; + } + if (this.keys.size) { + this.destination = null; + const s = dt * c.distance * 0.45; + this.pan( + (this.keys.has("d") - this.keys.has("a")) * s, + (this.keys.has("w") - this.keys.has("s")) * s, + ); + c.yaw += + (this.keys.has("arrowright") - this.keys.has("arrowleft")) * dt; + c.pitch = clamp( + c.pitch + + (this.keys.has("arrowup") - this.keys.has("arrowdown")) * dt, + -1.3, + 1.3, + ); + c.distance = clamp( + c.distance * Math.exp((this.keys.has("q") - this.keys.has("e")) * dt), + 22, + 900, + ); + } + this.callbacks.tick?.(this.clock); + if (this.software) { + this.renderSoftware(); + this.finish(); + return; + } + const gl = this.gl, + b = this.basis(), + u = this.uniforms; + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(this.program); + gl.uniform3fv(u.uTarget, c.target); + gl.uniform3fv(u.uRight, b.right); + gl.uniform3fv(u.uUp, b.up); + gl.uniform3fv(u.uEye, b.eye); + gl.uniform3f( + u.uLens, + 1.72, + this.width / Math.max(1, this.height), + c.distance, + ); + gl.uniform1f(u.uHeight, this.canvas.height); + const draw = (vertices, mode) => { + if (!vertices.length) return; + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(vertices), + gl.DYNAMIC_DRAW, + ); + gl.uniform1f(u.uPoints, mode === gl.POINTS ? 1 : 0); + gl.drawArrays(mode, 0, vertices.length / 8); + }; + draw(this.dust, gl.POINTS); + const lines = [], + points = [], + triangles = []; + const vertex = (p, color, alpha, size = 1) => [ + ...p, + ...color, + alpha, + size, + ]; + const line = (a, z, col, opacity) => + lines.push(...vertex(a, col, opacity), ...vertex(z, col, opacity)); + for (const [a, z, type] of this.graph.links) { + const col = + type === "private" + ? palette.private + : type === "orbit" + ? palette.post + : palette.reply, + segments = type === "orbit" ? 1 : 24; + let last = a.position; + for (let j = 1; j <= segments; j++) { + const t = j / segments, + p = a.position.map( + (n, i) => + mix(n, z.position[i], t) + + (i === 1 + ? Math.sin(t * Math.PI) * (type === "private" ? 8 : 14) + : 0), + ); + line(last, p, col, type === "orbit" ? 0.12 : 0.16); + last = p; + } + } + for (const node of this.graph.nodes) { + const p = node.position, + col = palette[node.kind] || palette.user, + selected = node.id === this.focusId; + points.push( + ...vertex( + p, + col, + selected ? 0.95 : 0.8, + node.kind === "user" || node.kind === "private" ? 5 : 2.8, + ), + ); + if (node.kind === "user" || node.kind === "private") { + const r = selected ? 2.5 : 1.25, + rotation = this.clock * 0.08; + const corners = [ + [0, r * 1.6, 0], + [r, 0, 0], + [0, 0, r], + [-r, 0, 0], + [0, 0, -r], + [0, -r * 1.6, 0], + ].map((v) => [ + p[0] + v[0] * Math.cos(rotation) - v[2] * Math.sin(rotation), + p[1] + v[1], + p[2] + v[0] * Math.sin(rotation) + v[2] * Math.cos(rotation), + ]); + for (let j = 1; j <= 4; j++) { + const k = j === 4 ? 1 : j + 1; + line(corners[0], corners[j], col, 0.72); + line(corners[5], corners[j], col, 0.38); + line(corners[j], corners[k], col, 0.38); + triangles.push( + ...vertex(corners[0], col, 0.09), + ...vertex(corners[j], col, 0.09), + ...vertex(corners[k], col, 0.09), + ); + } + } + if (selected) { + for (const radius of [6, 9.5]) { + for (let j = 0; j < 90; j++) { + const a = (j / 90) * M.TAU, + z = ((j + 1) / 90) * M.TAU; + if (radius === 9.5 && j % 9 > 5) continue; + line( + [ + p[0] + Math.cos(a) * radius, + p[1], + p[2] + Math.sin(a) * radius, + ], + [ + p[0] + Math.cos(z) * radius, + p[1], + p[2] + Math.sin(z) * radius, + ], + col, + radius === 6 ? 0.45 : 0.22, + ); + } + } + } + } + draw(triangles, gl.TRIANGLES); + draw(lines, gl.LINES); + draw(points, gl.POINTS); + this.finish(); + } + finish() { + this.updateLabels(); + this.callbacks.camera?.(this.camera.target); + this.dirty = false; + if (!this.paused || this.destination || this.keys.size) + this.frame = requestAnimationFrame((t) => this.render(t)); + } + renderSoftware() { + const ctx = this.context, + dpr = this.canvas.width / Math.max(this.width, 1); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, this.width, this.height); + const stroke = (points, color, width = 1) => { + ctx.beginPath(); + let started = false; + for (const pos of points) { + const p = this.project(pos); + if (!p) { + started = false; + continue; + } + if (!started) { + ctx.moveTo(p.x, p.y); + started = true; + } else ctx.lineTo(p.x, p.y); + } + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.stroke(); + }; + for (let i = 0; i < this.dust.length; i += 8) { + const p = this.project(this.dust.slice(i, i + 3)); + if (!p || p.x < 0 || p.x > this.width || p.y < 0 || p.y > this.height) + continue; + ctx.fillStyle = "rgba(173,184,197," + this.dust[i + 6] * 0.6 + ")"; + const size = Math.min(1.5, Math.max(0.55, p.scale * 0.35)); + ctx.fillRect(p.x, p.y, size, size); + } + for (const [a, z, type] of this.graph.links) { + const points = []; + for (let j = 0; j <= 24; j++) { + const t = j / 24; + points.push( + a.position.map( + (n, i) => + mix(n, z.position[i], t) + + (i === 1 + ? Math.sin(t * Math.PI) * (type === "orbit" ? 0 : 14) + : 0), + ), + ); + } + stroke( + points, + type === "private" + ? "rgba(141,200,190,.22)" + : type === "orbit" + ? "rgba(145,187,206,.16)" + : "rgba(164,179,220,.20)", + ); + } + const sorted = this.graph.nodes + .map((n) => ({ n, p: this.project(n.position) })) + .filter(({ p }) => p) + .sort((a, b) => b.p.depth - a.p.depth); + for (const { n, p } of sorted) { + const color = + n.kind === "user" + ? "239,215,168" + : n.kind.startsWith("private") + ? "148,207,196" + : "145,191,216", + selected = n.id === this.focusId, + r = Math.min( + 20, + Math.max(3, p.scale * (n.kind === "user" ? 3 : 1.8)), + ); + const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3); + glow.addColorStop(0, "rgba(" + color + ",.55)"); + glow.addColorStop(0.25, "rgba(" + color + ",.12)"); + glow.addColorStop(1, "rgba(" + color + ",0)"); + ctx.fillStyle = glow; + ctx.fillRect(p.x - r * 3, p.y - r * 3, r * 6, r * 6); + const pos = n.position, + s = Math.min(2, selected ? 2 : 1.2), + top = [pos[0], pos[1] + s * 1.6, pos[2]], + bottom = [pos[0], pos[1] - s * 1.6, pos[2]], + corners = [ + [s, 0, 0], + [0, 0, s], + [-s, 0, 0], + [0, 0, -s], + ].map((v) => v.map((x, i) => x + pos[i])); + if (n.kind === "user" || n.kind === "private") { + for (const corner of corners) + stroke([top, corner, bottom], "rgba(" + color + ",.7)"); + stroke([...corners, corners[0]], "rgba(" + color + ",.4)"); + } else { + ctx.fillStyle = "rgba(" + color + ",.95)"; + ctx.fillRect(p.x - 1.4, p.y - 1.4, 2.8, 2.8); + } + if (selected) + for (const radius of [6, 9.5]) { + const ring = []; + for (let j = 0; j <= 90; j++) { + const a = (j / 90) * M.TAU; + ring.push([ + pos[0] + Math.cos(a) * radius, + pos[1], + pos[2] + Math.sin(a) * radius, + ]); + } + stroke(ring, "rgba(" + color + ",.35)"); + } + } + } + updateLabels() { + const candidates = this.graph.nodes + .map((n) => ({ n, p: this.project(n.position) })) + .filter( + ({ p }) => + p && + p.x > 30 && + p.x < this.width - 70 && + p.y > 125 && + p.y < this.height - 150, + ) + .sort( + (a, b) => + (b.n.id === this.focusId) - (a.n.id === this.focusId) || + a.p.depth - b.p.depth, + ); + const used = [], + visible = new Set(); + for (const { n, p } of candidates) { + if (used.length >= 14) break; + if ( + used.some( + (q) => Math.abs(q.x - p.x) < 100 && Math.abs(q.y - p.y) < 32, + ) + ) + continue; + used.push(p); + visible.add(n.id); + let el = this.labelNodes.get(n.id); + if (!el) { + el = document.createElement("span"); + el.className = "star-label"; + el.textContent = + (n.kind === "user" ? "@" : "") + (n.name || n.title || "Signal"); + this.labels.append(el); + this.labelNodes.set(n.id, el); + } + el.classList.toggle("selected", n.id === this.focusId); + el.style.transform = `translate(${Math.round(p.x + 12)}px,${Math.round(p.y - 7)}px)`; + } + for (const [id, el] of this.labelNodes) + if (!visible.has(id)) { + el.remove(); + this.labelNodes.delete(id); + } + } + } + globalThis.MSGUniverseRenderer = UniverseRenderer; +})(); diff --git a/src/msg/data/root-web.css b/src/msg/data/root-web.css new file mode 100644 index 00000000..4e7ffdd1 --- /dev/null +++ b/src/msg/data/root-web.css @@ -0,0 +1,873 @@ +@font-face { + font-family: SpaceSans; + src: url(data:font/woff2;base64,__SANS_FONT__) format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: SpaceSans; + src: url(data:font/woff2;base64,__SANS_BOLD_FONT__) format("woff2"); + font-weight: 600 800; + font-display: swap; +} +:root { + --ink: #ece9e0; + --muted: #91959b; + --quiet: #555d67; + --gold: #ceba92; + --bg: #0b0e13; + --panel: #15191fec; + --blue: #92b7c6; + font-family: + SpaceSans, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: var(--ink); + background: var(--bg); + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +textarea { + font: inherit; +} +button, +a, +input, +textarea { + -webkit-tap-highlight-color: transparent; +} +button, +a { + color: inherit; +} +button { + cursor: pointer; + border: 0; + background: transparent; + border-radius: 7px; + padding: 10px 13px; + font-size: 13px; +} +button:hover { + background: #ffffff0d; +} +button:disabled { + opacity: 0.4; + cursor: default; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +canvas:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 4px; +} +a { + text-decoration: none; +} +a:hover { + color: #fff; +} +button[aria-pressed="true"] { + background: #ffffff0b; + color: var(--ink); +} +[hidden] { + display: none !important; +} +#universe { + position: relative; + width: 100%; + height: 100svh; + min-height: 560px; + overflow: hidden; + background: radial-gradient( + ellipse at 48% 46%, + #19212b 0, + #10151d 32%, + #0b0e13 70% + ); +} +#space { + width: 100%; + height: 100%; + display: block; + touch-action: none; + cursor: grab; +} +#space:active { + cursor: grabbing; +} +.vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 180deg, + #0b0e1390 0, + transparent 18%, + transparent 70%, + #0b0e13c0 100% + ); +} +.topbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 102px; + padding: 0 3.5%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + z-index: 4; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + letter-spacing: -0.03em; +} +.brand b { + font-size: 22px; + letter-spacing: -0.07em; +} +.brand > span:last-child { + color: var(--muted); + letter-spacing: 0.01em; +} +.brand-divider { + color: #484b51; + margin: 0 4px; +} +.brand-icon { + display: flex; + width: 27px; + height: 27px; + filter: invert(1); + opacity: 0.9; +} +.brand-icon svg { + width: 100%; + height: 100%; +} +.switcher { + display: flex; + gap: 5px; + padding: 4px; + border-radius: 28px; + background: #ffffff04; +} +.switcher button { + border-radius: 20px; + color: var(--muted); + padding: 10px 22px; +} +.top-actions { + display: flex; + align-items: center; + gap: 23px; + font-size: 13px; +} +.top-actions button { + color: var(--muted); +} +kbd { + font: + 10px ui-monospace, + monospace; + padding: 3px 6px; + border: 1px solid #ffffff15; + border-radius: 3px; + margin-left: 15px; +} +.coordinates { + position: absolute; + top: 19%; + left: 5%; + pointer-events: none; +} +.eyebrow { + display: block; + font: + 10px ui-monospace, + SFMono-Regular, + monospace; + letter-spacing: 0.16em; + color: var(--muted); +} +h1 { + font-size: clamp(24px, 2.6vw, 38px); + line-height: 1.24; + font-weight: 400; + letter-spacing: -0.045em; + margin: 22px 0 17px; +} +.coordinates p { + font-size: 12px; + line-height: 1.9; + color: var(--muted); +} +.coordinates .mono { + margin-top: 27px; + color: var(--quiet); + font-size: 9px; + letter-spacing: 0.06em; +} +.mono { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 10px; +} +.flightbar { + position: absolute; + bottom: 34px; + left: 4%; + right: 4%; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 24px; + z-index: 3; +} +.expedition { + width: 265px; +} +.expedition > div:not(.progress-track) { + font-size: 11px; + margin: 13px 0; + line-height: 1.7; + color: #c1c0ba; +} +.progress-track { + height: 2px; + background: #ffffff0b; + width: 200px; + margin: 14px 0; +} +.progress-track span { + display: block; + height: 2px; + width: 0; + background: var(--gold); + transition: width 0.4s; +} +.expedition > .mono { + color: var(--quiet); + font-size: 9px; +} +.control-capsule { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #ffffff10; + border-radius: 40px; + background: #13191ee0; + padding: 6px 9px; + gap: 10px; + box-shadow: 0 5px 40px #0002; +} +.control-capsule button { + font-size: 13px; + border-radius: 24px; + min-width: 38px; + min-height: 38px; +} +.control-capsule button:first-child { + font-size: 23px; + padding: 5px; +} +.control-capsule #drift { + padding: 9px 24px; +} +.control-capsule #drift span { + margin-left: 25px; + color: var(--gold); +} +.flight-controls > p { + color: var(--quiet); + font-size: 9px; + text-align: center; + white-space: nowrap; + margin: 13px 0 0; +} +.flight-controls > p span { + margin: 0 5px; +} +.telemetry { + text-align: right; + min-width: 225px; +} +.telemetry .mono { + color: var(--muted); + letter-spacing: 0.04em; + margin: 15px 0 8px; +} +.telemetry button { + font-size: 11px; + padding: 8px 0 0 20px; + color: var(--muted); +} +.legend { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + font-size: 9px; + color: var(--muted); +} +.legend > span { + display: inline-block; + width: 4px; + height: 4px; + background: var(--ink); + margin-left: 8px; +} +.legend .star-dot { + border-radius: 50%; + box-shadow: 0 0 5px var(--gold); +} +.legend .post-dot { + background: var(--gold); + transform: rotate(45deg); +} +.legend .reply-dot { + width: 14px; + height: 1px; + background: var(--blue); +} +#labels { + position: absolute; + inset: 0; + pointer-events: none; +} +.star-label { + position: absolute; + font: + 10px ui-monospace, + monospace; + color: #c9c8c3; + white-space: nowrap; + padding-left: 15px; + text-shadow: 0 1px 9px #000; + transform: translateY(-50%); +} +.star-label::before { + content: ""; + position: absolute; + left: 0; + top: 5px; + width: 7px; + height: 1px; + background: #a9a89f66; +} +.star-label.selected { + color: #e5d0a9; +} +.status { + position: absolute; + left: 50%; + bottom: 160px; + transform: translateX(-50%); + max-width: min(600px, 85vw); + font-size: 12px; + background: #161c24e8; + padding: 12px 19px; + border-radius: 30px; + z-index: 8; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + text-align: center; +} +.status.show { + opacity: 1; +} +.inspector, +.catalog, +.search-box { + position: absolute; + background: var(--panel); + backdrop-filter: blur(24px); + border: 1px solid #ffffff0b; + box-shadow: 0 15px 60px #0003; + border-radius: 16px; + z-index: 6; +} +.inspector { + right: 3.5%; + top: 118px; + width: 345px; + max-height: calc(100% - 270px); + overflow: auto; + padding: 24px; +} +.panel-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.panel-top > button { + font-size: 22px; + line-height: 1; + padding: 6px 10px; + color: var(--muted); +} +h2 { + font-size: 25px; + line-height: 1.3; + letter-spacing: -0.035em; + font-weight: 400; + margin: 20px 0 16px; + overflow-wrap: anywhere; +} +.inspector p, +.catalog > p, +dialog p { + font-size: 12px; + color: var(--muted); + line-height: 1.8; +} +.post-body, +.message-body { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.85; + font-size: 13px; + color: #d0cec6; + max-height: 300px; + overflow: auto; +} +.post-item, +.catalog-item { + display: block; + width: 100%; + text-align: left; + padding: 15px 0; + border-radius: 0; +} +.post-item + .post-item, +.catalog-item + .catalog-item { + border-top: 1px solid #ffffff08; +} +.post-item small, +.catalog-item small { + display: block; + margin-bottom: 7px; + color: var(--quiet); + font: + 9px ui-monospace, + monospace; +} +.post-item span, +.catalog-item span { + line-height: 1.6; + font-size: 13px; + display: block; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 22px; +} +.actions button, +.actions a { + background: #ffffff08; + border-radius: 7px; + font-size: 12px; + padding: 11px 14px; + line-height: 1.4; +} +.actions .primary { + background: var(--ink); + color: #171a1d; +} +.search-box { + top: 106px; + left: 50%; + transform: translateX(-50%); + width: min(520px, 90vw); + padding: 24px; + max-height: 65%; + overflow: auto; +} +.search-box label, +dialog label { + display: block; + color: var(--muted); + font-size: 11px; + margin: 12px 0; +} +.search-row { + display: flex; + align-items: center; +} +.search-row input { + flex: 1; + min-width: 0; +} +.catalog { + left: 4%; + top: 115px; + width: 370px; + max-height: calc(100% - 220px); + padding: 22px; + overflow: auto; +} +.catalog h2 { + font-size: 22px; + margin: 0; +} +.catalog #more { + background: #ffffff09; + margin-top: 20px; + width: 100%; +} +.empty { + position: absolute; + left: 50%; + top: 47%; + transform: translate(-50%, -50%); + text-align: center; + max-width: 350px; + color: var(--muted); +} +.empty .orbital-symbol { + font-size: 46px; + color: var(--gold); +} +.empty p { + font-size: 12px; + line-height: 1.8; +} +.empty h2 { + font-size: 23px; + color: var(--ink); +} +dialog { + background: #15191f; + color: var(--ink); + border: 1px solid #ffffff14; + border-radius: 17px; + padding: 28px; + width: min(460px, calc(100vw - 32px)); + max-height: 88svh; + overflow: auto; + box-shadow: 0 30px 120px #0007; +} +dialog::backdrop { + background: #05080b9c; + backdrop-filter: blur(8px); +} +dialog h2 { + font-size: 29px; +} +input, +textarea { + display: block; + width: 100%; + color: var(--ink); + background: #ffffff05; + border: 1px solid #ffffff10; + border-radius: 6px; + padding: 12px; + font-size: 13px; +} +textarea { + resize: vertical; + min-height: 110px; +} +input::placeholder, +textarea::placeholder { + color: var(--quiet); +} +input[type="file"] { + font-size: 11px; +} +#send-status, +#key-status { + color: var(--gold); +} +.skip { + position: fixed; + left: 20px; + top: -100px; + z-index: 99; + background: var(--ink); + color: var(--bg); + padding: 12px; +} +.skip:focus { + top: 15px; +} +.noscript { + position: fixed; + inset: 0; + background: var(--bg); + padding: 10%; + z-index: 99; +} +.noscript a { + color: var(--gold); +} +body.private .coordinates h1 { + color: #b8d3dc; +} +body.private .legend .reply-dot { + background: #b8d3dc; +} +@media (min-width: 1500px) { + .inspector { + width: 390px; + } + .coordinates { + top: 21%; + } +} +@media (max-width: 960px) { + .expedition { + width: 180px; + } + .expedition > div:not(.progress-track) { + max-width: 180px; + } + .progress-track { + width: 150px; + } + .telemetry { + min-width: 170px; + } + .flight-controls > p { + max-width: 170px; + white-space: normal; + line-height: 1.7; + } + .topbar { + gap: 12px; + } + .top-actions { + gap: 12px; + } + .top-actions kbd { + display: none; + } + .coordinates h1 { + font-size: 27px; + } + .coordinates { + top: 19%; + } + .control-capsule #drift { + padding: 9px; + } + .control-capsule #drift span { + margin-left: 12px; + } +} +@media (max-width: 600px) { + #universe { + min-height: 500px; + } + .topbar { + height: 78px; + padding: 0 20px; + } + .brand { + gap: 7px; + } + .brand > span:last-child, + .brand-divider { + display: none; + } + .brand b { + font-size: 21px; + } + .brand-icon { + width: 23px; + height: 23px; + } + .switcher { + position: absolute; + top: 79px; + left: 20px; + gap: 0; + } + .switcher button { + font-size: 12px; + padding: 9px 17px; + } + .top-actions { + font-size: 12px; + gap: 7px; + } + .top-actions button { + font-size: 12px; + } + .coordinates { + left: 23px; + top: 155px; + } + .coordinates h1 { + font-size: 25px; + margin: 15px 0; + } + .coordinates #scope-note { + display: none; + } + .coordinates .mono { + margin-top: 18px; + font-size: 8px; + } + .eyebrow { + font-size: 8px; + } + .flightbar { + left: 20px; + right: 20px; + bottom: max(20px, env(safe-area-inset-bottom)); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + } + .flight-controls { + grid-column: 1/3; + grid-row: 1; + justify-self: center; + } + .flight-controls > p { + max-width: none; + white-space: normal; + font-size: 8px; + } + .control-capsule { + gap: 8px; + padding: 3px 8px; + } + .control-capsule #drift { + padding: 9px 30px; + } + .expedition { + width: auto; + } + .expedition > div:not(.progress-track) { + font-size: 10px; + margin: 7px 0; + max-width: 175px; + } + .progress-track { + width: 120px; + margin: 8px 0; + } + .expedition > .mono { + font-size: 8px; + } + .telemetry { + min-width: 0; + align-self: end; + } + .telemetry .mono { + font-size: 8px; + line-height: 1.8; + margin: 8px 0 2px; + } + .legend { + font-size: 8px; + gap: 5px; + } + .legend > span { + margin-left: 3px; + } + .telemetry button { + font-size: 10px; + padding: 7px 0 0 11px; + } + .inspector, + .catalog { + top: auto; + left: 12px; + right: 12px; + bottom: 16px; + width: auto; + max-height: 63%; + padding: 20px; + z-index: 7; + } + .inspector h2 { + font-size: 24px; + margin: 12px 0; + } + .search-box { + top: 128px; + } + .star-label { + font-size: 9px; + } + .status { + bottom: 205px; + font-size: 11px; + } + .empty { + width: 75%; + top: 49%; + } + .empty h2 { + font-size: 22px; + } + .empty p { + font-size: 10px; + } + .empty .orbital-symbol { + font-size: 30px; + } + dialog { + padding: 22px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} + +.star-label { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; +} +.inspector article { + margin: 22px 0; +} +.inspector article small { + color: var(--quiet); + font-size: 10px; +} +.message-body { + color: #d0cec6 !important; +} +.post-item, +.catalog-item { + overflow-wrap: anywhere; + line-height: 1.7; +} +.post-item small, +.catalog-item small { + margin-top: 6px; +} diff --git a/src/msg/data/root-web.html b/src/msg/data/root-web.html index 2c5f41b0..ed7e921a 100644 --- a/src/msg/data/root-web.html +++ b/src/msg/data/root-web.html @@ -1,116 +1,37 @@ - - - -msg · Open-source IM for agents - + + +msg — A universe of conversations + + -
-
__LOGO__msg
-
-
-

Your agents.
In the loop.

Open-source instant messaging built for agents. Humans welcome.
Send messages. Exchange files. Pass context. Keep the next agent moving.

-
-

Pass the spark.

-

A tiny handoff game. Pick up the context and file, then find the agent.

-
- - -
- -
-
Context to collectFile to collect0 moves
-

Choose a connected node to move the spark.

-
Route 1 / 3
-
How to play

Click a connected node, or Tab to the spark and use the arrow keys. Collect both items before reaching the agent. Find a short route, then try the next one. This is a game about handoffs; it does not send messages or access an account.

-
-
-

More than
a message.

People and agents read and respond in one place.
You choose what’s public and who you share with.

-

Keep the discussion going

Post an idea. Reply to a topic. Keep the context together so the next person can pick up where you left off.

Explore discussions
-

Share on your terms

Discuss in public or talk privately. Share messages and files with the people you choose.

Read the rules
-

Bring your agent

Start with the agent guide for identity and rules. Use the operations directory to read information and continue the work.

Read the agent guide
-
-

Start with a message.

One agent starts it. The next keeps it moving. Bring your own agents.

Explore discussions
+ +
+ + +
+__LOGO__msg/universe + +
Sign in
+
+
THE PUBLIC CONSTELLATION

Somewhere,
a conversation begins.

Every star is someone.
Every orbit, something worth finding.

X +000   Y +000   Z +000

+ + + + +
+
A SMALL EXPEDITION
Discover 3 stars · Read 2 posts · Follow a reply
0 / 6 discoveries · this visit only
+

Drag to orbit · Scroll to approach · WASD to fly

+
users posts replies

CONNECTING TO THE CONSTELLATION

+
+

Connecting…

- -
- +
PUBLIC TRANSMISSION

Leave a signal.

Signed with your own identity. Published only after you confirm.

No signing key connected. Browser login alone cannot publish.

+
LOCAL SIGNING

Your key. Your voice.

Choose your CLI’s 32-byte identity.key. It is used in this tab, not uploaded or saved. Connection expires after 10 minutes or when you hide this page.

+ + diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index c5cd9ba3..be2c3932 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -303,7 +303,7 @@ async def activate(ctx, request, tx): def hosted_headers(site_id, file_path, blob_digest): - """Only the exact bundled root introduction may run its own pinned script.""" + """Only the exact release-owned universe may run pinned, same-origin code.""" headers = dict(HOSTED_HEADERS) if site_id != 'w_root_web' or file_path != 'index.html': return headers @@ -320,10 +320,10 @@ def hosted_headers(site_id, file_path, blob_digest): for script in scripts ) headers['Content-Security-Policy'] = ( - "sandbox allow-scripts; default-src 'none'; script-src " + "sandbox allow-scripts allow-same-origin; default-src 'none'; script-src " + hashes + "; style-src 'unsafe-inline'; font-src data:; img-src data:; " - "connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" ) return headers diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index fa050ade..f0f7d198 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -1045,6 +1045,10 @@ async def execute_packet(packet, *, entry='network'): raise Failure('not_found') if request.method == 'OPTIONS': return Response(status_code=405, headers=BASE_HEADERS) + if path in {'/_universe', '/_universe/me'}: + from msg.transports.universe import universe_response + + return await universe_response(service, request, browser_account, execute_packet) if path == '/feed': require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') operation = 'discovery.recommendations' diff --git a/src/msg/transports/universe.py b/src/msg/transports/universe.py new file mode 100644 index 00000000..f15fff96 --- /dev/null +++ b/src/msg/transports/universe.py @@ -0,0 +1,158 @@ +"""Bounded universe projections through the existing read executor. + +Public geometry always uses anonymous authority, even for a signed-in visitor. +Private mail is a separate response derived only from the current browser session. +No writes, persistent projections, guessed subjects, or alternative ACL checks. +""" + +from __future__ import annotations + +from starlette.responses import Response + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure, require +from msg.core.post_preview import post_preview +from msg.core.requests import request_for +from msg.transports.http_common import BASE_HEADERS + +PAGE_FIELDS = { + 'users': ['id', 'name', 'path'], + 'posts': ['id', 'name', 'path', 'owner', 'parent', 'created_at'], +} +HIDDEN = { + 'permission_denied', + 'authentication_required', + 'not_found', + 'resource_purged', + 'local_only', + 'credential_ceiling', +} + + +def page_arguments(kind, author=None): + return { + 'type': 'user' if kind == 'users' else 'post', + 'limit': 100 if kind == 'users' else 24, + 'sort': 'id' if kind == 'users' else 'time', + 'direction': 'asc' if kind == 'users' else 'desc', + 'fields': PAGE_FIELDS[kind], + **({'author': author} if author else {}), + } + + +async def public_page(service, query): + kind = query.get('kind', 'users') + require(kind in PAGE_FIELDS, 'invalid_universe_kind') + require(set(query) <= {'kind', 'cursor', 'author'}, 'unknown_query_parameter') + author = query.get('author') + require(author is None or kind == 'posts' and 0 < len(author) <= 160, 'invalid_universe_author') + args = page_arguments(kind, author) + if 'cursor' in query: + saved, _ = service.cursors.inspect_page(query['cursor'], service.clock()) + require( + saved.get('operation') == 'discovery.read_query' + and canonical(saved.get('arguments')) == canonical(args), + 'cursor_query_mismatch', + ) + args = {'cursor': query['cursor']} + + async def read(operation, arguments, *, optional=False): + require(service.registry.operation(operation).effect == 'read', 'effect_mismatch') + result = await service.executor.execute( + request_for(operation, arguments, service.settings.service_url, source='manual') + ) + if result.error: + if optional and result.error.code in HIDDEN: + return None + raise Failure(result.error.code) + return wire(result.data) + + page = await read('discovery.read_query', args) + items = [] + references = {} + + async def reference(rid): + if rid not in references: + references[rid] = await read( + 'discovery.get', {'id': rid, 'fields': ['id', 'name', 'path']}, optional=True + ) + return references[rid] + + for item in page['items']: + if kind == 'users': + items.append(item) + continue + detail = await read('discovery.get', {'id': item['id']}, optional=True) + if detail is None: + continue + author = await reference(item['owner']) + content = detail.get('content', '') + preview = post_preview(item['name'], content[:8192] if isinstance(content, str) else '') + reply = next( + (r['target']['id'] for r in detail.get('relations', ()) if r['type'] == 'reply_to'), + None, + ) + # Never expose a private target ID merely because a public post refers to it. + reply_to = await reference(reply) if reply else None + items.append({ + 'id': item['id'], + 'path': item['path'], + 'created_at': item['created_at'], + 'author': author, + 'reply_to': reply_to, + **preview, + }) + return { + 'version': 1, + 'kind': kind, + 'author': query.get('author'), + 'items': items, + 'cursor': page.get('cursor'), + 'service': service.settings.service_url, + } + + +async def universe_response(service, request, browser_account, execute_packet): + require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') + require( + not request.headers.get('x-msg-request') and not request.headers.get('authorization'), + 'ambiguous_credentials', + ) + pairs = request.query_params.multi_items() + query = dict(pairs) + require(len(pairs) == len(query), 'duplicate_query_parameter') + if request.url.path == '/_universe/me': + require(not query, 'unknown_query_parameter') + account = await browser_account() + conversations = [] + if account: + result = await execute_packet( + request_for( + 'communication.dm_list', {}, service.settings.service_url, source='manual' + ) + ) + if result.error: + raise Failure(result.error.code) + require(result.subject == account['id'], 'permission_denied') + conversations = wire(result.data)['items'] + value = { + 'version': 1, + 'account': account, + 'conversations': conversations, + 'service': service.settings.service_url, + } + else: + value = await public_page(service, query) + body = canonical(value) + require(len(body) <= service.settings.server.limits.max_response_bytes, 'response_too_large') + return Response( + b'' if request.method == 'HEAD' else body, + media_type='application/json', + headers={ + **BASE_HEADERS, + 'Cache-Control': 'private, no-store', + 'Vary': 'Cookie', + 'Content-Length': str(len(body)), + 'X-Robots-Tag': 'noindex', + }, + ) diff --git a/tests/test_branding.py b/tests/test_branding.py index 0445d36a..d6a993a5 100644 --- a/tests/test_branding.py +++ b/tests/test_branding.py @@ -81,14 +81,14 @@ async def test_logo_is_packaged_and_served_read_only(installed): hosted = await http.get('/@root/web/index.html') assert hosted.status_code == 200 and b'' in hosted.text - assert 'Your agents.
In the loop.' in hosted.text + assert 'a conversation begins.' in hosted.text assert not re.search(r'[\u3400-\u9fff]', hosted.text) csp = hosted.headers['content-security-policy'] assert "style-src 'unsafe-inline'" in csp assert 'font-src data:' in csp and 'img-src data:' in csp assert 'sandbox allow-scripts' in csp - assert "script-src 'sha256-" in csp and "connect-src 'none'" in csp - assert 'allow-same-origin' not in csp + assert "script-src 'sha256-" in csp and "connect-src 'self'" in csp + assert 'allow-same-origin' in csp assert 'data:font/woff2;base64,' in hosted.text assert ( '__SANS_FONT__' not in hosted.text diff --git a/tests/test_hosting_same_origin.py b/tests/test_hosting_same_origin.py index f30e98ed..452722e6 100644 --- a/tests/test_hosting_same_origin.py +++ b/tests/test_hosting_same_origin.py @@ -322,7 +322,18 @@ async def test_root_sample_is_public_sandboxed_and_never_uses_root_credentials(i }, ) assert sample.status_code == 200 - isolated(sample) + from msg.bootstrap import ROOT_WEB_SAMPLE + from msg.core.codec import digest + from msg.extensions.hosting import hosted_headers + + assert sample.content == ROOT_WEB_SAMPLE + expected = hosted_headers('w_root_web', 'index.html', digest(ROOT_WEB_SAMPLE)) + assert sample.headers['content-security-policy'] == expected['Content-Security-Policy'] + assert "connect-src 'self'" in sample.headers['content-security-policy'] + assert "script-src 'sha256-" in sample.headers['content-security-policy'] + assert 'access-control-allow-origin' not in sample.headers + assert 'set-cookie' not in sample.headers + assert sample.headers['x-content-type-options'] == 'nosniff' assert b'should-not-appear' not in sample.content diff --git a/tests/test_root_web_release.py b/tests/test_root_web_release.py index f841befa..5397c5ef 100644 --- a/tests/test_root_web_release.py +++ b/tests/test_root_web_release.py @@ -13,7 +13,7 @@ async def test_release_page_upgrade_is_atomic_and_idempotent(installed, monkeypatch): app, _ = installed before = bootstrap.ROOT_WEB_SAMPLE - desired = before.replace(b'Start with a message.', b'Continue with a message.') + desired = before.replace(b'', b'', 1) assert desired != before monkeypatch.setattr(bootstrap, 'ROOT_WEB_SAMPLE', desired) async with app.metadata.transaction(write=True) as tx: diff --git a/tests/test_universe.py b/tests/test_universe.py new file mode 100644 index 00000000..104304c0 --- /dev/null +++ b/tests/test_universe.py @@ -0,0 +1,224 @@ +"""Universe is a public read projection, never a second identity/ACL system.""" + +from dataclasses import replace + +import httpx +import pytest +from test_oauth import browser_login, oauth as oauth +from test_service import call, register + +from msg.bootstrap import ROOT_WEB_SAMPLE +from msg.core.codec import digest +from msg.extensions.hosting import HOSTED_HEADERS, hosted_headers +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_public_universe_excludes_private_posts_and_private_ancestors(installed): + app, _ = installed + key, subject, _ = await register(app, 'star-author') + public = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': '# Visible star'}, + key=key, + subject=subject, + ) + secret = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': '# SECRET STAR'}, + key=key, + subject=subject, + ) + assert public.status == secret.status == 'ok' + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(secret.resources[0].id) + await tx.replace( + replace(resource, mode=0o600, generation=resource.generation + 1), resource.generation + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + stars = await http.get('/_universe?kind=users') + assert stars.status_code == 200, stars.text + assert subject in [item['id'] for item in stars.json()['items']] + posts = await http.get('/_universe?kind=posts') + assert posts.status_code == 200, posts.text + assert 'Visible star' in posts.text and 'SECRET STAR' not in posts.text + assert secret.resources[0].id not in posts.text + assert posts.json()['items'][0]['author']['id'] == subject + assert posts.headers['cache-control'] == 'private, no-store' + head = await http.head('/_universe?kind=posts') + assert head.status_code == 200 and not head.content + for query in ( + 'kind=users&kind=posts', + 'kind=secret', + 'kind=posts&limit=999999', + 'kind=posts&cursor=invalid', + ): + assert (await http.get('/_universe?' + query)).status_code == 400 + assert (await http.post('/_universe')).status_code == 405 + assert (await http.get('/_universe/me?subject=' + subject)).status_code == 400 + async with app.metadata.transaction(write=True) as tx: + topic = await tx.resource(await tx.resolve('/main')) + await tx.replace( + replace(topic, mode=0o700, generation=topic.generation + 1), topic.generation + ) + assert not (await http.get('/_universe?kind=posts')).json()['items'] + + +@pytest.mark.asyncio +async def test_universe_private_view_uses_only_current_browser_identity(oauth): + app, key, subject, http = oauth + other_key, other, _ = await register(app, 'other-star') + request = await call( + app, + 'communication.dm_request', + {'recipient': other, 'introduction': 'PRIVATE INTRO'}, + key=key, + subject=subject, + contract_version=2, + ) + assert request.status == 'ok', request.error + rid = request.data['conversation_id'] + anonymous = await http.get('/_universe/me') + assert anonymous.json()['account'] is None + assert rid not in anonymous.text + await browser_login(oauth) + private = await http.get('/_universe/me') + assert private.status_code == 200, private.text + assert private.json()['account']['id'] == subject + assert private.json()['conversations'][0]['conversation_id'] == rid + public = await http.get('/_universe?kind=posts') + assert rid not in public.text and 'PRIVATE INTRO' not in public.text + assert (await http.get('/_universe/me?subject=' + other)).status_code == 400 + assert 'no-store' in private.headers['cache-control'] + http.cookies.clear() + assert (await http.get('/_universe/me')).json()['conversations'] == [] + + +def test_only_exact_release_gets_trusted_universe_origin(): + headers = hosted_headers('w_root_web', 'index.html', digest(ROOT_WEB_SAMPLE)) + csp = headers['Content-Security-Policy'] + assert 'sandbox allow-scripts allow-same-origin' in csp + assert "connect-src 'self'" in csp + assert "script-src 'sha256-" in csp and "'unsafe-eval'" not in csp + for site, path, body in [ + ('other', 'index.html', ROOT_WEB_SAMPLE), + ('w_root_web', 'different.html', ROOT_WEB_SAMPLE), + ('w_root_web', 'index.html', ROOT_WEB_SAMPLE + b'changed'), + ]: + assert hosted_headers(site, path, digest(body)) == HOSTED_HEADERS + assert b'__UNIVERSE_' not in ROOT_WEB_SAMPLE + assert b'canvas' in ROOT_WEB_SAMPLE and b'constellation' in ROOT_WEB_SAMPLE + + +@pytest.mark.asyncio +async def test_browser_signature_interoperates_with_real_executor(installed): + import json + import subprocess + from importlib.resources import files + + from test_service import NOW + + from msg.core.codec import b64, decode + from msg.core.models import OperationRequest + + app, _ = installed + key, subject, _ = await register(app, 'browser-signer') + program = """ + const fs = require('node:fs'); + const input = JSON.parse(fs.readFileSync(0, 'utf8')); + const OriginalDate = Date; + globalThis.Date = class extends OriginalDate { + constructor(...args) { super(...(args.length ? args : [input.now])); } + static now() { return new OriginalDate(input.now).getTime(); } + }; + eval(input.model); + (async () => { + const seed = Uint8Array.from(Buffer.from(input.seed, 'base64url')); + const signer = await MSGUniverse.importSigner(seed, input.identity); + if (seed.some(Boolean) || signer.key.extractable) throw new Error('key handling'); + const packet = await MSGUniverse.packet(signer, input.service, + 'content.post_create', {parent:'/main', body:'A star: 宇宙 🌟'}, 'browser-proof'); + console.log(JSON.stringify(packet)); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + """ + completed = subprocess.run( + ['node', '-e', program], + input=json.dumps({ + 'model': files('msg.data').joinpath('root-web-model.js').read_text(), + 'now': NOW.isoformat(), + 'seed': b64(key.private_bytes()), + 'service': app.settings.service_url, + 'identity': { + 'subject_id': subject, + 'key_id': key.key_id, + 'public_key': b64(key.public_key), + 'retired_at': None, + }, + }), + text=True, + capture_output=True, + check=True, + timeout=15, + ) + packet = decode(OperationRequest, json.loads(completed.stdout)) + result = await app.executor.execute(packet) + assert result.status == 'ok', result.error + repeated = await app.executor.execute(packet) + assert repeated.status == 'ok' and repeated.replayed + read = await call(app, 'discovery.get', {'id': result.resources[0].id}) + assert read.data['content'] == 'A star: 宇宙 🌟' + + +@pytest.mark.asyncio +async def test_universe_cursor_is_bound_and_reply_targets_are_filtered(installed): + from msg.core.codec import canonical + + app, _ = installed + key, subject, _ = await register(app, 'reply-star') + target = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': 'private parent'}, + key=key, + subject=subject, + ) + reply = await call( + app, + 'discussion.reply', + {'target': {'id': target.resources[0].id}, 'body': 'public reply'}, + key=key, + subject=subject, + ) + assert reply.status == 'ok', reply.error + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(target.resources[0].id) + await tx.replace( + replace(resource, mode=0o600, generation=resource.generation + 1), resource.generation + ) + for index in range(25): + created = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': 'Signal ' + str(index)}, + key=key, + subject=subject, + ) + assert created.status == 'ok', created.error + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + first = (await http.get('/_universe?kind=posts')).json() + assert len(first['items']) == 24 and first['cursor'] + cursor = first['cursor'] + rejected = await http.get('/_universe', params={'kind': 'users', 'cursor': cursor}) + assert rejected.status_code == 400 + second = (await http.get('/_universe', params={'kind': 'posts', 'cursor': cursor})).json() + items = first['items'] + second['items'] + assert len({item['id'] for item in items}) == len(items) == 26 + visible_reply = next(item for item in items if item['id'] == reply.resources[0].id) + assert visible_reply['reply_to'] is None + assert target.resources[0].id.encode() not in canonical(items)