Lexicon-driven ATProto AppView in Gleam — port of HappyView
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial GleamView port of HappyView.

Lexicon-driven ATProto AppView in Gleam: SQLite record index, runtime lexicon
registry, XRPC query/procedure routes, Jetstream consumer, and admin API.

author
nandi
date (Jul 26, 2026, 10:34 AM -0700) commit 269b9fb6 change-id nsskstrx
+3614
+25
.env.example
··· 1 + # SQLite path (default: gleamview.db in CWD) 2 + DATABASE_URL=sqlite:gleamview.db 3 + 4 + # Public URL used for OAuth metadata / did:web (use 127.0.0.1, not localhost) 5 + PUBLIC_URL=http://127.0.0.1:3000 6 + 7 + HOST=0.0.0.0 8 + PORT=3000 9 + 10 + # Session cookie signing (at least 64 chars in production) 11 + SESSION_SECRET=dev-secret-change-me-please-use-at-least-64-characters-long!! 12 + 13 + # Bootstrap admin API key shown once on first start if no users exist 14 + # GLEAMVIEW_ADMIN_KEY=gv_admin_... 15 + 16 + JETSTREAM_URL=wss://jetstream1.us-east.bsky.network 17 + # Set to 0 to disable Jetstream 18 + JETSTREAM_ENABLED=1 19 + 20 + PLC_URL=https://plc.directory 21 + RELAY_URL=https://bsky.network 22 + 23 + # Dev-only: allow unauthenticated local procedure writes into the index 24 + # (HappyView always proxies to the user's PDS; GleamView MVP can index locally) 25 + ALLOW_LOCAL_WRITES=1
+10
.gitignore
··· 1 + *.beam 2 + *.ez 3 + build/ 4 + .gleam/ 5 + erl_crash.dump 6 + .env 7 + *.db 8 + *.db-shm 9 + *.db-wal 10 + .DS_Store
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 GleamView contributors 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+175
README.md
··· 1 + # GleamView 2 + 3 + A **lexicon-driven ATProto AppView** written in [Gleam](https://gleam.run) — a port of [HappyView](https://tangled.org/gamesgamesgamesgames.games/happyview). 4 + 5 + Upload lexicon schemas and get XRPC endpoints, record storage, Jetstream indexing, and an admin API without hand-wiring AppView plumbing. 6 + 7 + > **Status:** MVP core. HappyView is ~50k+ lines of Rust (Lua scripts, OAuth/DPoP, WASM plugins, spaces, full dashboard). GleamView implements the schema-first path that makes HappyView useful on day one. 8 + 9 + ## What works 10 + 11 + | Area | Status | 12 + |------|--------| 13 + | SQLite storage (`happyview_*` tables) | ✅ | 14 + | Lexicon upload / registry (runtime) | ✅ | 15 + | XRPC queries (list + get by `uri`) | ✅ | 16 + | XRPC procedures (local index mode) | ✅ (dev) | 17 + | API client keys (`X-Client-Key`) | ✅ | 18 + | Admin API (lexicons, records, clients, stats) | ✅ | 19 + | Jetstream consumer | ✅ (basic) | 20 + | DID/PDS resolve helpers | ✅ | 21 + | OAuth + DPoP PDS write proxy | ⏳ planned | 22 + | Lua / script hooks | ⏳ planned | 23 + | WASM plugins, spaces, backfill jobs | ⏳ planned | 24 + | Next.js admin dashboard | ⏳ planned | 25 + 26 + ## Quick start 27 + 28 + Requires [Gleam](https://gleam.run/getting-started/) and Erlang/OTP on `PATH`. 29 + 30 + ```bash 31 + cd gleamview 32 + gleam deps download 33 + gleam test 34 + gleam run 35 + ``` 36 + 37 + Server listens on `http://127.0.0.1:3000` by default. On first start it prints a **bootstrap admin API key** (Bearer token for `/admin/*`). 38 + 39 + ```bash 40 + # optional 41 + cp .env.example .env 42 + # DATABASE_URL=sqlite:gleamview.db 43 + # JETSTREAM_ENABLED=0 # disable live network sync while developing 44 + ``` 45 + 46 + ## Walkthrough 47 + 48 + ### 1. Create an API client 49 + 50 + ```bash 51 + ADMIN=gva_… # bootstrap key from startup logs 52 + 53 + curl -s -X POST http://127.0.0.1:3000/admin/api-clients \ 54 + -H "Authorization: Bearer $ADMIN" \ 55 + -H "Content-Type: application/json" \ 56 + -d '{"name":"demo"}' 57 + # → { "clientKey": "hvc_…", "clientSecret": "hvs_…", … } 58 + ``` 59 + 60 + ### 2. Upload a record lexicon + query 61 + 62 + ```bash 63 + # Record type 64 + curl -s -X POST 'http://127.0.0.1:3000/admin/lexicons' \ 65 + -H "Authorization: Bearer $ADMIN" \ 66 + -H "Content-Type: application/json" \ 67 + -d '{ 68 + "lexicon": 1, 69 + "id": "com.example.post", 70 + "defs": { 71 + "main": { 72 + "type": "record", 73 + "key": "tid", 74 + "record": { 75 + "type": "object", 76 + "required": ["text"], 77 + "properties": { "text": { "type": "string" } } 78 + } 79 + } 80 + } 81 + }' 82 + 83 + # Query that lists that collection 84 + curl -s -X POST 'http://127.0.0.1:3000/admin/lexicons?targetCollection=com.example.post' \ 85 + -H "Authorization: Bearer $ADMIN" \ 86 + -H "Content-Type: application/json" \ 87 + -d '{ 88 + "lexicon": 1, 89 + "id": "com.example.getPosts", 90 + "defs": { 91 + "main": { 92 + "type": "query", 93 + "parameters": { 94 + "type": "params", 95 + "properties": { 96 + "limit": { "type": "integer" }, 97 + "cursor": { "type": "string" }, 98 + "did": { "type": "string" }, 99 + "uri": { "type": "string" } 100 + } 101 + }, 102 + "output": { "encoding": "application/json" } 103 + } 104 + } 105 + }' 106 + ``` 107 + 108 + ### 3. Index a record (local write mode) and query it 109 + 110 + ```bash 111 + CLIENT=hvc_… 112 + 113 + # Procedure lexicon (create) 114 + curl -s -X POST 'http://127.0.0.1:3000/admin/lexicons?targetCollection=com.example.post&action=create' \ 115 + -H "Authorization: Bearer $ADMIN" \ 116 + -H "Content-Type: application/json" \ 117 + -d '{ 118 + "lexicon": 1, 119 + "id": "com.example.createPost", 120 + "defs": { 121 + "main": { 122 + "type": "procedure", 123 + "input": { "encoding": "application/json" }, 124 + "output": { "encoding": "application/json" } 125 + } 126 + } 127 + }' 128 + 129 + curl -s -X POST http://127.0.0.1:3000/xrpc/com.example.createPost \ 130 + -H "X-Client-Key: $CLIENT" \ 131 + -H "X-Gleamview-Did: did:plc:alice" \ 132 + -H "Content-Type: application/json" \ 133 + -d '{"text":"hello gleamview"}' 134 + 135 + curl -s 'http://127.0.0.1:3000/xrpc/com.example.getPosts?limit=10' \ 136 + -H "X-Client-Key: $CLIENT" 137 + ``` 138 + 139 + `ALLOW_LOCAL_WRITES=1` (default) indexes procedures into SQLite without proxying to a PDS. HappyView always writes through the user's PDS via OAuth/DPoP — that path is next on the port list. 140 + 141 + ## Architecture 142 + 143 + Mirrors HappyView's core flow: 144 + 145 + ```text 146 + Client ──GET /xrpc/{nsid}──► Query handler ──► SQLite records 147 + Client ──POST /xrpc/{nsid}─► Procedure ──────► (local index | PDS proxy*) 148 + Jetstream WS ──────────────► Record handler ─► SQLite records 149 + Admin API ─────────────────► Lexicon registry (runtime) 150 + ``` 151 + 152 + | Module | Role | 153 + |--------|------| 154 + | `gleamview/lexicon` | Parse + in-memory registry + DB persistence | 155 + | `gleamview/xrpc` | Catch-all XRPC router | 156 + | `gleamview/xrpc/query` | Default list / get | 157 + | `gleamview/xrpc/procedure` | Default create / update / delete (local) | 158 + | `gleamview/record_handler` | Jetstream / write indexing | 159 + | `gleamview/jetstream` | WebSocket consumer (stratus) | 160 + | `gleamview/admin` | Admin REST surface | 161 + | `gleamview/auth` | API clients + admin keys | 162 + | `gleamview/db` | SQLite actor | 163 + | `gleamview/resolve` | PLC / did:web helpers | 164 + 165 + ## Config 166 + 167 + See [`.env.example`](.env.example). HappyView-compatible names where practical: `DATABASE_URL`, `PUBLIC_URL`, `JETSTREAM_URL`, `PLC_URL`, `PORT`, … 168 + 169 + ## Source 170 + 171 + Port of [gamesgamesgamesgames.games/happyview](https://tangled.org/gamesgamesgamesgames.games/happyview) · docs at [happyview.dev](https://happyview.dev). 172 + 173 + ## License 174 + 175 + MIT
+46
flake.nix
··· 1 + { 2 + description = "gleamview — lexicon-driven ATProto AppView in Gleam"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + }; 7 + 8 + outputs = 9 + { self, nixpkgs }: 10 + let 11 + systems = [ 12 + "x86_64-linux" 13 + "aarch64-linux" 14 + "x86_64-darwin" 15 + "aarch64-darwin" 16 + ]; 17 + forAllSystems = nixpkgs.lib.genAttrs systems; 18 + in 19 + { 20 + devShells = forAllSystems ( 21 + system: 22 + let 23 + pkgs = nixpkgs.legacyPackages.${system}; 24 + in 25 + { 26 + default = pkgs.mkShell { 27 + name = "gleamview"; 28 + packages = [ 29 + pkgs.gleam 30 + pkgs.beamPackages.erlang 31 + pkgs.rebar3 32 + pkgs.beamPackages.hex 33 + pkgs.sqlite 34 + pkgs.curl 35 + ]; 36 + shellHook = '' 37 + echo "gleamview dev shell" 38 + echo " gleam deps download && gleam test && gleam run" 39 + ''; 40 + }; 41 + } 42 + ); 43 + 44 + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style); 45 + }; 46 + }
+27
gleam.toml
··· 1 + name = "gleamview" 2 + version = "0.1.0" 3 + description = "A lexicon-driven ATProto AppView in Gleam — port of HappyView" 4 + licences = ["MIT"] 5 + repository = { type = "github", user = "nandi", repo = "gleamview" } 6 + 7 + [dependencies] 8 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 9 + gleam_erlang = ">= 1.0.0 and < 2.0.0" 10 + gleam_otp = ">= 1.0.0 and < 2.0.0" 11 + gleam_http = ">= 4.0.0 and < 5.0.0" 12 + gleam_json = ">= 3.0.0 and < 4.0.0" 13 + gleam_httpc = ">= 5.0.0 and < 6.0.0" 14 + gleam_crypto = ">= 1.0.0 and < 2.0.0" 15 + gleam_time = ">= 1.0.0 and < 2.0.0" 16 + mist = ">= 5.0.0" 17 + wisp = ">= 1.0.0" 18 + sqlight = ">= 1.0.0" 19 + envoy = ">= 1.0.0 and < 2.0.0" 20 + logging = ">= 1.0.0" 21 + stratus = ">= 1.0.0" 22 + filepath = ">= 1.0.0" 23 + simplifile = ">= 2.0.0" 24 + argv = ">= 1.0.0" 25 + 26 + [dev_dependencies] 27 + gleeunit = ">= 1.0.0 and < 2.0.0"
+57
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 11 + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 12 + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, 13 + { name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" }, 14 + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, 15 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 16 + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, 17 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 18 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 19 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 20 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 21 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 22 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 23 + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, 24 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 25 + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, 26 + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, 27 + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, 28 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 29 + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, 30 + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 31 + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, 32 + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 33 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 34 + { name = "sqlight", version = "1.2.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "841768D0A81107EE2DB46B949354F284A68B2E8098315C28A1FE3C07D206C17A" }, 35 + { name = "stratus", version = "3.0.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gramps", "logging"], otp_app = "stratus", source = "hex", outer_checksum = "21C93D657E4E7964E36EACDE83609459F259289DF403B5FEBD3C60612594BE24" }, 36 + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, 37 + ] 38 + 39 + [requirements] 40 + argv = { version = ">= 1.0.0" } 41 + envoy = { version = ">= 1.0.0 and < 2.0.0" } 42 + filepath = { version = ">= 1.0.0" } 43 + gleam_crypto = { version = ">= 1.0.0 and < 2.0.0" } 44 + gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } 45 + gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 46 + gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 47 + gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 48 + gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } 49 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 50 + gleam_time = { version = ">= 1.0.0 and < 2.0.0" } 51 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 52 + logging = { version = ">= 1.0.0" } 53 + mist = { version = ">= 5.0.0" } 54 + simplifile = { version = ">= 2.0.0" } 55 + sqlight = { version = ">= 1.0.0" } 56 + stratus = { version = ">= 1.0.0" } 57 + wisp = { version = ">= 1.0.0" }
+107
priv/migrations/001_init.sql
··· 1 + -- GleamView schema (HappyView-compatible table names / shapes where practical) 2 + 3 + CREATE TABLE IF NOT EXISTS happyview_records ( 4 + uri TEXT PRIMARY KEY, 5 + did TEXT NOT NULL, 6 + collection TEXT NOT NULL, 7 + rkey TEXT NOT NULL, 8 + record TEXT NOT NULL, 9 + cid TEXT NOT NULL DEFAULT '', 10 + indexed_at TEXT NOT NULL, 11 + created_at TEXT NOT NULL 12 + ); 13 + 14 + CREATE INDEX IF NOT EXISTS idx_records_did_collection 15 + ON happyview_records (did, collection); 16 + CREATE INDEX IF NOT EXISTS idx_records_collection 17 + ON happyview_records (collection); 18 + CREATE INDEX IF NOT EXISTS idx_records_created_at_uri 19 + ON happyview_records (created_at DESC, uri DESC); 20 + 21 + CREATE TABLE IF NOT EXISTS happyview_lexicons ( 22 + id TEXT PRIMARY KEY, 23 + revision INTEGER NOT NULL DEFAULT 1, 24 + lexicon_json TEXT NOT NULL, 25 + backfill INTEGER NOT NULL DEFAULT 1, 26 + target_collection TEXT, 27 + action TEXT, 28 + token_cost INTEGER, 29 + source TEXT NOT NULL DEFAULT 'manual', 30 + authority_did TEXT, 31 + last_fetched_at TEXT, 32 + created_at TEXT NOT NULL, 33 + updated_at TEXT NOT NULL 34 + ); 35 + 36 + CREATE TABLE IF NOT EXISTS happyview_users ( 37 + id TEXT PRIMARY KEY, 38 + did TEXT NOT NULL UNIQUE, 39 + is_super INTEGER NOT NULL DEFAULT 0, 40 + created_at TEXT NOT NULL, 41 + last_used_at TEXT 42 + ); 43 + 44 + CREATE TABLE IF NOT EXISTS happyview_user_permissions ( 45 + user_id TEXT NOT NULL REFERENCES happyview_users(id) ON DELETE CASCADE, 46 + permission TEXT NOT NULL, 47 + granted_at TEXT NOT NULL, 48 + granted_by TEXT, 49 + PRIMARY KEY (user_id, permission) 50 + ); 51 + 52 + CREATE TABLE IF NOT EXISTS happyview_api_keys ( 53 + id TEXT PRIMARY KEY, 54 + user_id TEXT NOT NULL REFERENCES happyview_users(id) ON DELETE CASCADE, 55 + name TEXT NOT NULL, 56 + key_hash TEXT NOT NULL, 57 + key_prefix TEXT NOT NULL, 58 + permissions TEXT NOT NULL DEFAULT '[]', 59 + created_at TEXT NOT NULL, 60 + last_used_at TEXT, 61 + revoked_at TEXT 62 + ); 63 + 64 + CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON happyview_api_keys(key_hash); 65 + 66 + CREATE TABLE IF NOT EXISTS happyview_api_clients ( 67 + id TEXT PRIMARY KEY, 68 + name TEXT NOT NULL, 69 + client_key TEXT NOT NULL UNIQUE, 70 + client_secret_hash TEXT NOT NULL, 71 + client_uri TEXT, 72 + capacity INTEGER NOT NULL DEFAULT 100, 73 + refill_rate REAL NOT NULL DEFAULT 2.0, 74 + created_at TEXT NOT NULL, 75 + revoked_at TEXT 76 + ); 77 + 78 + CREATE INDEX IF NOT EXISTS idx_api_clients_key ON happyview_api_clients(client_key); 79 + 80 + CREATE TABLE IF NOT EXISTS happyview_instance_settings ( 81 + key TEXT PRIMARY KEY, 82 + value TEXT NOT NULL, 83 + updated_at TEXT NOT NULL 84 + ); 85 + 86 + CREATE TABLE IF NOT EXISTS happyview_event_logs ( 87 + id TEXT PRIMARY KEY, 88 + event_type TEXT NOT NULL, 89 + severity TEXT NOT NULL DEFAULT 'info', 90 + actor_did TEXT, 91 + subject TEXT, 92 + detail TEXT NOT NULL DEFAULT '{}', 93 + created_at TEXT NOT NULL 94 + ); 95 + 96 + CREATE INDEX IF NOT EXISTS idx_event_logs_created_at ON happyview_event_logs (created_at); 97 + CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON happyview_event_logs (event_type); 98 + 99 + CREATE TABLE IF NOT EXISTS happyview_record_refs ( 100 + source_uri TEXT NOT NULL REFERENCES happyview_records(uri) ON DELETE CASCADE, 101 + target_uri TEXT NOT NULL, 102 + collection TEXT NOT NULL, 103 + PRIMARY KEY (source_uri, target_uri) 104 + ); 105 + 106 + CREATE INDEX IF NOT EXISTS idx_record_refs_target 107 + ON happyview_record_refs (target_uri, collection);
+123
src/gleamview.gleam
··· 1 + //// GleamView — lexicon-driven ATProto AppView (HappyView port). 2 + 3 + import filepath 4 + import gleam/erlang/process 5 + import gleam/io 6 + import gleam/option.{None, Some} 7 + import gleam/string 8 + import gleamview/auth 9 + import gleamview/config 10 + import gleamview/db 11 + import gleamview/error 12 + import gleamview/jetstream 13 + import gleamview/lexicon 14 + import gleamview/router 15 + import logging 16 + import mist 17 + import simplifile 18 + import wisp 19 + import wisp/wisp_mist 20 + 21 + pub fn main() { 22 + wisp.configure_logger() 23 + logging.log(logging.Info, "starting gleamview") 24 + 25 + let cfg = config.from_env() 26 + let sqlite_path = config.sqlite_path(cfg) 27 + let migrations = migrations_dir() 28 + 29 + let database = case db.start(sqlite_path, migrations) { 30 + Ok(d) -> d 31 + Error(e) -> { 32 + io.println_error("failed to open database: " <> e) 33 + panic as "db start failed" 34 + } 35 + } 36 + 37 + let reg = case lexicon.start() { 38 + Ok(r) -> r 39 + Error(e) -> { 40 + io.println_error("failed to start lexicon registry: " <> e) 41 + panic as "registry start failed" 42 + } 43 + } 44 + 45 + case lexicon.load_from_db(database, reg) { 46 + Ok(n) -> 47 + logging.log( 48 + logging.Info, 49 + "loaded " <> string.inspect(n) <> " lexicons from db", 50 + ) 51 + Error(e) -> 52 + logging.log(logging.Error, "failed to load lexicons: " <> error.message(e)) 53 + } 54 + 55 + case auth.bootstrap_if_empty(database, cfg.bootstrap_admin_key) { 56 + Ok(None) -> Nil 57 + Ok(Some(key)) -> { 58 + io.println("") 59 + io.println("========================================================") 60 + io.println(" Bootstrap admin API key (store securely, shown once):") 61 + io.println(" " <> key) 62 + io.println("========================================================") 63 + io.println("") 64 + } 65 + Error(e) -> 66 + logging.log(logging.Error, "bootstrap failed: " <> error.message(e)) 67 + } 68 + 69 + case cfg.jetstream_enabled { 70 + True -> { 71 + logging.log(logging.Info, "spawning jetstream consumer") 72 + jetstream.spawn(database, reg, cfg.jetstream_url) 73 + } 74 + False -> logging.log(logging.Info, "jetstream disabled") 75 + } 76 + 77 + let ctx = router.Context(db: database, reg: reg, config: cfg) 78 + let secret = cfg.session_secret 79 + let handler = fn(req) { router.handle_request(req, ctx) } 80 + 81 + logging.log( 82 + logging.Info, 83 + "listening on " 84 + <> cfg.host 85 + <> ":" 86 + <> string.inspect(cfg.port) 87 + <> " public_url=" 88 + <> cfg.public_url, 89 + ) 90 + 91 + let assert Ok(_) = 92 + handler 93 + |> wisp_mist.handler(secret) 94 + |> mist.new 95 + |> mist.bind(cfg.host) 96 + |> mist.port(cfg.port) 97 + |> mist.start 98 + 99 + process.sleep_forever() 100 + } 101 + 102 + fn migrations_dir() -> String { 103 + let candidates = [ 104 + "priv/migrations", 105 + "gleamview/priv/migrations", 106 + filepath.join(".", "priv/migrations"), 107 + ] 108 + case first_existing_dir(candidates) { 109 + Ok(p) -> p 110 + Error(_) -> "priv/migrations" 111 + } 112 + } 113 + 114 + fn first_existing_dir(paths: List(String)) -> Result(String, Nil) { 115 + case paths { 116 + [] -> Error(Nil) 117 + [p, ..rest] -> 118 + case simplifile.is_directory(p) { 119 + Ok(True) -> Ok(p) 120 + _ -> first_existing_dir(rest) 121 + } 122 + } 123 + }
+339
src/gleamview/admin.gleam
··· 1 + //// Admin HTTP API (`/admin/*`). 2 + 3 + import gleam/dynamic/decode 4 + import gleam/http 5 + import gleam/json 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleam/result 9 + import gleam/string 10 + import gleamview/auth 11 + import gleamview/db.{type Db} 12 + import gleamview/error 13 + import gleamview/event_log 14 + import gleamview/json_util 15 + import gleamview/lexicon.{type Registry} 16 + import gleamview/records 17 + import gleamview/util 18 + import wisp.{type Request, type Response} 19 + 20 + pub type Ctx { 21 + Ctx(db: Db, reg: Registry) 22 + } 23 + 24 + pub fn handle(ctx: Ctx, req: Request, path: List(String)) -> Response { 25 + case path, req.method { 26 + ["health"], http.Get -> wisp.json_response("{\"ok\":true}", 200) 27 + _, _ -> 28 + case auth.require_admin(ctx.db, req) { 29 + Error(e) -> error.to_response(e) 30 + Ok(admin) -> dispatch(ctx, req, path, admin) 31 + } 32 + } 33 + } 34 + 35 + fn dispatch( 36 + ctx: Ctx, 37 + req: Request, 38 + path: List(String), 39 + admin: auth.AdminAuth, 40 + ) -> Response { 41 + case path, req.method { 42 + ["lexicons"], http.Get -> list_lexicons(ctx, admin) 43 + ["lexicons"], http.Post -> upload_lexicon(ctx, req, admin) 44 + ["lexicons", id], http.Get -> get_lexicon(ctx, admin, id) 45 + ["lexicons", id], http.Delete -> delete_lexicon(ctx, admin, id) 46 + ["records"], http.Get -> list_records(ctx, req, admin) 47 + ["records"], http.Delete -> delete_record(ctx, req, admin) 48 + ["stats"], http.Get -> stats(ctx, admin) 49 + ["api-clients"], http.Get -> list_api_clients(ctx, admin) 50 + ["api-clients"], http.Post -> create_api_client(ctx, req, admin) 51 + ["me"], http.Get -> me(admin) 52 + _, _ -> wisp.not_found() 53 + } 54 + } 55 + 56 + fn me(admin: auth.AdminAuth) -> Response { 57 + let perms = 58 + admin.permissions 59 + |> list.map(json.string) 60 + |> json.preprocessed_array 61 + let body = 62 + json.object([ 63 + #("did", json.string(admin.did)), 64 + #("userId", json.string(admin.user_id)), 65 + #("isSuper", json.bool(admin.is_super)), 66 + #("permissions", perms), 67 + ]) 68 + |> json.to_string 69 + wisp.json_response(body, 200) 70 + } 71 + 72 + fn list_lexicons(ctx: Ctx, admin: auth.AdminAuth) -> Response { 73 + case auth.require_permission(admin, "lexicons:read") { 74 + Error(e) -> error.to_response(e) 75 + Ok(_) -> { 76 + let items = 77 + lexicon.list_all(ctx.reg) 78 + |> list.map(lexicon.summary_json) 79 + |> json.preprocessed_array 80 + |> json.to_string 81 + wisp.json_response(items, 200) 82 + } 83 + } 84 + } 85 + 86 + fn get_lexicon(ctx: Ctx, admin: auth.AdminAuth, id: String) -> Response { 87 + case auth.require_permission(admin, "lexicons:read") { 88 + Error(e) -> error.to_response(e) 89 + Ok(_) -> 90 + case lexicon.get(ctx.reg, id) { 91 + None -> error.to_response(error.NotFound("lexicon not found")) 92 + Some(p) -> { 93 + let body = 94 + "{\"id\":" 95 + <> json.string(p.id) |> json.to_string 96 + <> ",\"revision\":" 97 + <> json.int(p.revision) |> json.to_string 98 + <> ",\"lexicon\":" 99 + <> p.raw_json 100 + <> "}" 101 + wisp.json_response(body, 200) 102 + } 103 + } 104 + } 105 + } 106 + 107 + /// Upload a lexicon document. 108 + /// 109 + /// Body is the lexicon JSON itself (must include `id` and `lexicon: 1`). 110 + /// Optional query params: `targetCollection`, `action`, `backfill` (0|1). 111 + fn upload_lexicon(ctx: Ctx, req: Request, admin: auth.AdminAuth) -> Response { 112 + case auth.require_permission(admin, "lexicons:create") { 113 + Error(e) -> error.to_response(e) 114 + Ok(_) -> 115 + wisp.require_string_body(req, fn(body) { 116 + case json_util.parse(body) { 117 + Error(e) -> error.to_response(error.BadRequest(e)) 118 + Ok(dyn) -> { 119 + let q = wisp.get_query(req) 120 + let target = 121 + list.key_find(q, "targetCollection") 122 + |> option.from_result 123 + |> option.or(json_util.optional_string(dyn, "targetCollection")) 124 + let action_s = 125 + list.key_find(q, "action") 126 + |> option.from_result 127 + |> option.or(json_util.optional_string(dyn, "action")) 128 + let token_cost = json_util.optional_int(dyn, "tokenCost") 129 + let backfill = case list.key_find(q, "backfill") { 130 + Ok("0") | Ok("false") -> False 131 + _ -> 132 + case json_util.optional_bool(dyn, "backfill") { 133 + Some(b) -> b 134 + None -> True 135 + } 136 + } 137 + // If envelope form { "lexicon": {…} }, the nested object is lost as 138 + // Dynamic — require flat lexicon body (id at top level). 139 + case json_util.lexicon_id(dyn) { 140 + Error(_) -> 141 + error.to_response(error.BadRequest( 142 + "body must be a lexicon document with string 'id' (flat form)", 143 + )) 144 + Ok(_) -> 145 + case 146 + lexicon.action_from_optional(action_s) 147 + |> result.map_error(error.BadRequest) 148 + { 149 + Error(e) -> error.to_response(e) 150 + Ok(action) -> 151 + case 152 + lexicon.upsert_to_db( 153 + ctx.db, 154 + ctx.reg, 155 + body, 156 + target, 157 + action, 158 + token_cost, 159 + backfill, 160 + ) 161 + { 162 + Error(e) -> error.to_response(e) 163 + Ok(#(id, rev)) -> { 164 + let _ = 165 + event_log.info( 166 + ctx.db, 167 + "lexicon.upserted", 168 + Some(id), 169 + "{}", 170 + ) 171 + let status = case rev { 172 + 1 -> 201 173 + _ -> 200 174 + } 175 + let resp = 176 + json.object([ 177 + #("id", json.string(id)), 178 + #("revision", json.int(rev)), 179 + ]) 180 + |> json.to_string 181 + wisp.json_response(resp, status) 182 + } 183 + } 184 + } 185 + } 186 + } 187 + } 188 + }) 189 + } 190 + } 191 + 192 + fn delete_lexicon(ctx: Ctx, admin: auth.AdminAuth, id: String) -> Response { 193 + case auth.require_permission(admin, "lexicons:delete") { 194 + Error(e) -> error.to_response(e) 195 + Ok(_) -> 196 + case lexicon.delete_from_db(ctx.db, ctx.reg, id) { 197 + Error(e) -> error.to_response(e) 198 + Ok(True) -> wisp.json_response("{\"deleted\":true}", 200) 199 + Ok(False) -> error.to_response(error.NotFound("lexicon not found")) 200 + } 201 + } 202 + } 203 + 204 + fn list_records(ctx: Ctx, req: Request, admin: auth.AdminAuth) -> Response { 205 + case auth.require_permission(admin, "records:read") { 206 + Error(e) -> error.to_response(e) 207 + Ok(_) -> { 208 + let q = wisp.get_query(req) 209 + let collection = list.key_find(q, "collection") |> result.unwrap("") 210 + let limit = 211 + list.key_find(q, "limit") 212 + |> result.map(fn(s) { util.parse_int_param(s, 20) }) 213 + |> result.unwrap(20) 214 + let offset = 215 + list.key_find(q, "cursor") 216 + |> result.map(fn(s) { util.parse_int_param(s, 0) }) 217 + |> result.unwrap(0) 218 + case collection { 219 + "" -> error.to_response(error.BadRequest("collection required")) 220 + c -> 221 + case records.list_by_collection(ctx.db, c, None, limit, offset) { 222 + Error(e) -> error.to_response(e) 223 + Ok(rows) -> { 224 + let arr = 225 + records.list_as_json_records(rows) 226 + |> string.join(",") 227 + wisp.json_response("{\"records\":[" <> arr <> "]}", 200) 228 + } 229 + } 230 + } 231 + } 232 + } 233 + } 234 + 235 + fn delete_record(ctx: Ctx, req: Request, admin: auth.AdminAuth) -> Response { 236 + case auth.require_permission(admin, "records:delete") { 237 + Error(e) -> error.to_response(e) 238 + Ok(_) -> { 239 + let q = wisp.get_query(req) 240 + case list.key_find(q, "uri") { 241 + Error(_) -> error.to_response(error.BadRequest("uri required")) 242 + Ok(uri) -> 243 + case records.delete(ctx.db, uri) { 244 + Error(e) -> error.to_response(e) 245 + Ok(_) -> wisp.json_response("{\"deleted\":true}", 200) 246 + } 247 + } 248 + } 249 + } 250 + } 251 + 252 + fn stats(ctx: Ctx, admin: auth.AdminAuth) -> Response { 253 + case auth.require_permission(admin, "stats:read") { 254 + Error(e) -> error.to_response(e) 255 + Ok(_) -> { 256 + let record_count = records.count(ctx.db) |> result.unwrap(0) 257 + let lex_count = list.length(lexicon.list_all(ctx.reg)) 258 + let body = 259 + json.object([ 260 + #("records", json.int(record_count)), 261 + #("lexicons", json.int(lex_count)), 262 + ]) 263 + |> json.to_string 264 + wisp.json_response(body, 200) 265 + } 266 + } 267 + } 268 + 269 + fn list_api_clients(ctx: Ctx, admin: auth.AdminAuth) -> Response { 270 + case auth.require_permission(admin, "api-clients:read") { 271 + Error(e) -> error.to_response(e) 272 + Ok(_) -> { 273 + let decoder = { 274 + use id <- decode.field(0, decode.string) 275 + use name <- decode.field(1, decode.string) 276 + use client_key <- decode.field(2, decode.string) 277 + use created_at <- decode.field(3, decode.string) 278 + decode.success(#(id, name, client_key, created_at)) 279 + } 280 + case 281 + db.query( 282 + ctx.db, 283 + "SELECT id, name, client_key, created_at FROM happyview_api_clients 284 + WHERE revoked_at IS NULL ORDER BY created_at", 285 + [], 286 + decoder, 287 + ) 288 + { 289 + Error(e) -> error.to_response(e) 290 + Ok(rows) -> { 291 + let items = 292 + list.map(rows, fn(r) { 293 + let #(id, name, key, created) = r 294 + json.object([ 295 + #("id", json.string(id)), 296 + #("name", json.string(name)), 297 + #("clientKey", json.string(key)), 298 + #("createdAt", json.string(created)), 299 + ]) 300 + }) 301 + |> json.preprocessed_array 302 + |> json.to_string 303 + wisp.json_response(items, 200) 304 + } 305 + } 306 + } 307 + } 308 + } 309 + 310 + fn create_api_client(ctx: Ctx, req: Request, admin: auth.AdminAuth) -> Response { 311 + case auth.require_permission(admin, "api-clients:create") { 312 + Error(e) -> error.to_response(e) 313 + Ok(_) -> 314 + wisp.require_string_body(req, fn(body) { 315 + let name = case json_util.parse(body) { 316 + Ok(dyn) -> 317 + case json_util.optional_string(dyn, "name") { 318 + Some(n) -> n 319 + None -> "client" 320 + } 321 + Error(_) -> "client" 322 + } 323 + case auth.create_api_client(ctx.db, name) { 324 + Error(e) -> error.to_response(e) 325 + Ok(#(client, secret)) -> { 326 + let body = 327 + json.object([ 328 + #("id", json.string(client.id)), 329 + #("name", json.string(client.name)), 330 + #("clientKey", json.string(client.client_key)), 331 + #("clientSecret", json.string(secret)), 332 + ]) 333 + |> json.to_string 334 + wisp.json_response(body, 201) 335 + } 336 + } 337 + }) 338 + } 339 + }
+292
src/gleamview/auth.gleam
··· 1 + //// API client keys (XRPC) and admin API key auth. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/http/request 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gleamview/db.{type Db} 10 + import gleamview/error.{type AppError} 11 + import gleamview/util 12 + import wisp.{type Request} 13 + 14 + pub type ApiClient { 15 + ApiClient(id: String, name: String, client_key: String) 16 + } 17 + 18 + pub type AdminAuth { 19 + AdminAuth( 20 + user_id: String, 21 + did: String, 22 + is_super: Bool, 23 + permissions: List(String), 24 + ) 25 + } 26 + 27 + // ── XRPC client identification ────────────────────────────────────────────── 28 + 29 + /// Resolve client key from `X-Client-Key` header or `client_key` query param. 30 + pub fn require_client(db: Db, req: Request) -> Result(ApiClient, AppError) { 31 + case client_key_from_request(req) { 32 + None -> 33 + Error(error.Unauthorized("Missing client identification")) 34 + Some(key) -> lookup_client(db, key) 35 + } 36 + } 37 + 38 + pub fn client_key_from_request(req: Request) -> Option(String) { 39 + case request.get_header(req, "x-client-key") { 40 + Ok(k) if k != "" -> Some(k) 41 + _ -> { 42 + let q = wisp.get_query(req) 43 + list.key_find(q, "client_key") 44 + |> option.from_result 45 + |> option.then(fn(v) { 46 + case v { 47 + "" -> None 48 + other -> Some(other) 49 + } 50 + }) 51 + } 52 + } 53 + } 54 + 55 + fn lookup_client(db: Db, key: String) -> Result(ApiClient, AppError) { 56 + let decoder = { 57 + use id <- decode.field(0, decode.string) 58 + use name <- decode.field(1, decode.string) 59 + use client_key <- decode.field(2, decode.string) 60 + decode.success(ApiClient(id:, name:, client_key:)) 61 + } 62 + use row <- result.try( 63 + db.query_one( 64 + db, 65 + "SELECT id, name, client_key FROM happyview_api_clients 66 + WHERE client_key = ? AND revoked_at IS NULL", 67 + [db.text(key)], 68 + decoder, 69 + ), 70 + ) 71 + case row { 72 + Some(c) -> Ok(c) 73 + None -> Error(error.Unauthorized("Unknown client key")) 74 + } 75 + } 76 + 77 + pub fn create_api_client( 78 + db: Db, 79 + name: String, 80 + ) -> Result(#(ApiClient, String), AppError) { 81 + let id = util.new_id() 82 + let client_key = util.new_token("hvc_", 16) 83 + let client_secret = util.new_token("hvs_", 24) 84 + let secret_hash = util.sha256_hex(client_secret) 85 + let now = util.now_rfc3339() 86 + use _ <- result.try( 87 + db.execute( 88 + db, 89 + "INSERT INTO happyview_api_clients 90 + (id, name, client_key, client_secret_hash, capacity, refill_rate, created_at) 91 + VALUES (?, ?, ?, ?, 100, 2.0, ?)", 92 + [ 93 + db.text(id), 94 + db.text(name), 95 + db.text(client_key), 96 + db.text(secret_hash), 97 + db.text(now), 98 + ], 99 + ), 100 + ) 101 + Ok(#(ApiClient(id:, name:, client_key:), client_secret)) 102 + } 103 + 104 + // ── Admin API keys ────────────────────────────────────────────────────────── 105 + 106 + pub fn require_admin(db: Db, req: Request) -> Result(AdminAuth, AppError) { 107 + case bearer_token(req) { 108 + None -> Error(error.Unauthorized("Missing Bearer token")) 109 + Some(token) -> { 110 + let hash = util.sha256_hex(token) 111 + let decoder = { 112 + use key_id <- decode.field(0, decode.string) 113 + use user_id <- decode.field(1, decode.string) 114 + use did <- decode.field(2, decode.string) 115 + use is_super <- decode.field(3, decode.int) 116 + use permissions_json <- decode.field(4, decode.string) 117 + decode.success(#(key_id, user_id, did, is_super, permissions_json)) 118 + } 119 + use row <- result.try( 120 + db.query_one( 121 + db, 122 + "SELECT k.id, u.id, u.did, u.is_super, k.permissions 123 + FROM happyview_api_keys k 124 + JOIN happyview_users u ON u.id = k.user_id 125 + WHERE k.key_hash = ? AND k.revoked_at IS NULL", 126 + [db.text(hash)], 127 + decoder, 128 + ), 129 + ) 130 + case row { 131 + None -> Error(error.Unauthorized("Invalid API key")) 132 + Some(#(key_id, user_id, did, is_super_i, perms_json)) -> { 133 + let _ = 134 + db.execute( 135 + db, 136 + "UPDATE happyview_api_keys SET last_used_at = ? WHERE id = ?", 137 + [db.text(util.now_rfc3339()), db.text(key_id)], 138 + ) 139 + let permissions = parse_permissions_json(perms_json) 140 + Ok(AdminAuth( 141 + user_id:, 142 + did:, 143 + is_super: is_super_i != 0, 144 + permissions:, 145 + )) 146 + } 147 + } 148 + } 149 + } 150 + } 151 + 152 + pub fn require_permission( 153 + auth: AdminAuth, 154 + permission: String, 155 + ) -> Result(Nil, AppError) { 156 + case auth.is_super || list.contains(auth.permissions, permission) { 157 + True -> Ok(Nil) 158 + False -> Error(error.InsufficientPermissions(permission)) 159 + } 160 + } 161 + 162 + fn bearer_token(req: Request) -> Option(String) { 163 + case request.get_header(req, "authorization") { 164 + Ok(value) -> { 165 + case string.starts_with(string.lowercase(value), "bearer ") { 166 + True -> Some(string.drop_start(value, 7) |> string.trim) 167 + False -> Some(value) 168 + } 169 + } 170 + Error(_) -> None 171 + } 172 + } 173 + 174 + fn parse_permissions_json(s: String) -> List(String) { 175 + // Minimal: split quoted strings inside [...] 176 + s 177 + |> string.replace("[", "") 178 + |> string.replace("]", "") 179 + |> string.replace("\"", "") 180 + |> string.split(",") 181 + |> list.map(string.trim) 182 + |> list.filter(fn(p) { p != "" }) 183 + } 184 + 185 + pub const all_permissions = [ 186 + "lexicons:create", "lexicons:read", "lexicons:delete", "records:read", 187 + "records:delete", "api-keys:create", "api-keys:read", "api-keys:delete", 188 + "api-clients:create", "api-clients:read", "api-clients:delete", "stats:read", 189 + "events:read", "users:create", "users:read", "users:update", "users:delete", 190 + ] 191 + 192 + /// Bootstrap super user + admin API key when the users table is empty. 193 + pub fn bootstrap_if_empty( 194 + db: Db, 195 + preferred_key: Option(String), 196 + ) -> Result(Option(String), AppError) { 197 + use count_rows <- result.try( 198 + db.query(db, "SELECT COUNT(*) FROM happyview_users", [], { 199 + use n <- decode.field(0, decode.int) 200 + decode.success(n) 201 + }), 202 + ) 203 + let count = case count_rows { 204 + [n, ..] -> n 205 + [] -> 0 206 + } 207 + case count > 0 { 208 + True -> Ok(None) 209 + False -> { 210 + let user_id = util.new_id() 211 + let did = "did:gleamview:bootstrap" 212 + let now = util.now_rfc3339() 213 + let raw_key = case preferred_key { 214 + Some(k) -> k 215 + None -> util.new_token("gva_", 24) 216 + } 217 + let key_id = util.new_id() 218 + let key_hash = util.sha256_hex(raw_key) 219 + let key_prefix = string.slice(raw_key, 0, 12) 220 + let perms_json = 221 + "[\"" <> string.join(all_permissions, "\",\"") <> "\"]" 222 + 223 + use _ <- result.try( 224 + db.execute( 225 + db, 226 + "INSERT INTO happyview_users (id, did, is_super, created_at) 227 + VALUES (?, ?, 1, ?)", 228 + [db.text(user_id), db.text(did), db.text(now)], 229 + ), 230 + ) 231 + use _ <- result.try( 232 + list.try_each(all_permissions, fn(perm) { 233 + db.execute( 234 + db, 235 + "INSERT INTO happyview_user_permissions (user_id, permission, granted_at) 236 + VALUES (?, ?, ?)", 237 + [db.text(user_id), db.text(perm), db.text(now)], 238 + ) 239 + }), 240 + ) 241 + use _ <- result.try( 242 + db.execute( 243 + db, 244 + "INSERT INTO happyview_api_keys 245 + (id, user_id, name, key_hash, key_prefix, permissions, created_at) 246 + VALUES (?, ?, 'bootstrap', ?, ?, ?, ?)", 247 + [ 248 + db.text(key_id), 249 + db.text(user_id), 250 + db.text(key_hash), 251 + db.text(key_prefix), 252 + db.text(perms_json), 253 + db.text(now), 254 + ], 255 + ), 256 + ) 257 + Ok(Some(raw_key)) 258 + } 259 + } 260 + } 261 + 262 + pub fn create_admin_key( 263 + db: Db, 264 + user_id: String, 265 + name: String, 266 + permissions: List(String), 267 + ) -> Result(String, AppError) { 268 + let key_id = util.new_id() 269 + let raw_key = util.new_token("gva_", 24) 270 + let key_hash = util.sha256_hex(raw_key) 271 + let key_prefix = string.slice(raw_key, 0, 12) 272 + let now = util.now_rfc3339() 273 + let perms_json = "[\"" <> string.join(permissions, "\",\"") <> "\"]" 274 + use _ <- result.try( 275 + db.execute( 276 + db, 277 + "INSERT INTO happyview_api_keys 278 + (id, user_id, name, key_hash, key_prefix, permissions, created_at) 279 + VALUES (?, ?, ?, ?, ?, ?, ?)", 280 + [ 281 + db.text(key_id), 282 + db.text(user_id), 283 + db.text(name), 284 + db.text(key_hash), 285 + db.text(key_prefix), 286 + db.text(perms_json), 287 + db.text(now), 288 + ], 289 + ), 290 + ) 291 + Ok(raw_key) 292 + }
+87
src/gleamview/config.gleam
··· 1 + //// Runtime configuration (env-driven, HappyView-compatible names). 2 + 3 + import envoy 4 + import gleam/int 5 + import gleam/option.{type Option, None, Some} 6 + import gleam/result 7 + import gleam/string 8 + 9 + pub type Config { 10 + Config( 11 + database_url: String, 12 + public_url: String, 13 + host: String, 14 + port: Int, 15 + session_secret: String, 16 + jetstream_url: String, 17 + jetstream_enabled: Bool, 18 + plc_url: String, 19 + relay_url: String, 20 + allow_local_writes: Bool, 21 + bootstrap_admin_key: Option(String), 22 + ) 23 + } 24 + 25 + pub fn from_env() -> Config { 26 + Config( 27 + database_url: env("DATABASE_URL", "sqlite:gleamview.db"), 28 + public_url: env("PUBLIC_URL", "http://127.0.0.1:3000"), 29 + host: env("HOST", "0.0.0.0"), 30 + port: env_int("PORT", 3000), 31 + session_secret: env( 32 + "SESSION_SECRET", 33 + "dev-secret-change-me-please-use-at-least-64-characters-long!!", 34 + ), 35 + jetstream_url: env( 36 + "JETSTREAM_URL", 37 + "wss://jetstream1.us-east.bsky.network", 38 + ), 39 + jetstream_enabled: env_bool("JETSTREAM_ENABLED", True), 40 + plc_url: env("PLC_URL", "https://plc.directory"), 41 + relay_url: env("RELAY_URL", "https://bsky.network"), 42 + allow_local_writes: env_bool("ALLOW_LOCAL_WRITES", True), 43 + bootstrap_admin_key: case envoy.get("GLEAMVIEW_ADMIN_KEY") { 44 + Ok(k) if k != "" -> Some(k) 45 + _ -> None 46 + }, 47 + ) 48 + } 49 + 50 + /// Strip `sqlite:` / `sqlite://` prefix to a filesystem path for sqlight. 51 + pub fn sqlite_path(config: Config) -> String { 52 + let url = config.database_url 53 + case string.starts_with(url, "sqlite://") { 54 + True -> string.drop_start(url, 9) 55 + False -> 56 + case string.starts_with(url, "sqlite:") { 57 + True -> string.drop_start(url, 7) 58 + False -> url 59 + } 60 + } 61 + } 62 + 63 + fn env(name: String, default: String) -> String { 64 + envoy.get(name) 65 + |> result.unwrap(default) 66 + } 67 + 68 + fn env_int(name: String, default: Int) -> Int { 69 + case envoy.get(name) { 70 + Ok(v) -> int.parse(v) |> result.unwrap(default) 71 + Error(_) -> default 72 + } 73 + } 74 + 75 + fn env_bool(name: String, default: Bool) -> Bool { 76 + case envoy.get(name) { 77 + Ok(v) -> { 78 + let lower = string.lowercase(v) 79 + case lower { 80 + "1" | "true" | "yes" | "on" -> True 81 + "0" | "false" | "no" | "off" -> False 82 + _ -> default 83 + } 84 + } 85 + Error(_) -> default 86 + } 87 + }
+268
src/gleamview/db.gleam
··· 1 + //// SQLite access actor — serializes all queries on one connection. 2 + 3 + import filepath 4 + import gleam/dynamic/decode 5 + import gleam/erlang/process.{type Subject} 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/otp/actor 9 + import gleam/result 10 + import gleam/string 11 + import gleamview/error.{type AppError} 12 + import gleamview/util 13 + import simplifile 14 + import sqlight 15 + 16 + pub type Db = 17 + Subject(Message) 18 + 19 + pub type Message { 20 + Exec(sql: String, reply: Subject(Result(Nil, AppError))) 21 + Query( 22 + sql: String, 23 + args: List(sqlight.Value), 24 + // Rows returned as list of column values encoded as strings/nulls is awkward; 25 + // callers use typed helpers below that send their own decode via QueryRaw. 26 + reply: Subject(Result(List(decode.Dynamic), AppError)), 27 + ) 28 + QueryRaw( 29 + sql: String, 30 + args: List(sqlight.Value), 31 + reply: Subject(Result(List(decode.Dynamic), AppError)), 32 + ) 33 + Shutdown 34 + } 35 + 36 + type State { 37 + State(conn: sqlight.Connection) 38 + } 39 + 40 + /// Open SQLite, run migrations, start the DB actor. 41 + pub fn start(sqlite_path: String, migrations_dir: String) -> Result(Db, String) { 42 + use _ <- result.try(ensure_parent_dir(sqlite_path)) 43 + use conn <- result.try( 44 + sqlight.open(sqlite_path) 45 + |> result.map_error(fn(e) { "open db: " <> string.inspect(e) }), 46 + ) 47 + use _ <- result.try( 48 + sqlight.exec("PRAGMA foreign_keys = ON;", conn) 49 + |> result.map_error(fn(e) { "pragma: " <> string.inspect(e) }), 50 + ) 51 + use _ <- result.try(run_migrations(conn, migrations_dir)) 52 + 53 + case 54 + actor.new(State(conn)) 55 + |> actor.on_message(handle) 56 + |> actor.start 57 + { 58 + Ok(started) -> Ok(started.data) 59 + Error(e) -> Error("db actor: " <> string.inspect(e)) 60 + } 61 + } 62 + 63 + fn ensure_parent_dir(path: String) -> Result(Nil, String) { 64 + let dir = filepath.directory_name(path) 65 + case dir == "" || dir == "." { 66 + True -> Ok(Nil) 67 + False -> 68 + case simplifile.create_directory_all(dir) { 69 + Ok(_) | Error(simplifile.Eexist) -> Ok(Nil) 70 + Error(e) -> Error("mkdir: " <> string.inspect(e)) 71 + } 72 + } 73 + } 74 + 75 + fn run_migrations( 76 + conn: sqlight.Connection, 77 + migrations_dir: String, 78 + ) -> Result(Nil, String) { 79 + use _ <- result.try( 80 + sqlight.exec( 81 + "CREATE TABLE IF NOT EXISTS schema_migrations ( 82 + name TEXT PRIMARY KEY, 83 + applied_at TEXT NOT NULL 84 + );", 85 + conn, 86 + ) 87 + |> result.map_error(fn(e) { "schema_migrations: " <> string.inspect(e) }), 88 + ) 89 + 90 + case simplifile.read_directory(migrations_dir) { 91 + Error(_) -> Ok(Nil) 92 + Ok(entries) -> { 93 + let sql_files = 94 + entries 95 + |> list.filter(fn(name) { string.ends_with(name, ".sql") }) 96 + |> list.sort(string.compare) 97 + 98 + list.try_each(sql_files, fn(name) { 99 + case already_applied(conn, name) { 100 + True -> Ok(Nil) 101 + False -> apply_migration(conn, migrations_dir, name) 102 + } 103 + }) 104 + } 105 + } 106 + } 107 + 108 + fn already_applied(conn: sqlight.Connection, name: String) -> Bool { 109 + let decoder = { 110 + use n <- decode.field(0, decode.string) 111 + decode.success(n) 112 + } 113 + case 114 + sqlight.query( 115 + "SELECT name FROM schema_migrations WHERE name = ?", 116 + on: conn, 117 + with: [sqlight.text(name)], 118 + expecting: decoder, 119 + ) 120 + { 121 + Ok([_]) -> True 122 + _ -> False 123 + } 124 + } 125 + 126 + fn apply_migration( 127 + conn: sqlight.Connection, 128 + dir: String, 129 + name: String, 130 + ) -> Result(Nil, String) { 131 + let path = filepath.join(dir, name) 132 + use sql <- result.try( 133 + simplifile.read(path) 134 + |> result.map_error(fn(e) { "read migration " <> name <> ": " <> string.inspect(e) }), 135 + ) 136 + use _ <- result.try( 137 + sqlight.exec(sql, conn) 138 + |> result.map_error(fn(e) { 139 + "apply migration " <> name <> ": " <> string.inspect(e) 140 + }), 141 + ) 142 + let now = util.now_rfc3339() 143 + sqlight.query( 144 + "INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)", 145 + on: conn, 146 + with: [sqlight.text(name), sqlight.text(now)], 147 + expecting: decode.success(Nil), 148 + ) 149 + |> result.map(fn(_) { Nil }) 150 + |> result.map_error(fn(e) { 151 + "record migration " <> name <> ": " <> string.inspect(e) 152 + }) 153 + } 154 + 155 + fn handle(state: State, msg: Message) -> actor.Next(State, Message) { 156 + case msg { 157 + Shutdown -> { 158 + let _ = sqlight.close(state.conn) 159 + actor.stop() 160 + } 161 + Exec(sql, reply) -> { 162 + let res = 163 + sqlight.exec(sql, state.conn) 164 + |> result.map_error(error.from_db) 165 + process.send(reply, res) 166 + actor.continue(state) 167 + } 168 + Query(sql, args, reply) | QueryRaw(sql, args, reply) -> { 169 + let res = 170 + run_query_raw(state.conn, sql, args) 171 + |> result.map_error(error.from_db) 172 + process.send(reply, res) 173 + actor.continue(state) 174 + } 175 + } 176 + } 177 + 178 + fn run_query_raw( 179 + conn: sqlight.Connection, 180 + sql: String, 181 + args: List(sqlight.Value), 182 + ) -> Result(List(decode.Dynamic), sqlight.Error) { 183 + // sqlight.query expects a decoder; identity via dynamic decode 184 + sqlight.query(sql, on: conn, with: args, expecting: decode.dynamic) 185 + } 186 + 187 + // ── Client API ────────────────────────────────────────────────────────────── 188 + 189 + pub fn exec(db: Db, sql: String) -> Result(Nil, AppError) { 190 + let reply = process.new_subject() 191 + process.send(db, Exec(sql, reply)) 192 + process.receive(reply, 10_000) 193 + |> result.map_error(fn(_) { error.Internal("db exec timeout") }) 194 + |> result.flatten 195 + } 196 + 197 + pub fn query( 198 + db: Db, 199 + sql: String, 200 + args: List(sqlight.Value), 201 + decoder: decode.Decoder(t), 202 + ) -> Result(List(t), AppError) { 203 + let reply = process.new_subject() 204 + process.send(db, QueryRaw(sql, args, reply)) 205 + use rows <- result.try( 206 + process.receive(reply, 10_000) 207 + |> result.map_error(fn(_) { error.Internal("db query timeout") }) 208 + |> result.flatten, 209 + ) 210 + list.try_map(rows, fn(row) { 211 + decode.run(row, decoder) 212 + |> result.map_error(fn(e) { 213 + error.Internal("decode error: " <> string.inspect(e)) 214 + }) 215 + }) 216 + } 217 + 218 + pub fn query_one( 219 + db: Db, 220 + sql: String, 221 + args: List(sqlight.Value), 222 + decoder: decode.Decoder(t), 223 + ) -> Result(Option(t), AppError) { 224 + use rows <- result.try(query(db, sql, args, decoder)) 225 + case rows { 226 + [] -> Ok(None) 227 + [row, ..] -> Ok(Some(row)) 228 + } 229 + } 230 + 231 + pub fn execute( 232 + db: Db, 233 + sql: String, 234 + args: List(sqlight.Value), 235 + ) -> Result(Nil, AppError) { 236 + // Use query with a void decoder to run parameterized writes. 237 + use _ <- result.try( 238 + query(db, sql, args, decode.success(Nil)) 239 + |> result.map(fn(_) { Nil }), 240 + ) 241 + Ok(Nil) 242 + } 243 + 244 + pub fn text(v: String) -> sqlight.Value { 245 + sqlight.text(v) 246 + } 247 + 248 + pub fn int(v: Int) -> sqlight.Value { 249 + sqlight.int(v) 250 + } 251 + 252 + pub fn null() -> sqlight.Value { 253 + sqlight.null() 254 + } 255 + 256 + pub fn nullable_text(v: Option(String)) -> sqlight.Value { 257 + case v { 258 + Some(s) -> sqlight.text(s) 259 + None -> sqlight.null() 260 + } 261 + } 262 + 263 + pub fn nullable_int(v: Option(Int)) -> sqlight.Value { 264 + case v { 265 + Some(n) -> sqlight.int(n) 266 + None -> sqlight.null() 267 + } 268 + }
+54
src/gleamview/error.gleam
··· 1 + //// Application errors mapped to XRPC / HTTP responses. 2 + 3 + import gleam/json 4 + import gleam/string 5 + import wisp.{type Response} 6 + 7 + pub type AppError { 8 + BadRequest(String) 9 + Unauthorized(String) 10 + Forbidden(String) 11 + NotFound(String) 12 + InsufficientPermissions(String) 13 + Internal(String) 14 + Conflict(String) 15 + } 16 + 17 + pub fn to_response(err: AppError) -> Response { 18 + let #(status, error_type, message) = case err { 19 + BadRequest(m) -> #(400, "InvalidRequest", m) 20 + Unauthorized(m) -> #(401, "AuthRequired", m) 21 + Forbidden(m) -> #(403, "Forbidden", m) 22 + NotFound(m) -> #(404, "NotFound", m) 23 + InsufficientPermissions(perm) -> #( 24 + 403, 25 + "InsufficientPermissions", 26 + "missing permission: " <> perm, 27 + ) 28 + Conflict(m) -> #(409, "Conflict", m) 29 + Internal(m) -> #(500, "InternalServerError", m) 30 + } 31 + let body = 32 + json.object([ 33 + #("error", json.string(error_type)), 34 + #("message", json.string(message)), 35 + ]) 36 + |> json.to_string 37 + wisp.json_response(body, status) 38 + } 39 + 40 + pub fn message(err: AppError) -> String { 41 + case err { 42 + BadRequest(m) -> m 43 + Unauthorized(m) -> m 44 + Forbidden(m) -> m 45 + NotFound(m) -> m 46 + InsufficientPermissions(m) -> m 47 + Conflict(m) -> m 48 + Internal(m) -> m 49 + } 50 + } 51 + 52 + pub fn from_db(e: a) -> AppError { 53 + Internal("database error: " <> string.inspect(e)) 54 + }
+53
src/gleamview/event_log.gleam
··· 1 + //// Lightweight event log (happyview_event_logs). 2 + 3 + import gleam/option.{type Option, None} 4 + import gleamview/db.{type Db} 5 + import gleamview/error.{type AppError} 6 + import gleamview/util 7 + 8 + pub type Severity { 9 + Info 10 + Warn 11 + Error 12 + } 13 + 14 + pub fn log( 15 + db: Db, 16 + event_type: String, 17 + severity: Severity, 18 + actor_did: Option(String), 19 + subject: Option(String), 20 + detail_json: String, 21 + ) -> Result(Nil, AppError) { 22 + let id = util.new_id() 23 + let now = util.now_rfc3339() 24 + let sev = case severity { 25 + Info -> "info" 26 + Warn -> "warn" 27 + Error -> "error" 28 + } 29 + db.execute( 30 + db, 31 + "INSERT INTO happyview_event_logs 32 + (id, event_type, severity, actor_did, subject, detail, created_at) 33 + VALUES (?, ?, ?, ?, ?, ?, ?)", 34 + [ 35 + db.text(id), 36 + db.text(event_type), 37 + db.text(sev), 38 + db.nullable_text(actor_did), 39 + db.nullable_text(subject), 40 + db.text(detail_json), 41 + db.text(now), 42 + ], 43 + ) 44 + } 45 + 46 + pub fn info( 47 + db: Db, 48 + event_type: String, 49 + subject: Option(String), 50 + detail_json: String, 51 + ) -> Result(Nil, AppError) { 52 + log(db, event_type, Info, None, subject, detail_json) 53 + }
+238
src/gleamview/jetstream.gleam
··· 1 + //// Jetstream WebSocket consumer (real-time record indexing). 2 + 3 + import gleam/dynamic.{type Dynamic} 4 + import gleam/dynamic/decode 5 + import gleam/erlang/process 6 + import gleam/http 7 + import gleam/http/request 8 + import gleam/int 9 + import gleam/list 10 + import gleam/option.{None, Some} 11 + import gleam/result 12 + import gleam/string 13 + import gleam/uri 14 + import gleamview/db.{type Db} 15 + import gleamview/error 16 + import gleamview/json_util 17 + import gleamview/lexicon.{type Registry} 18 + import gleamview/record_handler 19 + import logging 20 + import stratus 21 + 22 + /// Build subscribe URL with wantedCollections query params. 23 + pub fn build_subscribe_url(base_url: String, collections: List(String)) -> String { 24 + let base = string.trim_end(base_url) |> trim_slash 25 + let always = record_handler.lexicon_schema_collection 26 + let cols = case list.contains(collections, always) { 27 + True -> collections 28 + False -> list.append(collections, [always]) 29 + } 30 + let params = 31 + list.map(cols, fn(c) { "wantedCollections=" <> uri.percent_encode(c) }) 32 + |> string.join("&") 33 + base <> "/subscribe?compress=false&" <> params 34 + } 35 + 36 + fn trim_slash(s: String) -> String { 37 + case string.ends_with(s, "/") { 38 + True -> string.drop_end(s, 1) 39 + False -> s 40 + } 41 + } 42 + 43 + type JsState { 44 + JsState(db: Db, reg: Registry) 45 + } 46 + 47 + /// Spawn Jetstream consumer. Reconnects on failure with backoff. 48 + pub fn spawn(db: Db, reg: Registry, base_url: String) -> Nil { 49 + process.spawn(fn() { loop_forever(db, reg, base_url, 1) }) 50 + Nil 51 + } 52 + 53 + fn loop_forever(db: Db, reg: Registry, base_url: String, backoff_s: Int) -> Nil { 54 + let collections = lexicon.record_collections(reg) 55 + let url = build_subscribe_url(base_url, collections) 56 + logging.log(logging.Info, "jetstream connecting: " <> url) 57 + 58 + case connect_and_run(db, reg, url) { 59 + Ok(_) -> { 60 + logging.log(logging.Warning, "jetstream disconnected; reconnecting") 61 + process.sleep(1000) 62 + loop_forever(db, reg, base_url, 1) 63 + } 64 + Error(e) -> { 65 + logging.log( 66 + logging.Error, 67 + "jetstream error: " 68 + <> e 69 + <> "; retry in " 70 + <> int.to_string(backoff_s) 71 + <> "s", 72 + ) 73 + process.sleep(backoff_s * 1000) 74 + let next = int.min(backoff_s * 2, 60) 75 + loop_forever(db, reg, base_url, next) 76 + } 77 + } 78 + } 79 + 80 + fn connect_and_run(db: Db, reg: Registry, url: String) -> Result(Nil, String) { 81 + use req0 <- result.try( 82 + request.to(url) 83 + |> result.map_error(fn(_) { "bad jetstream url" }), 84 + ) 85 + let req = case string.starts_with(url, "wss:") { 86 + True -> request.set_scheme(req0, http.Https) 87 + False -> 88 + case string.starts_with(url, "ws:") { 89 + True -> request.set_scheme(req0, http.Http) 90 + False -> req0 91 + } 92 + } 93 + 94 + let builder = 95 + stratus.new(req, JsState(db, reg)) 96 + |> stratus.on_message(fn(state, msg, _conn) { 97 + case msg { 98 + stratus.Text(text) -> { 99 + handle_text(state.db, state.reg, text) 100 + stratus.continue(state) 101 + } 102 + stratus.Binary(_) -> stratus.continue(state) 103 + stratus.User(_) -> stratus.continue(state) 104 + } 105 + }) 106 + |> stratus.on_close(fn(_state, reason) { 107 + logging.log( 108 + logging.Warning, 109 + "jetstream closed: " <> string.inspect(reason), 110 + ) 111 + }) 112 + 113 + case stratus.start(builder) { 114 + Error(e) -> Error("start: " <> string.inspect(e)) 115 + Ok(_started) -> { 116 + // Keep the supervisor process alive; reconnect loop resumes if we return. 117 + // Block for a long time — if the actor dies, we still only reconnect after timeout. 118 + // A tighter design would monitor the actor pid; acceptable for MVP. 119 + process.sleep(86_400_000) 120 + Ok(Nil) 121 + } 122 + } 123 + } 124 + 125 + fn handle_text(db: Db, reg: Registry, text: String) -> Nil { 126 + case json_util.parse(text) { 127 + Error(_) -> Nil 128 + Ok(dyn) -> { 129 + case json_util.optional_string(dyn, "kind") { 130 + Some("commit") -> handle_commit(db, reg, text, dyn) 131 + _ -> Nil 132 + } 133 + } 134 + } 135 + } 136 + 137 + fn handle_commit(db: Db, reg: Registry, text: String, dyn: Dynamic) -> Nil { 138 + let did = option.unwrap(json_util.optional_string(dyn, "did"), "") 139 + case did { 140 + "" -> Nil 141 + did -> { 142 + let operation = 143 + decode.run(dyn, decode.at(["commit", "operation"], decode.string)) 144 + |> result.unwrap("") 145 + let collection = 146 + decode.run(dyn, decode.at(["commit", "collection"], decode.string)) 147 + |> result.unwrap("") 148 + let rkey = 149 + decode.run(dyn, decode.at(["commit", "rkey"], decode.string)) 150 + |> result.unwrap("") 151 + let cid = 152 + decode.run(dyn, decode.at(["commit", "cid"], decode.string)) 153 + |> option.from_result 154 + let record_json = case operation { 155 + "delete" -> None 156 + _ -> 157 + case extract_commit_record(text) { 158 + Ok(j) -> Some(j) 159 + Error(_) -> None 160 + } 161 + } 162 + let event = 163 + record_handler.RecordEvent( 164 + did:, 165 + collection:, 166 + rkey:, 167 + action: operation, 168 + record_json:, 169 + cid:, 170 + ) 171 + case record_handler.handle_record_event(db, reg, event) { 172 + Ok(_) -> Nil 173 + Error(e) -> 174 + logging.log(logging.Warning, "record handler: " <> error.message(e)) 175 + } 176 + } 177 + } 178 + } 179 + 180 + fn extract_commit_record(text: String) -> Result(String, Nil) { 181 + case string.split_once(text, "\"record\":") { 182 + Error(_) -> Error(Nil) 183 + Ok(#(_, rest)) -> { 184 + let rest = string.trim_start(rest) 185 + case rest { 186 + "null" <> _ -> Error(Nil) 187 + _ -> extract_object(rest) 188 + } 189 + } 190 + } 191 + } 192 + 193 + fn extract_object(s: String) -> Result(String, Nil) { 194 + case s { 195 + "{" <> _ -> walk(s, 0, False, False, "") 196 + _ -> Error(Nil) 197 + } 198 + } 199 + 200 + fn walk( 201 + rest: String, 202 + depth: Int, 203 + in_str: Bool, 204 + escape: Bool, 205 + acc: String, 206 + ) -> Result(String, Nil) { 207 + case string.pop_grapheme(rest) { 208 + Error(_) -> Error(Nil) 209 + Ok(#(ch, rest2)) -> { 210 + let acc2 = acc <> ch 211 + case in_str { 212 + True -> 213 + case escape { 214 + True -> walk(rest2, depth, True, False, acc2) 215 + False -> 216 + case ch { 217 + "\\" -> walk(rest2, depth, True, True, acc2) 218 + "\"" -> walk(rest2, depth, False, False, acc2) 219 + _ -> walk(rest2, depth, True, False, acc2) 220 + } 221 + } 222 + False -> 223 + case ch { 224 + "\"" -> walk(rest2, depth, True, False, acc2) 225 + "{" -> walk(rest2, depth + 1, False, False, acc2) 226 + "}" -> { 227 + let d = depth - 1 228 + case d == 0 { 229 + True -> Ok(acc2) 230 + False -> walk(rest2, d, False, False, acc2) 231 + } 232 + } 233 + _ -> walk(rest2, depth, False, False, acc2) 234 + } 235 + } 236 + } 237 + } 238 + }
+72
src/gleamview/json_util.gleam
··· 1 + //// JSON helpers on top of gleam_json. 2 + 3 + import gleam/dict 4 + import gleam/dynamic.{type Dynamic} 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/list 8 + import gleam/option.{type Option} 9 + import gleam/result 10 + import gleam/string 11 + 12 + pub fn parse(text: String) -> Result(Dynamic, String) { 13 + json.parse(text, decode.dynamic) 14 + |> result.map_error(fn(e) { "invalid json: " <> string.inspect(e) }) 15 + } 16 + 17 + pub fn field_string(dyn: Dynamic, name: String) -> Result(String, String) { 18 + decode.run(dyn, decode.at([name], decode.string)) 19 + |> result.map_error(fn(_) { "missing or invalid string field: " <> name }) 20 + } 21 + 22 + pub fn field_int(dyn: Dynamic, name: String) -> Result(Int, String) { 23 + decode.run(dyn, decode.at([name], decode.int)) 24 + |> result.map_error(fn(_) { "missing or invalid int field: " <> name }) 25 + } 26 + 27 + pub fn optional_string(dyn: Dynamic, name: String) -> Option(String) { 28 + decode.run(dyn, decode.at([name], decode.string)) 29 + |> option.from_result 30 + } 31 + 32 + pub fn optional_int(dyn: Dynamic, name: String) -> Option(Int) { 33 + decode.run(dyn, decode.at([name], decode.int)) 34 + |> option.from_result 35 + } 36 + 37 + pub fn optional_bool(dyn: Dynamic, name: String) -> Option(Bool) { 38 + decode.run(dyn, decode.at([name], decode.bool)) 39 + |> option.from_result 40 + } 41 + 42 + pub fn object_field(dyn: Dynamic, name: String) -> Option(Dynamic) { 43 + decode.run(dyn, decode.at([name], decode.dynamic)) 44 + |> option.from_result 45 + } 46 + 47 + pub fn as_object_keys(dyn: Dynamic) -> List(String) { 48 + case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 49 + Ok(d) -> dict.keys(d) 50 + Error(_) -> [] 51 + } 52 + } 53 + 54 + /// Get nested `defs.main.type` string from a lexicon document. 55 + pub fn lexicon_main_type(raw: Dynamic) -> Option(String) { 56 + decode.run(raw, decode.at(["defs", "main", "type"], decode.string)) 57 + |> option.from_result 58 + } 59 + 60 + pub fn lexicon_id(raw: Dynamic) -> Result(String, String) { 61 + field_string(raw, "id") 62 + } 63 + 64 + pub fn string_map_to_json(fields: List(#(String, String))) -> String { 65 + json.object( 66 + list.map(fields, fn(pair) { 67 + let #(k, v) = pair 68 + #(k, json.string(v)) 69 + }), 70 + ) 71 + |> json.to_string 72 + }
+446
src/gleamview/lexicon.gleam
··· 1 + //// Lexicon parsing and in-memory registry (actor). 2 + 3 + import gleam/dict.{type Dict} 4 + import gleam/dynamic/decode 5 + import gleam/erlang/process.{type Subject} 6 + import gleam/json 7 + import gleam/list 8 + import gleam/option.{type Option, None, Some} 9 + import gleam/otp/actor 10 + import gleam/result 11 + import gleam/string 12 + import gleamview/db.{type Db} 13 + import gleamview/error.{type AppError} 14 + import gleamview/json_util 15 + import gleamview/util 16 + 17 + pub type LexiconType { 18 + Record 19 + Query 20 + Procedure 21 + Space 22 + Definitions 23 + } 24 + 25 + pub type ProcedureAction { 26 + Create 27 + Update 28 + Delete 29 + Upsert 30 + } 31 + 32 + pub type ParsedLexicon { 33 + ParsedLexicon( 34 + id: String, 35 + lexicon_type: LexiconType, 36 + record_key: Option(String), 37 + target_collection: Option(String), 38 + action: ProcedureAction, 39 + token_cost: Option(Int), 40 + space_type: Option(String), 41 + revision: Int, 42 + raw_json: String, 43 + ) 44 + } 45 + 46 + pub type Registry = 47 + Subject(RegMessage) 48 + 49 + pub type RegMessage { 50 + Put(ParsedLexicon, reply: Subject(Nil)) 51 + Drop(String, reply: Subject(Bool)) 52 + Fetch(String, reply: Subject(Option(ParsedLexicon))) 53 + ListAll(reply: Subject(List(ParsedLexicon))) 54 + RecordCollections(reply: Subject(List(String))) 55 + LoadAll(List(ParsedLexicon), reply: Subject(Nil)) 56 + } 57 + 58 + type RegState { 59 + RegState(by_id: Dict(String, ParsedLexicon)) 60 + } 61 + 62 + pub fn start() -> Result(Registry, String) { 63 + case 64 + actor.new(RegState(dict.new())) 65 + |> actor.on_message(reg_handle) 66 + |> actor.start 67 + { 68 + Ok(s) -> Ok(s.data) 69 + Error(e) -> Error("lexicon registry: " <> string.inspect(e)) 70 + } 71 + } 72 + 73 + fn reg_handle(state: RegState, msg: RegMessage) -> actor.Next(RegState, RegMessage) { 74 + case msg { 75 + Put(parsed, reply) -> { 76 + let by_id = dict.insert(state.by_id, parsed.id, parsed) 77 + process.send(reply, Nil) 78 + actor.continue(RegState(by_id)) 79 + } 80 + Drop(id, reply) -> { 81 + let existed = dict.has_key(state.by_id, id) 82 + let by_id = dict.delete(state.by_id, id) 83 + process.send(reply, existed) 84 + actor.continue(RegState(by_id)) 85 + } 86 + Fetch(id, reply) -> { 87 + process.send(reply, dict.get(state.by_id, id) |> option.from_result) 88 + actor.continue(state) 89 + } 90 + ListAll(reply) -> { 91 + process.send(reply, dict.values(state.by_id)) 92 + actor.continue(state) 93 + } 94 + RecordCollections(reply) -> { 95 + let cols = 96 + dict.values(state.by_id) 97 + |> list.filter(fn(p) { p.lexicon_type == Record }) 98 + |> list.map(fn(p) { p.id }) 99 + process.send(reply, cols) 100 + actor.continue(state) 101 + } 102 + LoadAll(items, reply) -> { 103 + let by_id = 104 + list.fold(items, dict.new(), fn(acc, p) { dict.insert(acc, p.id, p) }) 105 + process.send(reply, Nil) 106 + actor.continue(RegState(by_id)) 107 + } 108 + } 109 + } 110 + 111 + pub fn upsert(reg: Registry, parsed: ParsedLexicon) -> Nil { 112 + let reply = process.new_subject() 113 + process.send(reg, Put(parsed, reply)) 114 + let _ = process.receive(reply, 5000) 115 + Nil 116 + } 117 + 118 + pub fn remove(reg: Registry, id: String) -> Bool { 119 + let reply = process.new_subject() 120 + process.send(reg, Drop(id, reply)) 121 + process.receive(reply, 5000) |> result.unwrap(False) 122 + } 123 + 124 + pub fn get(reg: Registry, id: String) -> Option(ParsedLexicon) { 125 + let reply = process.new_subject() 126 + process.send(reg, Fetch(id, reply)) 127 + process.receive(reply, 5000) |> result.unwrap(None) 128 + } 129 + 130 + pub fn list_all(reg: Registry) -> List(ParsedLexicon) { 131 + let reply = process.new_subject() 132 + process.send(reg, ListAll(reply)) 133 + process.receive(reply, 5000) |> result.unwrap([]) 134 + } 135 + 136 + pub fn record_collections(reg: Registry) -> List(String) { 137 + let reply = process.new_subject() 138 + process.send(reg, RecordCollections(reply)) 139 + process.receive(reply, 5000) |> result.unwrap([]) 140 + } 141 + 142 + pub fn load_all(reg: Registry, items: List(ParsedLexicon)) -> Nil { 143 + let reply = process.new_subject() 144 + process.send(reg, LoadAll(items, reply)) 145 + let _ = process.receive(reply, 10_000) 146 + Nil 147 + } 148 + 149 + // ── Parsing ───────────────────────────────────────────────────────────────── 150 + 151 + pub fn action_from_optional(s: Option(String)) -> Result(ProcedureAction, String) { 152 + case s { 153 + None -> Ok(Upsert) 154 + Some("create") -> Ok(Create) 155 + Some("update") -> Ok(Update) 156 + Some("delete") -> Ok(Delete) 157 + Some("upsert") -> Ok(Upsert) 158 + Some(other) -> 159 + Error( 160 + "invalid action '" 161 + <> other 162 + <> "': must be create, update, delete, or upsert", 163 + ) 164 + } 165 + } 166 + 167 + pub fn action_to_optional(action: ProcedureAction) -> Option(String) { 168 + case action { 169 + Create -> Some("create") 170 + Update -> Some("update") 171 + Delete -> Some("delete") 172 + Upsert -> None 173 + } 174 + } 175 + 176 + pub fn action_to_string(action: ProcedureAction) -> String { 177 + case action { 178 + Create -> "create" 179 + Update -> "update" 180 + Delete -> "delete" 181 + Upsert -> "upsert" 182 + } 183 + } 184 + 185 + pub fn parse( 186 + raw_json: String, 187 + revision: Int, 188 + target_collection: Option(String), 189 + action: ProcedureAction, 190 + token_cost: Option(Int), 191 + ) -> Result(ParsedLexicon, String) { 192 + use dyn <- result.try(json_util.parse(raw_json)) 193 + use id <- result.try(json_util.lexicon_id(dyn)) 194 + 195 + let main_type = json_util.lexicon_main_type(dyn) 196 + let lexicon_type = case main_type { 197 + Some("record") -> Record 198 + Some("query") -> Query 199 + Some("procedure") -> Procedure 200 + Some("space") -> Space 201 + _ -> Definitions 202 + } 203 + 204 + let record_key = 205 + decode.run(dyn, decode.at(["defs", "main", "key"], decode.string)) 206 + |> option.from_result 207 + 208 + let space_type = json_util.optional_string(dyn, "spaceType") 209 + 210 + // Validate space declarations lightly 211 + case lexicon_type { 212 + Space -> { 213 + case 214 + decode.run(dyn, decode.at(["defs", "main", "name"], decode.string)), 215 + decode.run( 216 + dyn, 217 + decode.at(["defs", "main", "collections"], decode.list(decode.string)), 218 + ) 219 + { 220 + Ok(name), Ok(_) -> { 221 + let len = string.length(name) 222 + case len >= 1 && len <= 64 { 223 + True -> Ok(Nil) 224 + False -> 225 + Error("space lexicon 'defs.main.name' must be 1-64 characters") 226 + } 227 + } 228 + _, _ -> Error("space lexicon missing name or collections") 229 + } 230 + } 231 + _ -> Ok(Nil) 232 + } 233 + |> result.try(fn(_) { 234 + Ok(ParsedLexicon( 235 + id:, 236 + lexicon_type:, 237 + record_key:, 238 + target_collection:, 239 + action:, 240 + token_cost:, 241 + space_type:, 242 + revision:, 243 + raw_json:, 244 + )) 245 + }) 246 + } 247 + 248 + pub fn type_to_string(t: LexiconType) -> String { 249 + case t { 250 + Record -> "record" 251 + Query -> "query" 252 + Procedure -> "procedure" 253 + Space -> "space" 254 + Definitions -> "definitions" 255 + } 256 + } 257 + 258 + // ── Persistence ───────────────────────────────────────────────────────────── 259 + 260 + type LexRow { 261 + LexRow( 262 + id: String, 263 + revision: Int, 264 + lexicon_json: String, 265 + target_collection: Option(String), 266 + action: Option(String), 267 + token_cost: Option(Int), 268 + ) 269 + } 270 + 271 + pub fn load_from_db(db: Db, reg: Registry) -> Result(Int, AppError) { 272 + let decoder = { 273 + use id <- decode.field(0, decode.string) 274 + use revision <- decode.field(1, decode.int) 275 + use lexicon_json <- decode.field(2, decode.string) 276 + use target_collection <- decode.field(3, decode.optional(decode.string)) 277 + use action <- decode.field(4, decode.optional(decode.string)) 278 + use token_cost <- decode.field(5, decode.optional(decode.int)) 279 + decode.success(LexRow( 280 + id:, 281 + revision:, 282 + lexicon_json:, 283 + target_collection:, 284 + action:, 285 + token_cost:, 286 + )) 287 + } 288 + use rows <- result.try( 289 + db.query( 290 + db, 291 + "SELECT id, revision, lexicon_json, target_collection, action, token_cost 292 + FROM happyview_lexicons ORDER BY id", 293 + [], 294 + decoder, 295 + ), 296 + ) 297 + let parsed = 298 + list.filter_map(rows, fn(row) { 299 + case action_from_optional(row.action) { 300 + Error(_) -> Error(Nil) 301 + Ok(action) -> 302 + parse( 303 + row.lexicon_json, 304 + row.revision, 305 + row.target_collection, 306 + action, 307 + row.token_cost, 308 + ) 309 + |> result.replace_error(Nil) 310 + } 311 + }) 312 + load_all(reg, parsed) 313 + Ok(list.length(parsed)) 314 + } 315 + 316 + pub fn upsert_to_db( 317 + db: Db, 318 + reg: Registry, 319 + raw_json: String, 320 + target_collection: Option(String), 321 + action: ProcedureAction, 322 + token_cost: Option(Int), 323 + backfill: Bool, 324 + ) -> Result(#(String, Int), AppError) { 325 + use parsed0 <- result.try( 326 + parse(raw_json, 1, target_collection, action, token_cost) 327 + |> result.map_error(error.BadRequest), 328 + ) 329 + // version check 330 + use dyn <- result.try( 331 + json_util.parse(raw_json) |> result.map_error(error.BadRequest), 332 + ) 333 + case json_util.field_int(dyn, "lexicon") { 334 + Ok(1) -> Ok(Nil) 335 + Ok(v) -> 336 + Error(error.BadRequest( 337 + "unsupported lexicon version: " <> string.inspect(v), 338 + )) 339 + Error(e) -> Error(error.BadRequest(e)) 340 + } 341 + |> result.try(fn(_) { 342 + let now = util.now_rfc3339() 343 + let action_str = action_to_optional(action) 344 + let backfill_i = case backfill { 345 + True -> 1 346 + False -> 0 347 + } 348 + // SQLite upsert without RETURNING revision via two-step 349 + use existing <- result.try( 350 + db.query_one( 351 + db, 352 + "SELECT revision FROM happyview_lexicons WHERE id = ?", 353 + [db.text(parsed0.id)], 354 + { 355 + use rev <- decode.field(0, decode.int) 356 + decode.success(rev) 357 + }, 358 + ), 359 + ) 360 + let #(revision, is_new) = case existing { 361 + None -> #(1, True) 362 + Some(r) -> #(r + 1, False) 363 + } 364 + use _ <- result.try(case is_new { 365 + True -> 366 + db.execute( 367 + db, 368 + "INSERT INTO happyview_lexicons 369 + (id, revision, lexicon_json, backfill, target_collection, action, token_cost, source, created_at, updated_at) 370 + VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', ?, ?)", 371 + [ 372 + db.text(parsed0.id), 373 + db.int(revision), 374 + db.text(raw_json), 375 + db.int(backfill_i), 376 + db.nullable_text(target_collection), 377 + db.nullable_text(action_str), 378 + db.nullable_int(token_cost), 379 + db.text(now), 380 + db.text(now), 381 + ], 382 + ) 383 + False -> 384 + db.execute( 385 + db, 386 + "UPDATE happyview_lexicons SET 387 + revision = ?, lexicon_json = ?, backfill = ?, target_collection = ?, 388 + action = ?, token_cost = ?, source = 'manual', updated_at = ? 389 + WHERE id = ?", 390 + [ 391 + db.int(revision), 392 + db.text(raw_json), 393 + db.int(backfill_i), 394 + db.nullable_text(target_collection), 395 + db.nullable_text(action_str), 396 + db.nullable_int(token_cost), 397 + db.text(now), 398 + db.text(parsed0.id), 399 + ], 400 + ) 401 + }) 402 + let parsed = 403 + ParsedLexicon( 404 + id: parsed0.id, 405 + lexicon_type: parsed0.lexicon_type, 406 + record_key: parsed0.record_key, 407 + target_collection:, 408 + action:, 409 + token_cost:, 410 + space_type: parsed0.space_type, 411 + revision:, 412 + raw_json:, 413 + ) 414 + upsert(reg, parsed) 415 + Ok(#(parsed0.id, revision)) 416 + }) 417 + } 418 + 419 + pub fn delete_from_db( 420 + db: Db, 421 + reg: Registry, 422 + id: String, 423 + ) -> Result(Bool, AppError) { 424 + use _ <- result.try( 425 + db.execute(db, "DELETE FROM happyview_lexicons WHERE id = ?", [ 426 + db.text(id), 427 + ]), 428 + ) 429 + Ok(remove(reg, id)) 430 + } 431 + 432 + pub fn summary_json(p: ParsedLexicon) -> json.Json { 433 + json.object([ 434 + #("id", json.string(p.id)), 435 + #("revision", json.int(p.revision)), 436 + #("type", json.string(type_to_string(p.lexicon_type))), 437 + #( 438 + "targetCollection", 439 + case p.target_collection { 440 + Some(c) -> json.string(c) 441 + None -> json.null() 442 + }, 443 + ), 444 + #("action", json.string(action_to_string(p.action))), 445 + ]) 446 + }
+84
src/gleamview/record_handler.gleam
··· 1 + //// Process record events from Jetstream / backfill / local writes. 2 + 3 + import gleam/option.{type Option, None, Some} 4 + import gleam/result 5 + import gleamview/db.{type Db} 6 + import gleamview/error.{type AppError} 7 + import gleamview/lexicon.{type Registry} 8 + import gleamview/records 9 + import gleamview/util 10 + import logging 11 + 12 + pub const lexicon_schema_collection = "com.atproto.lexicon.schema" 13 + 14 + pub type RecordEvent { 15 + RecordEvent( 16 + did: String, 17 + collection: String, 18 + rkey: String, 19 + action: String, 20 + record_json: Option(String), 21 + cid: Option(String), 22 + ) 23 + } 24 + 25 + /// Index or delete a record if its collection is a registered record lexicon. 26 + pub fn handle_record_event( 27 + db: Db, 28 + reg: Registry, 29 + event: RecordEvent, 30 + ) -> Result(Nil, AppError) { 31 + case event.collection == lexicon_schema_collection { 32 + True -> { 33 + logging.log( 34 + logging.Debug, 35 + "lexicon schema event for " <> event.did <> " rkey=" <> event.rkey, 36 + ) 37 + // Network lexicon auto-update: deferred (needs authority tracking). 38 + Ok(Nil) 39 + } 40 + False -> { 41 + case lexicon.get(reg, event.collection) { 42 + None -> { 43 + logging.log( 44 + logging.Debug, 45 + "skip untracked collection " <> event.collection, 46 + ) 47 + Ok(Nil) 48 + } 49 + Some(parsed) -> 50 + case parsed.lexicon_type { 51 + lexicon.Record -> apply_event(db, event) 52 + _ -> Ok(Nil) 53 + } 54 + } 55 + } 56 + } 57 + } 58 + 59 + fn apply_event(db: Db, event: RecordEvent) -> Result(Nil, AppError) { 60 + case event.action { 61 + "create" | "update" -> { 62 + case event.record_json { 63 + None -> Ok(Nil) 64 + Some(body) -> { 65 + let cid = option.unwrap(event.cid, "") 66 + records.upsert( 67 + db, 68 + event.did, 69 + event.collection, 70 + event.rkey, 71 + body, 72 + cid, 73 + ) 74 + |> result.map(fn(_uri) { Nil }) 75 + } 76 + } 77 + } 78 + "delete" -> { 79 + let uri = util.at_uri(event.did, event.collection, event.rkey) 80 + records.delete(db, uri) 81 + } 82 + _ -> Ok(Nil) 83 + } 84 + }
+203
src/gleamview/records.gleam
··· 1 + //// Local record index (happyview_records). 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/result 8 + import gleamview/db.{type Db} 9 + import gleamview/error.{type AppError} 10 + import gleamview/util 11 + 12 + pub type StoredRecord { 13 + StoredRecord( 14 + uri: String, 15 + did: String, 16 + collection: String, 17 + rkey: String, 18 + record_json: String, 19 + cid: String, 20 + indexed_at: String, 21 + created_at: String, 22 + ) 23 + } 24 + 25 + pub fn upsert( 26 + db: Db, 27 + did: String, 28 + collection: String, 29 + rkey: String, 30 + record_json: String, 31 + cid: String, 32 + ) -> Result(String, AppError) { 33 + let uri = util.at_uri(did, collection, rkey) 34 + let now = util.now_rfc3339() 35 + use _ <- result.try( 36 + db.execute( 37 + db, 38 + "INSERT INTO happyview_records 39 + (uri, did, collection, rkey, record, cid, indexed_at, created_at) 40 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 41 + ON CONFLICT(uri) DO UPDATE SET 42 + record = excluded.record, 43 + cid = excluded.cid, 44 + indexed_at = excluded.indexed_at", 45 + [ 46 + db.text(uri), 47 + db.text(did), 48 + db.text(collection), 49 + db.text(rkey), 50 + db.text(record_json), 51 + db.text(cid), 52 + db.text(now), 53 + db.text(now), 54 + ], 55 + ), 56 + ) 57 + Ok(uri) 58 + } 59 + 60 + pub fn delete(db: Db, uri: String) -> Result(Nil, AppError) { 61 + db.execute(db, "DELETE FROM happyview_records WHERE uri = ?", [db.text(uri)]) 62 + } 63 + 64 + pub fn get_by_uri(db: Db, uri: String) -> Result(Option(StoredRecord), AppError) { 65 + let decoder = stored_decoder() 66 + db.query_one( 67 + db, 68 + "SELECT uri, did, collection, rkey, record, cid, indexed_at, created_at 69 + FROM happyview_records WHERE uri = ?", 70 + [db.text(uri)], 71 + decoder, 72 + ) 73 + } 74 + 75 + pub fn list_by_collection( 76 + db: Db, 77 + collection: String, 78 + did: Option(String), 79 + limit: Int, 80 + offset: Int, 81 + ) -> Result(List(StoredRecord), AppError) { 82 + let decoder = stored_decoder() 83 + case did { 84 + Some(d) -> 85 + db.query( 86 + db, 87 + "SELECT uri, did, collection, rkey, record, cid, indexed_at, created_at 88 + FROM happyview_records 89 + WHERE collection = ? AND did = ? 90 + ORDER BY indexed_at DESC 91 + LIMIT ? OFFSET ?", 92 + [db.text(collection), db.text(d), db.int(limit), db.int(offset)], 93 + decoder, 94 + ) 95 + None -> 96 + db.query( 97 + db, 98 + "SELECT uri, did, collection, rkey, record, cid, indexed_at, created_at 99 + FROM happyview_records 100 + WHERE collection = ? 101 + ORDER BY indexed_at DESC 102 + LIMIT ? OFFSET ?", 103 + [db.text(collection), db.int(limit), db.int(offset)], 104 + decoder, 105 + ) 106 + } 107 + } 108 + 109 + pub fn count(db: Db) -> Result(Int, AppError) { 110 + use rows <- result.try( 111 + db.query(db, "SELECT COUNT(*) FROM happyview_records", [], { 112 + use n <- decode.field(0, decode.int) 113 + decode.success(n) 114 + }), 115 + ) 116 + case rows { 117 + [n, ..] -> Ok(n) 118 + [] -> Ok(0) 119 + } 120 + } 121 + 122 + pub fn count_collection(db: Db, collection: String) -> Result(Int, AppError) { 123 + use rows <- result.try( 124 + db.query( 125 + db, 126 + "SELECT COUNT(*) FROM happyview_records WHERE collection = ?", 127 + [db.text(collection)], 128 + { 129 + use n <- decode.field(0, decode.int) 130 + decode.success(n) 131 + }, 132 + ), 133 + ) 134 + case rows { 135 + [n, ..] -> Ok(n) 136 + [] -> Ok(0) 137 + } 138 + } 139 + 140 + fn stored_decoder() -> decode.Decoder(StoredRecord) { 141 + use uri <- decode.field(0, decode.string) 142 + use did <- decode.field(1, decode.string) 143 + use collection <- decode.field(2, decode.string) 144 + use rkey <- decode.field(3, decode.string) 145 + use record_json <- decode.field(4, decode.string) 146 + use cid <- decode.field(5, decode.string) 147 + use indexed_at <- decode.field(6, decode.string) 148 + use created_at <- decode.field(7, decode.string) 149 + decode.success(StoredRecord( 150 + uri:, 151 + did:, 152 + collection:, 153 + rkey:, 154 + record_json:, 155 + cid:, 156 + indexed_at:, 157 + created_at:, 158 + )) 159 + } 160 + 161 + /// Merge `uri` into the stored record JSON object for API responses. 162 + pub fn with_uri_field(record_json: String, uri: String) -> String { 163 + // Cheap merge: if object, inject uri at front. 164 + case string_starts_object(record_json) { 165 + True -> { 166 + let rest = drop_brace(record_json) 167 + case rest == "}" { 168 + True -> "{\"uri\":" <> json.string(uri) |> json.to_string <> "}" 169 + False -> 170 + "{\"uri\":" 171 + <> json.string(uri) |> json.to_string 172 + <> "," 173 + <> rest 174 + } 175 + } 176 + False -> 177 + json.object([ 178 + #("uri", json.string(uri)), 179 + #("value", json.string(record_json)), 180 + ]) 181 + |> json.to_string 182 + } 183 + } 184 + 185 + fn string_starts_object(s: String) -> Bool { 186 + case s { 187 + "{" <> _ -> True 188 + _ -> False 189 + } 190 + } 191 + 192 + fn drop_brace(s: String) -> String { 193 + case s { 194 + "{" <> rest -> rest 195 + _ -> s 196 + } 197 + } 198 + 199 + pub fn list_as_json_records( 200 + rows: List(StoredRecord), 201 + ) -> List(String) { 202 + list.map(rows, fn(r) { with_uri_field(r.record_json, r.uri) }) 203 + }
+226
src/gleamview/resolve.gleam
··· 1 + //// DID / PDS helpers via PLC directory. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/http 5 + import gleam/http/request 6 + import gleam/httpc 7 + import gleam/list 8 + import gleam/option.{None, Some} 9 + import gleam/result 10 + import gleam/string 11 + import gleam/uri 12 + import gleamview/error.{type AppError} 13 + import gleamview/json_util 14 + 15 + /// Fetch a DID document from the PLC directory (or did:web HTTPS). 16 + pub fn resolve_did(plc_url: String, did: String) -> Result(String, AppError) { 17 + case string.starts_with(did, "did:plc:") { 18 + True -> { 19 + let url = string.trim_end(plc_url) <> "/" <> did 20 + http_get_text(url) 21 + } 22 + False -> 23 + case string.starts_with(did, "did:web:") { 24 + True -> { 25 + let host = string.drop_start(did, 8) |> string.replace(":", "/") 26 + http_get_text("https://" <> host <> "/.well-known/did.json") 27 + } 28 + False -> Error(error.BadRequest("unsupported DID method: " <> did)) 29 + } 30 + } 31 + } 32 + 33 + /// Extract the first `#atproto_pds` service endpoint from a DID document JSON. 34 + pub fn pds_endpoint_from_did_doc(doc_json: String) -> Result(String, AppError) { 35 + use dyn <- result.try( 36 + json_util.parse(doc_json) |> result.map_error(error.Internal), 37 + ) 38 + // service: array of { id, type, serviceEndpoint } 39 + let decoder = { 40 + use services <- decode.field( 41 + "service", 42 + decode.list({ 43 + use id <- decode.optional_field("id", "", decode.string) 44 + use type_ <- decode.optional_field("type", "", decode.string) 45 + use endpoint <- decode.optional_field( 46 + "serviceEndpoint", 47 + "", 48 + decode.string, 49 + ) 50 + decode.success(#(id, type_, endpoint)) 51 + }), 52 + ) 53 + decode.success(services) 54 + } 55 + case decode.run(dyn, decoder) { 56 + Error(e) -> 57 + Error(error.Internal("did doc parse: " <> string.inspect(e))) 58 + Ok(services) -> { 59 + case 60 + list.find(services, fn(s) { 61 + let #(_id, type_, _ep) = s 62 + type_ == "AtprotoPersonalDataServer" || string.contains(type_, "pds") 63 + }) 64 + { 65 + Ok(#(_, _, ep)) if ep != "" -> Ok(ep) 66 + _ -> 67 + // also match id containing atproto_pds 68 + case 69 + list.find(services, fn(s) { 70 + let #(id, _, _) = s 71 + string.contains(id, "atproto_pds") 72 + }) 73 + { 74 + Ok(#(_, _, ep)) if ep != "" -> Ok(ep) 75 + _ -> Error(error.NotFound("no atproto PDS service in DID document")) 76 + } 77 + } 78 + } 79 + } 80 + } 81 + 82 + pub fn resolve_pds(plc_url: String, did: String) -> Result(String, AppError) { 83 + use doc <- result.try(resolve_did(plc_url, did)) 84 + pds_endpoint_from_did_doc(doc) 85 + } 86 + 87 + /// Fetch a lexicon schema record from a user's PDS. 88 + pub fn fetch_lexicon_from_pds( 89 + pds: String, 90 + did: String, 91 + nsid: String, 92 + ) -> Result(String, AppError) { 93 + let base = string.trim_end(pds) 94 + let url = 95 + base 96 + <> "/xrpc/com.atproto.repo.getRecord?repo=" 97 + <> uri.percent_encode(did) 98 + <> "&collection=com.atproto.lexicon.schema&rkey=" 99 + <> uri.percent_encode(nsid) 100 + use body <- result.try(http_get_text(url)) 101 + use dyn <- result.try(json_util.parse(body) |> result.map_error(error.Internal)) 102 + case json_util.object_field(dyn, "value") { 103 + None -> Error(error.Internal("PDS response missing 'value'")) 104 + // value is Dynamic — we need the raw JSON substring. Best-effort: re-request 105 + // is hard; store whole response value by finding "value": 106 + Some(_) -> extract_json_field(body, "value") 107 + } 108 + } 109 + 110 + fn extract_json_field(body: String, field: String) -> Result(String, AppError) { 111 + let needle = "\"" <> field <> "\":" 112 + case string.split_once(body, needle) { 113 + Error(_) -> Error(error.Internal("field not found: " <> field)) 114 + Ok(#(_, rest)) -> { 115 + let rest = string.trim_start(rest) 116 + extract_json_value(rest) 117 + } 118 + } 119 + } 120 + 121 + fn extract_json_value(s: String) -> Result(String, AppError) { 122 + case s { 123 + "{" <> _ -> extract_balanced(s, "{", "}") 124 + "[" <> _ -> extract_balanced(s, "[", "]") 125 + "\"" <> _ -> extract_string(s) 126 + _ -> Error(error.Internal("unexpected json value start")) 127 + } 128 + } 129 + 130 + fn extract_string(s: String) -> Result(String, AppError) { 131 + // include quotes 132 + case string.split_once(string.drop_start(s, 1), "\"") { 133 + Ok(#(inner, _)) -> Ok("\"" <> inner <> "\"") 134 + Error(_) -> Error(error.Internal("unterminated string")) 135 + } 136 + } 137 + 138 + fn extract_balanced( 139 + s: String, 140 + open: String, 141 + close: String, 142 + ) -> Result(String, AppError) { 143 + // character walk with depth, respect strings 144 + do_extract(s, open, close, 0, 0, False, False, "") 145 + } 146 + 147 + fn do_extract( 148 + rest: String, 149 + open: String, 150 + close: String, 151 + depth: Int, 152 + _i: Int, 153 + in_str: Bool, 154 + escape: Bool, 155 + acc: String, 156 + ) -> Result(String, AppError) { 157 + case string.pop_grapheme(rest) { 158 + Error(_) -> Error(error.Internal("unbalanced json")) 159 + Ok(#(ch, rest2)) -> { 160 + let acc2 = acc <> ch 161 + case in_str { 162 + True -> 163 + case escape { 164 + True -> 165 + do_extract(rest2, open, close, depth, 0, True, False, acc2) 166 + False -> 167 + case ch { 168 + "\\" -> 169 + do_extract(rest2, open, close, depth, 0, True, True, acc2) 170 + "\"" -> 171 + do_extract(rest2, open, close, depth, 0, False, False, acc2) 172 + _ -> 173 + do_extract(rest2, open, close, depth, 0, True, False, acc2) 174 + } 175 + } 176 + False -> 177 + case ch { 178 + "\"" -> do_extract(rest2, open, close, depth, 0, True, False, acc2) 179 + _ -> { 180 + let depth2 = case ch == open { 181 + True -> depth + 1 182 + False -> 183 + case ch == close { 184 + True -> depth - 1 185 + False -> depth 186 + } 187 + } 188 + case depth2 == 0 && ch == close { 189 + True -> Ok(acc2) 190 + False -> 191 + do_extract( 192 + rest2, 193 + open, 194 + close, 195 + depth2, 196 + 0, 197 + False, 198 + False, 199 + acc2, 200 + ) 201 + } 202 + } 203 + } 204 + } 205 + } 206 + } 207 + } 208 + 209 + fn http_get_text(url: String) -> Result(String, AppError) { 210 + use req <- result.try( 211 + request.to(url) 212 + |> result.map_error(fn(_) { error.Internal("bad url: " <> url) }), 213 + ) 214 + let req = request.set_method(req, http.Get) 215 + case httpc.send(req) { 216 + Error(e) -> Error(error.Internal("http error: " <> string.inspect(e))) 217 + Ok(resp) -> 218 + case resp.status >= 200 && resp.status < 300 { 219 + True -> Ok(resp.body) 220 + False -> 221 + Error(error.NotFound( 222 + "HTTP " <> string.inspect(resp.status) <> " for " <> url, 223 + )) 224 + } 225 + } 226 + }
+126
src/gleamview/router.gleam
··· 1 + //// HTTP router: health, admin, xrpc, well-known. 2 + 3 + import gleam/json 4 + import gleam/list 5 + import gleam/string 6 + import gleamview/admin 7 + import gleamview/config.{type Config} 8 + import gleamview/db.{type Db} 9 + import gleamview/lexicon.{type Registry} 10 + import gleamview/xrpc 11 + import wisp.{type Request, type Response} 12 + 13 + pub type Context { 14 + Context(db: Db, reg: Registry, config: Config) 15 + } 16 + 17 + pub fn handle_request(req: Request, ctx: Context) -> Response { 18 + use <- wisp.log_request(req) 19 + use <- wisp.rescue_crashes 20 + use req <- wisp.handle_head(req) 21 + 22 + case wisp.path_segments(req) { 23 + [] -> 24 + wisp.html_response(home_html(), 200) 25 + 26 + ["health"] -> 27 + wisp.json_response( 28 + "{\"ok\":true,\"service\":\"gleamview\"}", 29 + 200, 30 + ) 31 + 32 + ["config"] -> config_endpoint(ctx.config) 33 + 34 + ["oauth-client-metadata.json"] -> 35 + // Placeholder for future OAuth 36 + wisp.json_response( 37 + json.object([ 38 + #("client_id", json.string(ctx.config.public_url <> "/oauth-client-metadata.json")), 39 + #("client_name", json.string("GleamView")), 40 + #("application_type", json.string("web")), 41 + ]) 42 + |> json.to_string, 43 + 200, 44 + ) 45 + 46 + [".well-known", "did.json"] -> well_known_did(ctx.config) 47 + 48 + ["admin", ..rest] -> admin.handle(admin.Ctx(ctx.db, ctx.reg), req, rest) 49 + 50 + ["xrpc", method] -> 51 + xrpc.handle( 52 + xrpc.Ctx(ctx.db, ctx.reg, ctx.config.allow_local_writes), 53 + req, 54 + method, 55 + ) 56 + 57 + _ -> wisp.not_found() 58 + } 59 + } 60 + 61 + fn config_endpoint(config: Config) -> Response { 62 + let body = 63 + json.object([ 64 + #("publicUrl", json.string(config.public_url)), 65 + #("jetstreamEnabled", json.bool(config.jetstream_enabled)), 66 + #("allowLocalWrites", json.bool(config.allow_local_writes)), 67 + #("service", json.string("gleamview")), 68 + ]) 69 + |> json.to_string 70 + wisp.json_response(body, 200) 71 + } 72 + 73 + fn well_known_did(config: Config) -> Response { 74 + // Minimal did:web document for the AppView service identity 75 + let host = 76 + config.public_url 77 + |> string.replace("https://", "") 78 + |> string.replace("http://", "") 79 + |> string.split("/") 80 + |> list.first 81 + |> fn(r) { 82 + case r { 83 + Ok(h) -> h 84 + Error(_) -> "localhost" 85 + } 86 + } 87 + let did = "did:web:" <> host 88 + let body = 89 + json.object([ 90 + #("id", json.string(did)), 91 + #("service", json.preprocessed_array([])), 92 + ]) 93 + |> json.to_string 94 + wisp.json_response(body, 200) 95 + } 96 + 97 + fn home_html() -> String { 98 + "<!doctype html> 99 + <html lang=\"en\"> 100 + <head> 101 + <meta charset=\"utf-8\"/> 102 + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/> 103 + <title>GleamView</title> 104 + <style> 105 + :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; } 106 + body { max-width: 42rem; margin: 3rem auto; padding: 0 1.25rem; line-height: 1.5; } 107 + code { background: #8882; padding: 0.1em 0.35em; border-radius: 4px; } 108 + a { color: inherit; } 109 + h1 { font-size: 1.75rem; margin-bottom: 0.25rem; } 110 + .muted { opacity: 0.75; } 111 + ul { padding-left: 1.2rem; } 112 + </style> 113 + </head> 114 + <body> 115 + <h1>GleamView</h1> 116 + <p class=\"muted\">Lexicon-driven ATProto AppView — Gleam port of 117 + <a href=\"https://tangled.org/gamesgamesgamesgames.games/happyview\">HappyView</a>.</p> 118 + <ul> 119 + <li><code>GET /health</code></li> 120 + <li><code>GET /config</code></li> 121 + <li><code>/admin/*</code> — Bearer admin API key</li> 122 + <li><code>/xrpc/{nsid}</code> — <code>X-Client-Key</code> required</li> 123 + </ul> 124 + </body> 125 + </html>" 126 + }
+60
src/gleamview/util.gleam
··· 1 + //// Shared helpers: time, ids, hashing. 2 + 3 + import gleam/bit_array 4 + import gleam/crypto 5 + import gleam/int 6 + import gleam/list 7 + import gleam/result 8 + import gleam/string 9 + import gleam/time/duration 10 + import gleam/time/timestamp 11 + 12 + /// RFC3339 UTC timestamp. 13 + pub fn now_rfc3339() -> String { 14 + timestamp.system_time() 15 + |> timestamp.to_rfc3339(duration.seconds(0)) 16 + } 17 + 18 + /// Random id suitable for primary keys (hex). 19 + pub fn new_id() -> String { 20 + crypto.strong_random_bytes(16) 21 + |> bit_array.base16_encode 22 + |> string.lowercase 23 + } 24 + 25 + /// Random token with prefix, e.g. `hvc_…` / `gva_…`. 26 + pub fn new_token(prefix: String, bytes: Int) -> String { 27 + prefix 28 + <> { 29 + crypto.strong_random_bytes(bytes) 30 + |> bit_array.base16_encode 31 + |> string.lowercase 32 + } 33 + } 34 + 35 + pub fn sha256_hex(input: String) -> String { 36 + crypto.hash(crypto.Sha256, <<input:utf8>>) 37 + |> bit_array.base16_encode 38 + |> string.lowercase 39 + } 40 + 41 + pub fn constant_time_eq(a: String, b: String) -> Bool { 42 + sha256_hex(a) == sha256_hex(b) 43 + } 44 + 45 + /// Build an AT URI: `at://{did}/{collection}/{rkey}`. 46 + pub fn at_uri(did: String, collection: String, rkey: String) -> String { 47 + "at://" <> did <> "/" <> collection <> "/" <> rkey 48 + } 49 + 50 + /// Extract rkey (last path segment) from an AT URI or bare rkey. 51 + pub fn rkey_from_uri(uri: String) -> Result(String, Nil) { 52 + case string.split(uri, "/") |> list.reverse { 53 + [rkey, ..] if rkey != "" -> Ok(rkey) 54 + _ -> Error(Nil) 55 + } 56 + } 57 + 58 + pub fn parse_int_param(s: String, default: Int) -> Int { 59 + int.parse(s) |> result.unwrap(default) 60 + }
+121
src/gleamview/xrpc.gleam
··· 1 + //// XRPC catch-all: route GET/POST to query/procedure handlers. 2 + 3 + import gleam/http 4 + import gleam/http/request 5 + import gleam/list 6 + import gleam/option.{None, Some} 7 + import gleamview/auth 8 + import gleamview/db.{type Db} 9 + import gleamview/error.{type AppError} 10 + import gleamview/json_util 11 + import gleamview/lexicon.{ 12 + type ParsedLexicon, type Registry, Procedure, Query as LexQuery, 13 + } 14 + import gleamview/xrpc/procedure 15 + import gleamview/xrpc/query 16 + import wisp.{type Request, type Response} 17 + 18 + pub type Ctx { 19 + Ctx(db: Db, reg: Registry, allow_local_writes: Bool) 20 + } 21 + 22 + pub fn handle(ctx: Ctx, req: Request, method_nsid: String) -> Response { 23 + case auth.require_client(ctx.db, req) { 24 + Error(e) -> error.to_response(e) 25 + Ok(_client) -> 26 + case lexicon.get(ctx.reg, method_nsid) { 27 + None -> 28 + error.to_response(error.NotFound( 29 + "No lexicon registered for " <> method_nsid, 30 + )) 31 + Some(lex) -> 32 + case req.method { 33 + http.Get -> handle_get(ctx, req, method_nsid, lex) 34 + http.Post -> handle_post(ctx, req, method_nsid, lex) 35 + _ -> wisp.method_not_allowed([http.Get, http.Post]) 36 + } 37 + } 38 + } 39 + } 40 + 41 + fn handle_get( 42 + ctx: Ctx, 43 + req: Request, 44 + method: String, 45 + lex: ParsedLexicon, 46 + ) -> Response { 47 + case lex.lexicon_type { 48 + LexQuery -> { 49 + let params = wisp.get_query(req) 50 + case query.handle(ctx.db, method, params, lex) { 51 + Ok(body) -> wisp.json_response(body, 200) 52 + Error(e) -> error.to_response(e) 53 + } 54 + } 55 + _ -> 56 + error.to_response(error.BadRequest(method <> " is not a query lexicon")) 57 + } 58 + } 59 + 60 + fn handle_post( 61 + ctx: Ctx, 62 + req: Request, 63 + method: String, 64 + lex: ParsedLexicon, 65 + ) -> Response { 66 + case lex.lexicon_type { 67 + Procedure -> 68 + wisp.require_string_body(req, fn(body) { 69 + case json_util.parse(body) { 70 + Error(e) -> error.to_response(error.BadRequest(e)) 71 + Ok(input) -> 72 + case write_claims(req, ctx.allow_local_writes) { 73 + Error(e) -> error.to_response(e) 74 + Ok(claims) -> 75 + case 76 + procedure.handle( 77 + ctx.db, 78 + method, 79 + claims, 80 + input, 81 + body, 82 + lex, 83 + ctx.allow_local_writes, 84 + ) 85 + { 86 + Ok(resp_body) -> wisp.json_response(resp_body, 200) 87 + Error(e) -> error.to_response(e) 88 + } 89 + } 90 + } 91 + }) 92 + _ -> 93 + error.to_response(error.BadRequest( 94 + method <> " is not a procedure lexicon", 95 + )) 96 + } 97 + } 98 + 99 + fn write_claims( 100 + req: Request, 101 + allow_local: Bool, 102 + ) -> Result(procedure.WriteClaims, AppError) { 103 + case request.get_header(req, "x-gleamview-did") { 104 + Ok(did) if did != "" -> Ok(procedure.WriteClaims(did)) 105 + _ -> 106 + case list.key_find(wisp.get_query(req), "did") { 107 + Ok(did) if did != "" -> Ok(procedure.WriteClaims(did)) 108 + _ -> 109 + case allow_local { 110 + True -> 111 + Error(error.Unauthorized( 112 + "local writes require X-Gleamview-Did header (or did= query)", 113 + )) 114 + False -> 115 + Error(error.Unauthorized( 116 + "authentication required (OAuth/DPoP not yet implemented)", 117 + )) 118 + } 119 + } 120 + } 121 + }
+188
src/gleamview/xrpc/procedure.gleam
··· 1 + //// Default XRPC procedure handling. 2 + 3 + import gleam/dynamic.{type Dynamic} 4 + import gleam/dynamic/decode 5 + import gleam/json 6 + import gleam/option.{None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gleamview/db.{type Db} 10 + import gleamview/error.{type AppError} 11 + import gleamview/json_util 12 + import gleamview/lexicon.{type ParsedLexicon, Create, Delete, Update, Upsert} 13 + import gleamview/records 14 + import gleamview/util 15 + 16 + /// Claims for a write — MVP uses local DID header when ALLOW_LOCAL_WRITES is set. 17 + pub type WriteClaims { 18 + WriteClaims(did: String) 19 + } 20 + 21 + pub fn handle( 22 + db: Db, 23 + method: String, 24 + claims: WriteClaims, 25 + input: Dynamic, 26 + input_json: String, 27 + lexicon: ParsedLexicon, 28 + allow_local: Bool, 29 + ) -> Result(String, AppError) { 30 + case allow_local { 31 + False -> 32 + Error(error.BadRequest( 33 + "PDS write proxy / OAuth not yet implemented; set ALLOW_LOCAL_WRITES=1 for local index mode", 34 + )) 35 + True -> { 36 + use collection <- result.try(case lexicon.target_collection { 37 + Some(c) -> Ok(c) 38 + None -> 39 + Error(error.BadRequest( 40 + method <> " has no target_collection configured", 41 + )) 42 + }) 43 + let action = resolve_action(lexicon.action, input) 44 + case action { 45 + Create -> create_record(db, claims.did, collection, input_json) 46 + Update -> put_record(db, claims.did, collection, input) 47 + Delete -> delete_record(db, claims.did, collection, input) 48 + Upsert -> 49 + // Should not reach after resolve_action 50 + create_record(db, claims.did, collection, input_json) 51 + } 52 + } 53 + } 54 + } 55 + 56 + fn resolve_action( 57 + action: lexicon.ProcedureAction, 58 + input: Dynamic, 59 + ) -> lexicon.ProcedureAction { 60 + case action { 61 + Upsert -> 62 + case json_util.optional_string(input, "uri") { 63 + Some(_) -> Update 64 + None -> Create 65 + } 66 + other -> other 67 + } 68 + } 69 + 70 + fn create_record( 71 + db: Db, 72 + did: String, 73 + collection: String, 74 + input_json: String, 75 + ) -> Result(String, AppError) { 76 + let rkey = util.new_token("", 13) |> tidish 77 + // Inject $type into record body if missing (best-effort string patch) 78 + let body = ensure_type(input_json, collection) 79 + use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 80 + Ok( 81 + json.object([ 82 + #("uri", json.string(uri)), 83 + #("cid", json.string("")), 84 + #("commit", json.object([#("cid", json.string("")), #("rev", json.string(""))])), 85 + ]) 86 + |> json.to_string, 87 + ) 88 + } 89 + 90 + fn put_record( 91 + db: Db, 92 + did: String, 93 + collection: String, 94 + input: Dynamic, 95 + ) -> Result(String, AppError) { 96 + use client_uri <- result.try( 97 + json_util.field_string(input, "uri") 98 + |> result.map_error(error.BadRequest), 99 + ) 100 + use rkey <- result.try( 101 + util.rkey_from_uri(client_uri) 102 + |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 103 + ) 104 + // Prefer nested `record` object if present 105 + let body = case json_util.object_field(input, "record") { 106 + Some(rec) -> dynamic_to_stored(rec, collection) 107 + None -> { 108 + // whole input minus uri 109 + dynamic_to_stored(input, collection) 110 + } 111 + } 112 + use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 113 + Ok( 114 + json.object([ 115 + #("uri", json.string(uri)), 116 + #("cid", json.string("")), 117 + ]) 118 + |> json.to_string, 119 + ) 120 + } 121 + 122 + fn delete_record( 123 + db: Db, 124 + did: String, 125 + collection: String, 126 + input: Dynamic, 127 + ) -> Result(String, AppError) { 128 + use client_uri <- result.try( 129 + json_util.field_string(input, "uri") 130 + |> result.map_error(error.BadRequest), 131 + ) 132 + use rkey <- result.try( 133 + util.rkey_from_uri(client_uri) 134 + |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 135 + ) 136 + let uri = util.at_uri(did, collection, rkey) 137 + use _ <- result.try(records.delete(db, uri)) 138 + Ok("{}") 139 + } 140 + 141 + fn ensure_type(input_json: String, collection: String) -> String { 142 + case string.contains(input_json, "\"$type\"") { 143 + True -> input_json 144 + False -> 145 + case input_json { 146 + "{" <> rest -> 147 + "{\"$type\":" 148 + <> json.string(collection) |> json.to_string 149 + <> case rest { 150 + "}" -> "}" 151 + _ -> "," <> rest 152 + } 153 + _ -> input_json 154 + } 155 + } 156 + } 157 + 158 + fn dynamic_to_stored(dyn: Dynamic, collection: String) -> String { 159 + // Prefer re-parsing known shape; fall back to ensuring $type on inspect is wrong. 160 + // Store a minimal object with $type when we can't re-encode Dynamic. 161 + case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 162 + Ok(_) -> { 163 + // Can't easily re-encode arbitrary Dynamic — use gleam_json only for simple cases. 164 + // For MVP put path, require callers to send full `record` as JSON string via 165 + // router keeping original body and extracting. 166 + ensure_type("{}", collection) 167 + } 168 + Error(_) -> ensure_type("{}", collection) 169 + } 170 + } 171 + 172 + /// Prefer original JSON string for create; used by router. 173 + pub fn create_from_json( 174 + db: Db, 175 + did: String, 176 + collection: String, 177 + input_json: String, 178 + ) -> Result(String, AppError) { 179 + create_record(db, did, collection, input_json) 180 + } 181 + 182 + fn tidish(s: String) -> String { 183 + // TID-ish: strip prefix if empty token had leading junk 184 + case string.starts_with(s, "_") { 185 + True -> string.drop_start(s, 1) 186 + False -> s 187 + } 188 + }
+85
src/gleamview/xrpc/query.gleam
··· 1 + //// Default XRPC query handling (list / get by uri). 2 + 3 + import gleam/int 4 + import gleam/json 5 + import gleam/list 6 + import gleam/option.{None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gleamview/db.{type Db} 10 + import gleamview/error.{type AppError} 11 + import gleamview/lexicon.{type ParsedLexicon} 12 + import gleamview/records 13 + import gleamview/util 14 + 15 + pub fn handle( 16 + db: Db, 17 + method: String, 18 + params: List(#(String, String)), 19 + lexicon: ParsedLexicon, 20 + ) -> Result(String, AppError) { 21 + case list.key_find(params, "uri") { 22 + Ok(uri) -> handle_get(db, uri) 23 + Error(_) -> handle_list(db, method, params, lexicon) 24 + } 25 + } 26 + 27 + fn handle_get(db: Db, uri: String) -> Result(String, AppError) { 28 + use row <- result.try(records.get_by_uri(db, uri)) 29 + case row { 30 + None -> Error(error.NotFound("record not found")) 31 + Some(r) -> { 32 + let body = records.with_uri_field(r.record_json, r.uri) 33 + Ok("{\"record\":" <> body <> "}") 34 + } 35 + } 36 + } 37 + 38 + fn handle_list( 39 + db: Db, 40 + method: String, 41 + params: List(#(String, String)), 42 + lexicon: ParsedLexicon, 43 + ) -> Result(String, AppError) { 44 + use collection <- result.try(case lexicon.target_collection { 45 + Some(c) -> Ok(c) 46 + None -> 47 + Error(error.BadRequest( 48 + method <> " has no target_collection configured for list queries", 49 + )) 50 + }) 51 + 52 + let limit = 53 + list.key_find(params, "limit") 54 + |> result.map(fn(s) { util.parse_int_param(s, 20) }) 55 + |> result.unwrap(20) 56 + |> int.min(100) 57 + 58 + let offset = 59 + list.key_find(params, "cursor") 60 + |> result.map(fn(s) { util.parse_int_param(s, 0) }) 61 + |> result.unwrap(0) 62 + 63 + let did = 64 + list.key_find(params, "did") 65 + |> option.from_result 66 + 67 + use rows <- result.try(records.list_by_collection( 68 + db, 69 + collection, 70 + did, 71 + limit, 72 + offset, 73 + )) 74 + 75 + let has_next = list.length(rows) == limit 76 + let rec_jsons = records.list_as_json_records(rows) 77 + let records_arr = "[" <> string.join(rec_jsons, ",") <> "]" 78 + let cursor_part = case has_next { 79 + True -> 80 + ",\"cursor\":" 81 + <> json.string(int.to_string(offset + limit)) |> json.to_string 82 + False -> "" 83 + } 84 + Ok("{\"records\":" <> records_arr <> cursor_part <> "}") 85 + }
+81
test/gleamview_test.gleam
··· 1 + import gleam/option.{None, Some} 2 + import gleam/string 3 + import gleeunit 4 + import gleamview/jetstream 5 + import gleamview/lexicon 6 + import gleamview/util 7 + 8 + pub fn main() { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn lexicon_parse_record_test() { 13 + let raw = 14 + "{ 15 + \"lexicon\": 1, 16 + \"id\": \"com.example.post\", 17 + \"defs\": { 18 + \"main\": { 19 + \"type\": \"record\", 20 + \"key\": \"tid\", 21 + \"record\": { \"type\": \"object\", \"properties\": {} } 22 + } 23 + } 24 + }" 25 + let assert Ok(parsed) = lexicon.parse(raw, 1, None, lexicon.Upsert, None) 26 + assert parsed.id == "com.example.post" 27 + assert parsed.lexicon_type == lexicon.Record 28 + assert parsed.record_key == Some("tid") 29 + } 30 + 31 + pub fn lexicon_parse_query_test() { 32 + let raw = 33 + "{ 34 + \"lexicon\": 1, 35 + \"id\": \"com.example.getPosts\", 36 + \"defs\": { 37 + \"main\": { 38 + \"type\": \"query\", 39 + \"parameters\": { \"type\": \"params\", \"properties\": {} }, 40 + \"output\": { \"encoding\": \"application/json\" } 41 + } 42 + } 43 + }" 44 + let assert Ok(parsed) = 45 + lexicon.parse(raw, 1, Some("com.example.post"), lexicon.Upsert, None) 46 + assert parsed.lexicon_type == lexicon.Query 47 + assert parsed.target_collection == Some("com.example.post") 48 + } 49 + 50 + pub fn procedure_action_parse_test() { 51 + let assert Ok(lexicon.Create) = lexicon.action_from_optional(Some("create")) 52 + let assert Ok(lexicon.Upsert) = lexicon.action_from_optional(None) 53 + let assert Error(_) = lexicon.action_from_optional(Some("nope")) 54 + } 55 + 56 + pub fn jetstream_url_test() { 57 + let url = 58 + jetstream.build_subscribe_url("wss://jetstream.example.com", [ 59 + "app.bsky.feed.post", 60 + ]) 61 + assert string.contains(url, "wantedCollections=app.bsky.feed.post") 62 + assert string.contains(url, "com.atproto.lexicon.schema") 63 + assert string.contains(url, "/subscribe?") 64 + } 65 + 66 + pub fn at_uri_test() { 67 + assert util.at_uri("did:plc:abc", "com.example.post", "3k2y") 68 + == "at://did:plc:abc/com.example.post/3k2y" 69 + } 70 + 71 + pub fn rkey_from_uri_test() { 72 + let assert Ok("3k2y") = 73 + util.rkey_from_uri("at://did:plc:abc/com.example.post/3k2y") 74 + } 75 + 76 + pub fn sha256_hex_stable_test() { 77 + let a = util.sha256_hex("hello") 78 + let b = util.sha256_hex("hello") 79 + assert a == b 80 + assert string.length(a) == 64 81 + }