Side A is a solid, focused fix that centralizes hostname/base-URL logic into one util function, removing duplicated hardcoded strings across multiple files—clear, low-risk improvement. Side B is a larger, more substantive change that removes an entire unused ThreadVisibility concept from events, reducer, timeline, RPC, CLI, and tests, simplifies rooms from a HashMap to a HashSet, adds a new CLI `room create` command with documentation, and updates integration tests—representing more real design simplification and feature completion across the stack, despite being messier and touching more files/tests.
constitution · epochs · watch · epoch 3
c_c25451965a7f (tommy-mor) vs c_1c1c8e7a2de8 (tommy-mor)
download prompt · raw event · cmp_87e3600c700f3d
council reasoning
Side B adds the missing CLI `room create` path and lasting domain simplification (drop ThreadVisibility/RoomState, rooms as HashSet, cleaner RoomCreated events and RPC), with matching docs/tests; Side A is a useful but narrower consolidation of hardcoded OAuth/redirect base URLs plus a staging deploy branch flip.
Side A fixes a real deployment and correctness issue by centralizing base URL generation in `app.util/get-base-url`, replacing scattered hardcoded host/environment logic across multiple OAuth/login callback paths, using `window.location.origin` on the frontend, and updating the deployment workflow to deploy from `staging`. Side B adds a useful `room create` CLI command and simplifies room handling by removing unused visibility state, but much of the patch is API/docs plumbing and feature exposure rather than fixing cross-environment correctness problems.
sides
A — c_c25451965a7f (tommy-mor)
message
[6120bd96] fix redirect urls for custom hosts and deploy from staging Use HOSTNAME and window.location.origin instead of hardcoded staging.sorter.social, and trigger fly deploys on pushes to staging. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml
index 3e693915fe6f99a5c2221b2921bf8ebc305180fc..5e11e5c312f62bed34d8cc68bd4a5538f52476b9 100644
--- a/.github/workflows/fly-deploy.yml
+++ b/.github/workflows/fly-deploy.yml
@@ -3,11 +3,11 @@ name: deploy to fly.io
on:
push:
branches:
- - main
+ - staging
workflow_dispatch:
concurrency:
- group: fly-deploy-main
+ group: fly-deploy-staging
cancel-in-progress: true
jobs:
diff --git a/borter/src/app/linear.clj b/borter/src/app/linear.clj
index f77d8a974e0cf05f9c33233bb625bdb190d2007b..5ef0e34cf1d543826675c6facb66aec079b40561 100644
--- a/borter/src/app/linear.clj
+++ b/borter/src/app/linear.clj
@@ -6,6 +6,7 @@
[ring.util.response :as response]
[app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[sluj.core :refer [sluj]]))
@@ -13,9 +14,7 @@
(def client-secret (System/getenv "BORTER_LINEAR_CLIENT_SECRET"))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "sorter.social" "https://sorter.social/api/linear/callback"
- "http://localhost:3000/api/linear/callback"))
+ (str (util/get-base-url) "/api/linear/callback"))
(defn code->token [code]
(def code code)
diff --git a/borter/src/app/login.clj b/borter/src/app/login.clj
index 5d545db9bef7765ba8b3fe7d00261c8ce84e9e1a..86817f8e94bc174e5b108859cc0b342953ab7acb 100644
--- a/borter/src/app/login.clj
+++ b/borter/src/app/login.clj
@@ -1,6 +1,7 @@
(ns app.login
(:require [crypto.password.bcrypt :as password]
[app.database :as db]
+ [app.util :as util]
[honey.sql.helpers :as h]
[hato.client :as hc]
[clojure.data.json :as json]
@@ -12,10 +13,7 @@
(def environment (or (System/getenv "ENVIRONMENT") "development"))
(defn get-base-url []
- (case environment
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "development" "http://localhost:3000")) ; fallback for development
+ (util/get-base-url))
(defn create-email-content
"Creates a standardized email structure with customizable content"
diff --git a/borter/src/app/oauth.clj b/borter/src/app/oauth.clj
index 1166f2ee30c9e813840711c72d1ad8f1c62e1ffe..2cd0c62f1fe267f7f5f725a97bc3ba0d0889517f 100644
--- a/borter/src/app/oauth.clj
+++ b/borter/src/app/oauth.clj
@@ -3,6 +3,7 @@
[clojure.data.json :as json]
[hato.client :as hc]
[app.database :as db]
+ [app.util :as util]
[honey.sql.helpers :as h]
[ring.util.codec :as codec])
(:import [java.util Base64]))
@@ -13,12 +14,7 @@
encoded-bytes))
(defn get-hostname []
- (let [env (System/getenv "ENVIRONMENT")]
- (println "Current environment:" env)
- (case env
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "http://localhost:3000")))
+ (util/get-base-url))
(defn get-origin-hostname [req]
(let [origin (get-in req [:headers "origin"])
diff --git a/borter/src/app/spotify.clj b/borter/src/app/spotify.clj
index 3e11713119141812fa2707ec956fb7ed612ee69a..b38d734351a1d4f1e142d93d4435ffe7bd20c02d 100644
--- a/borter/src/app/spotify.clj
+++ b/borter/src/app/spotify.clj
@@ -1,5 +1,6 @@
(ns app.spotify
(:require [app.oauth :as oauth]
+ [app.util :as util]
[hato.client :as hc]
[clojure.data.json :as json]
[ring.util.codec :as codec]
@@ -47,10 +48,7 @@
(defn get-hostname []
- (case (System/getenv "ENVIRONMENT")
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "http://localhost:3000"))
+ (util/get-base-url))
(defn create-spotify-tag
"Creates a tag for a Spotify entity (artist, album, track) if it doesn't exist"
diff --git a/borter/src/app/twitter.clj b/borter/src/app/twitter.clj
index ef7b18fa1fcb82bb944b3035ffed44499181237f..b9a3fdccc15d13918a4d11d8c0443de94fd172b4 100644
--- a/borter/src/app/twitter.clj
+++ b/borter/src/app/twitter.clj
@@ -1,6 +1,7 @@
(ns app.twitter
(:require [app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[hato.client :as hc]
[clojure.data.json :as json]
@@ -37,9 +38,7 @@
(clojure.string/join "&" (map (fn [[k v]] (str (name k) "=" v)) params)))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "sorter.isnt.online" "https://sorter.isnt.online/api/twitter/callback"
- "http://localhost:3000/api/twitter/callback"))
+ (str (util/get-base-url) "/api/twitter/callback"))
(defn encode-b64 [s]
(.encodeToString (java.util.Base64/getEncoder) (.getBytes s)))
diff --git a/borter/src/app/util.clj b/borter/src/app/util.clj
index 384a64306c84aa8288ec44cd5de57edc248a826d..6a8aa0875accb28dc81bf57e645e3961b0a3ace5 100644
--- a/borter/src/app/util.clj
+++ b/borter/src/app/util.clj
@@ -2,6 +2,18 @@
(:require [clojure.string :as string])
(:import [java.net URLEncoder]))
+(defn get-base-url
+ "Public site base URL for redirects and oauth callbacks."
+ []
+ (if-let [hostname (not-empty (System/getenv "HOSTNAME"))]
+ (if (string/starts-with? hostname "http")
+ (string/replace hostname #"/$" "")
+ (str "https://" (string/replace hostname #"/$" "")))
+ (case (or (System/getenv "ENVIRONMENT") "development")
+ "production" "https://sorter.social"
+ "staging" "https://staging.sorter.social"
+ "http://localhost:3000")))
+
(defn urlencode-params [params]
(clojure.string/join "&" (map (fn [[k v]] (str k "=" (URLEncoder/encode (str v) "UTF-8"))) params)))
diff --git a/borter/src/app/youtube.clj b/borter/src/app/youtube.clj
index 647ef7c6d4f2503a63a7c074d46dc815b2a23547..fc9a2f5ed516692f19e06b4c0221f510547cf8c0 100644
--- a/borter/src/app/youtube.clj
+++ b/borter/src/app/youtube.clj
@@ -6,6 +6,7 @@
[ring.util.response :as response]
[app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[sluj.core :refer [sluj]]))
@@ -32,9 +33,7 @@
:body (json/read-str {:key-fn keyword})))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "localhost" "http://localhost:3000/api/youtube/callback"
- "https://sorter.isnt.online/api/youtube/callback"))
+ (str (util/get-base-url) "/api/youtube/callback"))
diff --git a/forter/src/utils/authUtils.js b/forter/src/utils/authUtils.js
index 145e5db7ad6f7a6439d68c63beb4d07d31b2de08..98ed7fa671f9887f44dc1479d85569951eb4773e 100644
--- a/forter/src/utils/authUtils.js
+++ b/forter/src/utils/authUtils.js
@@ -5,8 +5,11 @@ import { current_session, fetchSession } from "../session";
let lastSyncTime = 0;
const SYNC_THROTTLE_MS = 5000; // Only sync once every 5 seconds
-// Get base URL based on Vite's mode
+// Get base URL based on current origin, with build-mode fallbacks for SSR/build
const getBaseUrl = () => {
+ if (typeof window !== 'undefined' && window.location?.origin) {
+ return window.location.origin;
+ }
switch (import.meta.env.MODE) {
case 'production':
return 'https://sorter.social';
B — c_1c1c8e7a2de8 (tommy-mor)
message
[62d18183] room create path
diff preview
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
~/intro/scoping {
Scoped by room:
public … Shared site (room id "public").
- private <ROOM_ID> … Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+ private <ROOM_ID> … Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
@@ -144,7 +144,7 @@ Examples:
Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
-Global (no room prefix): identity, whoami, feed, search, healthz.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
}
~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
# Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
# Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
npx slugsocial public garden pair languages
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE] Post a .sorter doc (stdin if no
check [FILE] Validate without submitting (public garden dry-run)
+invite-link --caps view,post[,…] [--uses N] Mint shareable /join/… link (private rooms; Manage required)
+audit [--json] List principals + capabilities (private rooms; View or Manage)
+
Global (no public/private prefix):
+room create <slug> Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
identity start --rig <name> --model <provider/model> New delegate id + OAuth pending session
identity poll <session> Complete OAuth; saves bearer token
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
sub: ScopedCmd,
},
+ /// Private rooms: create (requires signed-in CLI token from `identity …`)
+ Room {
+ #[command(subcommand)]
+ sub: RoomCmd,
+ },
+
/// Show all activity since you last posted (global feed)
///
/// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
},
}
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+ /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+ Create {
+ /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+ #[arg(value_name = "SLUG")]
+ slug: String,
+ #[arg(long)]
+ json: bool,
+ },
+}
+
#[derive(Subcommand, Debug)]
enum IdentityCmd {
/// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
match cmd {
Command::Public { sub } => run_scoped(base, "public", sub).await?,
Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+ Command::Room { sub } => match sub {
+ RoomCmd::Create { slug, json } => {
+ let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::RoomCreate { slug }],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::RoomCreated { room_id } => {
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "ok": true,
+ "room_id": room_id,
+ }))?
+ );
+ } else {
+ println!("{room_id}");
+ println!();
+ println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+ println!(" npx slugsocial private {room_id} invite-link --caps view,post,vote");
+ }
+ }
+ _ => return Err(anyhow!("unexpected RPC result")),
+ }
+ }
+ },
Command::Healthz { json } => {
let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
canonical_path::{canonicalize_item, canonicalize_tag},
dsl,
events::{
- AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+ AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
},
identity::{parse_agent, parse_username},
path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
let scope = scope_from_room_wire(&room_key);
let is_private = !matches!(scope, ScopeId::Public);
- if is_private && !reduced.rooms.contains_key(&room_key) {
+ if is_private && !reduced.rooms.contains(&room_key) {
drop(reduced);
return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
}
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
let reduced = state.reduced.read().await;
line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
}
- RpcCommand::RoomCreate { slug, visibility } => {
+ RpcCommand::RoomCreate { slug } => {
// Scope the first read so its guard drops before any nested `read().await` / `write().await`.
// A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
// whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
} else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
line_err("slug must be lowercase alphanumeric with hyphens", None)
} else {
- match visibility.as_deref().unwrap_or("private") {
- "private" | "public" => {
- let vis = if visibility.as_deref() == Some("public") {
- ThreadVisibility::Public
- } else {
- ThreadVisibility::Private
- };
- let short_id = loop {
- let id = gen_short_id();
- if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
- break id;
- }
- };
- let room_id = format!("{short_id}/{slug}");
- let ts = now_ms();
- let tc_ev = Event::RoomCreated(RoomCreated {
- ts,
- room_id: room_id.clone(),
- slug: slug.clone(),
- owner: principal.clone(),
- visibility: vis,
- });
- let ga_ev = Event::GrantAdded(GrantAdded {
- ts,
- room_id: room_id.clone(),
- username: principal.clone(),
- capabilities: vec![
- ThreadCapability::View,
- ThreadCapability::Post,
- ThreadCapability::Vote,
- ThreadCapability::AddItem,
- ThreadCapability::Manage,
- ],
- granted_by: principal.clone(),
- });
- if let Err(e) = state.event_log.append(&tc_ev).await {
- line_err(format!("{e}"), None)
- } else if let Err(e) = state.event_log.append(&ga_ev).await {
- line_err(format!("{e}"), None)
- } else {
- let mut r = state.reduced.write().await;
- r.apply_event(tc_ev);
- r.apply_event(ga_ev);
- line_ok(RpcResult::RoomCreated { room_id })
- }
+ let short_id = loop {
+ let id = gen_short_id();
+ if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+ break id;
}
- other => line_err(format!("unknown visibility: {other}"), None),
+ };
+ let room_id = format!("{short_id}/{slug}");
+ let ts = now_ms();
+ let tc_ev = Event::RoomCreated(RoomCreated {
+ ts,
+ room_id: room_id.clone(),
+ slug: slug.clone(),
+ owner: principal.clone(),
+ });
+ let ga_ev = Event::GrantAdded(GrantAdded {
+ ts,
+ room_id: room_id.clone(),
+ username: principal.clone(),
+ capabilities: vec![
+ ThreadCapability::View,
+ ThreadCapability::Post,
+ ThreadCapability::Vot
… preview truncated; 10,232 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.