Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode logout` | account | clear the logged-in account |
| `moshcode save` | account | save this machine's pit settings to your account |
| `moshcode load` | account | bring your saved pit settings onto this machine |
| `moshcode export` | account | operator only: export the app's users as CSV, optionally cleaned |
| `moshcode console` | account | serve or connect to the browser terminal |
| `moshcode dns` | hosting | resolve Moshpit names on this machine |
| `moshcode name` | hosting | prove you hold a Moshpit name, so an app can use it as your identity |
Expand Down Expand Up @@ -1592,6 +1593,37 @@ MOSHCODE_NO_AUTOSYNC=1 moshcode # turn it off for this pit
MOSHCODE_AUTOSYNC_MS=900000 moshcode # every fifteen minutes instead
```

## Exporting users (`moshcode export users`, operators only)

The app's operators can pull the list of accounts that signed up with an email,
without touching the database:

```sh
moshcode export users -o users.csv # email, display_name, created_at, id, signup_method
moshcode export users --format json -o users.json # the same rows, plus counts
moshcode export users --clean -o users.csv # only the addresses worth mailing
```

Who counts as an operator is decided by the app, not the CLI: the account you
are logged in as (`moshcode login`) must be listed in `ADMIN_EMAILS` on
app.moshcode.sh. Everyone else gets a 403. Accounts with no email (passkey or
CoinPay sign-ups that never added one) are counted, never listed.

`--clean` pipes the CSV through `email-cleaner` from
[cli-tools](https://github.com/profullstack/cli-tools)
(`moshcode install cli-tools`). If it is not on your PATH the export fails
instead of quietly skipping the cleaning. The valid rows come out in the same
columns; with `-o users.csv` the rejected ones go to `users.rejected.csv` with a
`reasons` column. Any of email-cleaner's own flags given after `--clean`
(`--allow-role`, `--allow-disposable`, `--allow-duplicates`, `--allow-unlikely`,
`--allow-no-website`, `--no-dns`, `--fix-typos`) are passed straight through.

Files are written owner-readable only (0600), and a file already at that path
is copied to `<name>.bak-NNN.csv` first. Counts and paths go to stderr, so
stdout stays pure CSV for a pipe. In the pit, `/export users` always writes a
file (`~/.moshcode/exports/` unless you give `-o`) and prints only where it
went and the counts, never the addresses.

## Browser terminal (`moshcode console`)

A real terminal in the browser — arrow keys, history, full-screen TUIs — because
Expand Down
2 changes: 2 additions & 0 deletions apps/pwa/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ DATABASE_AUTH_TOKEN=

# auth
SESSION_SECRET=change-me-32-bytes-hex
# operators: account emails allowed to call /api/admin/* (comma-separated; unset = nobody)
ADMIN_EMAILS=

# CLI ↔ app signed approval ingest (must match the moshcode CLI's MOSHCODE_WEBHOOK_SECRET)
MOSHCODE_WEBHOOK_SECRET=change-me
Expand Down
4 changes: 4 additions & 0 deletions apps/pwa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ CLI: `doppler run -- npm start`.
+ token), `SESSION_SECRET`, `MOSHCODE_WEBHOOK_SECRET`, `RESEND_API_KEY`,
`PUBLIC_ORIGIN=https://app.moshcode.sh`, `MCP_PUBLIC_ORIGIN=https://moshcode.sh`,
and the `COINPAY_*` values.
- `ADMIN_EMAILS`: comma-separated account emails allowed to call `/api/admin/*`
(the operator user export behind `moshcode export users`). Unset means
nobody: every admin route answers 403.
- Point the domain **app.moshcode.sh** at the service. Root `moshcode.sh` stays a
marketing site, but must proxy `/.well-known/oauth-*`, `/oauth/*`, `/device`,
and `/api/v1/mcp/*` to this service so canonical MCP URLs work at the apex.
Expand All @@ -51,6 +54,7 @@ CLI: `doppler run -- npm start`.
| `GET/POST /oauth/authorize` | user | authorize one MCP client, share, and scope set |
| `POST /oauth/device_authorization` | MCP client | begin RFC 8628 device authorization |
| `POST /webhooks/coinpay` | CoinPay | confirm a top-up → credit balance |
| `GET /api/admin/users/export?format=csv\|json` | operator (Bearer key or session, `ADMIN_EMAILS`) | every account with an email: email, display_name, created_at, id, signup_method; counts of the rest |
| `GET /healthz` | Railway | health check |

