Skip to content

Commit 521a1f5

Browse files
ralyodioclaude
andauthored
feat(store): CI/CD publishing — publisher API tokens + publish script (#6)
Lets extensions be published from git/CI instead of the web form. The only backend gap was headless auth, so this adds long-lived publisher API tokens: - migration 0005_publisher_tokens (stores only the sha256 of the token) - mint/list/revoke endpoints; minting requires a real session (a leaked CI token can't mint more); store currentUser() resolves `tbpub_…` bearers - scripts/publish-extension.sh: zip → scp to files.profullstack.com → register the new version via the API with the token (generic; any CI) - docs/ci-publishing.md: token + SSH-key setup and a paste-in GitHub workflow scp upload + the version endpoint + slug lookup already existed, so CI reuses them. Typecheck clean; token mint/resolve/list/revoke round-trip verified. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ecf405c commit 521a1f5

5 files changed

Lines changed: 262 additions & 1 deletion

File tree

‎docs/ci-publishing.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# CI/CD publishing to the TronBrowser store
2+
3+
Publish a new version of your extension automatically from git — on every tag or
4+
push — instead of using the web form. Works from GitHub Actions, GitLab CI, or any
5+
shell.
6+
7+
## How it works
8+
9+
1. **One-time setup (web UI):** create the listing and pay the one-time **$1**
10+
fee at `https://tronbrowser.dev/store/submit.html`, and register your **SSH
11+
key** (the publisher identity that can upload to files.profullstack.com).
12+
2. **Mint a publisher API token** (shown once) — your CI uses it instead of a
13+
browser session.
14+
3. **Each CI run** builds your MV3 bundle, `scp`s it to
15+
`files.profullstack.com`, and registers the new version via the store API.
16+
Listings update **free and instantly** after the initial paid listing.
17+
18+
## 1. Mint a publisher token
19+
20+
Signed in, from a browser session:
21+
22+
```bash
23+
curl -X POST https://tronbrowser.dev/api/store/publisher/tokens \
24+
-H 'content-type: application/json' --cookie 'tb_session=…' \
25+
-d '{"name":"github-actions"}'
26+
# => { "ok": true, "token": "tbpub_…", ... } # store the token now — shown once
27+
```
28+
29+
List or revoke: `GET /api/store/publisher/tokens`, `DELETE /api/store/publisher/tokens/:id`.
30+
31+
## 2. Add CI secrets
32+
33+
| Secret | Value |
34+
| --- | --- |
35+
| `TRONBROWSER_STORE_TOKEN` | the `tbpub_…` token from step 1 |
36+
| `TRONBROWSER_SSH_KEY` | the **private** SSH key whose public key is registered with your publisher account |
37+
38+
## 3. The publish step
39+
40+
It calls [`scripts/publish-extension.sh`](../scripts/publish-extension.sh).
41+
Required env: `TRONBROWSER_STORE_TOKEN`, `TRONBROWSER_SSH_KEY`, `STORE_SLUG`.
42+
Optional: `STORE_URL`, `SCP_TARGET`, `MANIFEST` (default `manifest.json`),
43+
`BUNDLE` (dir to zip, or a `.zip`/`.crx`; default `dist`). Needs `jq`, `curl`,
44+
`ssh`/`scp`, `zip` (all preinstalled on `ubuntu-latest`).
45+
46+
### GitHub Actions (paste into your extension's repo)
47+
48+
```yaml
49+
name: Publish to TronBrowser store
50+
on:
51+
push:
52+
tags: ['v*']
53+
jobs:
54+
publish:
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v4
58+
- run: npm ci && npm run build # produce ./dist
59+
- name: Publish
60+
env:
61+
TRONBROWSER_STORE_TOKEN: ${{ secrets.TRONBROWSER_STORE_TOKEN }}
62+
TRONBROWSER_SSH_KEY: ${{ secrets.TRONBROWSER_SSH_KEY }}
63+
STORE_SLUG: my-extension
64+
BUNDLE: dist
65+
MANIFEST: dist/manifest.json
66+
run: curl -fsSL https://raw.githubusercontent.com/profullstack/tronbrowser.dev/main/scripts/publish-extension.sh | bash
67+
```
68+
69+
### Any other CI / local
70+
71+
```bash
72+
export TRONBROWSER_STORE_TOKEN=tbpub_…
73+
export TRONBROWSER_SSH_KEY="$(cat ~/.ssh/id_ed25519)"
74+
export STORE_SLUG=my-extension BUNDLE=dist
75+
./scripts/publish-extension.sh
76+
```
77+
78+
## Notes
79+
80+
- The version number comes from your `manifest.json` `version` — bump it each
81+
release or Chromium won't auto-update clients.
82+
- The first publish for a slug must be the paid web listing; CI handles every
83+
version after that.
84+
- Tokens are long-lived and revocable; treat them like a deploy key.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Long-lived publisher API tokens for headless / CI publishing.
2+
--
3+
-- A publisher mints a token in the web UI (shown once) and stores it as a CI
4+
-- secret. CI then sends it as `Authorization: Bearer tbpub_...` to push new
5+
-- extension versions without a browser session. We store only the SHA-256 hash
6+
-- of the token, never the token itself.
7+
8+
CREATE TABLE IF NOT EXISTS publisher_tokens (
9+
id TEXT PRIMARY KEY,
10+
user_id TEXT NOT NULL,
11+
token_hash TEXT NOT NULL UNIQUE, -- sha256 hex of the raw token
12+
name TEXT, -- human label, e.g. "github-actions"
13+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
14+
last_used_at TEXT,
15+
FOREIGN KEY (user_id) REFERENCES users(id)
16+
);
17+
18+
CREATE INDEX IF NOT EXISTS idx_publisher_tokens_user ON publisher_tokens (user_id);

‎scripts/publish-extension.sh‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env bash
2+
# Publish a new version of a TronBrowser store extension from CI (or locally).
3+
#
4+
# Build-free: expects a prebuilt Manifest V3 bundle (a directory to zip, or a
5+
# ready .zip/.crx). It (1) zips the bundle, (2) scps it to files.profullstack.com
6+
# using your publisher SSH key, then (3) registers the new version via the store
7+
# API with a publisher token. Generic — works from GitHub Actions, GitLab CI, or
8+
# your shell. Requires: bash, curl, jq, ssh/scp, zip.
9+
#
10+
# The listing must already EXIST and have its one-time $1 fee paid (do that once
11+
# in the web UI at <store>/store/submit.html). After that, every push is free
12+
# and goes live instantly via this script.
13+
#
14+
# Required env:
15+
# TRONBROWSER_STORE_TOKEN publisher API token (tbpub_...), minted in the web UI
16+
# TRONBROWSER_SSH_KEY private SSH key registered with your publisher account
17+
# STORE_SLUG the extension's slug
18+
# Optional env:
19+
# STORE_URL default https://tronbrowser.dev
20+
# SCP_TARGET default files@files.profullstack.com
21+
# MANIFEST path to manifest.json (default: manifest.json)
22+
# BUNDLE dir to zip, or a .zip/.crx file (default: dist)
23+
set -euo pipefail
24+
25+
STORE_URL="${STORE_URL:-https://tronbrowser.dev}"
26+
SCP_TARGET="${SCP_TARGET:-files@files.profullstack.com}"
27+
MANIFEST="${MANIFEST:-manifest.json}"
28+
BUNDLE="${BUNDLE:-dist}"
29+
: "${TRONBROWSER_STORE_TOKEN:?set TRONBROWSER_STORE_TOKEN (mint one in the store web UI)}"
30+
: "${TRONBROWSER_SSH_KEY:?set TRONBROWSER_SSH_KEY (your publisher private key)}"
31+
: "${STORE_SLUG:?set STORE_SLUG}"
32+
33+
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
34+
35+
# 1) Produce the artifact.
36+
case "$BUNDLE" in
37+
*.zip|*.crx) artifact="$BUNDLE" ;;
38+
*) artifact="$work/${STORE_SLUG}.zip"; ( cd "$BUNDLE" && zip -qr "$artifact" . ) ;;
39+
esac
40+
fname="$(basename "$artifact")"
41+
case "$fname" in *.crx) ftype=crx ;; *) ftype=zip ;; esac
42+
43+
# 2) Upload to files.profullstack.com under the slug's path.
44+
key="$work/id_key"; printf '%s\n' "$TRONBROWSER_SSH_KEY" > "$key"; chmod 600 "$key"
45+
host="${SCP_TARGET#*@}"
46+
ssh-keyscan -H "$host" >> "$work/known_hosts" 2>/dev/null || true
47+
scp -i "$key" -o UserKnownHostsFile="$work/known_hosts" "$artifact" \
48+
"${SCP_TARGET}:/public/extensions/${STORE_SLUG}/${fname}"
49+
50+
# 3) Resolve the listing id, then register the new version with the token.
51+
id="$(curl -fsS "${STORE_URL}/api/store/extensions/${STORE_SLUG}" | jq -r '.id // empty')"
52+
if [ -z "$id" ]; then
53+
echo "error: listing '${STORE_SLUG}' not found — create it and pay the one-time \$1 fee in the web UI first." >&2
54+
exit 1
55+
fi
56+
57+
body="$(jq -n --argjson m "$(cat "$MANIFEST")" --arg f "$fname" --arg t "$ftype" \
58+
'{manifest: $m, files: {($t): $f}, source: "pr"}')"
59+
resp="$(curl -fsS -X POST "${STORE_URL}/api/store/extensions/${id}/versions" \
60+
-H "authorization: Bearer ${TRONBROWSER_STORE_TOKEN}" \
61+
-H 'content-type: application/json' -d "$body")"
62+
63+
echo "published ${STORE_SLUG}: $resp"

