diff --git a/apps/pwa/src/migrations/003_cli_device_codes.sql b/apps/pwa/src/migrations/003_cli_device_codes.sql
new file mode 100644
index 0000000..bcd4421
--- /dev/null
+++ b/apps/pwa/src/migrations/003_cli_device_codes.sql
@@ -0,0 +1,15 @@
+-- Device-authorization codes for `logicsrc login` on machines with no browser
+-- (SSH sessions, droplets, containers). The CLI polls /cli/device/token with the
+-- device_code while the human approves the short user_code in a browser anywhere.
+CREATE TABLE IF NOT EXISTS cli_device_codes (
+ device_code_hash TEXT PRIMARY KEY, -- sha256 of the CLI's secret device_code
+ user_code TEXT NOT NULL UNIQUE, -- short human-typed code (XXXX-XXXX)
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied | used
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_polled_at INTEGER
+);
+
+CREATE INDEX IF NOT EXISTS idx_cli_device_user_code ON cli_device_codes(user_code)
diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs
index 46223c3..1445e85 100644
--- a/apps/pwa/src/routes/cli.mjs
+++ b/apps/pwa/src/routes/cli.mjs
@@ -3,13 +3,21 @@
// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback
// POST /cli/token CLI exchanges code + verifier → an lsk_ API key (bearer)
// GET /api/me Bearer → who am I (for `logicsrc whoami`)
+//
+// …and the device-authorization flow, for CLIs on a machine with no browser
+// (SSH, droplets, containers) where a 127.0.0.1 redirect_uri is unreachable:
+// POST /cli/device/code CLI asks for a device_code + short user_code
+// GET /cli/device human opens this anywhere, types/confirms the code
+// POST /cli/device approve (or deny) the pending code
+// POST /cli/device/token CLI polls with device_code → an lsk_ API key
import { Router } from "express";
import crypto from "node:crypto";
import { get, run } from "../db.mjs";
-import { token } from "../lib/crypto.mjs";
+import { token, sha256 } from "../lib/crypto.mjs";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
+import { config } from "../config.mjs";
export const cliRouter = Router();
@@ -78,6 +86,141 @@ cliRouter.post("/cli/token", async (req, res) => {
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
});
+// ---- device authorization (no browser on the CLI's machine) ----
+
+const DEVICE_TTL_MS = 10 * 60 * 1000;
+const DEVICE_POLL_SECONDS = 5;
+// Unambiguous alphabet — no 0/O, 1/I/L, U/V confusion when read off a screen.
+const CODE_ALPHABET = "BCDFGHJKMNPQRSTWXYZ23456789";
+
+function userCode() {
+ const bytes = crypto.randomBytes(8);
+ let out = "";
+ for (let i = 0; i < 8; i++) {
+ out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
+ if (i === 3) out += "-";
+ }
+ return out;
+}
+
+/** Normalize whatever the human typed (spaces, lowercase, missing dash). */
+function normalizeUserCode(input) {
+ const raw = String(input || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
+ return raw.length === 8 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
+}
+
+cliRouter.post("/cli/device/code", async (req, res) => {
+ const name = String(req.body?.name || "logicsrc cli").slice(0, 40);
+ const deviceCode = token(32);
+ const now = Date.now();
+
+ // Retry on the (vanishingly unlikely) user_code collision.
+ let code;
+ for (let attempt = 0; attempt < 5 && !code; attempt++) {
+ const candidate = userCode();
+ const clash = await get(`SELECT user_code FROM cli_device_codes WHERE user_code = ? AND expires_at > ?`, [candidate, now]);
+ if (!clash) code = candidate;
+ }
+ if (!code) return res.status(503).json({ error: "could not allocate a user code — try again" });
+
+ await run(
+ `INSERT INTO cli_device_codes (device_code_hash,user_code,name,status,created_at,expires_at) VALUES (?,?,?,'pending',?,?)`,
+ [sha256(deviceCode), code, name, now, now + DEVICE_TTL_MS]
+ );
+ res.json({
+ device_code: deviceCode,
+ user_code: code,
+ verification_uri: `${config.origin}/cli/device`,
+ verification_uri_complete: `${config.origin}/cli/device?user_code=${encodeURIComponent(code)}`,
+ expires_in: Math.floor(DEVICE_TTL_MS / 1000),
+ interval: DEVICE_POLL_SECONDS
+ });
+});
+
+const devicePage = (req, body) =>
+ page({ title: "LogicSRC ▸ authorize CLI", body: `${appBar(req.user)}${body}${footer}` });
+
+const deviceResult = (req, res, status, heading, detail) =>
+ res.status(status).type("html").send(devicePage(req, `
+
${heading}
+
${detail}
+
`));
+
+cliRouter.get("/cli/device", requireAuth, async (req, res) => {
+ const code = normalizeUserCode(req.query.user_code);
+ const row = code ? await get(`SELECT * FROM cli_device_codes WHERE user_code = ?`, [code]) : null;
+ const pending = row && row.status === "pending" && row.expires_at > Date.now();
+
+ // No (or an unusable) code in the URL → ask the human to type the one their terminal is showing.
+ if (!pending) {
+ const problem = !code ? "" : !row ? "That code doesn't exist — check for typos."
+ : row.status !== "pending" ? "That code was already used."
+ : "That code expired — run logicsrc login again.";
+ return res.status(code ? 400 : 200).type("html").send(devicePage(req, `
+
🔑
+
Authorize the LogicSRC CLI
+
Enter the code shown in your terminal.
+ ${problem ? `
${problem}
` : ""}
+
+
`));
+ }
+
+ res.type("html").send(devicePage(req, `
+
🔑
+
Authorize the LogicSRC CLI
+
Grant ${esc(row.name || "logicsrc cli")} access to manage teams & encrypted credentials as ${esc(req.user.email || req.user.display_name)}.
+
${esc(row.user_code)}
+
Only approve this if the code matches the one in your terminal.
+
+
`));
+});
+
+cliRouter.post("/cli/device", requireAuth, async (req, res) => {
+ const code = normalizeUserCode(req.body?.user_code);
+ const deny = req.body?.action === "deny";
+ const row = code ? await get(`SELECT * FROM cli_device_codes WHERE user_code = ?`, [code]) : null;
+ if (!row) return deviceResult(req, res, 400, "Unknown code", "That code doesn't exist — check for typos.");
+ if (row.status !== "pending") return deviceResult(req, res, 400, "Already used", "That code was already approved or denied.");
+ if (row.expires_at < Date.now()) return deviceResult(req, res, 400, "Code expired", "Run logicsrc login again for a fresh code.");
+
+ await run(`UPDATE cli_device_codes SET status = ?, user_id = ? WHERE user_code = ?`, [deny ? "denied" : "approved", req.user.id, code]);
+ return deny
+ ? deviceResult(req, res, 200, "Denied", "Nothing was granted. You can close this tab.")
+ : deviceResult(req, res, 200, `You're in.`, "Return to your terminal — you can close this tab.");
+});
+
+cliRouter.post("/cli/device/token", async (req, res) => {
+ const deviceCode = req.body?.device_code;
+ if (!deviceCode) return res.status(400).json({ error: "invalid_request" });
+ const row = await get(`SELECT * FROM cli_device_codes WHERE device_code_hash = ?`, [sha256(String(deviceCode))]);
+ if (!row) return res.status(400).json({ error: "invalid_grant" });
+
+ const now = Date.now();
+ // Rate-limit impatient pollers, per the device-flow convention.
+ const tooSoon = row.last_polled_at && now - row.last_polled_at < (DEVICE_POLL_SECONDS - 1) * 1000;
+ await run(`UPDATE cli_device_codes SET last_polled_at = ? WHERE device_code_hash = ?`, [now, row.device_code_hash]);
+ if (tooSoon) return res.status(400).json({ error: "slow_down", interval: DEVICE_POLL_SECONDS });
+ if (row.status === "used") return res.status(400).json({ error: "invalid_grant" });
+ if (row.status === "denied") return res.status(400).json({ error: "access_denied" });
+ if (row.expires_at < now) return res.status(400).json({ error: "expired_token" });
+ if (row.status !== "approved") return res.status(400).json({ error: "authorization_pending", interval: DEVICE_POLL_SECONDS });
+
+ await run(`UPDATE cli_device_codes SET status = 'used' WHERE device_code_hash = ?`, [row.device_code_hash]);
+ const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
+ if (!user) return res.status(400).json({ error: "invalid_grant" });
+ const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli");
+ res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
+});
+
cliRouter.get("/api/me", async (req, res) => {
const user = await userForApiKey(bearer(req));
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md
index 4fc9051..64a48bf 100644
--- a/docs/credential-sharing.md
+++ b/docs/credential-sharing.md
@@ -179,8 +179,8 @@ re-wraps (seals) it to the new member's public key. The private key lives only i
### CLI
```bash
-# One-time: log in by email (registers this device's identity key).
-logicsrc login --email you@example.com
+# One-time: log in through your browser (registers this device's identity key).
+logicsrc login
# Owner: create a team, push a local .env into an encrypted vault, invite people.
logicsrc teams create acme --name "Acme Inc"
@@ -188,7 +188,7 @@ logicsrc teams push acme prod --env .env # encrypt + upload
logicsrc teams invite acme teammate@example.com # emails an accept link
# Teammate: accept, then get granted, then pull + decrypt locally.
-logicsrc login --email teammate@example.com
+logicsrc login
logicsrc teams accept
# …an existing member runs: logicsrc teams grant acme prod teammate@example.com
logicsrc teams pull acme prod --env .env # download + decrypt
@@ -199,6 +199,21 @@ logicsrc teams members acme
logicsrc teams vaults acme
```
+`logicsrc login` picks its flow from the machine it runs on:
+
+- **Has its own browser** → loopback OAuth-PKCE: a `127.0.0.1` listener catches
+ the callback. Force it with `--web`.
+- **No browser** (SSH, droplet, container, CI) → device authorization: the CLI
+ prints a short code, you approve it from a browser on any other machine.
+ Force it with `--device`. A loopback redirect would be useless here — the
+ browser's `127.0.0.1` is not the CLI's machine.
+- **Unattended** → `--token lsk_…` from **Settings ▸ API keys**.
+
+It talks to the hosted credentials app by default. Point it elsewhere (local dev,
+self-hosted) with `LOGICSRC_API=http://localhost:8080 logicsrc login` or
+`logicsrc login --api-url …`; the chosen origin is remembered in
+`~/.logicsrc/identity.json` once login succeeds.
+
Because `team` is a normal provider, the generic sync surface works too — e.g.
`logicsrc credentials plan --from env --from-path .env --to team --to-project acme
--to-config prod`, then `diff`, `sync`, `audit`, and `rollback` behave exactly as
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 87c9f90..2f61bd5 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -80,11 +80,13 @@ program.action(async (options) => {
program
.command("login")
- .option("--api-url ", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL)
+ .option("--api-url ", "LogicSRC credentials app URL (default: $LOGICSRC_API, else the hosted app)")
.option("--token ", "Use an existing API key instead of the browser flow (CI)")
- .description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.")
+ .option("--device", "Force the device-code flow (approve from a browser on another machine)")
+ .option("--web", "Force the loopback browser flow (needs a browser on THIS machine)")
+ .description("Log in via your browser for team credential sharing; registers your device identity key.")
.action(async (options) => {
- await loginAction({ apiUrl: options.apiUrl, token: options.token });
+ await loginAction({ apiUrl: options.apiUrl, token: options.token, device: options.device, web: options.web });
});
program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {
diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts
index 5d95047..9ab3b92 100644
--- a/packages/cli/src/teams.ts
+++ b/packages/cli/src/teams.ts
@@ -10,6 +10,7 @@ import {
updateIdentity,
requireAuth,
defaultApiUrl,
+ resolveApiUrl,
createCredentialEngine,
unwrapVaultKey,
wrapVaultKey,
@@ -25,12 +26,24 @@ import { print, type OutputFormat } from "./format.js";
function authedClient(): { client: TeamClient; identity: ReturnType } {
const identity = requireAuth();
- const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
+ const client = new TeamClient({ apiUrl: resolveApiUrl(identity), token: identity.apiToken });
return { client, identity };
}
const b64url = (buf: Buffer): string => buf.toString("base64url");
+/**
+ * Can a browser on THIS machine reach a loopback server on THIS machine?
+ * Over SSH (or in a container/CI) it can't — the human's browser is elsewhere,
+ * so its 127.0.0.1 is a different machine and the callback never arrives.
+ */
+function hasLocalBrowser(): boolean {
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
+ if (process.env.CI) return false;
+ if (process.platform === "darwin" || process.platform === "win32") return true;
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
+}
+
function openBrowser(url: string): void {
const [cmd, args] =
process.platform === "darwin" ? ["open", [url]]
@@ -102,6 +115,67 @@ function loopbackLogin(apiUrl: string, timeoutMs = 180000): Promise<{ token: str
});
}
+interface LoginResult { token: string; email: string | null; userId?: string }
+
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Device-authorization login — for machines with no browser of their own.
+ * We print a short code; the human approves it from any browser, anywhere.
+ */
+async function deviceLogin(apiUrl: string, timeoutMs = 600000): Promise {
+ const base = apiUrl.replace(/\/+$/, "");
+ const startRes = await fetch(`${base}/cli/device/code`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: `logicsrc cli @ ${hostname()}` })
+ });
+ if (startRes.status === 404) throw new DeviceFlowUnsupported();
+ if (!startRes.ok) throw new Error(`could not start device login (${startRes.status})`);
+ const start = (await startRes.json()) as {
+ device_code: string; user_code: string; verification_uri: string;
+ verification_uri_complete?: string; expires_in?: number; interval?: number;
+ };
+
+ console.error("\n🔑 Authorize the LogicSRC CLI from any browser:");
+ console.error(` 1. open ${start.verification_uri}`);
+ console.error(` 2. enter the code: ${start.user_code}\n`);
+ if (hasLocalBrowser() && start.verification_uri_complete) openBrowser(start.verification_uri_complete);
+
+ let interval = Math.max(1, start.interval ?? 5);
+ const deadline = Date.now() + Math.min(timeoutMs, (start.expires_in ?? 600) * 1000);
+ process.stderr.write(" waiting for approval…");
+ try {
+ while (Date.now() < deadline) {
+ await sleep(interval * 1000);
+ const res = await fetch(`${base}/cli/device/token`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ device_code: start.device_code })
+ });
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string; interval?: number; access_token?: string; user?: { email?: string; id?: string };
+ };
+ if (res.ok && body.access_token) {
+ return { token: body.access_token, email: body.user?.email ?? null, userId: body.user?.id };
+ }
+ if (body.error === "authorization_pending") { process.stderr.write("."); continue; }
+ if (body.error === "slow_down") { interval = Math.max(interval + 2, body.interval ?? interval); continue; }
+ if (body.error === "access_denied") throw new Error("authorization was denied in the browser");
+ if (body.error === "expired_token") throw new Error("the code expired — run `logicsrc login` again");
+ throw new Error(`device login failed (${body.error || res.status})`);
+ }
+ } finally {
+ process.stderr.write("\n");
+ }
+ throw new Error("login timed out — run `logicsrc login` again");
+}
+
+/** Thrown when the server predates the device flow, so we can fall back. */
+class DeviceFlowUnsupported extends Error {
+ constructor() { super("device flow not supported by this server"); }
+}
+
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise {
const { vaults } = await client.listVaults(slug);
const found = vaults.find((v) => v.name === vault);
@@ -109,11 +183,12 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string):
return found.id;
}
-export async function loginAction(options: { apiUrl?: string; token?: string }): Promise {
+export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise {
const identity = await loadOrCreateIdentity();
- const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, "");
+ const apiUrl = resolveApiUrl(identity, options.apiUrl);
- // Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI.
+ // --token for CI; otherwise a browser flow: loopback OAuth-PKCE when this
+ // machine has its own browser, device-code when it doesn't (SSH, containers).
let token = options.token;
let email: string | null = null;
let userId: string | undefined;
@@ -123,7 +198,20 @@ export async function loginAction(options: { apiUrl?: string; token?: string }):
email = me.user.email;
userId = me.user.id;
} else {
- const result = await loopbackLogin(apiUrl);
+ const useDevice = options.device ?? (options.web ? false : !hasLocalBrowser());
+ let result: LoginResult;
+ if (useDevice) {
+ try {
+ result = await deviceLogin(apiUrl);
+ } catch (error) {
+ if (!(error instanceof DeviceFlowUnsupported)) throw error;
+ console.error("⚠️ This server has no device flow — falling back to the loopback flow.");
+ console.error(" If your browser is on another machine, forward the callback port over SSH.");
+ result = await loopbackLogin(apiUrl);
+ }
+ } else {
+ result = await loopbackLogin(apiUrl);
+ }
token = result.token;
email = result.email;
userId = result.userId;
@@ -145,12 +233,12 @@ export async function logoutAction(): Promise {
export async function whoamiAction(format: OutputFormat): Promise {
const identity = readIdentity();
if (!identity?.apiToken) {
- print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login --email you@example.com" }, format);
+ print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login" }, format);
return;
}
const { client } = authedClient();
const me = await client.me();
- print({ loggedIn: true, email: me.user.email, apiUrl: identity.apiUrl, publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format);
+ print({ loggedIn: true, email: me.user.email, apiUrl: resolveApiUrl(identity), publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format);
}
export async function teamsCreateAction(slug: string, options: { name?: string; format: OutputFormat }): Promise {
diff --git a/plugins/credential-sharing/src/identity.ts b/plugins/credential-sharing/src/identity.ts
index e3ce6ad..a5f91d9 100644
--- a/plugins/credential-sharing/src/identity.ts
+++ b/plugins/credential-sharing/src/identity.ts
@@ -38,8 +38,37 @@ export function identityPath(): string {
: join(logicsrcHome(), "identity.json");
}
+/**
+ * The hosted LogicSRC credentials app (`apps/pwa`) — where `logicsrc login`
+ * goes when nothing else is configured. This is deliberately NOT logicsrc.com:
+ * that origin serves the marketing site and has no /cli routes.
+ */
+export const DEFAULT_API_URL = "https://logicsrc-credentials-production.up.railway.app";
+
+/** An explicitly configured API origin, if any. `LOGICSRC_API` is the documented one. */
+export function envApiUrl(): string | undefined {
+ return (
+ process.env.LOGICSRC_API ||
+ process.env.LOGICSRC_API_URL ||
+ // Legacy: CommandBoard is a different service, so this is the last resort.
+ process.env.COMMANDBOARD_API_URL ||
+ undefined
+ );
+}
+
export function defaultApiUrl(): string {
- return process.env.COMMANDBOARD_API_URL || process.env.LOGICSRC_API_URL || "http://localhost:4010";
+ return envApiUrl() || DEFAULT_API_URL;
+}
+
+/**
+ * Resolve the API origin for a command: an explicit `--api-url` wins, then the
+ * environment, then whatever a *logged-in* identity was registered against.
+ * A stored URL from an identity that never completed login is ignored — that
+ * is how machines got stuck pointing at a dev `localhost` server.
+ */
+export function resolveApiUrl(identity?: Pick, override?: string): string {
+ const stored = identity?.apiToken ? identity.apiUrl : undefined;
+ return (override || envApiUrl() || stored || DEFAULT_API_URL).replace(/\/+$/, "");
}
function writeSecure(file: string, data: unknown): void {
@@ -96,7 +125,7 @@ export async function updateIdentity(
export function requireAuth(file = identityPath()): LocalIdentity & { apiToken: string; email: string } {
const identity = readIdentity(file);
if (!identity?.apiToken || !identity.email) {
- throw new Error('Not logged in. Run "logicsrc login --email you@example.com" first.');
+ throw new Error('Not logged in. Run "logicsrc login" first.');
}
return identity as LocalIdentity & { apiToken: string; email: string };
}
diff --git a/plugins/credential-sharing/src/index.ts b/plugins/credential-sharing/src/index.ts
index 23e585c..e43bfe9 100644
--- a/plugins/credential-sharing/src/index.ts
+++ b/plugins/credential-sharing/src/index.ts
@@ -89,6 +89,9 @@ export {
identityPath,
logicsrcHome,
defaultApiUrl,
+ envApiUrl,
+ resolveApiUrl,
+ DEFAULT_API_URL,
type LocalIdentity
} from "./identity.js";
export {