## Wiring the CLI
Expand Down
16 changes: 16 additions & 0 deletions apps/pwa/src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,21 @@ export const config = {
get coinpayLoginEnabled() {
return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId);
},
/**
* Operators: the accounts allowed to reach /api/admin/*.
*
* `ADMIN_EMAILS`, a comma- or space-separated list of account emails. Read
* on every call rather than once at boot, so a test (or a restart-free env
* change on a dev box) sees the current value. Unset means nobody is an
* operator, which is the safe default: the admin routes answer 403 to all.
*/
get adminEmails() {
return parseAdminEmails(process.env.ADMIN_EMAILS);
},
secure: (process.env.NODE_ENV || "development") === "production",
};

/** `"a@x.com, B@y.com"` to a Set of lowercased addresses. Blank entries dropped. */
export function parseAdminEmails(value = "") {
return new Set(String(value || "").split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.includes("@")));
}
94 changes: 94 additions & 0 deletions apps/pwa/src/lib/user-export.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// The operator's user export: who signed up, with what, and when.
//
// Deliberately narrow. The query below names the columns it reads, and the two
// secrets on the users row (password_hash, coinpay_sub) are only ever asked
// about as "is it set", never selected. A `SELECT *` here would put a password
// hash one refactor away from a CSV on somebody's laptop.
import { all } from "../db.mjs";
import { config } from "../config.mjs";

/** Column order of the CSV, and of each JSON row. The CLI relies on it. */
export const EXPORT_COLUMNS = ["email", "display_name", "created_at", "id", "signup_method"];

/** Is this account an operator? Only an allowlisted email is. */
export function isAdmin(user, allow = config.adminEmails) {
const email = String(user?.email || "").trim().toLowerCase();
return Boolean(email) && allow.has(email);
}

/**
* How the account was created, from what the row carries.
*
* CoinPay first: a CoinPay account never has a password, and its email (when
* it has one) was added later. Then password, which only the email form sets.
* An account with neither was created by a passkey ceremony; "unknown" is for
* a row that has none of the three, which should not exist but would otherwise
* be reported as something it is not.
*/
export function signupMethod({ has_coinpay, has_password, has_passkey }) {
if (Number(has_coinpay)) return "coinpay";
if (Number(has_password)) return "password";
if (Number(has_passkey)) return "passkey";
return "unknown";
}

/**
* One CSV field. Quoted when it has to be, and a display name that starts like
* a spreadsheet formula gets a leading apostrophe: names are typed by strangers,
* and this file is exactly the kind that gets opened in a spreadsheet.
*/
export function csvField(value, { neutralize = false } = {}) {
let s = value === null || value === undefined ? "" : String(value);
if (neutralize && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

/** Rows (objects keyed by EXPORT_COLUMNS, plus any extra columns) to CSV text. */
export function toCsv(rows, columns = EXPORT_COLUMNS) {
const lines = [columns.join(",")];
for (const row of rows) {
lines.push(columns.map((c) => csvField(row[c], { neutralize: c === "display_name" })).join(","));
}
return `${lines.join("\n")}\n`;
}

/**
* Every account with an email, oldest first, plus counts of the ones without.
*
* `{ users: [{ email, display_name, created_at, id, signup_method }], counts }`
* where created_at is ISO 8601. Accounts without an email are counted, by
* signup method, and never listed: there is nothing on them to export.
*/
export async function exportUsers() {
const rows = await all(
`SELECT u.id, u.email, u.display_name, u.created_at,
CASE WHEN u.password_hash IS NOT NULL AND u.password_hash <> '' THEN 1 ELSE 0 END AS has_password,
CASE WHEN u.coinpay_sub IS NOT NULL AND u.coinpay_sub <> '' THEN 1 ELSE 0 END AS has_coinpay,
CASE WHEN EXISTS (SELECT 1 FROM webauthn_credentials w WHERE w.user_id = u.id) THEN 1 ELSE 0 END AS has_passkey
FROM users u
ORDER BY u.created_at ASC, u.id ASC`,
);
const users = [];
const withoutEmail = { total: 0, password: 0, passkey: 0, coinpay: 0, unknown: 0 };
for (const r of rows) {
const method = signupMethod(r);
const email = String(r.email ?? "").trim();
if (!email) {
withoutEmail.total += 1;
withoutEmail[method] += 1;
continue;
}
const created = Number(r.created_at);
users.push({
email,
display_name: r.display_name ?? "",
created_at: Number.isFinite(created) ? new Date(created).toISOString() : "",
id: r.id,
signup_method: method,
});
}
return {
users,
counts: { total: rows.length, with_email: users.length, without_email: withoutEmail.total, without_email_by_method: withoutEmail },
};
}
52 changes: 52 additions & 0 deletions apps/pwa/src/routes/admin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Operator-only routes.
//
// GET /api/admin/users/export?format=csv|json every account with an email
//
// "Operator" is an allowlist, ADMIN_EMAILS, checked against the account behind
// the request: a CLI API key (what `moshcode export users` sends) or the
// browser session. Nobody else gets anything but a 403, and an unset allowlist
// means that is everybody.
import { Router } from "express";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { EXPORT_COLUMNS, exportUsers, isAdmin, toCsv } from "../lib/user-export.mjs";

export const adminRouter = Router();

/**
* Resolve the caller and require an operator.
*
* A Bearer header is authoritative when present: a request that sends a bad
* key is refused even if it also carries a session cookie, so a script never
* succeeds by accident on the strength of a browser login.
*/
export async function requireAdmin(req, res, next) {
try {
const sent = bearer(req);
const user = sent ? await userForApiKey(sent) : req.user;
if (!user) return res.status(401).json({ error: "sign in, or send an API key: Authorization: Bearer mck_..." });
if (!isAdmin(user)) return res.status(403).json({ error: "operator access is required" });
req.adminUser = user;
next();
} catch (error) {
next(error);
}
}

adminRouter.get("/api/admin/users/export", requireAdmin, async (req, res, next) => {
try {
const format = String(req.query.format || "csv").toLowerCase();
if (!["csv", "json"].includes(format)) return res.status(400).json({ error: "format must be csv or json" });
const { users, counts } = await exportUsers();
// An address list is the last thing a shared cache should keep.
res.set("Cache-Control", "no-store");
console.log(`[admin] ${req.adminUser.email} exported ${users.length} users (${format})`);
if (format === "json") return res.json({ columns: EXPORT_COLUMNS, users, counts });
res.set("X-Users-Total", String(counts.total));
res.set("X-Users-With-Email", String(counts.with_email));
res.set("X-Users-Without-Email", String(counts.without_email));
res.set("Content-Disposition", `attachment; filename="moshcode-users-${new Date().toISOString().slice(0, 10)}.csv"`);
res.type("text/csv; charset=utf-8").send(toCsv(users));
} catch (error) {
next(error);
}
});
2 changes: 2 additions & 0 deletions apps/pwa/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { pagesRouter } from "./routes/pages.mjs";
import { settingsSyncRouter } from "./routes/settings-sync.mjs";
import { moshpitRouter } from "./routes/moshpit.mjs";
import { socialsRouter } from "./routes/socials.mjs";
import { adminRouter } from "./routes/admin.mjs";
import { MAX_BATCH, MAX_PUBLISH_BYTES } from "./lib/moshpit-content.mjs";
import { endExpiredLeases, expireOffers } from "./moshpit.mjs";

Expand Down Expand Up @@ -81,6 +82,7 @@ app.use(coinpayRouter);
app.use(approvalsRouter);
app.use(creditsRouter);
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
app.use(adminRouter); // /api/admin/*, ADMIN_EMAILS operators only
app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions
app.use(organizationsRouter); // organizations, teams, membership, and session sharing
app.use(mcpOAuthBrowserRouter); // /oauth/authorize — logged-in consent + CSRF
Expand Down
122 changes: 122 additions & 0 deletions apps/pwa/test/admin-users-export.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// GET /api/admin/users/export: operators only, and never a secret column.
//
// Boots the real router against a throwaway libsql file, the same way
// settings-sync.test.mjs does, and skips cleanly when the PWA dependencies are
// not installed (the root `node --test` in CI never installs them).
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import test from "node:test";

const require = createRequire(import.meta.url);
let deps = null;
try {
deps = { express: require("express"), cookieParser: require("cookie-parser") };
} catch {
deps = null;
}
const skip = deps ? false : "PWA dependencies are not installed";

const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-admin-export-test-"));
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
process.env.SESSION_SECRET = "test-secret";

let booted = null;
async function boot() {
if (booted) return booted;
const { migrate } = await import("../src/migrate.mjs");
await migrate();
const { run } = await import("../src/db.mjs");
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
const { adminRouter } = await import("../src/routes/admin.mjs");
const { createApiKey } = await import("../src/lib/apikey.mjs");

const app = deps.express();
app.use(deps.express.json());
app.use(deps.cookieParser());
app.use(sessionMiddleware);
app.use(csrfGuard);
app.use(adminRouter);
const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;

// The operator, signed up with a password.
await run(`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES ('op','Boss@Example.com','scrypt$SECRETHASH','boss',1700000000000)`);
// A regular user.
await run(`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES ('u1','fan@example.org','scrypt$OTHERHASH','=cmd|calc,"x"',1700000001000)`);
// A passkey account that later added an email.
await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('pk','key@example.net','keys',1700000002000)`);
await run(`INSERT INTO webauthn_credentials (id, user_id, public_key, created_at) VALUES ('cred1','pk','pub',1)`);
// CoinPay accounts: one with an email, one without.
await run(`INSERT INTO users (id, email, coinpay_sub, display_name, created_at) VALUES ('cp','coin@example.com','coinpay-SUB-SECRET','coin',1700000003000)`);
await run(`INSERT INTO users (id, coinpay_sub, display_name, created_at) VALUES ('cp2','coinpay-SUB-2','anon',1700000004000)`);
// A passkey account with no email at all.
await run(`INSERT INTO users (id, display_name, created_at) VALUES ('pk2','ghost',1700000005000)`);
await run(`INSERT INTO webauthn_credentials (id, user_id, public_key, created_at) VALUES ('cred2','pk2','pub',1)`);

const opKey = (await createApiKey("op", "cli")).plaintext;
const userKey = (await createApiKey("u1", "cli")).plaintext;
const call = (key, route) => fetch(`${base}${route}`, { headers: key ? { authorization: `Bearer ${key}` } : {} });
booted = { server, call, opKey, userKey };
return booted;
}

test.after(() => booted?.server.close());

test("no key is a 401, a non-operator is a 403, and an unset allowlist refuses everyone", { skip }, async () => {
const { call, opKey, userKey } = await boot();
process.env.ADMIN_EMAILS = "boss@example.com";
assert.equal((await call(null, "/api/admin/users/export")).status, 401);
assert.equal((await call("mck_not_a_key", "/api/admin/users/export")).status, 401);
const denied = await call(userKey, "/api/admin/users/export?format=json");
assert.equal(denied.status, 403);
const body = await denied.text();
assert.ok(!body.includes("@"), "a refusal must not leak an address");

delete process.env.ADMIN_EMAILS;
assert.equal((await call(opKey, "/api/admin/users/export")).status, 403, "unset ADMIN_EMAILS means nobody is an operator");
process.env.ADMIN_EMAILS = "someone-else@example.com";
assert.equal((await call(opKey, "/api/admin/users/export")).status, 403);
});

test("the CSV has the documented columns, only emailed rows, and no secrets", { skip }, async () => {
const { call, opKey } = await boot();
process.env.ADMIN_EMAILS = " other@x.io , BOSS@example.com ";
const res = await call(opKey, "/api/admin/users/export?format=csv");
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type"), /text\/csv/);
assert.equal(res.headers.get("cache-control"), "no-store");
assert.equal(res.headers.get("x-users-total"), "6");
assert.equal(res.headers.get("x-users-without-email"), "2");
const text = await res.text();
const lines = text.trimEnd().split("\n");
assert.equal(lines[0], "email,display_name,created_at,id,signup_method");
assert.equal(lines.length, 5, "four accounts have an email");
assert.equal(lines[1], "Boss@Example.com,boss,2023-11-14T22:13:20.000Z,op,password");
// A formula-looking name is neutralised and quoted, not executed.
assert.equal(lines[2], `fan@example.org,"'=cmd|calc,""x""",2023-11-14T22:13:21.000Z,u1,password`);
assert.equal(lines[3], "key@example.net,keys,2023-11-14T22:13:22.000Z,pk,passkey");
assert.equal(lines[4], "coin@example.com,coin,2023-11-14T22:13:23.000Z,cp,coinpay");
for (const secret of ["SECRETHASH", "OTHERHASH", "coinpay-SUB", "password_hash", "coinpay_sub"]) {
assert.ok(!text.includes(secret), `the export leaked ${secret}`);
}
});

test("JSON carries the same rows plus counts of users without an email", { skip }, async () => {
const { call, opKey } = await boot();
process.env.ADMIN_EMAILS = "boss@example.com";
const res = await call(opKey, "/api/admin/users/export?format=json");
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.columns, ["email", "display_name", "created_at", "id", "signup_method"]);
assert.equal(body.users.length, 4);
for (const u of body.users) assert.deepEqual(Object.keys(u), body.columns);
assert.deepEqual(body.counts, {
total: 6, with_email: 4, without_email: 2,
without_email_by_method: { total: 2, password: 0, passkey: 1, coinpay: 1, unknown: 0 },
});
assert.ok(!JSON.stringify(body).includes("SECRET"));
assert.equal((await call(opKey, "/api/admin/users/export?format=xml")).status, 400);
});
7 changes: 7 additions & 0 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,13 @@ async function main() {
return;
}
if (cmd === "logout") { logout(); return; }
// Operator-only, and the app is what enforces that: a non-operator key gets
// a 403 back. Lazy like fleet: nothing else needs it.
if (cmd === "export") {
const { exportCommand } = await import("../src/export-users.mjs");
process.exitCode = await exportCommand(rest);
return;
}
if (cmd === "save") { process.exitCode = await saveCommand(rest); return; }
if (cmd === "load") { process.exitCode = await loadCommand(rest); return; }
if (cmd === "run") {
Expand Down
Loading
Loading