‎services/api/src/store/db.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Data access for the extension store. Mirrors the style of ../db.ts (raw
22
// libSQL via the shared db() client).
3-
import { db } from '../db.js';
3+
import { createHash, randomBytes } from 'node:crypto';
4+
import { db, type User } from '../db.js';
45
import { uuid } from '../auth.js';
56

67
export interface Extension {
@@ -276,3 +277,55 @@ export async function upsertPublisherKey(k: {
276277
args: [k.userId, k.handle, k.pubkey, k.fingerprint, k.provisioned ? new Date().toISOString() : null],
277278
});
278279
}
280+
281+
/* ---------- publisher API tokens (headless / CI publishing) ---------- */
282+
283+
export interface PublisherToken {
284+
id: string;
285+
user_id: string;
286+
name: string | null;
287+
created_at: string;
288+
last_used_at: string | null;
289+
}
290+
291+
const TOKEN_PREFIX = 'tbpub_';
292+
const hashToken = (raw: string): string => createHash('sha256').update(raw).digest('hex');
293+
294+
/** Mint a long-lived publisher token. Returns the RAW token (shown once). */
295+
export async function createPublisherToken(
296+
userId: string,
297+
name?: string | null,
298+
): Promise<{ token: string; id: string; name: string | null }> {
299+
const token = TOKEN_PREFIX + randomBytes(24).toString('base64url');
300+
const id = uuid();
301+
await db().execute({
302+
sql: 'INSERT INTO publisher_tokens (id, user_id, token_hash, name) VALUES (?, ?, ?, ?)',
303+
args: [id, userId, hashToken(token), name ?? null],
304+
});
305+
return { token, id, name: name ?? null };
306+
}
307+
308+
/** Resolve a raw `tbpub_…` token to its user, stamping last_used_at. */
309+
export async function userByPublisherToken(raw: string): Promise<User | null> {
310+
if (!raw.startsWith(TOKEN_PREFIX)) return null;
311+
const hash = hashToken(raw);
312+
const r = await db().execute({ sql: 'SELECT user_id FROM publisher_tokens WHERE token_hash = ?', args: [hash] });
313+
const row = r.rows[0];
314+
if (!row) return null;
315+
await db().execute({ sql: "UPDATE publisher_tokens SET last_used_at = datetime('now') WHERE token_hash = ?", args: [hash] });
316+
const u = await db().execute({ sql: 'SELECT * FROM users WHERE id = ?', args: [String(row.user_id)] });
317+
return (u.rows[0] as unknown as User) ?? null;
318+
}
319+
320+
export async function listPublisherTokens(userId: string): Promise<PublisherToken[]> {
321+
const r = await db().execute({
322+
sql: 'SELECT id, user_id, name, created_at, last_used_at FROM publisher_tokens WHERE user_id = ? ORDER BY created_at DESC',
323+
args: [userId],
324+
});
325+
return r.rows as unknown as PublisherToken[];
326+
}
327+
328+
export async function revokePublisherToken(userId: string, id: string): Promise<boolean> {
329+
const r = await db().execute({ sql: 'DELETE FROM publisher_tokens WHERE id = ? AND user_id = ?', args: [id, userId] });
330+
return (r.rowsAffected ?? 0) > 0;
331+
}

‎services/api/src/store/routes.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
setExtensionStatus, addVersion, latestVersion, createPayment, setPaymentRef,
1212
markPaidByRef, hasPaidListing, latestScan, addFlag, openFlagCount,
1313
publisherKey, handleTaken, upsertPublisherKey,
14+
createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken,
1415
} from './db.js';
1516
import { validateManifest, slugify } from './manifest.js';
1617
import {
@@ -27,10 +28,20 @@ const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
2728

2829
async function currentUser(c: any): Promise<User | null> {
2930
const bearer = c.req.header('authorization')?.replace(/^Bearer\s+/i, '');
31+
// Long-lived publisher API tokens (CI) are distinguishable by prefix; anything
32+
// else in the bearer position is treated as a normal session token.
33+
if (bearer?.startsWith('tbpub_')) return userByPublisherToken(bearer);
3034
const sess = bearer || getCookie(c, 'tb_session');
3135
return sess ? userBySession(sess) : null;
3236
}
3337

38+
/** A user resolved from a browser SESSION only (not an API token). */
39+
async function sessionUser(c: any): Promise<User | null> {
40+
const bearer = c.req.header('authorization')?.replace(/^Bearer\s+/i, '');
41+
if (bearer?.startsWith('tbpub_')) return null;
42+
return currentUser(c);
43+
}
44+
3445
function xmlEscape(s: string): string {
3546
return s.replace(/[<>&'"]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[ch]!));
3647
}
@@ -180,6 +191,38 @@ store.post('/publisher/key', async (c) => {
180191
});
181192
});
182193

194+
/* ---------- publisher API tokens (headless / CI publishing) ----------
195+
A token authenticates CI as the publisher so it can push new versions without
196+
a browser session. Minting requires a real session (so a leaked CI token
197+
can't mint more). The raw token is returned ONCE; only its hash is stored. */
198+
store.post('/publisher/tokens', async (c) => {
199+
const user = await sessionUser(c);
200+
if (!user) return c.json({ error: 'mint tokens from a signed-in browser session' }, 401);
201+
const body = await c.req.json().catch(() => ({}));
202+
const name = String(body.name || 'ci').trim().slice(0, 40) || 'ci';
203+
const { token, id } = await createPublisherToken(user.id, name);
204+
return c.json({
205+
ok: true,
206+
token,
207+
id,
208+
name,
209+
note: 'Shown once — store it as the CI secret TRONBROWSER_STORE_TOKEN.',
210+
});
211+
});
212+
213+
store.get('/publisher/tokens', async (c) => {
214+
const user = await currentUser(c);
215+
if (!user) return c.json({ error: 'unauthorized' }, 401);
216+
return c.json({ tokens: await listPublisherTokens(user.id) });
217+
});
218+
219+
store.delete('/publisher/tokens/:id', async (c) => {
220+
const user = await sessionUser(c);
221+
if (!user) return c.json({ error: 'unauthorized' }, 401);
222+
const ok = await revokePublisherToken(user.id, c.req.param('id'));
223+
return c.json({ ok }, ok ? 200 : 404);
224+
});
225+
183226
/* ---------- publisher: submit an MV3 version (upload or PR) ----------
184227
Accepts JSON: { manifest, bundleUrl?, crxUrl?, bundleSha256?, sizeBytes?, source? }
185228
`manifest` may be the manifest.json string or object. We keep Chromium's

0 commit comments

Comments
 (0)