Showcase HTTPS (0.36.0): auto per-user wildcard TLS

- server: certbot DNS-01 GoDaddy hooks + showcase-cert.sh issues *.<user>.amerc.ai
  wildcard cert and writes a per-user 443 nginx vhost -> edge
- amerc-api: registration triggers cert provisioning async (idempotent) + returns https URL
- Mansion shows the HTTPS-provisioning note
- verified: https://demo1.artheru.amerc.ai serves over TLS (Let's Encrypt *.artheru cert)
This commit is contained in:
artheru 2026-06-10 05:46:28 +08:00
parent 0ecf037b5a
commit adbac1b9f0
5 changed files with 8 additions and 4 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.35.0-account" /> <meta name="version" content="0.36.0-https" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!-- Open Graph / social share --> <!-- Open Graph / social share -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.35.0-account", "version": "0.36.0-https",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -6,6 +6,7 @@ import http from 'node:http';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, createReadStream } from 'node:fs'; import { existsSync, mkdirSync, createReadStream } from 'node:fs';
import { dirname, join, basename } from 'node:path'; import { dirname, join, basename } from 'node:path';
@ -496,8 +497,10 @@ const server = http.createServer(async (req, res) => {
const host = `${name}.${handle}.amerc.ai`; const host = `${name}.${handle}.amerc.ai`;
if (db.prepare('SELECT id FROM showcases WHERE host=?').get(host)) return send(res, 409, { ok: false, error: 'showcase already registered' }); if (db.prepare('SELECT id FROM showcases WHERE host=?').get(host)) return send(res, 409, { ok: false, error: 'showcase already registered' });
const dns = await godaddyEnsureWildcard(handle); const dns = await godaddyEnsureWildcard(handle);
// provision per-user wildcard TLS cert + nginx vhost (idempotent, async)
try { if (existsSync('/home/amerc/showcase-cert.sh')) spawn('/home/amerc/showcase-cert.sh', [handle], { detached: true, stdio: 'ignore' }).unref(); } catch { /* best effort */ }
db.prepare('INSERT INTO showcases(owner,name,host,created_at) VALUES(?,?,?,?)').run(id.user.handle, name, host, Date.now()); db.prepare('INSERT INTO showcases(owner,name,host,created_at) VALUES(?,?,?,?)').run(id.user.handle, name, host, Date.now());
return send(res, 200, { ok: true, host, url: `http://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending') }); return send(res, 200, { ok: true, host, url: `https://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending'), tls: 'HTTPS provisions automatically (~1-2 min on your first showcase)' });
} }
if ((m = path.match(/^\/showcase\/(\d+)$/)) && method === 'DELETE') { if ((m = path.match(/^\/showcase\/(\d+)$/)) && method === 'DELETE') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });

View File

@ -50,6 +50,7 @@ function Showcase({ handle }) {
{created && ( {created && (
<div className="mn-created"> <div className="mn-created">
<p> <b>{created.host}</b> reserved · DNS {created.dns}</p> <p> <b>{created.host}</b> reserved · DNS {created.dns}</p>
{created.tls && <p className="ap-hint">🔒 {created.tls}</p>}
<label className="ap-field">public URL<code className="ap-token" onClick={() => copy(created.url)}>{created.url} </code></label> <label className="ap-field">public URL<code className="ap-token" onClick={() => copy(created.url)}>{created.url} </code></label>
<label className="ap-field">broker WS (kebab connects here)<code className="ap-token" onClick={() => copy(created.edgeWs)}>{created.edgeWs} </code></label> <label className="ap-field">broker WS (kebab connects here)<code className="ap-token" onClick={() => copy(created.edgeWs)}>{created.edgeWs} </code></label>
<label className="ap-field">edge token (bind {created.host})<code className="ap-token" onClick={() => copy(created.edgeToken)}>{created.edgeToken.slice(0, 28)} </code></label> <label className="ap-field">edge token (bind {created.host})<code className="ap-token" onClick={() => copy(created.edgeToken)}>{created.edgeToken.slice(0, 28)} </code></label>

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.35.0-account'; const RELEASE = '0.36.0-https';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },