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
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@ Stale saves are rejected so concurrent edits cannot silently overwrite each othe
Owners can restore prior versions, pin links to a version, and revoke links.

```sh
readm3 login --token-stdin # token from /admin → API tokens
readm3 login --coinpay # or sign in with CoinPay in the browser
readm3 login # approve in the browser; over SSH, paste the code it shows
readm3 login --token-stdin # or a token from /admin → API tokens
readm3 whoami # the account, and its linked CoinPay account
readm3 orgs list
readm3 share README.md --org ORG_ID # view link by default; without --org, your personal workspace
Expand Down Expand Up @@ -539,11 +539,21 @@ unlinked on `/account` when it also has an email; an account that signs in only
CoinPay cannot be, since that would lock it out. Links, unlinks, sign-ins and app calls
are recorded in `identity_events`; identities are in `user_identities`.

`readm3 login --coinpay` runs the same grant from the terminal against a loopback
redirect (`http://127.0.0.1:PORT/callback`, any port), then
`POST /api/v1/coinpay/oauth/cli-exchange {code, code_verifier, redirect_uri}` has
readm3 make the exchange and return an ordinary 90-day readm3 API token, saved like
`login --token-stdin`. Set `READM3_NO_BROWSER=1` to print the link without opening it.
### CLI sign-in (OAuth 2.1)

`readm3 login` signs the CLI in with readm3's own OAuth 2.1 server
([`@profullstack/auth-system/oauth2`](https://www.npmjs.com/package/@profullstack/auth-system),
client `readm3-cli`): authorization code + PKCE S256 only, access tokens for an hour,
refresh tokens that rotate on every use, and a reused refresh token revokes the whole
sign-in. The browser step is `/oauth/authorize`, which sends a signed-out browser
through `/account` (email or CoinPay) and then asks to approve. Where the CLI can open a
browser on the same machine it waits on a loopback redirect (`http://127.0.0.1:PORT/callback`);
over SSH, or with `--no-browser` / `READM3_NO_BROWSER=1`, it prints the link to open in
any browser and `/oauth/cli` shows a code to paste back. Tokens are kept in `cloud.json`
(0600) and refreshed by every command; `readm3 logout` revokes them on the server.
`readm3 login --coinpay` is the same sign-in. Metadata is at
`/.well-known/oauth-authorization-server`; tokens at `/oauth/token`, revocation at `/oauth/revoke`.
`POST /api/v1/coinpay/oauth/cli-exchange` remains for CLI versions before 0.14.0.

| Endpoint | Purpose |
| --- | --- |
Expand Down Expand Up @@ -637,7 +647,7 @@ The CLI, document commands, and MCP use one credential file, `cloud.json`, under
in `/admin` to invalidate remote copies. Tokens can also be created and revoked from the account page.

```sh
readm3 login --token-stdin # paste a token, then Ctrl+D
readm3 login # or --token-stdin: paste a token, then Ctrl+D
readm3 whoami
readm3 settings --theme nord --flavor github
readm3 save ./notes # explicitly import and save Markdown
Expand Down
37 changes: 36 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "readm3-desktop",
"productName": "readm3",
"version": "0.13.2",
"version": "0.14.0",
"private": true,
"description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/readm3",
"version": "0.13.2",
"version": "0.14.0",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -43,6 +43,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@profullstack/auth-system": "^0.8.3",
"@profullstack/footer": "latest",
"@profullstack/hqtui": "^0.5.0",
"@profullstack/synconfig": "^0.1.3",
Expand Down
22 changes: 21 additions & 1 deletion server/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ export const IDENTITY_SCHEMA = `
CREATE INDEX IF NOT EXISTS user_identities_user ON user_identities(userId);
CREATE TABLE IF NOT EXISTS identity_events (id TEXT PRIMARY KEY, userId TEXT REFERENCES users(id) ON DELETE SET NULL, provider TEXT NOT NULL, providerUserId TEXT NOT NULL, event TEXT NOT NULL CHECK(event IN ('link','unlink','signin','api')), clientId TEXT, createdAt TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS identity_events_user ON identity_events(userId, createdAt);`;
/**
* OAuth 2.1 codes and tokens for `readm3 login` (server/oauth.ts, @profullstack/auth-system/oauth2).
* Only SHA-256 hashes are stored. Shared with server/store.ts like IDENTITY_SCHEMA.
*/
export const OAUTH_SCHEMA = `
CREATE TABLE IF NOT EXISTS oauth_codes (codeHash TEXT PRIMARY KEY, userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, clientId TEXT NOT NULL, redirectUri TEXT NOT NULL, codeChallenge TEXT NOT NULL, scope TEXT NOT NULL, expiresAt TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS oauth_tokens (tokenHash TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('access','refresh')), userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, clientId TEXT NOT NULL, scope TEXT NOT NULL, familyId TEXT NOT NULL, expiresAt TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, used INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS oauth_tokens_family ON oauth_tokens(familyId);`;
/** Access tokens from `readm3 login` look like r3_at_<43 chars>; session and API tokens are exactly 43 characters. */
export const OAUTH_ACCESS_PREFIX = "r3_at_";
export const isOAuthAccessToken = (token: string) => token.length > 43 && token.startsWith(OAUTH_ACCESS_PREFIX);
/** Joins the users row `u` for a live OAuth access token (bind: tokenHash, now). */
export const OAUTH_ACCESS_JOIN = "oauth_tokens t JOIN users u ON u.id=t.userId";
export const OAUTH_ACCESS_WHERE = "t.tokenHash=? AND t.kind='access' AND t.revoked=0 AND t.expiresAt>?";
/** Where a sign-in may send the browser afterwards: same-origin pages only. */
export const SAFE_NEXT = /^\/(?:admin|viewer|account|oauth\/authorize)(?:\?|$)/;
/** A user may authenticate with a verified email, or with a linked identity. Email alone stays the only way to create one by mail. */
export const VERIFIED_IDENTITY = "(EXISTS (SELECT 1 FROM account_emails ae WHERE ae.userId=u.id) OR EXISTS (SELECT 1 FROM user_identities ui WHERE ui.userId=u.id))";

Expand All @@ -49,6 +65,7 @@ export class Accounts {
CREATE INDEX IF NOT EXISTS email_challenges_email ON email_challenges(email);
CREATE TABLE IF NOT EXISTS account_rate_limits (key TEXT PRIMARY KEY, count INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
${IDENTITY_SCHEMA}
${OAUTH_SCHEMA}
`);
}
private rate(key: string, limit: number, seconds: number) {
Expand All @@ -66,6 +83,9 @@ export class Accounts {
}
/** Only verified identities (an email, or a linked CoinPay account) are returned. Use this for every workspace API. */
authenticate(token: string): Account | null {
if (isOAuthAccessToken(token))
return this.db.query(`SELECT ${publicColumns} FROM ${OAUTH_ACCESS_JOIN} LEFT JOIN account_emails e ON e.userId=u.id
WHERE ${OAUTH_ACCESS_WHERE} AND ${VERIFIED_IDENTITY}`).get(hash(token), stamp()) as Account | null;
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return this.db.query(`SELECT ${publicColumns} FROM sessions s JOIN users u ON u.id=s.userId
LEFT JOIN account_emails e ON e.userId=u.id WHERE s.tokenHash=? AND s.expiresAt>? AND ${VERIFIED_IDENTITY}`).get(hash(token), stamp()) as Account | null;
Expand All @@ -90,7 +110,7 @@ export class Accounts {
return row;
}
private async requestLink(input: unknown, ip: string, requestedNext?: unknown) {
const next = typeof requestedNext === "string" && /^\/(?:admin|viewer)(?:\?|$)/.test(requestedNext) ? requestedNext : null;
const next = typeof requestedNext === "string" && SAFE_NEXT.test(requestedNext) ? requestedNext : null;
this.rate(`send-ip:${ip}`, 20, 3600);
const email = typeof input === "string" ? input.trim().toLowerCase() : "";
if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(email) || email.split("@")[0].length > 64) {
Expand Down
4 changes: 2 additions & 2 deletions server/coinpay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* CoinPay identity to an existing account and never becomes an account email:
* an identity that is not linked yet is a new user, never a merge.
*/
import type { Accounts } from "./accounts.ts";
import { SAFE_NEXT, type Accounts } from "./accounts.ts";
import {
Store,
HttpError,
Expand Down Expand Up @@ -366,7 +366,7 @@ export class CoinPay {
this.require();
const requested = new URL(request.url).searchParams.get("next");
const next =
requested && /^\/(?:admin|viewer|account)(?:\?|$)/.test(requested)
requested && SAFE_NEXT.test(requested)
? requested
: null;
const state = secret();
Expand Down
Loading
Loading