eleven / ocho Public

API: post messages from a script (server stays blind) (#13)

* server: add POST /api/messages + lchat-post helper for scripted posting

Lets bots, cron jobs, CI and webhooks post into a chat without a browser,
while keeping the server blind. The encryption stays in the caller's trust
domain: a secret link already carries both halves needed — the invite token
(path) and the group key (#fragment) — so the helper encrypts locally with
the same web/static/crypto.js the browser uses and hands the server only the
ciphertext blob.

- POST /api/messages?token=… accepts {"blob":"<ciphertext>"} and is a
  non-WebSocket doorway to the exact "msg" path in handleWS: token-gated,
  blob-capped (MaxBytesReader before decode, like uploadBlob), stored with
  the author's hashed token, broadcast to live clients, and pushed. The
  server never sees plaintext or the key.
- tools/lchat-post.mjs turns plaintext into that blob from a secret link or
  LCHAT_* env vars; text from an arg or stdin. Reuses crypto.js so blobs are
  byte-for-byte what the browser produces.
- Go test covers bad-token/empty/oversized rejection and the store+read
  round-trip; README documents the flow.

Deliberately rejected the alternative of a plaintext endpoint that encrypts
server-side — that would put the group key on the server and break E2E for
the whole instance, retroactively.

Co-Authored-By: Claude <noreply@anthropic.com>

* server: support --type/LCHAT_TYPE for the message payload kind

lchat-post hardcoded type:"chat"; let callers stamp a different payload
kind (system notices, presence, …) that other clients render per message.js.
Defaults to "chat". Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 97e5b27abb788506d7eae05ab0c7591a07d27cdc parent ea68fb8
4 files changed, +249 −0
  • README.md +31 −0
  • handlers_test.go +59 −0
  • main.go +46 −0
  • tools/lchat-post.mjs +113 −0
diff --git a/README.md b/README.md
index e3d7761..cbd850e 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,36 @@ https://group.luan.chat/admin#token=<ADMIN_TOKEN>
2. **A guest** opens their link, picks a screen name, and joins.
3. Everyone shares **one feed**, like an iMessage group chat.
+## Posting from a script (bots, cron, CI)
+
+You can post into a chat from outside the browser **without weakening the
+encryption**. The trick: the encryption stays on *your* side, never the server's.
+A secret link already carries both halves needed — the invite token (the path)
+and the group key (the `#fragment`) — so a small helper can encrypt locally and
+hand the server only ciphertext, exactly like the browser does.
+
+```sh
+# text as an argument
+tools/lchat-post.mjs --link "https://group.luan.chat/join/TOKEN#k=KEY" "deploy finished ✅"
+
+# or pipe it in, with a custom screen name
+echo "build #42 passed" | tools/lchat-post.mjs --link "$LCHAT_LINK" --name CI
+```
+
+Config can come from flags or `LCHAT_*` env vars (`LCHAT_LINK`, or the split
+`LCHAT_HOST` / `LCHAT_TOKEN` / `LCHAT_KEY`; `LCHAT_NAME` for the screen name;
+`--type` / `LCHAT_TYPE` for the payload kind, default `chat` — a client only
+renders kinds it accepts, see `web/static/message.js`).
+The helper reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the
+browser produces and render as ordinary chat messages.
+
+Under the hood it `POST`s to `/api/messages?token=…` with `{"blob":"<ciphertext>"}`.
+The server stores, broadcasts, and pushes it just like a WebSocket `msg` frame —
+**it never sees the plaintext or the key.** Writing the encrypt step in another
+language is ~15 lines (AES-256-GCM, random 12-byte IV, `base64url(iv||ct)`); keep
+the key on the calling host, never on the server, and the "compromise the server,
+get nothing" guarantee holds.
+
## Tests
```sh
@@ -195,6 +225,7 @@ db.go SQLite: config, invites, messages (ciphertext only)
hub.go WebSocket fan-out hub + message ingest
web/pages/*.html chat / join / admin pages
web/static/* crypto.js (Web Crypto), chat.js, join.js, admin.js, style.css
+tools/lchat-post.mjs CLI to post an encrypted message from a script (see above)
```
## Not in this first slice (candidates for next)
diff --git a/handlers_test.go b/handlers_test.go
index a45fcb1..c3a422a 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -321,6 +321,65 @@ func TestMessagesPaginationAPI(t *testing.T) {
}
}
+func TestPostMessageAPI(t *testing.T) {
+ ts, srv := newTestServer(t)
+ resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`)
+ var inv struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(resp.Body).Decode(&inv)
+
+ post := func(token, body string) *http.Response {
+ r, err := http.Post(ts.URL+"/api/messages?token="+token, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ return r
+ }
+
+ // A bogus token can't post.
+ if r := post("bogus", `{"blob":"abc"}`); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("bad token: want 403, got %d", r.StatusCode)
+ }
+ // Empty blob is rejected.
+ if r := post(inv.Token, `{"blob":""}`); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("empty blob: want 400, got %d", r.StatusCode)
+ }
+ // Oversized blob is rejected (mirrors the WebSocket cap).
+ if r := post(inv.Token, `{"blob":"`+strings.Repeat("a", maxBlobBytes+1)+`"}`); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("oversized blob: want 400, got %d", r.StatusCode)
+ }
+ // A valid post is stored with the author's hashed token and echoed back.
+ r := post(inv.Token, `{"blob":"cipher-blob-xyz"}`)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("valid post: want 200, got %d", r.StatusCode)
+ }
+ var m struct {
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ }
+ json.NewDecoder(r.Body).Decode(&m)
+ if m.ID == 0 || m.Blob != "cipher-blob-xyz" {
+ t.Fatalf("unexpected response: %+v", m)
+ }
+ // It must come back through the read API as the same ciphertext.
+ gr, _ := http.Get(ts.URL + "/api/messages?token=" + inv.Token)
+ var out struct {
+ Messages []struct {
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ } `json:"messages"`
+ }
+ json.NewDecoder(gr.Body).Decode(&out)
+ if len(out.Messages) != 1 || out.Messages[0].Blob != "cipher-blob-xyz" {
+ t.Fatalf("message not stored: %+v", out.Messages)
+ }
+ // The stored author hash is the token's hash — server never sees the token raw.
+ if _, _, err := srv.db.inviteStatus(inv.Token); err != nil {
+ t.Fatalf("invite status: %v", err)
+ }
+}
+
func TestBlobUploadDownload(t *testing.T) {
ts, _ := newTestServer(t)
resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`)
diff --git a/main.go b/main.go
index 12cf8c8..48d765b 100644
--- a/main.go
+++ b/main.go
@@ -103,6 +103,7 @@ func (s *Server) handler() http.Handler {
// Chat API (transport auth via invite token; never sees plaintext)
mux.HandleFunc("GET /api/messages", s.getMessages)
+ mux.HandleFunc("POST /api/messages", s.postMessage)
mux.HandleFunc("GET /ws", s.handleWS)
// Encrypted attachments: bytes are E2E-encrypted in the browser; the server
@@ -223,6 +224,51 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
}
+// postMessage is a non-WebSocket doorway to the exact same path as the "msg"
+// frame in handleWS: a caller that holds a valid invite token POSTs an already
+// AES-GCM-encrypted ciphertext blob, and we store + broadcast + push it. It lets
+// bots, cron jobs and webhooks post without holding a WebSocket open.
+//
+// The end-to-end property is preserved precisely because the caller encrypts:
+// the group key never reaches the server, and (as everywhere else) we store only
+// the opaque blob and the author's hashed token — never plaintext. The companion
+// `lchat-post` helper turns plaintext into the blob in the *caller's* trust
+// domain.
+func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ // Cap the body before decoding so a valid-token holder can't force a huge
+ // allocation — the length check below would fire too late on its own. Mirrors
+ // uploadBlob's MaxBytesReader and handleWS's SetReadLimit.
+ r.Body = http.MaxBytesReader(w, r.Body, maxBlobBytes+1024)
+ var body struct {
+ Blob string `json:"blob"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ // Same guards as the WebSocket "msg" case: non-empty, within the blob cap.
+ if body.Blob == "" || len(body.Blob) > maxBlobBytes {
+ httpError(w, http.StatusBadRequest, "missing or oversized blob")
+ return
+ }
+ m, err := s.db.insertMessage(body.Blob, hashToken(token))
+ if err != nil {
+ log.Println("insert message:", err)
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob})
+ s.hub.broadcast <- out
+ // Notify others (not the poster), best-effort, async — mirrors handleWS.
+ go s.pushNewMessage(m.Blob, token)
+ writeJSON(w, http.StatusOK, m)
+}
+
// tokenOK reports whether a token is a valid participant credential: either a
// current non-revoked invite (legacy) or a device token (invite redesign).
func (s *Server) tokenOK(token string) bool {
diff --git a/tools/lchat-post.mjs b/tools/lchat-post.mjs
new file mode 100755
index 0000000..8cef3c0
--- /dev/null
+++ b/tools/lchat-post.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+// lchat-post — post a message to an lchat instance from outside the browser
+// (bots, cron, CI, webhooks) while keeping the server blind.
+//
+// The encryption happens HERE, in the caller's trust domain: we hold the group
+// key (carried in a secret link's #fragment), AES-GCM-encrypt the payload with
+// the exact same crypto.js the browser uses, and POST only the ciphertext blob
+// to POST /api/messages. The group key never reaches the server, so the
+// end-to-end guarantee — "compromise the server, get nothing" — is preserved.
+//
+// Usage:
+// lchat-post --link "https://chat.example.com/join/TOKEN#k=KEY" "hello"
+// echo "deploy finished" | lchat-post --link "$LCHAT_LINK" --name CI
+// LCHAT_LINK=… lchat-post "hello" # link via env
+// lchat-post --host https://h --token T --key K "hi" # split form
+//
+// Config (flags win over env):
+// --link / LCHAT_LINK full secret link: <origin>/join/<token>#k=<key>
+// --host / LCHAT_HOST instance origin (split form)
+// --token / LCHAT_TOKEN invite token (split form)
+// --key / LCHAT_KEY base64url AES key (split form)
+// --name / LCHAT_NAME screen name shown in chat (default: "bot")
+// --type / LCHAT_TYPE payload kind (default: "chat"); a client only
+// renders kinds it accepts — see web/static/message.js
+//
+// Text comes from the positional args, or from stdin if none are given.
+
+import { importKey, encrypt } from "../web/static/crypto.js";
+import { MESSAGE_TYPE_CHAT } from "../web/static/message.js";
+
+function parseArgs(argv) {
+ const opts = {};
+ const rest = [];
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--link" || a === "--host" || a === "--token" || a === "--key" || a === "--name" || a === "--type") {
+ opts[a.slice(2)] = argv[++i];
+ } else if (a === "-h" || a === "--help") {
+ opts.help = true;
+ } else {
+ rest.push(a);
+ }
+ }
+ return { opts, rest };
+}
+
+// A secret link packs everything we need: origin (where to POST), the token
+// (path), and the key (#fragment). Pull them apart the way the browser does.
+function fromLink(link) {
+ const u = new URL(link);
+ const token = u.pathname.replace(/^\/join\//, "").replace(/\/$/, "");
+ const key = new URLSearchParams(u.hash.replace(/^#/, "")).get("k");
+ return { host: u.origin, token, key };
+}
+
+async function readStdin() {
+ const chunks = [];
+ for await (const c of process.stdin) chunks.push(c);
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+async function main() {
+ const { opts, rest } = parseArgs(process.argv.slice(2));
+ if (opts.help) {
+ process.stdout.write(
+ "lchat-post — post an encrypted message to an lchat instance\n" +
+ " lchat-post --link <secret-link> [--name NAME] [--type KIND] [text]\n" +
+ " echo text | lchat-post --link <secret-link>\n"
+ );
+ return;
+ }
+
+ let host = opts.host || process.env.LCHAT_HOST;
+ let token = opts.token || process.env.LCHAT_TOKEN;
+ let key = opts.key || process.env.LCHAT_KEY;
+ const link = opts.link || process.env.LCHAT_LINK;
+ if (link) ({ host, token, key } = fromLink(link));
+ const name = opts.name || process.env.LCHAT_NAME || "bot";
+ const type = opts.type || process.env.LCHAT_TYPE || MESSAGE_TYPE_CHAT;
+
+ if (!host || !token || !key) {
+ throw new Error("need a --link, or all of --host/--token/--key (or the LCHAT_* env vars)");
+ }
+
+ const text = (rest.length ? rest.join(" ") : await readStdin()).trim();
+ if (!text) throw new Error("nothing to post (empty text)");
+
+ // Identical shape and crypto to the browser composer. `type` defaults to
+ // "chat" so other clients render it normally; override it to emit a different
+ // payload kind (a client only renders kinds it accepts — see message.js). ts
+ // is the local clock in ms.
+ const payload = { type, name, text, ts: Date.now() };
+ const cryptoKey = await importKey(key);
+ const blob = await encrypt(cryptoKey, payload);
+
+ const url = `${host.replace(/\/$/, "")}/api/messages?token=${encodeURIComponent(token)}`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ blob }),
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(`server returned ${res.status}: ${detail.trim() || res.statusText}`);
+ }
+ const { id } = await res.json();
+ process.stderr.write(`posted message #${id}\n`);
+}
+
+main().catch((e) => {
+ process.stderr.write(`lchat-post: ${e.message}\n`);
+ process.exit(1);
+});