Side A introduces a genuinely new, well-scoped capability (a workspace-only sorterc tool with a documented CLI, reused validation/reducer logic, and unit tests) that provides ongoing developer value beyond this commit. Side B is a solid, real bugfix (fixing actual crashes like calling .getInputStream instead of .getRequestBody, and str/split with a string instead of a regex) that restores broken E2E auth tests, but its scope and lasting impact are narrower than A's new tool and test coverage.
constitution · epochs · watch · epoch 3
c_0c466eea1ed4 (tommy-mor) vs c_597d3f736194 (tommy-mor)
download prompt · raw event · cmp_05aa1ddb6f605a
council reasoning
A adds reusable offline compile/scan APIs (offline.rs ~300 LOC with ranking simulation, JSONL replay, and unit tests) plus a documented workspace binary that permanently improves DSL/CI workflows. B only repairs test doubles (regex split, getRequestBody, nil-safe state encode, try/catch) and a couple of Playwright helpers so E2E auth runs again—valuable but narrower and test-only.
Side A adds a substantial new offline tooling capability by introducing a reusable server-side `offline` module plus a `sorterc` CLI that compiles `.sorter` files, replays JSONL event logs, reports malformed ingests, and includes tests and documentation. Side B fixes important OAuth test infrastructure issues (query parsing, request body handling, redirects, null handling, and Playwright selectors), but those changes are confined to test mocks and restore existing E2E behavior rather than adding broadly reusable project functionality.
sides
A — c_0c466eea1ed4 (tommy-mor)
message
[9e20d06c] Add sorterc dev tool for offline DSL compile and JSONL lint. Introduce a workspace-only binary that validates .sorter files into ranking JSON and scans events.jsonl for corrupt or unreplayable ingests. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index bf8153d9c723af97122c9ffdd4a7cfe82e853bb6..a07734f089b466440c3ae6fc1087ce85fc24ce62 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1826,6 +1826,17 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "sorterc"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "clap",
+ "serde",
+ "serde_json",
+ "slugsocial-server",
+]
+
[[package]]
name = "spin"
version = "0.9.8"
diff --git a/Cargo.toml b/Cargo.toml
index 149cbf07901eab57c593184ff8719a75d530f1da..25337acdd61e44b20f354c78fed4a88caf896280 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["server", "cli"]
+members = ["server", "cli", "sorterc"]
resolver = "2"
diff --git a/agents.md b/agents.md
index d8b801e454fdf37e7ac6038b91a69f83b0746d59..ce646ed3cd7123be4732dccec4a6800467e651e7 100644
--- a/agents.md
+++ b/agents.md
@@ -93,6 +93,17 @@ SLUG_GOOGLE_CLIENT_SECRET=mock
After OAuth completes, the pending-session poll returns a `slug_…` bearer token for API calls.
+### Dev-only offline tooling
+
+**`sorterc`** — workspace binary, not published via npm. Compiles `.sorter` files and lints `events.jsonl` without a server:
+
+```
+cargo run -p sorterc -- compile path/to/doc.sorter [--base events.jsonl] [--room public] [--pretty]
+cargo run -p sorterc -- scan path/to/events.jsonl [--pretty]
+```
+
+`compile` validates DSL, simulates ingest against empty (or `--base`) reducer state, and prints JSON rankings. `scan` reports corrupt JSONL lines and ingests that fail DSL replay.
+
### Testing
- **Rust tests:** `cargo nextest run --workspace` (163 tests; requires `cargo-nextest`)
diff --git a/server/src/lib.rs b/server/src/lib.rs
index c1d477d21aea03aff00e6f0689b0b4379d0d68d2..ad8e31099c807fb5844acb16cd5086a2f19327a7 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -10,6 +10,7 @@ pub mod form_template;
pub mod html;
pub mod identity;
pub mod middleware;
+pub mod offline;
pub mod path_types;
pub mod ranking;
pub mod reducer;
diff --git a/server/src/offline.rs b/server/src/offline.rs
new file mode 100644
index 0000000000000000000000000000000000000000..54ad0ded096a305ef8454ab2cdd1c3af71b14f5d
--- /dev/null
+++ b/server/src/offline.rs
@@ -0,0 +1,333 @@
+//! Offline `.sorter` compilation and JSONL diagnostics (no network, no auth).
+
+use std::collections::HashSet;
+use std::path::Path;
+
+use serde::Serialize;
+use slug_types::{CheckScopeRanking, RankComponent, RankRow, paths::GardenItemUrl};
+
+use crate::{
+ api::{resolve_item, validate_ingest_document},
+ dsl,
+ events::{Event, Ingest},
+ path_types::ItemId,
+ reducer::{ReducerState, ScopeId, scope_from_room_wire},
+ scope_rank::build_children_rankings,
+};
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileStats {
+ pub items: usize,
+ pub votes: usize,
+ pub prose_blocks: usize,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CompileResult {
+ pub ok: bool,
+ pub threads: Vec<String>,
+ pub rankings: Vec<CheckScopeRanking>,
+ pub stats: CompileStats,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileError {
+ pub ok: bool,
+ pub error: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub hint: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct BadJsonLine {
+ pub line: usize,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MalformedIngest {
+ pub line: usize,
+ pub id: String,
+ pub room_id: String,
+ pub thread_tag: String,
+ pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ScanResult {
+ pub ok: bool,
+ pub path: String,
+ pub total_lines: usize,
+ pub parsed_events: usize,
+ pub bad_json_lines: Vec<BadJsonLine>,
+ pub malformed_ingests: Vec<MalformedIngest>,
+ pub skipped_ingests: usize,
+}
+
+fn document_stats(doc: &dsl::Document) -> CompileStats {
+ let mut items = 0usize;
+ let mut votes = 0usize;
+ let mut prose_blocks = 0usize;
+ for stmt in &doc.statements {
+ match stmt {
+ dsl::Stmt::Item { .. } => items += 1,
+ dsl::Stmt::Vote { .. } => votes += 1,
+ dsl::Stmt::Prose { .. } => prose_blocks += 1,
+ }
+ }
+ CompileStats {
+ items,
+ votes,
+ prose_blocks,
+ }
+}
+
+fn threads_in_document(text: &str) -> Vec<String> {
+ let mut out = HashSet::new();
+ for line in text.lines() {
+ let trimmed = line.trim();
+ if !trimmed.starts_with('#') {
+ continue;
+ }
+ let rest = trimmed.trim_start_matches('#').trim();
+ if rest.is_empty() {
+ continue;
+ }
+ let tag = rest.split_whitespace().next().unwrap_or(rest);
+ let tag = tag.split(':').next().unwrap_or(tag).trim();
+ if tag.is_empty() {
+ continue;
+ }
+ out.insert(format!("#{}", crate::canonical_path::canonicalize_tag(tag)));
+ }
+ let mut tags: Vec<String> = out.into_iter().collect();
+ tags.sort();
+ tags
+}
+
+fn voted_parent_scopes(doc: &dsl::Document) -> Vec<ItemId> {
+ let mut parents = HashSet::new();
+ for stmt in &doc.statements {
+ if let dsl::Stmt::Vote { item1, item2, .. } = stmt {
+ if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {
+ if let Some(p) = a.parent() {
+ parents.insert(p);
+ }
+ if let Some(p) = b.parent() {
+ parents.insert(p);
+ }
+ }
+ }
+ }
+ let mut out: Vec<ItemId> = parents.into_iter().collect();
+ out.sort();
+ out
+}
+
+fn rankings_for_simulated(
+ simulated: &ReducerState,
+ scope: &ScopeId,
+ room_wire: &str,
+ doc: &dsl::Document,
+) -> Vec<CheckScopeRanking> {
+ voted_parent_scopes(doc)
+ .iter()
+ .map(|parent| {
+ let scoped_content = simulated
+ .content_for_scope(&scope)
+ .unwrap_or_else(|| simulated.public());
+ let scoped = build_children_rankings(scoped_content, parent);
+ let components: Vec<RankComponent> = scoped
+ .component_rankings
+ .into_iter()
+ .map(|comp| RankComponent {
+ pairs: comp.pairs,
+ ranking: comp
+ .ranked
+ .into_iter()
+ .map(|r| RankRow {
+ item: GardenItemUrl::from_stored(&r.item, room_wire),
+ score: r.score,
+ percent: None,
+ })
+ .collect(),
+ })
+ .collect();
+ CheckScopeRanking {
+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),
+ components,
+ unranked_items: scoped
+ .unranked_items
+ .into_iter()
+ .map(|it| GardenItemUrl::from_stored(&it, room_wire))
+ .collect(),
+ }
+ })
+ .collect()
+}
+
+/// Validate and simulate one `.sorter` document against optional base reducer state.
+pub fn compile_document(
+ base: &ReducerState,
+ room: &str,
+ text: &str,
+) -> Result<CompileResult, CompileError> {
+ let room_key = room.trim();
+ let scope = scope_from_room_wire(room_key);
+ let validated = validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {
+ CompileError {
+ ok: false,
+ error: message,
+ hint,
+ }
+ })?;
+
+ let event = Event::Ingest(Ingest {
+ ts: validated.ts,
+ id: uuid::Uuid::new_v4().to_string(),
+ raw: validated.raw_text.clone(),
+ principal: "offline".to_string(),
+ delegate: None,
+ room_id: room_key.to_string(),
+ thread_tag: "offline".to_string(),
+ });
+
+ let mut simulated = base.clone();
+ simulated.apply_event(event);
+
+ Ok(CompileResult {
+ ok: true,
+ threads: threads_in_document(text),
+ rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),
+ stats: document_stats(&validated.doc),
+ })
+}
+
+fn ingest_parse_error(raw: &str) -> Option<String> {
+ dsl::parse_full(raw).err().map(|e| e.to_string())
+}
+
+fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
+ let text = std::fs::read_to_string(path)?;
+ let mut events = Vec::new();
+ let mut bad_json_lines = Vec::new();
+ for (idx, line) in text.lines().enumerate() {
+ let line_no = idx + 1;
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ match serde_json::from_str::<Event>(trimmed) {
+ Ok(ev) => events.push((line_no, ev)),
+ Err(e) => bad_json_lines.push(BadJsonLine {
+ line: line_no,
+ message: e.to_string(),
+ }),
+ }
+ }
+ Ok((events, bad_json_lines))
+}
+
+/// Replay a JSONL event log into reducer state (same rules as server boot).
+pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec<BadJsonLine>), std::io::Error> {
+ let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+ let mut state = ReducerState::default();
+ for (_line_no, ev) in events {
+ state.apply_event(ev);
+ }
+ Ok((state, bad_json_lines))
+}
+
+/// Scan an events.jsonl for corrupt JSON lines and ingests that fail DSL replay.
+pub fn scan_jsonl(path: &Path) -> Result<ScanResult, std::io::Error> {
+ let text = std::fs::read_to_string(path)?;
+ let total_lines = text.lines().count();
+ let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+
+ let mut malformed_ingests = Vec::new();
+ let mut skipped_ingests = 0usize;
+ let mut state = ReducerState::default();
+ let parsed_events = events.len();
+
+ for (line_no, ev) in events {
+ if let Event::Ingest(ref ing) = ev {
+ if let Some(reason) = ingest_parse_error(&ing.raw) {
+ malformed_ingests.push(MalformedIngest {
+ line: line_no,
+ id: ing.id.clone(),
+ room_id: ing.room_id.clone(),
+ thread_tag: ing.thread_tag.clone(),
+ reason,
+ });
+ }
+ let before = state.ingests_by_id.len();
+ state.apply_event(ev);
+ if state.ingests_by_id.len() == before {
+ skipped_ingests += 1;
+ }
+ } else {
+ state.apply_event(ev);
+ }
+ }
+
+ let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty() && skipped_ingests == 0;
+
+ Ok(ScanResult {
+ ok,
+ path: path.display().to_string(),
+ total_lines,
+ parsed_events,
+ bad_json_lines,
+ malformed_ingests,
+ skipped_ingests,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const TUTORIAL: &str = include_str!("../tests/fixtures/tutorial.sorter");
+
+ #[test]
+ fn compile_tutorial_fixture_emits_rankings() {
+ let result = compile_document(&ReducerState::default(), "public", TUTORIAL).unwrap();
+ assert!(result.ok);
+ assert!(!result.threads.is_empty());
+ assert!(result.stats.items >= 6);
+ assert!(result.stats.votes >= 6);
+ assert!(!result.rankings.is_empty());
+ }
+
+ #[test]
+ fn compile_rejects_vote_on_missing_item() {
+ let err = compile_document(
+ &ReducerState::default(),
+ "public",
+ "{ reason }\n~/missing/a 2:1 ~/missing/b",
+ )
+ .unwrap_err();
+ assert!(!err.ok);
+ assert!(err.error.contains("undefined"));
+ }
+
+ #[test]
+ fn scan
… preview truncated; 7,950 characters omittedB — c_597d3f736194 (tommy-mor)
message
[075d4d37] Fix OAuth test mocks so Clojure E2E auth flows work again. HttpServer handlers were crashing on query parsing and token POSTs, which broke Playwright login; also read alias/history via real CSS selectors. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/test/auth_login.clj b/test/auth_login.clj
index 6f2f00d7aa7340e63d1ac465b0a2234cec7a983f..aa63b66ccb2471b710ba3d168d0461252b39fc10 100644
--- a/test/auth_login.clj
+++ b/test/auth_login.clj
@@ -14,27 +14,27 @@
(defn- type-alias! [pg text]
(page/evaluate pg
(.replace
- "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return;
+ "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return Promise.resolve('missing-form');
i.value = __TEXT__;
const cf = document.getElementById('alias-claim-field'); if (cf) cf.value = i.value;
return fetch(f.action, { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)).toString() })
.then(function (r) { return r.text(); })
- .then(function (t) { eval(t); }); })()"
+ .then(function (t) { eval(t); return document.getElementById('alias-status')?.textContent || ''; }); })()"
"__TEXT__"
- (pr-str text)))
- (Thread/sleep 400))
+ (pr-str text))))
-(defn- element-text [pg test-id]
+(defn- element-text [pg selector]
(let [raw (page/evaluate pg
- (str "document.querySelector('[data-testid=\"" test-id "\"]')?.textContent || ''"))]
+ (str "document.querySelector(" (pr-str selector) ")?.textContent || ''"))]
(when (string? raw) (str/trim raw))))
(defn- wait-for-text [pg test-id text timeout-ms]
- (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)
+ selector (str "[data-testid=\"" test-id "\"]")]
(loop []
- (let [got (or (element-text pg test-id) "")]
+ (let [got (or (element-text pg selector) "")]
(cond
(= got text) got
(< (System/currentTimeMillis) deadline) (do (Thread/sleep 200) (recur))
diff --git a/test/support/mock_oauth.clj b/test/support/mock_oauth.clj
index 5ba7e9be3cf6d2226648e9609a09ed45f306930f..333fe08d56cb06bac2a338cad1c73894d54c4495 100644
--- a/test/support/mock_oauth.clj
+++ b/test/support/mock_oauth.clj
@@ -7,7 +7,7 @@
(defn- query-param [query key]
(when query
(some (fn [pair]
- (let [[k v] (str/split pair "=" 2)]
+ (let [[k v] (str/split pair #"=" 2)]
(when (= k key)
(URLDecoder/decode (or v "") "UTF-8"))))
(str/split query #"&"))))
@@ -31,11 +31,11 @@
(defn- send-redirect [^HttpExchange ex location]
(.set (.getResponseHeaders ex) "Location" location)
- (.sendResponseHeaders ex 302 -1)
+ (.sendResponseHeaders ex 302 0)
(.close (.getResponseBody ex)))
(defn- read-form [^HttpExchange ex]
- (let [body (slurp (.getInputStream ex))]
+ (let [body (slurp (.getRequestBody ex))]
{:code (query-param body "code")
:grant (query-param body "grant_type")}))
@@ -45,7 +45,7 @@
(str/replace #"^[Bb]earer " "")))
(defn- parse-token-user [token]
- (when (str/starts-with? token "mock:")
+ (when (and token (str/starts-with? token "mock:"))
(parse-mock-user (subs token 5))))
(defn- authorize-redirect [exchange query]
@@ -55,7 +55,7 @@
user (parse-mock-user mock-user)
code (str "mock:" (:id user) ":" (:login user))
loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8")
- "&state=" (java.net.URLEncoder/encode state "UTF-8"))]
+ "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))]
(send-redirect exchange loc)))
(defn start-mock-oauth
@@ -65,49 +65,56 @@
handler
(proxy [HttpHandler] []
(handle [^HttpExchange exchange]
- (let [uri (.getRequestURI exchange)
- path (.getPath uri)
- query (.getQuery uri)
- method (.getRequestMethod exchange)]
- (cond
- ;; GitHub authorize
- (str/ends-with? path "/login/oauth/authorize")
- (authorize-redirect exchange query)
+ (try
+ (let [uri (.getRequestURI exchange)
+ path (.getPath uri)
+ query (.getQuery uri)
+ method (.getRequestMethod exchange)]
+ (cond
+ ;; GitHub authorize
+ (str/ends-with? path "/login/oauth/authorize")
+ (authorize-redirect exchange query)
- ;; Reddit authorize
- (str/ends-with? path "/api/v1/authorize")
- (authorize-redirect exchange query)
+ ;; Reddit authorize
+ (str/ends-with? path "/api/v1/authorize")
+ (authorize-redirect exchange query)
- ;; GitHub token
- (and (= method "POST") (str/ends-with? path "/login/oauth/access_token"))
- (let [code (or (:code (read-form exchange)) "mock:1002:newbie")]
- (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}")))
+ ;; GitHub token
+ (and (= method "POST") (str/ends-with? path "/login/oauth/access_token"))
+ (let [code (or (:code (read-form exchange)) "mock:1002:newbie")]
+ (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}")))
- ;; Reddit token (client_credentials for import + authorization_code for login)
- (and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
- (let [form (read-form exchange)
- grant (or (:grant form) "")
- code (or (:code form) "mock:t2_test:redditor")]
- (if (= grant "client_credentials")
- (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}")
- (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}"))))
+ ;; Reddit token (client_credentials for import + authorization_code for login)
+ (and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
+ (let [form (read-form exchange)
+ grant (or (:grant form) "")
+ code (or (:code form) "mock:t2_test:redditor")]
+ (if (= grant "client_credentials")
+ (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}")
+ (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}"))))
- ;; GitHub user
- (= path "/user")
- (let [token (bearer-token exchange)
- user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})]
- (send-json exchange 200
- (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}")))
+ ;; GitHub user
+ (= path "/user")
+ (let [token (bearer-token exchange)
+ user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})]
+ (send-json exchange 200
+ (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}")))
- ;; Reddit /api/v1/me
- (str/ends-with? path "/api/v1/me")
- (let [token (bearer-token exchange)
- user (or (parse-token-user token) {:id "t2_test" :login "redditor"})]
- (send-json exchange 200
- (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}")))
+ ;; Reddit /api/v1/me
+ (str/ends-with? path "/api/v1/me")
+ (let [token (bearer-token exchange)
+ user (or (parse-token-user token) {:id "t2_test" :login "redditor"})]
+ (send-json exchange 200
+ (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}")))
- :else
- (send-json exchange 404 "{\"error\":\"not found\"}")))))]
+ :else
+ (send-json exchange 404 "{\"error\":\"not found\"}")))
+ (catch Throwable t
+ (binding [*out* *err*]
+ (println "mock-oauth handler error:" t))
+ (try
+ (send-json exchange 500 "{\"error\":\"mock-oauth internal\"}")
+ (catch Throwable _))))))]
(.createContext server "/" handler)
(.setExecutor server nil)
(.start server)
diff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj
index a630cf0938722193e9af88382d60e777ff371be4..faa27945b6394363914c853627a0bad1e819a5f9 100644
--- a/test/support/mock_reddit.clj
+++ b/test/support/mock_reddit.clj
@@ -12,7 +12,7 @@
(defn- query-param [query key]
(when query
(some (fn [pair]
- (let [[k v] (str/split pair "=" 2)]
+ (let [[k v] (str/split pair #"=" 2)]
(when (= k key)
(URLDecoder/decode (or v "") "UTF-8"))))
(str/split query #"&"))))
@@ -34,11 +34,11 @@
(defn- send-redirect [^HttpExchange ex location]
(.set (.getResponseHeaders ex) "Location" location)
- (.sendResponseHeaders ex 302 -1)
+ (.sendResponseHeaders ex 302 0)
(.close (.getResponseBody ex)))
(defn- read-form [^HttpExchange ex]
- (let [body (slurp (.getInputStream ex))]
+ (let [body (slurp (.getRequestBody ex))]
{:code (query-param body "code")
:grant (query-param body "grant_type")}))
@@ -48,7 +48,7 @@
(str/replace #"^[Bb]earer " "")))
(defn- parse-token-user [token]
- (when (str/starts-with? token "mock:")
+ (when (and token (str/starts-with? token "mock:"))
(parse-mock-user (subs token 5))))
(defn start-mock-reddit
@@ -72,7 +72,7 @@
user (parse-mock-user (query-param query "mock_user"))
code (str "mock:" (:id user) ":" (:login user))
loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8")
- "&state=" (java.net.URLEncoder/encode state "UTF-8"))]
+ "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))]
(send-redirect exchange loc))
(and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.