openvibers/openvibe.design Commits

Add AI typefaces and AI vectors (#195)

OP OpenVibers committed 5 days ago · b7fa56aff0 · 20 files, +2374 −8

* Add AI typefaces and AI vectors

A person can now describe a typeface on /fonts/make and get a real font
file back: the model writes SVG outlines for the letters and figures in
batches, the server validates every glyph (one closed path, inside the
em box, no scripts or external references), normalises the metrics and
compiles .woff2 and .otf. The person sees a live specimen, can redraw one
glyph with a note, and downloads the font. The typeface belongs to them,
is offered under OFL by default, and the record says which model drew it.

/vectors/make turns a prompt into one clean SVG. The drawing is rebuilt
from an allowlist of elements and attributes, so no script, no
foreignObject and no external reference survives; it can be regenerated,
edited with a note, and downloaded as SVG or PNG, and it is kept in the
person's own library, private by default.

Both pages and their API and MCP tools need a signed-in person, every
model run is attributed on_behalf_of them and counted against a daily cap
in the config, and a refusal or a missing AI setup is shown in plain
words.

* Export and erase the typefaces and vectors with the account

ADR-033 asks that every table naming a person is either in the account-data
map or exempted with a reason. The three new tables name one, so they are
now in the map: a person takes their typefaces, their drawings and their
run count with them when they export or delete their account, and nobody
else's work is ever exported.

* AI typefaces and vectors: your faces drawn in themselves, your drawings as pictures; a saved SVG runs nothing

Opus's review and design pass. Your typefaces are a shelf: each tile draws Aa from that face's own stored outlines
(inline SVG, font units, baseline at 0); one with no letters says Not drawn yet rather than showing a system font.
Your drawings are a gallery of their own SVGs (img, which runs nothing). A saved drawing opened on its own is served
with default-src 'none', sandbox and nosniff, so even a sanitiser miss cannot run script; the test checks it.

Changes

ddiff --git a/migrations/0017_ai_typefaces.sql b/migrations/0017_ai_typefaces.sql
nnew file mode 100644
iindex 0000000..d18193d
---- /dev/null
++++ b/migrations/0017_ai_typefaces.sql
@@@ -0,0 +1,27 @@
++-- phase: expand
++-- The typefaces a person makes with the model (plan R1 item 8, Seven Domains §11).
++-- A new table; an older release ignores it.
++--
++-- A typeface is the person's: the glyph outlines the model drew, the metrics they were compiled
++-- with, and the model that drew them. The binaries are not stored here: they are compiled from the
++-- outlines on request, so a redrawn glyph replaces the file rather than adding to it.
++CREATE TABLE IF NOT EXISTS ai_typefaces (
++ id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ prompt text NOT NULL,
++ base_family text COLLATE "C",
++ style_notes text,
++ glyphs jsonb NOT NULL DEFAULT '[]'::jsonb,
++ metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
++ provider text COLLATE "C",
++ model text COLLATE "C",
++ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',
++ state text COLLATE "C" NOT NULL DEFAULT 'drawing',
++ note text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT ai_typefaces_glyphs_array CHECK (jsonb_typeof(glyphs) = 'array'),
++ CONSTRAINT ai_typefaces_state CHECK (state IN ('drawing', 'ready', 'refused'))
++);
++CREATE INDEX ai_typefaces_owner_created ON ai_typefaces (owner, created_at DESC);
ddiff --git a/migrations/0018_ai_vectors.sql b/migrations/0018_ai_vectors.sql
nnew file mode 100644
iindex 0000000..ad2db33
---- /dev/null
++++ b/migrations/0018_ai_vectors.sql
@@@ -0,0 +1,35 @@
++-- phase: expand
++-- The vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
++-- New tables; an older release ignores all of it.
++--
++-- A vector is one drawing: the SVG the model drew, cleaned before it was stored, and the note the
++-- person gave when they asked for it.
++CREATE TABLE IF NOT EXISTS ai_vectors (
++ id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ prompt text NOT NULL,
++ kind text COLLATE "C" NOT NULL DEFAULT 'icon',
++ svg text NOT NULL,
++ provider text COLLATE "C",
++ model text COLLATE "C",
++ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',
++ state text COLLATE "C" NOT NULL DEFAULT 'drawn',
++ note text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT ai_vectors_kind CHECK (kind IN ('icon', 'illustration', 'pattern')),
++ CONSTRAINT ai_vectors_state CHECK (state IN ('drawn', 'refused'))
++);
++CREATE INDEX ai_vectors_owner_created ON ai_vectors (owner, created_at DESC);
++
++-- How many model runs a person may make a day, and how many they have made. The cap is in the
++-- config; this is the count it is measured against.
++CREATE TABLE IF NOT EXISTS ai_make_runs (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL,
++ day date NOT NULL DEFAULT current_date,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT ai_make_runs_kind CHECK (kind IN ('typeface', 'vector'))
++);
++CREATE INDEX ai_make_runs_owner_day ON ai_make_runs (owner, day);
ddiff --git a/package-lock.json b/package-lock.json
iindex 8325e4c..35c76b6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,12 +15,14 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "opentype.js": "^2.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"pg": "^8.23.1",
-- "subset-font": "^2.9.0"
++ "subset-font": "^2.9.0",
++ "wawoff2": "^2.0.1"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.13"
@@@ -1321,6 +1323,15 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/opentype.js": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
++ "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
++ "license": "MIT",
++ "bin": {
++ "ot": "bin/ot"
++ }
++ },
"node_modules/openvibe-contracts": {
"version": "0.338.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
ddiff --git a/package.json b/package.json
iindex 78277a2..d227d6f 100644
---- a/package.json
++++ b/package.json
@@@ -21,12 +21,14 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "opentype.js": "^2.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"pg": "^8.23.1",
-- "subset-font": "^2.9.0"
++ "subset-font": "^2.9.0",
++ "wawoff2": "^2.0.1"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/public/css/typefaces.css b/public/css/typefaces.css
nnew file mode 100644
iindex 0000000..76d2c89
---- /dev/null
++++ b/public/css/typefaces.css
@@@ -0,0 +1,61 @@
++/* The typefaces and vectors a person makes with the model: plain maker pages, ready for the final Design art direction. */
++.tf{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
++.tf-hero{max-width:820px;margin:0 0 32px}.tf-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
++.tf-hero h1{margin:0;font-size:clamp(32px,5vw,60px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.tf-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,19px);line-height:1.5}
++
++.tf-form{display:grid;gap:10px;max-width:720px;margin:0 0 40px;padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
++.tf-form label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}
++.tf-form label small{font-weight:500;opacity:.8}
++.tf-form textarea,.tf-form input,.tf-form select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}
++.tf-form textarea{min-height:110px;resize:vertical;line-height:1.5}
++.tf-form button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}
++.tf-form button:hover{opacity:.92}
++.tf-cap{margin:2px 0 0;color:var(--text-secondary);font-size:13px}
++.tf-signin{padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);color:var(--text-secondary)}
++.tf-signin a{color:var(--accent);font-weight:700}
++
++.tf-owned{margin-top:8px}.tf-owned h2{font-size:20px}.tf-owned ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}
++.tf-owned li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;padding:12px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary)}
++.tf-owned a{font-weight:700;color:inherit;text-decoration:none;overflow-wrap:anywhere}.tf-owned a:hover{text-decoration:underline}
++.tf-owned span{color:var(--text-secondary);font-size:13px}
++.tf-n{margin-left:6px;font-size:.65em;font-weight:600;color:var(--text-secondary)}
++.tf-owned .tf-shelf,.tf-owned .tf-gallery{grid-template-columns:repeat(auto-fill,minmax(min(160px,100%),1fr));gap:10px}
++.tf-shelf li,.tf-gallery li{display:block;padding:0}
++.tf-shelf a,.tf-gallery a{display:grid;gap:4px;height:100%;padding:14px;border-radius:12px;box-sizing:border-box}
++.tf-shelf a:hover,.tf-gallery a:hover{text-decoration:none;background:color-mix(in srgb,var(--accent) 8%,transparent)}
++.tf-sample{display:block;height:64px;width:auto;max-width:100%;margin-bottom:6px;color:var(--text-primary)}
++.tf-sample.is-empty{display:flex;align-items:center;height:64px;font-size:14px;color:var(--text-secondary)}
++.tf-shelf b{font-size:15px;overflow-wrap:anywhere}
++.tf-gallery img{width:100%;height:auto;aspect-ratio:1;object-fit:contain;padding:10px;box-sizing:border-box;border-radius:10px;background:var(--bg-primary)}
++.tf-gallery span{font-size:13.5px;color:var(--text-primary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.tf-gallery small{font-size:12px;color:var(--text-secondary);text-transform:capitalize}
++
++.tf-back{display:inline-block;margin-bottom:24px;color:var(--accent)}
++.tf-name{font-size:clamp(34px,6vw,76px)!important}
++.tf-sample{margin:0 0 14px;font-size:clamp(28px,5vw,64px);line-height:1.2;overflow-wrap:anywhere}
++.tf-words{font-size:clamp(18px,2.6vw,30px);color:var(--text-secondary)}
++.tf-letters{display:flex;flex-wrap:wrap;gap:6px;margin:26px 0 8px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}
++.tf-letter{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:52px;padding:4px;border:1px solid var(--border);border-radius:8px;font-family:"OpenVibe Made",sans-serif;font-size:26px;line-height:1}
++.tf-missing{opacity:.28;font-family:inherit}
++.tf-specimen section{margin-top:40px}.tf-specimen h2{font-size:clamp(20px,3vw,28px)}
++.tf-download p{color:var(--text-secondary);line-height:1.6}.tf-download a{color:var(--accent);font-weight:700}
++.tf-note{max-width:70ch}
++.tf-redraw form{display:grid;gap:10px;max-width:640px;margin-top:16px}
++.tf-redraw label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}
++.tf-redraw input,.tf-redraw select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}
++.tf-redraw button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}
++.tf-lastnote{margin-top:12px;color:var(--text-secondary);font-size:13px}
++
++.tf-figure{margin:0 0 8px;padding:28px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);display:grid;place-items:center}
++.tf-figure svg{display:block;width:min(100%,420px);height:auto;color:var(--text-primary);fill:currentColor}
++.tf-vector .tf-hero h1{font-size:clamp(24px,3.6vw,40px)}
++
++@media(max-width:600px){
++ .tf-form{padding:14px}
++ .tf-letters{gap:4px;padding:12px}
++ .tf-letter{min-width:38px;min-height:44px;font-size:22px}
++ .tf-figure{padding:16px}
++}
++/* The selects keep the browser's own look (data-ov-native): the house's drawn select paints an empty one
++ with the accent, and a menu of open fonts should read as a field, not as a button. */
++.tf .tf-form select,.tf .tf-redraw select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}
ddiff --git a/server/app.js b/server/app.js
iindex edce41a..298b7c9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -101,12 +101,16 @@ async function createApp(opts = {}) {
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
await fonts.seedMissing();
++ // The typefaces and vectors a person makes with the model (plan R1 item 8): the store, the drawing
++ // service and the routes that serve them.
++ const aiStore = opts.aiStore || require('./typefaces/store').createStore({ db: s.db, log, now: () => new Date(now()) });
++ const maker = opts.maker || require('./typefaces/service').createService({ db: s.db, ai, store: aiStore, config, log, s, sharing });
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,
++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker,
random: opts.random || Math.random,
};
@@@ -159,7 +163,6 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
app.use(cookieParser());
-- app.use(createMcpRoutes(ctx));
const agentLayer = express();
const agentDescriptor = {
id: 'design', name: 'OpenVibe.Design', purpose: 'Public theme tokens and component specifications.',
@@@ -185,6 +188,9 @@ async function createApp(opts = {}) {
// ── Who is signed in, then sign-in itself ───────────────
app.use(sso.middleware());
app.use('/auth', sso.routes());
++ // The MCP endpoint, after the session middleware: the tools that make a typeface or a vector are charged to
++ // the person who calls them, so they need to know who that is. The read-only tools answer as before.
++ app.use(createMcpRoutes(ctx));
// ── Account export/deletion from OpenVibe.Events (loopback only) ──
// Raw body first: the SDK verifies the delivery signature over the exact bytes.
@@@ -279,6 +285,14 @@ async function createApp(opts = {}) {
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.use(createIconRoutes(ctx));
++ // The typefaces and vectors a person makes with the model (plan R1 item 8): the makers, the
++ // specimens and the API for agents. These come before the font library's /fonts/:family, so
++ // /fonts/make is the maker and not a family called "make".
++ {
++ const { createTypefaceRoutes, createVectorRoutes } = require('./http/typefaces');
++ app.use(createTypefaceRoutes(ctx));
++ app.use(createVectorRoutes(ctx));
++ }
app.use(createFontRoutes(ctx));
app.use('/api', createApi(ctx));
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
ddiff --git a/server/config.js b/server/config.js
iindex 73ebc2c..9d9e1be 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -99,6 +99,13 @@ function load(env = process.env) {
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
},
++ // The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs
++ // one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),
++ // so the cap counts runs, not pages.
++ aiMake: {
++ perDay: Math.max(0, int(env.DESIGN_AI_MAKE_PER_DAY, 20)),
++ },
++
// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
arena: {
perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 123af77..a59b414 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -44,6 +44,10 @@ function publicPages(themes = []) {
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
++ // The makers (plan R1 item 8): the pages an agent may drive. The drawings and the typefaces they
++ // produce are the person's and stay out of the crawl.
++ { path: '/fonts/make', changefreq: 'monthly', priority: 0.7, title: 'Make a typeface', text: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.' },
++ { path: '/vectors/make', changefreq: 'monthly', priority: 0.7, title: 'Make a vector', text: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.' },
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
path: `/t/${encodeURIComponent(String(t.slug))}`,
@@@ -99,6 +103,12 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
++ // The makers: an agent can drive these two through the API and the MCP tools. Each run is
++ // charged to the signed-in person who asks for it, and their drawings stay theirs.
++ { title: 'Make things', links: [
++ { title: 'Make a typeface', url: abs('/fonts/make'), note: 'describe a typeface and the model draws the letters; POST /api/v1/typefaces {prompt, baseFamily?, name?} returns the outlines and the font files (/api/v1/typefaces/<id>/font.woff2 and .otf); redraw one letter with POST /api/v1/typefaces/<id>/glyph {char, note}; MCP tool openvibe_design_make_typeface' },
++ { title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },
++ ] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
// The data an agent came for is a main section, not something to skip.
{ title: 'Machine-readable', links: [
ddiff --git a/server/http/mcp.js b/server/http/mcp.js
iindex 2bfff4e..f06be58 100644
---- a/server/http/mcp.js
++++ b/server/http/mcp.js
@@@ -60,7 +60,19 @@ const TOOLS = [
{ name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {
family: { type: 'string', minLength: 1, maxLength: 100 },
}) },
--].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
++ // The typefaces and vectors a person makes with the model (plan R1 item 8). These two write, so they are
++ // not marked read only: they need a signed-in person, and the run is charged to that person's allowance.
++ { name: 'openvibe_design_make_typeface', description: 'Draw a typeface from a description and return its glyph outlines, metrics and download links. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {
++ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'The typeface you want, in words ("rounded geometric sans for a kids app").' },
++ baseFamily: { type: 'string', maxLength: 80, description: 'An open font family to start from, as in the font library.' },
++ name: { type: 'string', maxLength: 60, description: 'The name of the face.' },
++ }) },
++ { name: 'openvibe_design_make_vector', description: 'Draw one clean SVG from a prompt and return it, sanitised and ready to use. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {
++ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'What to draw ("a paper plane folded from a single sheet").' },
++ kind: { type: 'string', enum: ['icon', 'illustration', 'pattern'], default: 'icon' },
++ note: { type: 'string', maxLength: 400, description: 'A note on the drawing, when you are changing one.' },
++ }) },
++].map(tool => ({ ...tool, annotations: { readOnlyHint: tool.name.indexOf('_make_') < 0, openWorldHint: false, destructiveHint: false } }));
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
@@@ -73,8 +85,11 @@ function toolResult(value) {
};
}
--async function callTool(name, args, { catalog, fonts }) {
++async function callTool(name, args, ctx, req) {
if (!object(args)) return toolError('Arguments must be an object.');
++ const { catalog, fonts } = ctx;
++ // The tools that make something are charged to the person who calls them, so they need a signed-in caller.
++ const person = req && req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
switch (name) {
case 'openvibe_design_list_themes': {
if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) return toolError('query must be a string of at most 200 characters.');
@@@ -148,6 +163,49 @@ async function callTool(name, args, { catalog, fonts }) {
const font = await fonts.get(args.family);
return font ? toolResult(font) : toolError('No font family by that slug.');
}
++ case 'openvibe_design_make_typeface': {
++ if (!person) return toolError('Sign in on openvibe.design to make a typeface. The run is charged to your own allowance.');
++ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');
++ if (args.baseFamily !== undefined && (typeof args.baseFamily !== 'string' || args.baseFamily.length > 80)) return toolError('baseFamily is a string of at most 80 characters.');
++ if (args.name !== undefined && (typeof args.name !== 'string' || args.name.length > 60)) return toolError('name is a string of at most 60 characters.');
++ try {
++ const drawn = await ctx.maker.drawTypeface({ owner: person, prompt: args.prompt.trim(),
++ baseFamily: args.baseFamily || null, name: args.name || null });
++ const id = ctx.s.newId('typ');
++ const saved = await ctx.aiStore.saveTypeface({
++ id, owner: person, name: drawn.name, prompt: args.prompt.trim(),
++ baseFamily: args.baseFamily || null, styleNotes: args.prompt.trim(),
++ glyphs: drawn.glyphs, metrics: drawn.metrics, provider: drawn.provider, model: drawn.model,
++ license: drawn.license, state: 'ready',
++ });
++ return toolResult({ id: saved.id, name: saved.name, glyphs: saved.glyphs.length, metrics: saved.metrics,
++ model: saved.model, provider: saved.provider, license: saved.license,
++ page: `${BASE}/fonts/made/${encodeURIComponent(saved.id)}`,
++ download: { woff2: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.woff2`, otf: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.otf` } });
++ } catch (err) {
++ return toolError((err && err.detail) || 'The typeface could not be drawn.');
++ }
++ }
++ case 'openvibe_design_make_vector': {
++ if (!person) return toolError('Sign in on openvibe.design to make a vector. The run is charged to your own allowance.');
++ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');
++ if (args.kind !== undefined && !['icon', 'illustration', 'pattern'].includes(args.kind)) return toolError('kind is icon, illustration or pattern.');
++ if (args.note !== undefined && (typeof args.note !== 'string' || args.note.length > 400)) return toolError('note is a string of at most 400 characters.');
++ try {
++ const drawn = await ctx.maker.drawVector({ owner: person, prompt: args.prompt.trim(),
++ kind: args.kind || 'icon', note: args.note || null });
++ const id = ctx.s.newId('vec');
++ const saved = await ctx.aiStore.saveVector({ id, owner: person, prompt: args.prompt.trim(),
++ kind: drawn.kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model,
++ license: drawn.license, state: 'drawn' });
++ return toolResult({ id: saved.id, prompt: saved.prompt, kind: saved.kind, svg: saved.svg,
++ model: saved.model, provider: saved.provider, license: saved.license,
++ page: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}`,
++ download: { svg: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.svg`, png: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.png` } });
++ } catch (err) {
++ return toolError((err && err.detail) || 'The drawing could not be made.');
++ }
++ }
default:
return null;
}
@@@ -181,7 +239,7 @@ function createMcpRoutes(ctx) {
else if (msg.method === 'ping') replies.push(rpc(id, {}));
else if (msg.method === 'tools/list') replies.push(rpc(id, { tools: TOOLS }));
else if (msg.method === 'tools/call') {
-- const result = await callTool(params.name, params.arguments || {}, ctx);
++ const result = await callTool(params.name, params.arguments || {}, ctx, req);
replies.push(result ? rpc(id, result) : error(id, -32602, 'Unknown tool'));
} else replies.push(error(id, -32601, 'Method not found'));
}
ddiff --git a/server/http/typefaces.js b/server/http/typefaces.js
nnew file mode 100644
iindex 0000000..e3f44ed
---- /dev/null
++++ b/server/http/typefaces.js
@@@ -0,0 +1,338 @@
++'use strict';
++/**
++ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
++ *
++ * GET /fonts/make the typeface maker: describe a face, start from an open one
++ * POST /fonts/make draw it
++ * GET /fonts/made/:id the specimen, the download and the redraw
++ * POST /fonts/made/:id/glyph redraw one glyph with a note
++ *
++ * GET /vectors/make the vector maker: one drawing from a prompt
++ * POST /vectors/make draw it
++ * GET /vectors/made/:id the drawing, the downloads and the notes
++ * POST /vectors/made/:id/edit regenerate it, or edit it with a note
++ *
++ * The API, for agents and for the pages themselves:
++ * POST /api/v1/typefaces draw a typeface
++ * GET /api/v1/typefaces/:id read it
++ * POST /api/v1/typefaces/:id/glyph redraw one glyph
++ * POST /api/v1/vectors draw a vector
++ * GET /api/v1/vectors/:id read it
++ * POST /api/v1/vectors/:id/edit regenerate it, or edit it with a note
++ *
++ * Signed in only: a run is charged to the person's own allowance, so there is always a person behind it.
++ * The outputs are the person's: nobody else's, and the site does not list them.
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const views = require('../views/typefaces');
++const { send } = require('../render/layout');
++const { writeAllowed } = require('./same-origin');
++
++/** The plain words a person gets when the model is not there or says no. */
++function plainError(err) {
++ if (!err) return 'Something went wrong.';
++ const status = Number(err.status) || 500;
++ const detail = String(err.detail || err.message || '').trim();
++ if (status === 503) return 'OpenVibe.AI is not configured for OpenVibe.Design right now. Try again later.';
++ if (status === 429) return detail || 'You have made all the runs you can today. More tomorrow.';
++ if (status === 401) return 'Sign in to make this on openvibe.design.';
++ if (status === 403) return detail || 'This belongs to somebody else.';
++ if (status === 404) return 'Not found.';
++ return detail && status < 500 ? detail : 'Something went wrong.';
++}
++
++function createTypefaceRoutes(ctx) {
++ const { config, ai, aiStore, maker, fonts, s } = ctx;
++ const store = aiStore;
++ const r = asyncRouter();
++ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
++ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });
++
++ /** A face of this person's, or a refusal in words. */
++ async function own(req, res, id) {
++ const owner = subjectOf(req);
++ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }
++ const row = await store.typeface(id, owner);
++ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }
++ return row;
++ }
++
++ // ── The maker ──
++ r.get('/fonts/make', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const owner = subjectOf(req);
++ const bases = await fonts.list().catch(() => []);
++ const mine = owner ? await store.typefacesOf(owner).catch(() => []) : [];
++ return page(res, 200, views.make({ bases, mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,
++ { cacheControl: 'private, no-store' });
++ });
++
++ r.post('/fonts/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a typeface on openvibe.design.');
++ try {
++ const prompt = String(req.body.prompt || '').trim();
++ const baseId = String(req.body.base || '').trim();
++ const name = String(req.body.name || '').trim().slice(0, 60);
++ if (!prompt) return res.status(422).type('text/plain').send('Describe the typeface you want.');
++ const base = baseId ? await fonts.get(baseId).catch(() => null) : null;
++ const id = s.newId('typ');
++ const drawn = await maker.drawTypeface({
++ owner, prompt,
++ baseFamily: base ? base.family : null,
++ baseNote: base ? 'Keep the letterforms of ' + base.family + ' (' + base.category + '), and change the style to the description above.' : null,
++ name: name || null,
++ });
++ await store.saveTypeface({
++ id, owner, name: drawn.name, prompt,
++ baseFamily: base ? base.family : null,
++ styleNotes: prompt,
++ glyphs: drawn.glyphs,
++ metrics: drawn.metrics,
++ provider: drawn.provider, model: drawn.model,
++ license: drawn.license, state: 'ready',
++ });
++ return res.redirect(303, '/fonts/made/' + encodeURIComponent(id));
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(plainError(err));
++ }
++ });
++
++ // ── The specimen ──
++ r.get('/fonts/made/:id', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const row = await own(req, res, req.params.id);
++ if (!row) return undefined;
++ const compiled = await require('../typefaces/compile').compile({
++ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',
++ }).catch(() => null);
++ return page(res, 200, views.specimen({ face: row, compiled, chars: maker.CORE }), req.viewer,
++ { cacheControl: 'private, no-store' });
++ });
++
++ r.post('/fonts/made/:id/glyph', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).type('text/plain').send('Sign in to redraw this on openvibe.design.');
++ const row = await store.typeface(req.params.id, owner);
++ if (!row) return res.status(404).type('text/plain').send('Not found.');
++ try {
++ const char = String(req.body.char || '').slice(0, 4);
++ const note = String(req.body.note || '').trim().slice(0, 400);
++ if (!char || !note) return res.status(422).type('text/plain').send('Say which letter and what to change.');
++ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });
++ if (!redrawn) return res.status(502).type('text/plain').send('The model did not draw that letter. Try again.');
++ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);
++ await store.saveTypeface({
++ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,
++ baseFamily: row.baseFamily, styleNotes: row.styleNotes,
++ glyphs, metrics: { ...row.metrics, glyphs: glyphs.length },
++ provider: row.provider, model: redrawn.model || row.model,
++ license: row.license, state: 'ready', note: note,
++ });
++ return res.redirect(303, '/fonts/made/' + encodeURIComponent(row.id));
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(plainError(err));
++ }
++ });
++
++ // ── The API ──
++ r.post('/api/v1/typefaces', express.json({ limit: '32kb' }), async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to make a typeface on openvibe.design.' });
++ try {
++ const prompt = String((req.body && req.body.prompt) || '').trim();
++ if (!prompt) return res.status(422).json({ error: 'Describe the typeface you want.' });
++ const drawn = await maker.drawTypeface({ owner, prompt, baseFamily: (req.body && req.body.baseFamily) || null, name: (req.body && req.body.name) || null });
++ const id = s.newId('typ');
++ const saved = await store.saveTypeface({
++ id, owner, name: drawn.name, prompt, baseFamily: (req.body && req.body.baseFamily) || null,
++ styleNotes: prompt, glyphs: drawn.glyphs, metrics: drawn.metrics,
++ provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'ready',
++ });
++ return res.status(201).json(saved);
++ } catch (err) {
++ return res.status(err.status || 500).json({ error: plainError(err) });
++ }
++ });
++
++ r.get('/api/v1/typefaces/:id', async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });
++ const row = await store.typeface(req.params.id, owner);
++ if (!row) return res.status(404).json({ error: 'Not found.' });
++ return res.json(row);
++ });
++
++ // The font files. They are compiled from the stored outlines on the way out, so a redrawn letter
++ // is in the next download without anything being rebuilt by hand.
++ for (const [ext, type] of [['woff2', 'font/woff2'], ['otf', 'font/otf']]) {
++ r.get('/api/v1/typefaces/:id/font.' + ext, async (req, res) => {
++ const row = await own(req, res, req.params.id);
++ if (!row) return undefined;
++ const compiled = await require('../typefaces/compile').compile({
++ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',
++ }).catch(() => null);
++ if (!compiled || !compiled.ok) return res.status(502).type('text/plain').send('The font could not be compiled.');
++ const file = ext === 'woff2' ? compiled.woff2 : compiled.otf;
++ return res.set('Cache-Control', 'private, no-store')
++ .set('Content-Disposition', 'attachment; filename="' + row.name.replace(/[^A-Za-z0-9 .'-]/g, '') + '.' + ext + '"')
++ .type(type).send(file);
++ });
++ }
++
++ r.post('/api/v1/typefaces/:id/glyph', express.json({ limit: '8kb' }), async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to redraw this on openvibe.design.' });
++ const row = await store.typeface(req.params.id, owner);
++ if (!row) return res.status(404).json({ error: 'Not found.' });
++ try {
++ const char = String((req.body && req.body.char) || '').slice(0, 4);
++ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);
++ if (!char || !note) return res.status(422).json({ error: 'Say which letter and what to change.' });
++ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });
++ if (!redrawn) return res.status(502).json({ error: 'The model did not draw that letter. Try again.' });
++ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);
++ const saved = await store.saveTypeface({
++ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,
++ baseFamily: row.baseFamily, styleNotes: row.styleNotes, glyphs,
++ metrics: { ...row.metrics, glyphs: glyphs.length },
++ provider: row.provider, model: redrawn.model || row.model, license: row.license, state: 'ready', note,
++ });
++ return res.json(saved);
++ } catch (err) {
++ return res.status(err.status || 500).json({ error: plainError(err) });
++ }
++ });
++
++ return r;
++}
++
++function createVectorRoutes(ctx) {
++ const { config, ai, aiStore, maker, s, sharing } = ctx;
++ const store = aiStore;
++ const r = asyncRouter();
++ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
++ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });
++
++ async function own(req, res, id) {
++ const owner = subjectOf(req);
++ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }
++ const row = await store.vector(id, owner);
++ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }
++ return row;
++ }
++
++ r.get('/vectors/make', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const owner = subjectOf(req);
++ const mine = owner ? await store.vectorsOf(owner).catch(() => []) : [];
++ return page(res, 200, views.vectorMake({ mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,
++ { cacheControl: 'private, no-store' });
++ });
++
++ r.post('/vectors/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a vector on openvibe.design.');
++ try {
++ const prompt = String(req.body.prompt || '').trim();
++ const kind = ['icon', 'illustration', 'pattern'].includes(req.body.kind) ? req.body.kind : 'icon';
++ if (!prompt) return res.status(422).type('text/plain').send('Describe the drawing you want.');
++ const drawn = await maker.drawVector({ owner, prompt, kind });
++ const id = s.newId('vec');
++ await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });
++ return res.redirect(303, '/vectors/made/' + encodeURIComponent(id));
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(plainError(err));
++ }
++ });
++
++ // The drawing itself, as a file. These come before the page, so /vectors/made/<id>.svg is the file
++ // and not a drawing called "<id>.svg".
++ r.get('/vectors/made/:id.svg', async (req, res) => {
++ const row = await own(req, res, req.params.id);
++ if (!row) return undefined;
++ // A saved drawing opened on its own is a document: no script, no fetches, whatever the sanitiser missed.
++ return res.set('Cache-Control', 'private, no-store').set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
++ .set('X-Content-Type-Options', 'nosniff').type('image/svg+xml').send(row.svg);
++ });
++ r.get('/vectors/made/:id.png', async (req, res) => {
++ const row = await own(req, res, req.params.id);
++ if (!row) return undefined;
++ const png = await maker.png(row.svg, Number(req.query.size) || 512).catch(() => null);
++ if (!png) return res.status(502).type('text/plain').send('The drawing could not be rendered as a PNG.');
++ return res.set('Cache-Control', 'private, no-store').type('image/png').send(png);
++ });
++
++ r.get('/vectors/made/:id', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const row = await own(req, res, req.params.id);
++ if (!row) return undefined;
++ return page(res, 200, views.vector({ vector: row }), req.viewer, { cacheControl: 'private, no-store' });
++ });
++
++ r.post('/vectors/made/:id/edit', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).type('text/plain').send('Sign in to change this on openvibe.design.');
++ const row = await store.vector(req.params.id, owner);
++ if (!row) return res.status(404).type('text/plain').send('Not found.');
++ try {
++ const note = String(req.body.note || '').trim().slice(0, 400);
++ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });
++ await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,
++ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });
++ return res.redirect(303, '/vectors/made/' + encodeURIComponent(row.id));
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(plainError(err));
++ }
++ });
++
++ // ── The API ──
++ r.post('/api/v1/vectors', express.json({ limit: '32kb' }), async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to make a vector on openvibe.design.' });
++ try {
++ const prompt = String((req.body && req.body.prompt) || '').trim();
++ const kind = ['icon', 'illustration', 'pattern'].includes(req.body && req.body.kind) ? req.body.kind : 'icon';
++ if (!prompt) return res.status(422).json({ error: 'Describe the drawing you want.' });
++ const drawn = await maker.drawVector({ owner, prompt, kind });
++ const id = s.newId('vec');
++ const saved = await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });
++ return res.status(201).json(saved);
++ } catch (err) {
++ return res.status(err.status || 500).json({ error: plainError(err) });
++ }
++ });
++
++ r.get('/api/v1/vectors/:id', async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });
++ const row = await store.vector(req.params.id, owner);
++ if (!row) return res.status(404).json({ error: 'Not found.' });
++ return res.json(row);
++ });
++
++ r.post('/api/v1/vectors/:id/edit', express.json({ limit: '8kb' }), async (req, res) => {
++ const owner = subjectOf(req);
++ if (!owner) return res.status(401).json({ error: 'Sign in to change this on openvibe.design.' });
++ const row = await store.vector(req.params.id, owner);
++ if (!row) return res.status(404).json({ error: 'Not found.' });
++ try {
++ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);
++ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });
++ const saved = await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,
++ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });
++ return res.json(saved);
++ } catch (err) {
++ return res.status(err.status || 500).json({ error: plainError(err) });
++ }
++ });
++
++ return r;
++}
++
++module.exports = { createTypefaceRoutes, createVectorRoutes, plainError };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex cc7b022..0a9c6bd 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -20,6 +20,13 @@ const TABLES = [
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
{ table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },
{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
++ // The typefaces a person made with the model (/fonts/make): the outlines the model drew and the notes they
++ // gave. The font files are compiled from the outlines on request, so deleting the rows deletes the fonts.
++ { table: 'ai_typefaces', subject: 'owner', file: 'ai_typefaces.json', order: 'created_at', columns: ['id', 'name', 'prompt', 'base_family', 'style_notes', 'glyphs', 'metrics', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
++ // The drawings a person made with the model (/vectors/make). A drawing is theirs; nobody else's is exported.
++ { table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
++ // How many model runs a person made and when. It is their own usage; it goes with the account.
++ { table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },
];
async function extraExport(db, subject) {
ddiff --git a/server/typefaces/compile.js b/server/typefaces/compile.js
nnew file mode 100644
iindex 0000000..a00804c
---- /dev/null
++++ b/server/typefaces/compile.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * A typeface, compiled from the glyphs the model drew (plan R1 item 8: AI typefaces).
++ *
++ * The glyphs arrive as outlines in a 0..1000 em box with the baseline at y = 0 and y growing up: font
++ * coordinates already. The compiler normalises the metrics (one ascender, one descender, one set of side
++ * bearings, one advance width per glyph), builds the font with opentype.js and writes it out.
++ *
++ * compile({ familyName, styleName, glyphs, license }) -> { ok: true, font, woff2, otf } | { ok: false, why }
++ *
++ * The glyph list is a list of { char, d, advance }. The .notdef glyph is always there, so a font that is
++ * missing a letter shows a box rather than nothing.
++ *
++ * The two files are the same font: the WOFF2 for the web (compressed with Google's woff2 encoder, the same one
++ * browsers use) and the OTF for anything that wants the outlines. The licence the person chose goes into the
++ * font's name table, and the model that drew it goes in too, so a font says where it came from.
++ */
++const opentype = require('opentype.js');
++const wawoff2 = require('wawoff2');
++const glyph = require('./glyph');
++const path = require('./path');
++
++/** The characters a face carries: Latin letters, digits and the punctuation a text needs. */
++const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?'\"()-/&@#%+=*";
++
++/** A name a font file may carry: no path separators, nothing a filesystem would mind. */
++const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 .'-]{0,60}$/;
++
++function fail(why) { return { ok: false, why }; }
++
++/**
++ * Normalise the metrics of a set of glyphs. One ascender and one descender for the whole face; the advance width
++ * of each glyph is its ink plus the same side bearing on each side, so the letters sit on one rhythm.
++ */
++function metrics(glyphs) {
++ const side = 60; // the side bearing, in font units
++ let top = 0, bottom = 0;
++ for (const g of glyphs) {
++ top = Math.max(top, g.bounds.y2);
++ bottom = Math.min(bottom, g.bounds.y1);
++ }
++ // The ascender and the descender are the face's own, rounded to a tidy step.
++ const ascender = Math.min(glyph.ASCENDER, Math.max(Math.round(top / 50) * 50, 400));
++ const descender = Math.max(glyph.DESCENDER, Math.min(Math.round(bottom / 50) * 50, -100));
++ const out = [];
++ for (const g of glyphs) {
++ const ink = g.bounds.x2 - g.bounds.x1;
++ const advance = Math.max(Math.round(ink) + side * 2, 120);
++ out.push({
++ char: g.char,
++ d: g.d,
++ advance,
++ // The ink is centred in the advance width, so every letter has the same breathing room.
++ left: Math.round((advance - ink) / 2) - g.bounds.x1,
++ });
++ }
++ return { ascender, descender, glyphs: out };
++}
++
++/**
++ * Compile the face. The outlines are checked once more here (the server never trusts a stored glyph), then the
++ * font is built and written as a WOFF2 and an OTF.
++ */
++async function compile({ familyName, styleName = 'Regular', glyphs, license = 'OFL-1.1', model = null, designer = null } = {}) {
++ const name = String(familyName || '').trim();
++ if (!FAMILY.test(name)) return fail('a font needs a name of letters, digits, spaces or dashes');
++ if (!Array.isArray(glyphs) || !glyphs.length) return fail('the face has no glyphs to compile');
++
++ // Every glyph is read again: a stored outline is data, and data is read before it is used.
++ const checked = [];
++ const seen = new Set();
++ for (const g of glyphs) {
++ const char = String(g.char == null ? '' : g.char);
++ if (!char || seen.has(char)) continue;
++ const r = glyph.clean(g.d);
++ if (!r.ok) continue;
++ seen.add(char);
++ checked.push({ char, ...r.glyph });
++ }
++ if (!checked.length) return fail('none of the glyphs could be read');
++
++ const m = metrics(checked);
++ const opentypeGlyphs = [];
++ // .notdef: a box, so a missing letter shows an empty one.
++ const box = new opentype.Path();
++ box.moveTo(80, 0); box.lineTo(420, 0); box.lineTo(420, 700); box.lineTo(80, 700); box.close();
++ opentypeGlyphs.push(new opentype.Glyph({ name: '.notdef', unicode: 0, advanceWidth: 500, path: box }));
++ // The space, always.
++ opentypeGlyphs.push(new opentype.Glyph({ name: 'space', unicode: 32, advanceWidth: 260, path: new opentype.Path() }));
++
++ for (const g of m.glyphs) {
++ const segments = path.parse(g.d);
++ if (!segments.ok) continue;
++ const p = path.pathOf(segments.segments);
++ // The outline is moved so the ink sits where the advance width says it does.
++ const moved = new opentype.Path();
++ for (const c of p.commands) {
++ const cmd = { ...c };
++ if (cmd.x !== undefined) cmd.x += g.left;
++ if (cmd.x1 !== undefined) cmd.x1 += g.left;
++ if (cmd.x2 !== undefined) cmd.x2 += g.left;
++ moved.commands.push(cmd);
++ }
++ const cp = g.char.codePointAt(0);
++ opentypeGlyphs.push(new opentype.Glyph({
++ name: 'uni' + cp.toString(16).toUpperCase().padStart(4, '0'),
++ unicode: cp,
++ advanceWidth: g.advance,
++ path: moved,
++ }));
++ }
++
++ // The name table says who made it and under what licence: a font that travels says where it came from.
++ // opentype.js writes one table per platform, so these go in as options rather than as edits afterwards.
++ const maker = String(designer || 'Made with OpenVibe.Design').slice(0, 60);
++ const font = new opentype.Font({
++ familyName: name,
++ styleName,
++ unitsPerEm: glyph.UPEM,
++ ascender: m.ascender,
++ descender: m.descender,
++ glyphs: opentypeGlyphs,
++ designer: maker,
++ license: String(license || 'OFL-1.1').slice(0, 60),
++ copyright: maker,
++ description: model ? 'Drawn by ' + String(model).slice(0, 60) + ' on OpenVibe.Design' : 'Made on OpenVibe.Design',
++ });
++ font.outlinesFormat = 'cff';
++
++ const otf = Buffer.from(font.toArrayBuffer());
++ const woff2 = Buffer.from(await wawoff2.compress(otf));
++ return {
++ ok: true,
++ font,
++ otf,
++ woff2,
++ metrics: { ascender: m.ascender, descender: m.descender, unitsPerEm: glyph.UPEM, glyphs: checked.length },
++ glyphs: m.glyphs,
++ };
++}
++
++module.exports = { compile, metrics, CHARSET, FAMILY };
ddiff --git a/server/typefaces/glyph.js b/server/typefaces/glyph.js
nnew file mode 100644
iindex 0000000..942c61f
---- /dev/null
++++ b/server/typefaces/glyph.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * A glyph outline, checked before it may become part of a font (plan R1 item 8: AI typefaces).
++ *
++ * The model writes outlines as SVG path data. Whatever comes back is hostile input until it has been read: the
++ * checks here are the ones a font needs, and nothing else is allowed through.
++ *
++ * clean(data) → { ok: true, glyph } | { ok: false, why }
++ *
++ * A glyph must be:
++ * - ONE path. A letter may have several contours (the hole in an "o"), but they all live in one path element.
++ * - CLOSED. Every contour ends with Z, so the outline has no open end to leak fill or show a seam.
++ * - INSIDE the em box. The outline may not leave the 0..upem box the font works in, with a small margin for
++ * the overshoot a curve needs.
++ * - PLAIN. Numbers and path commands only: no script, no style, no url(), no external reference of any kind.
++ *
++ * The result is the same outline, normalised: absolute commands, two decimals, and the metrics a font needs
++ * (the advance width and the bounds).
++ */
++const path = require('./path');
++
++/** The em box every outline lives in. The font's own units per em. */
++const UPEM = 1000;
++/** The baseline sits at 0; the ascenders reach 800 and the descenders fall to -200. */
++const ASCENDER = 800;
++const DESCENDER = -200;
++/** A curve may overshoot the em box by this much, or it would look cut off. */
++const OVERSHOOT = 20;
++
++/** What a model may send for one glyph: path data, or a whole <path> element. */
++const MAX_BYTES = 8000;
++
++/** Path data is commands and numbers only. Nothing else may appear in it. */
++const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s+\-eE]*$/;
++
++/**
++ * The markup a model may wrap the path in, and what may be inside it. A <path> element with a d attribute, and
++ * nothing else: no script, no style, no image, no use, no event handler, no external reference.
++ */
++const ELEMENT = /<\s*([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)>/;
++const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
++const DANGEROUS = /^(?:on|xmlns:xlink|xlink|href|src|formaction|style|srcset)$/i;
++
++/** Read one path element out of the model's answer. */
++function extract(data) {
++ const raw = String(data == null ? '' : data).trim();
++ if (!raw) return { ok: false, why: 'the glyph is empty' };
++ if (raw.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };
++ // A code fence or a sentence around the path is stripped, so an answer that says "here it is" still works.
++ const fenced = /\x60\x60\x60[a-z]*\s*([\s\S]*?)\x60\x60\x60/i.exec(raw);
++ const body = (fenced ? fenced[1] : raw).trim();
++ if (!body) return { ok: false, why: 'the glyph is empty' };
++
++ // Refuse anything that is not a path element before looking for one: a script or a style is never acceptable.
++ if (/<\s*(script|style|foreignObject|iframe|object|embed|image|use|a)\b/i.test(body))
++ return { ok: false, why: 'a glyph is a path, not a script or a reference' };
++ if (/javascript:|data:text\/html|<!\[CDATA\[|&#[x]?[0-9a-f]/i.test(body))
++ return { ok: false, why: 'the glyph carries something a font must not carry' };
++
++ // Bare path data (no element) is fine: that is what most models answer with.
++ if (!body.includes('<')) return { ok: true, d: body };
++
++ const match = ELEMENT.exec(body);
++ if (!match) return { ok: false, why: 'the glyph is not a path element' };
++ const name = match[1].toLowerCase();
++ if (name !== 'path') return { ok: false, why: 'a glyph is one <path> element, not a <' + name + '>' };
++ // Nothing may follow the path element.
++ const rest = body.slice(match.index + match[0].length).replace(/<\s*\/\s*path\s*>/gi, '').trim();
++ if (rest) return { ok: false, why: 'a glyph is one path and nothing else' };
++
++ let d = null;
++ for (const a of match[2].matchAll(ATTR)) {
++ const key = a[1].toLowerCase();
++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
++ if (DANGEROUS.test(key)) return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };
++ if (key === 'd') { d = value; continue; }
++ // fill, fill-rule and transform are the only harmless extras; a font sets its own colour anyway.
++ if (key === 'fill' || key === 'fill-rule' || key === 'transform') continue;
++ return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };
++ }
++ if (!d) return { ok: false, why: 'the path has no d attribute' };
++ return { ok: true, d };
++}
++
++/**
++ * Check one glyph. Returns the normalised outline and the metrics a font needs, or a plain reason in words.
++ */
++function clean(data) {
++ const found = extract(data);
++ if (!found.ok) return found;
++ const d = String(found.d || '').trim();
++ if (!d) return { ok: false, why: 'the path has no d attribute' };
++ if (d.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };
++ if (!PATH_DATA.test(d)) return { ok: false, why: 'the path data has something a path cannot hold' };
++
++ const parsed = path.parse(d);
++ if (!parsed.ok) return { ok: false, why: parsed.why };
++ const segments = parsed.segments;
++
++ // One path, several contours allowed (the hole in an "o"), but every contour closed.
++ const contours = path.subpaths(segments);
++ if (!contours.length) return { ok: false, why: 'the path draws nothing' };
++ if (contours.length > 4) return { ok: false, why: 'a letter is not that many separate shapes' };
++ if (!path.closed(segments)) return { ok: false, why: 'the outline is not closed' };
++
++ const box = path.bbox(segments);
++ if (!box) return { ok: false, why: 'the path draws nothing' };
++ if (box.x1 < -OVERSHOOT || box.y1 < DESCENDER - OVERSHOOT || box.x2 > UPEM + OVERSHOOT || box.y2 > ASCENDER + OVERSHOOT)
++ return { ok: false, why: 'the outline leaves the em box' };
++ // A letter has some size: a mark smaller than a fiftieth of the em reads as a speck, not a letter.
++ if (box.x2 - box.x1 < 20 || box.y2 - box.y1 < 20) return { ok: false, why: 'the outline is too small to read' };
++
++ // The outline is placed as the model drew it: the baseline is y = 0, y grows up.
++ return {
++ ok: true,
++ glyph: {
++ d: path.toData(segments),
++ advance: Math.round(box.x2 - box.x1) + 120, // the ink plus a side bearing on each side
++ bounds: { x1: Math.round(box.x1), y1: Math.round(box.y1), x2: Math.round(box.x2), y2: Math.round(box.y2) },
++ },
++ };
++}
++
++module.exports = { clean, extract, UPEM, ASCENDER, DESCENDER, OVERSHOOT, MAX_BYTES };
ddiff --git a/server/typefaces/path.js b/server/typefaces/path.js
nnew file mode 100644
iindex 0000000..159774d
---- /dev/null
++++ b/server/typefaces/path.js
@@@ -0,0 +1,338 @@
++'use strict';
++/**
++ * SVG path data, read for a typeface (plan R1 item 8: AI typefaces).
++ *
++ * One parser for the whole service: the model writes outlines as path data, and the server has to know three things
++ * about each one before it may become a glyph — is it a single closed contour inside the em box, how big is it, and
++ * what commands does it use. This module answers all three and hands back a clean, absolute command list.
++ *
++ * Absolute commands only (M L C Q Z), because a font's outlines are absolute: the model may write relative commands
++ * or arcs, and both are converted here. Arcs become cubic curves (the same curve, four segments at most), because a
++ * font's outlines are cubics and the compiler does not read arcs.
++ *
++ * parse(d) → { ok: true, segments, bbox, subpaths } | { ok: false, why }
++ * bbox(segments) → { x1, y1, x2, y2 } (the true bounds, curves included)
++ * closed(segments)→ true when every contour ends with Z
++ * pathOf(segments)→ an opentype.js Path
++ * toData(segments)→ normalised path data (absolute, two decimals)
++ */
++
++const NUM = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/;
++const COMMANDS = 'MmLlHhVvCcSsQqTtAaZz';
++const EPS = 1e-6;
++
++/** Round to two decimals and drop a trailing zero: the same outline, written once. */
++function round(n) {
++ const v = Math.round(Number(n) * 100) / 100;
++ return Object.is(v, -0) ? 0 : v;
++}
++
++function numText(n) {
++ return String(round(n));
++}
++
++/** The scanner: numbers and command letters, with the separators between them. */
++function scan(d) {
++ const src = String(d || '');
++ const out = [];
++ let i = 0;
++ while (i < src.length) {
++ const c = src[i];
++ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === ',') { i++; continue; }
++ if (COMMANDS.includes(c)) { out.push({ cmd: c }); i++; continue; }
++ const m = NUM.exec(src.slice(i));
++ if (!m || !m[0]) return { ok: false, why: 'unexpected character "' + c + '" in the path data' };
++ out.push({ num: Number(m[0]) });
++ i += m[0].length;
++ }
++ return { ok: true, tokens: out };
++}
++
++/** The points of one cubic, and where it actually turns (the curve's true bounds). */
++function cubicExtrema(p0, p1, p2, p3) {
++ const ts = [0, 1];
++ // B'(t) = 3(1-t)^2 (p1-p0) + 6(1-t)t (p2-p1) + 3t^2 (p3-p2) → a quadratic in t.
++ const a = -p0 + 3 * p1 - 3 * p2 + p3;
++ const b = 2 * (p0 - 2 * p1 + p2);
++ const c = p1 - p0;
++ const push = (t) => { if (t > EPS && t < 1 - EPS) ts.push(t); };
++ if (Math.abs(a) < EPS) {
++ if (Math.abs(b) > EPS) push(-c / b);
++ } else {
++ const disc = b * b - 4 * a * c;
++ if (disc >= 0) {
++ const sq = Math.sqrt(disc);
++ push((-b + sq) / (2 * a));
++ push((-b - sq) / (2 * a));
++ }
++ }
++ const at = (t) => {
++ const u = 1 - t;
++ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
++ };
++ return ts.map(at);
++}
++
++function quadExtrema(p0, p1, p2) {
++ const ts = [0, 1];
++ const den = p0 - 2 * p1 + p2;
++ if (Math.abs(den) > EPS) {
++ const t = (p0 - p1) / den;
++ if (t > EPS && t < 1 - EPS) ts.push(t);
++ }
++ const at = (t) => (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2;
++ return ts.map(at);
++}
++
++/** An elliptical arc as cubic segments (the endpoint-to-centre form, split into quarter-turns). */
++function arcToCubics(x1, y1, rx, ry, phiDeg, largeArc, sweep, x2, y2) {
++ if (rx === 0 || ry === 0) return [[x1, y1, x2, y2, x2, y2]];
++ const phi = (phiDeg % 360) * Math.PI / 180;
++ const cosP = Math.cos(phi), sinP = Math.sin(phi);
++ const dx2 = (x1 - x2) / 2, dy2 = (y1 - y2) / 2;
++ const x1p = cosP * dx2 + sinP * dy2;
++ const y1p = -sinP * dx2 + cosP * dy2;
++ let rxa = Math.abs(rx), rya = Math.abs(ry);
++ const lambda = (x1p * x1p) / (rxa * rxa) + (y1p * y1p) / (rya * rya);
++ if (lambda > 1) { const s = Math.sqrt(lambda); rxa *= s; rya *= s; }
++ const sign = largeArc === sweep ? -1 : 1;
++ const num = rxa * rxa * rya * rya - rxa * rxa * y1p * y1p - rya * rya * x1p * x1p;
++ const den = rxa * rxa * y1p * y1p + rya * rya * x1p * x1p;
++ const coef = sign * Math.sqrt(Math.max(0, num / (den || 1)));
++ const cxp = coef * (rxa * y1p / rya);
++ const cyp = coef * -(rya * x1p / rxa);
++ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
++ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
++ const angle = (ux, uy, vx, vy) => {
++ const dot = ux * vx + uy * vy;
++ const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) || 1;
++ let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
++ if (ux * vy - uy * vx < 0) a = -a;
++ return a;
++ };
++ const ux = (x1p - cxp) / rxa, uy = (y1p - cyp) / rya;
++ const vx = (-x1p - cxp) / rxa, vy = (-y1p - cyp) / rya;
++ const theta1 = angle(1, 0, ux, uy);
++ let dTheta = angle(ux, uy, vx, vy);
++ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
++ else if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
++ const parts = Math.ceil(Math.abs(dTheta) / (Math.PI / 2)) || 1;
++ const delta = dTheta / parts;
++ const k = (4 / 3) * Math.tan(delta / 4);
++ const out = [];
++ let th = theta1;
++ let px = x1, py = y1;
++ for (let i = 0; i < parts; i++) {
++ const th2 = th + delta;
++ const cos1 = Math.cos(th), sin1 = Math.sin(th), cos2 = Math.cos(th2), sin2 = Math.sin(th2);
++ const e1x = cx + rxa * cosP * cos1 - rya * sinP * sin1;
++ const e1y = cy + rxa * sinP * cos1 + rya * cosP * sin1;
++ const e2x = cx + rxa * cosP * cos2 - rya * sinP * sin2;
++ const e2y = cy + rxa * sinP * cos2 + rya * cosP * sin2;
++ const d1x = -rxa * cosP * sin1 - rya * sinP * cos1;
++ const d1y = -rxa * sinP * sin1 + rya * cosP * cos1;
++ const d2x = -rxa * cosP * sin2 - rya * sinP * cos2;
++ const d2y = -rxa * sinP * sin2 + rya * cosP * cos2;
++ out.push([px + k * d1x, py + k * d1y, e2x - k * d2x, e2y - k * d2y, e2x, e2y]);
++ px = e2x; py = e2y; th = th2;
++ }
++ return out;
++}
++
++/**
++ * Read the path data. Every command is made absolute; S and T take the previous control point into account; an arc
++ * becomes cubics. The result is a list of { type, args } with types M, L, C, Q and Z.
++ */
++function parse(d) {
++ const scanned = scan(d);
++ if (!scanned.ok) return scanned;
++ const tokens = scanned.tokens;
++ const segments = [];
++ let x = 0, y = 0, startX = 0, startY = 0;
++ let lastCtrl = null, lastQuad = null, cmd = null;
++ let i = 0;
++ const need = (n) => {
++ const vals = [];
++ for (let k = 0; k < n; k++) {
++ const t = tokens[i++];
++ if (!t || t.num === undefined) return null;
++ vals.push(t.num);
++ }
++ return vals;
++ };
++ while (i < tokens.length) {
++ const t = tokens[i];
++ if (t.cmd) { cmd = t.cmd; i++; }
++ else if (!cmd) return { ok: false, why: 'the path data starts with a number, not a command' };
++ else if (cmd === 'M') cmd = 'L'; // extra pairs after a move are lines
++ else if (cmd === 'm') cmd = 'l';
++ const rel = cmd === cmd.toLowerCase();
++ const abs = cmd.toUpperCase();
++ if (abs === 'Z') {
++ // The command letter was already consumed above; Z never repeats, so nothing is read here.
++ segments.push({ type: 'Z', args: [] });
++ x = startX; y = startY; lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ if (abs === 'M') {
++ const v = need(2); if (!v) return { ok: false, why: 'a move needs two numbers' };
++ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];
++ startX = x; startY = y;
++ segments.push({ type: 'M', args: [x, y] });
++ lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ if (abs === 'L') {
++ const v = need(2); if (!v) return { ok: false, why: 'a line needs two numbers' };
++ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];
++ segments.push({ type: 'L', args: [x, y] });
++ lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ if (abs === 'H') {
++ const v = need(1); if (!v) return { ok: false, why: 'a horizontal line needs one number' };
++ x = rel ? x + v[0] : v[0];
++ segments.push({ type: 'L', args: [x, y] });
++ lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ if (abs === 'V') {
++ const v = need(1); if (!v) return { ok: false, why: 'a vertical line needs one number' };
++ y = rel ? y + v[0] : v[0];
++ segments.push({ type: 'L', args: [x, y] });
++ lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ if (abs === 'C') {
++ const v = need(6); if (!v) return { ok: false, why: 'a curve needs six numbers' };
++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3], x + v[4], y + v[5]] : v;
++ segments.push({ type: 'C', args: p });
++ lastCtrl = [p[2], p[3]];
++ x = p[4]; y = p[5];
++ lastQuad = null;
++ continue;
++ }
++ if (abs === 'S') {
++ const v = need(4); if (!v) return { ok: false, why: 'a smooth curve needs four numbers' };
++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;
++ const c1 = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y];
++ segments.push({ type: 'C', args: [c1[0], c1[1], p[0], p[1], p[2], p[3]] });
++ lastCtrl = [p[0], p[1]];
++ x = p[2]; y = p[3];
++ lastQuad = null;
++ continue;
++ }
++ if (abs === 'Q') {
++ const v = need(4); if (!v) return { ok: false, why: 'a quadratic curve needs four numbers' };
++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;
++ segments.push({ type: 'Q', args: p });
++ lastQuad = [p[0], p[1]];
++ x = p[2]; y = p[3];
++ lastCtrl = null;
++ continue;
++ }
++ if (abs === 'T') {
++ const v = need(2); if (!v) return { ok: false, why: 'a smooth quadratic needs two numbers' };
++ const p = rel ? [x + v[0], y + v[1]] : v;
++ const c1 = lastQuad ? [2 * x - lastQuad[0], 2 * y - lastQuad[1]] : [x, y];
++ segments.push({ type: 'Q', args: [c1[0], c1[1], p[0], p[1]] });
++ lastQuad = c1;
++ x = p[0]; y = p[1];
++ lastCtrl = null;
++ continue;
++ }
++ if (abs === 'A') {
++ const v = need(7); if (!v) return { ok: false, why: 'an arc needs seven numbers' };
++ const [rx, ry, phi, laf, sf, ex, ey] = v;
++ const tx = rel ? x + ex : ex, ty = rel ? y + ey : ey;
++ const parts = arcToCubics(x, y, rx, ry, phi, laf !== 0, sf !== 0, tx, ty);
++ for (const p of parts) segments.push({ type: 'C', args: p });
++ x = tx; y = ty;
++ lastCtrl = null; lastQuad = null;
++ continue;
++ }
++ return { ok: false, why: 'the command "' + cmd + '" is not a path command' };
++ }
++ if (!segments.length) return { ok: false, why: 'the path data is empty' };
++ return { ok: true, segments };
++}
++
++/** The true bounds of the outline, curves included. */
++function bbox(segments) {
++ let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
++ const add = (x, y) => { if (x < x1) x1 = x; if (y < y1) y1 = y; if (x > x2) x2 = x; if (y > y2) y2 = y; };
++ let cx = 0, cy = 0, sx = 0, sy = 0;
++ for (const s of segments) {
++ if (s.type === 'M') { cx = sx = s.args[0]; cy = sy = s.args[1]; add(cx, cy); continue; }
++ if (s.type === 'L') { cx = s.args[0]; cy = s.args[1]; add(cx, cy); continue; }
++ if (s.type === 'C') {
++ const [a, b, c, d, e, f] = s.args;
++ const xs = cubicExtrema(cx, a, c, e);
++ const ys = cubicExtrema(cy, b, d, f);
++ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);
++ cx = e; cy = f;
++ continue;
++ }
++ if (s.type === 'Q') {
++ const [a, b, c, d] = s.args;
++ const xs = quadExtrema(cx, a, c), ys = quadExtrema(cy, b, d);
++ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);
++ cx = c; cy = d;
++ continue;
++ }
++ if (s.type === 'Z') { cx = sx; cy = sy; }
++ }
++ if (!Number.isFinite(x1)) return null;
++ return { x1, y1, x2, y2 };
++}
++
++/** The contours of the outline: each with its start and whether it was closed. */
++function subpaths(segments) {
++ const out = [];
++ let cur = null;
++ for (const s of segments) {
++ if (s.type === 'M') { cur = { start: [s.args[0], s.args[1]], closed: false, points: [[s.args[0], s.args[1]]] }; out.push(cur); continue; }
++ if (!cur) continue;
++ if (s.type === 'L') cur.points.push([s.args[0], s.args[1]]);
++ else if (s.type === 'C') cur.points.push([s.args[4], s.args[5]]);
++ else if (s.type === 'Q') cur.points.push([s.args[2], s.args[3]]);
++ else if (s.type === 'Z') cur.closed = true;
++ }
++ return out;
++}
++
++/** True when every contour ends with a close: a glyph's outline must not run off the edge. */
++function closed(segments) {
++ const subs = subpaths(segments);
++ if (!subs.length) return false;
++ return subs.every((s) => s.closed);
++}
++
++/** The same outline as path data: absolute, two decimals, arcs already cubics. */
++function toData(segments) {
++ const parts = [];
++ for (const s of segments) {
++ if (s.type === 'M') parts.push('M' + numText(s.args[0]) + ' ' + numText(s.args[1]));
++ else if (s.type === 'L') parts.push('L' + numText(s.args[0]) + ' ' + numText(s.args[1]));
++ else if (s.type === 'C') parts.push('C' + s.args.map(numText).join(' '));
++ else if (s.type === 'Q') parts.push('Q' + s.args.map(numText).join(' '));
++ else parts.push('Z');
++ }
++ return parts.join(' ');
++}
++
++/** The outline as an opentype.js Path (font coordinates: y up, the baseline at 0). */
++function pathOf(segments) {
++ const opentype = require('opentype.js');
++ const path = new opentype.Path();
++ for (const s of segments) {
++ if (s.type === 'M') path.moveTo(s.args[0], s.args[1]);
++ else if (s.type === 'L') path.lineTo(s.args[0], s.args[1]);
++ else if (s.type === 'C') path.bezierCurveTo(s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]);
++ else if (s.type === 'Q') path.quadraticCurveTo(s.args[0], s.args[1], s.args[2], s.args[3]);
++ else path.close();
++ }
++ return path;
++}
++
++module.exports = { parse, bbox, subpaths, closed, toData, pathOf, round, numText, scan };
ddiff --git a/server/typefaces/service.js b/server/typefaces/service.js
nnew file mode 100644
iindex 0000000..f6767b1
---- /dev/null
++++ b/server/typefaces/service.js
@@@ -0,0 +1,274 @@
++'use strict';
++/**
++ * The model, drawing (plan R1 item 8, Seven Domains §11).
++ *
++ * Two things are made here: a typeface and a vector. Both are the person's own work, both are attributed
++ * to them (on_behalf_of, so their own allowance pays for it), and both are capped per person per day.
++ *
++ * A typeface is drawn in batches. A face carries about ninety characters, and asking a model for ninety
++ * outlines in one answer does not work: it runs long, it repeats itself and it gives up on the tail. So the
++ * letters are asked for a batch at a time, with the same style notes carried through every batch, and each
++ * outline is checked before it is kept.
++ *
++ * A vector is drawn in one answer, cleaned, and saved.
++ */
++const glyph = require('./glyph');
++const { compile, CHARSET, FAMILY } = require('./compile');
++const { sanitize } = require('../vectors/svg');
++
++/** How many letters are asked for at a time. */
++const BATCH = 26;
++
++/** How many model runs a person may make a day, when the config does not say. */
++const DEFAULT_PER_DAY = 20;
++
++/** The style notes, carried through every batch so the letters look like one family. */
++const STYLE = [
++ 'You are a type designer drawing the outlines of a typeface.',
++ 'Answer with JSON only: {"glyphs": {"A": "<path d=\"...\"/>", "B": "<path d=\"...\"/>", ...}}.',
++ 'Each glyph is ONE <path> element with only a d attribute. Nothing else: no group, no fill, no style, no script, no explanation.',
++ 'The coordinates are font coordinates: x grows to the right, y grows UP, the baseline is y = 0.',
++ 'The em box is 1000 units wide: x from 0 to 1000, y from -200 (descenders) to 800 (cap height and ascenders).',
++ 'Cap height is 700, so an uppercase letter runs from y = 0 to y = 700. Lowercase x-height is 500.',
++ 'Letters sit on the baseline and start at x = 60, with about 60 units of space on each side.',
++ 'Every contour must be closed: the last command of each contour is Z.',
++ 'Curves are C or Q commands. Never use arcs (A). Never use relative commands.',
++ 'Letters with a hole (o, e, a, b, d, g, p, q, 0, 4, 6, 8, 9) have the hole as a second closed contour inside the first.',
++].join('\n');
++
++/** What a vector answer looks like. */
++const VECTOR_STYLE = [
++ 'You are an illustrator drawing one clean SVG.',
++ 'Answer with the SVG only: <svg viewBox="0 0 512 512"> ... </svg>.',
++ 'The drawing is made of path, circle, ellipse, line, polyline, polygon and rect elements, and nothing else.',
++ 'No script, no style element, no image, no use, no foreignObject, no external reference of any kind.',
++ 'Use fill and stroke for colour; keep the drawing flat and legible at small sizes.',
++].join('\n');
++
++function fail(status, code, detail) {
++ return Object.assign(new Error(detail), { status, code, detail });
++}
++
++/** The letters of the charset, in the batches they are drawn in. */
++function batches(charset = CHARSET) {
++ const out = [];
++ for (let i = 0; i < charset.length; i += BATCH) out.push(charset.slice(i, i + BATCH));
++ return out;
++}
++
++/** The letters a typeface must have to be a typeface at all. */
++const CORE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
++
++function createService({ db, ai, store, config = {}, log = console, s, sharing = null } = {}) {
++ const perDay = Math.max(0, Number((config.aiMake && config.aiMake.perDay) || DEFAULT_PER_DAY) || 0);
++
++ /** The cap: how many runs this person has left today. */
++ async function left(owner) {
++ if (!owner) return 0;
++ const used = await store.runsToday(owner);
++ return Math.max(0, perDay - used);
++ }
++
++ /** A name for the face: what the person wrote, made into a font name. */
++ function nameFrom(prompt, base) {
++ const words = String(prompt || '').trim().split(/\s+/).slice(0, 4).join(' ');
++ const candidate = (words || base || 'New Face').replace(/[^A-Za-z0-9 .'-]/g, ' ').trim().slice(0, 60);
++ return candidate && FAMILY.test(candidate) ? candidate : (base ? String(base).slice(0, 60) : 'New Face');
++ }
++
++ /** One model answer, read as JSON. */
++ function readJson(text) {
++ const body = String(text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
++ const start = body.indexOf('{');
++ const end = body.lastIndexOf('}');
++ if (start < 0 || end <= start) return null;
++ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }
++ }
++
++ /** The style notes the model is given, with the person's own words and the base they chose. */
++ function notesFor({ prompt, baseFamily, baseNote }) {
++ return [
++ STYLE,
++ '',
++ 'The typeface: ' + String(prompt || 'a plain, legible sans').slice(0, 600),
++ baseFamily ? 'It is based on ' + String(baseFamily).slice(0, 80) + ', and keeps that letterform.' : '',
++ baseNote ? String(baseNote).slice(0, 400) : '',
++ 'Draw these characters, and only these: ' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?' + '"' + '()-/&@#%+=*',
++ ].filter(Boolean).join('\n');
++ }
++
++ /**
++ * Draw a typeface. The letters are asked for a batch at a time; each outline is checked as it arrives,
++ * and a batch that comes back unusable is asked for once more, saying what was wrong with it.
++ */
++ async function drawTypeface({ owner, prompt, baseFamily = null, baseNote = null, name = null, license = 'OFL-1.1', onProgress = null } = {}) {
++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
++ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a typeface on openvibe.design.');
++ const remaining = await left(owner);
++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
++
++ const groups = batches();
++ const drawn = new Map();
++ let provider = null;
++ let model = null;
++ let runs = 0;
++
++ for (const group of groups) {
++ const ask = async (extra = '') => {
++ await store.countRun(owner, 'typeface');
++ runs++;
++ const answer = await ai.text({
++ system: STYLE,
++ prompt: notesFor({ prompt, baseFamily, baseNote }) + (extra ? '\n\n' + extra : ''),
++ maxTokens: 8000,
++ target: { service: 'design', type: 'typeface', id: String(baseFamily || 'new') },
++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
++ person: String(owner),
++ });
++ provider = provider || (answer.provider || null);
++ model = model || (answer.model || null);
++ return answer;
++ };
++ const keep = (answer) => {
++ const json = readJson(answer.text);
++ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};
++ const kept = [];
++ const whys = [];
++ for (const char of group) {
++ if (drawn.has(char)) continue;
++ const raw = got[char] !== undefined ? got[char] : (got[char.toUpperCase()] !== undefined ? got[char.toUpperCase()] : null);
++ if (raw === null || raw === undefined) { whys.push(char + ' was missing'); continue; }
++ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);
++ if (!clean.ok) { whys.push(char + ': ' + clean.why); continue; }
++ drawn.set(char, clean.glyph);
++ kept.push(char);
++ }
++ return { kept, whys };
++ };
++
++ let answer = await ask();
++ let result = keep(answer);
++ // A batch that came back unusable is asked for once more, saying what was wrong with it.
++ if (result.kept.length < group.length * 0.6) {
++ const why = result.whys.slice(0, 8).join('; ');
++ const again = await ask('The last answer had problems: ' + (why || 'too few usable letters') + '. Draw them again, carefully.');
++ const second = keep(again);
++ if (second.kept.length > result.kept.length) result = second;
++ }
++ if (onProgress) onProgress({ drawn: drawn.size, total: CORE.length });
++ }
++
++ const glyphs = [...drawn.entries()].map(([char, g]) => ({ char, d: g.d, advance: g.advance, bounds: g.bounds }));
++ const have = new Set(glyphs.map((g) => g.char));
++ const missing = CORE.split('').filter((c) => !have.has(c));
++ if (!glyphs.length) throw fail(502, 'ai.refused', 'The model did not draw any letters. Try again with a clearer description.');
++
++ const face = await compile({
++ familyName: name || nameFrom(prompt, baseFamily),
++ glyphs,
++ license,
++ model,
++ designer: 'Made on OpenVibe.Design',
++ });
++ if (!face.ok) throw fail(502, 'ai.refused', face.why);
++
++ return {
++ glyphs,
++ metrics: { ...face.metrics, missing },
++ provider,
++ model,
++ license,
++ runs,
++ name: name || nameFrom(prompt, baseFamily),
++ };
++ }
++
++ /**
++ * Draw one vector. The answer is cleaned before it is kept: an SVG is read out of the allowlist and
++ * written again from scratch, so nothing the model wrote survives unless it was allowed.
++ */
++ async function drawVector({ owner, prompt, kind = 'icon', note = null, onBehalf = true } = {}) {
++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
++ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a vector on openvibe.design.');
++ const remaining = await left(owner);
++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
++
++ await store.countRun(owner, 'vector');
++ const answer = await ai.text({
++ system: VECTOR_STYLE,
++ prompt: 'Draw: ' + String(prompt || '').slice(0, 800) + (note ? '\n\nThe note on the last drawing: ' + String(note).slice(0, 400) : ''),
++ maxTokens: 3000,
++ target: { service: 'design', type: 'vector', id: String(kind || 'icon') },
++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
++ ...(onBehalf ? { person: String(owner) } : {}),
++ });
++
++ // The SVG is taken out of the answer, then cleaned.
++ const raw = String(answer.text || '');
++ const start = raw.indexOf('<svg');
++ const end = raw.lastIndexOf('</svg>');
++ const markup = start >= 0 && end > start ? raw.slice(start, end + 6) : raw;
++ const clean = sanitize(markup);
++ if (!clean.ok) throw fail(502, 'ai.refused', 'The model drew something that is not a drawing: ' + clean.why);
++
++ return {
++ svg: clean.svg,
++ provider: answer.provider || null,
++ model: answer.model || null,
++ license: 'OFL-1.1',
++ kind: String(kind || 'icon'),
++ };
++ }
++
++ /**
++ * Redraw one glyph with a note: "make the a rounder", "the g needs a tail".
++ * The face's own style notes go with it, so the letter comes back like the rest of them.
++ */
++ async function redrawGlyph({ owner, face, char, note } = {}) {
++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
++ if (!owner) throw fail(401, 'auth.required', 'Sign in to redraw this on openvibe.design.');
++ const remaining = await left(owner);
++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
++ const wanted = String(char || '').slice(0, 4);
++ if (!wanted) throw fail(422, 'ai.invalid_input', 'Say which letter to redraw.');
++ const what = String(note || '').trim().slice(0, 400);
++ if (!what) throw fail(422, 'ai.invalid_input', 'Say what to change about the letter.');
++
++ await store.countRun(owner, 'typeface');
++ const answer = await ai.text({
++ system: STYLE,
++ prompt: [
++ notesFor({ prompt: face.prompt, baseFamily: face.baseFamily, baseNote: face.styleNotes && face.styleNotes !== face.prompt ? face.styleNotes : null }),
++ '',
++ 'Now draw ONE letter: "' + wanted + '".',
++ 'What to change: ' + what,
++ 'Answer with JSON only: {"glyphs": {"' + wanted + '": "<path d=\"...\"/>"}}.',
++ ].join('\n'),
++ maxTokens: 1200,
++ target: { service: 'design', type: 'typeface_glyph', id: String(face.id || 'new') },
++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
++ person: String(owner),
++ });
++ const json = readJson(answer.text);
++ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};
++ const raw = got[wanted] !== undefined ? got[wanted] : (got[wanted.toUpperCase()] !== undefined ? got[wanted.toUpperCase()] : null);
++ if (raw === null || raw === undefined) return null;
++ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);
++ if (!clean.ok) return null;
++ return { char: wanted, d: clean.glyph.d, advance: clean.glyph.advance, bounds: clean.glyph.bounds, model: answer.model || null };
++ }
++
++ /**
++ * A drawing as a PNG, for the download. resvg draws the SVG; it is already clean, so it is drawn as it is.
++ */
++ async function png(svg, size = 512) {
++ const pixels = Math.max(16, Math.min(2048, Number(size) || 512));
++ const { Resvg } = require('@resvg/resvg-js');
++ const rendered = new Resvg(String(svg || ''), { fitTo: { mode: 'width', value: pixels }, background: 'rgba(0,0,0,0)' });
++ return rendered.render().asPng();
++ }
++
++ return { drawTypeface, drawVector, redrawGlyph, png, left, batches, notesFor, perDay, CORE };
++}
++
++module.exports = { createService, batches, fail, DEFAULT_PER_DAY, STYLE, VECTOR_STYLE, CORE, BATCH };
ddiff --git a/server/typefaces/store.js b/server/typefaces/store.js
nnew file mode 100644
iindex 0000000..498dd65
---- /dev/null
++++ b/server/typefaces/store.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * Where the typefaces and vectors a person made live (plan R1 item 8, Seven Domains §11).
++ *
++ * Two tables, one rule: a row belongs to the person who made it and to nobody else. Nothing here is
++ * published, shared or listed by the site; the only way out is the person's own page.
++ *
++ * The daily cap is measured here too: every model run is counted, and the count is what the cap in the
++ * config is compared against.
++ */
++
++/** A typeface row, as the pages and the API see it. */
++function typefaceRow(row) {
++ if (!row) return null;
++ return {
++ id: String(row.id),
++ name: String(row.name),
++ prompt: String(row.prompt || ''),
++ baseFamily: row.base_family ? String(row.base_family) : null,
++ styleNotes: row.style_notes ? String(row.style_notes) : null,
++ glyphs: Array.isArray(row.glyphs) ? row.glyphs : [],
++ metrics: row.metrics && typeof row.metrics === 'object' ? row.metrics : {},
++ provider: row.provider ? String(row.provider) : null,
++ model: row.model ? String(row.model) : null,
++ license: String(row.license || 'OFL-1.1'),
++ state: String(row.state || 'drawing'),
++ note: row.note ? String(row.note) : null,
++ createdAt: row.created_at,
++ };
++}
++
++/** A vector row, as the pages and the API see it. */
++function vectorRow(row) {
++ if (!row) return null;
++ return {
++ id: String(row.id),
++ prompt: String(row.prompt || ''),
++ kind: String(row.kind || 'icon'),
++ svg: String(row.svg || ''),
++ provider: row.provider ? String(row.provider) : null,
++ model: row.model ? String(row.model) : null,
++ license: String(row.license || 'OFL-1.1'),
++ state: String(row.state || 'drawn'),
++ note: row.note ? String(row.note) : null,
++ createdAt: row.created_at,
++ };
++}
++
++function createStore({ db, log = console, now = () => new Date() } = {}) {
++ /** A typeface of this person's, or null. Another person's typeface is not found, not refused. */
++ async function typeface(id, owner) {
++ const row = await db.maybe('SELECT * FROM ai_typefaces WHERE id = $1 AND owner = $2', [String(id), String(owner)]);
++ return typefaceRow(row);
++ }
++
++ /** A vector of this person's, or null. */
++ async function vector(id, owner) {
++ const row = await db.maybe('SELECT * FROM ai_vectors WHERE id = $1 AND owner = $2', [String(id), String(owner)]);
++ return vectorRow(row);
++ }
++
++ /** The person's typefaces, newest first. */
++ async function typefacesOf(owner, limit = 24) {
++ const rows = await db.many('SELECT * FROM ai_typefaces WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);
++ return rows.map(typefaceRow);
++ }
++
++ /** The person's vectors, newest first. */
++ async function vectorsOf(owner, limit = 24) {
++ const rows = await db.many('SELECT * FROM ai_vectors WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);
++ return rows.map(vectorRow);
++ }
++
++ async function saveTypeface({ id, owner, name, prompt, baseFamily = null, styleNotes = null, glyphs = [], metrics = {}, provider = null, model = null, license = 'OFL-1.1', state = 'drawing', note = null }) {
++ await db.exec(
++ 'INSERT INTO ai_typefaces (id, owner, name, prompt, base_family, style_notes, glyphs, metrics, provider, model, license, state, note) ' +
++ 'VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13) ' +
++ 'ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, style_notes = EXCLUDED.style_notes, glyphs = EXCLUDED.glyphs, ' +
++ 'metrics = EXCLUDED.metrics, provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, ' +
++ 'state = EXCLUDED.state, note = EXCLUDED.note, updated_at = now()',
++ [String(id), String(owner), String(name), String(prompt || ''), baseFamily, styleNotes, JSON.stringify(glyphs), JSON.stringify(metrics), provider, model, String(license || 'OFL-1.1'), String(state || 'drawing'), note],
++ );
++ return await typeface(id, owner);
++ }
++
++ async function saveVector({ id, owner, prompt, kind = 'icon', svg, provider = null, model = null, license = 'OFL-1.1', state = 'drawn', note = null }) {
++ await db.exec(
++ 'INSERT INTO ai_vectors (id, owner, prompt, kind, svg, provider, model, license, state, note) ' +
++ 'VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ' +
++ 'ON CONFLICT (id) DO UPDATE SET prompt = EXCLUDED.prompt, kind = EXCLUDED.kind, svg = EXCLUDED.svg, ' +
++ 'provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, state = EXCLUDED.state, ' +
++ 'note = EXCLUDED.note, updated_at = now()',
++ [String(id), String(owner), String(prompt || ''), String(kind || 'icon'), String(svg || ''), provider, model, String(license || 'OFL-1.1'), String(state || 'drawn'), note],
++ );
++ return await vector(id, owner);
++ }
++
++ /**
++ * How many model runs this person has made today. The cap in the config is compared against this.
++ * The count is of runs, not of pages: one typeface is several runs, because the letters are drawn
++ * a batch at a time.
++ */
++ async function runsToday(owner, kind = null) {
++ const row = kind
++ ? await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND kind = $2 AND day = current_date', [String(owner), String(kind)])
++ : await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND day = current_date', [String(owner)]);
++ return (row && Number(row.n)) || 0;
++ }
++
++ /** One model run, counted. */
++ async function countRun(owner, kind) {
++ await db.exec('INSERT INTO ai_make_runs (owner, kind) VALUES ($1, $2)', [String(owner), String(kind)]);
++ }
++
++ return { typeface, vector, typefacesOf, vectorsOf, saveTypeface, saveVector, runsToday, countRun };
++}
++
++module.exports = { createStore, typefaceRow, vectorRow };
ddiff --git a/server/vectors/svg.js b/server/vectors/svg.js
nnew file mode 100644
iindex 0000000..8be7ac4
---- /dev/null
++++ b/server/vectors/svg.js
@@@ -0,0 +1,273 @@
++'use strict';
++/**
++ * An SVG, cleaned before it is shown or saved (plan R1 item 8: AI vectors).
++ *
++ * The model writes an SVG. A browser will run anything in an SVG it is given, so the answer is read here first and
++ * rebuilt from scratch out of what a drawing may contain. Nothing the model wrote is trusted: the output is the
++ * allowlist below and nothing else.
++ *
++ * sanitize(markup) -> { ok: true, svg } | { ok: false, why }
++ *
++ * What may stay:
++ * - the elements below: the shapes a drawing is made of
++ * - the attributes below: how a shape looks
++ * - one viewBox, so the drawing scales
++ *
++ * What may not:
++ * - script, style, foreignObject, animate, set, use, image, a, and every other element
++ * - any event handler (onclick), any href, any external reference, any url() that is not a local #id
++ * - entity declarations, CDATA, doctypes, processing instructions
++ * - anything that is not well-formed markup
++ */
++const path = require('../typefaces/path');
++
++/** The elements a drawing may be made of, with the spelling SVG gives them. */
++const ELEMENTS = new Map([
++ ['svg', 'svg'],
++ ['g', 'g'],
++ ['path', 'path'],
++ ['circle', 'circle'],
++ ['ellipse', 'ellipse'],
++ ['line', 'line'],
++ ['polyline', 'polyline'],
++ ['polygon', 'polygon'],
++ ['rect', 'rect'],
++ ['defs', 'defs'],
++ ['clippath', 'clipPath'],
++ ['mask', 'mask'],
++ ['lineargradient', 'linearGradient'],
++ ['radialgradient', 'radialGradient'],
++ ['stop', 'stop'],
++ ['title', 'title'],
++ ['desc', 'desc'],
++]);
++
++/** The attributes each element may carry. */
++const ATTRIBUTES = {
++ svg: new Set(['viewBox', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform', 'xmlns', 'version', 'preserveAspectRatio']),
++ g: new Set(['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),
++ path: new Set(['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),
++ circle: new Set(['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
++ ellipse: new Set(['cx', 'cy', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
++ line: new Set(['x1', 'y1', 'x2', 'y2', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'opacity', 'color', 'transform']),
++ polyline: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
++ polygon: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
++ rect: new Set(['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
++ defs: new Set([]),
++ clipPath: new Set(['id', 'clipPathUnits', 'transform']),
++ mask: new Set(['id', 'maskUnits', 'maskContentUnits', 'x', 'y', 'width', 'height']),
++ linearGradient: new Set(['id', 'x1', 'y1', 'x2', 'y2', 'gradientUnits', 'gradientTransform', 'spreadMethod']),
++ radialGradient: new Set(['id', 'cx', 'cy', 'r', 'fx', 'fy', 'gradientUnits', 'gradientTransform', 'spreadMethod']),
++ stop: new Set(['offset', 'stop-color', 'stop-opacity']),
++ title: new Set([]),
++ desc: new Set([]),
++};
++
++/** The attributes that are never allowed, whatever the element. */
++const NEVER = /^(?:on[a-z]+|href|xlink:href|src|srcset|formaction|action|xmlns:xlink|xml:base|base|content|ping|data|codebase|manifest|profile|usemap|class|style)$/i;
++
++/** The style properties a style attribute may hold. */
++const STYLE_PROPERTIES = new Set(['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', 'color', 'stop-color', 'stop-opacity', 'clip-rule', 'paint-order']);
++
++/** How large an SVG may be. */
++const MAX_BYTES = 200000;
++
++const TAG = /<\s*(\/\s*)?([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
++const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
++
++function esc(s) {
++ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++}
++
++function fail(why) { return { ok: false, why }; }
++
++/**
++ * A colour, a number, a length: the values a shape may be drawn with.
++ *
++ * A value that is merely empty or odd is dropped (the drawing goes on without it). A value that reaches
++ * outside the drawing, or that would run something, refuses the drawing: quietly drawing something else
++ * than the model meant is worse than saying no.
++ */
++function safeValue(value) {
++ const v = String(value).trim();
++ if (!v) return { ok: false, drop: true };
++ if (v.length > 300) return { ok: false, drop: true };
++ // No url() except a local reference, no javascript:, no expression.
++ if (/url\s*\(/i.test(v)) {
++ const only = /^url\(\s*['"]?#[A-Za-z][\w:.-]*['"]?\s*\)$/i.test(v);
++ if (!only) return { ok: false, why: 'the drawing reaches outside itself' };
++ }
++ if (/javascript:|expression\s*\(|@import|behavior\s*:|binding\s*:/i.test(v)) return { ok: false, why: 'the drawing carries something a drawing must not carry' };
++ if (/[<>&]/.test(v)) return { ok: false, drop: true };
++ return { ok: true, value: v };
++}
++
++/** A style attribute: only the properties above, and only safe values. */
++function safeStyle(value) {
++ const parts = String(value).split(';');
++ const kept = [];
++ for (const part of parts) {
++ const idx = part.indexOf(':');
++ if (idx < 0) continue;
++ const prop = part.slice(0, idx).trim().toLowerCase();
++ if (!STYLE_PROPERTIES.has(prop)) continue;
++ const val = safeValue(part.slice(idx + 1));
++ if (!val.ok) {
++ if (val.why) return { ok: false, why: val.why };
++ continue;
++ }
++ kept.push(prop + ':' + val.value);
++ }
++ return { ok: true, value: kept.join(';') };
++}
++
++/** The numbers of a points list: an even run of numbers, nothing else. */
++function safePoints(value) {
++ const raw = String(value).trim();
++ if (!raw || raw.length > 4000) return null;
++ const nums = raw.split(/[\s,]+/).filter(Boolean);
++ if (nums.length < 4 || nums.length % 2 !== 0) return null;
++ for (const n of nums) if (!/^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?$/.test(n)) return null;
++ return nums.join(' ');
++}
++
++/** The path data of a path: read with the same rules the font uses, so a drawing cannot smuggle anything. */
++function safePathData(value) {
++ const raw = String(value).trim();
++ if (!raw || raw.length > 20000) return null;
++ const parsed = path.parse(raw);
++ if (!parsed.ok) return null;
++ return path.toData(parsed.segments);
++}
++
++/** The attributes of one element, read into a plain object, with the ones that are not allowed refused. */
++function readAttrs(name, restRaw) {
++ const attrs = {};
++ for (const a of restRaw.matchAll(ATTR)) {
++ const key = a[1].toLowerCase();
++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
++ if (key === 'style') {
++ const style = safeStyle(value);
++ if (!style.ok) return style;
++ if (style.value) attrs.style = style.value;
++ continue;
++ }
++ if (NEVER.test(key)) return { ok: false, why: 'the drawing carries a ' + key + ' attribute' };
++ if (!ATTRIBUTES[name].has(key)) continue; // an attribute nobody needs is simply dropped
++ if (key === 'd') {
++ const d = safePathData(value);
++ if (d === null) return { ok: false, why: 'the drawing has a path it cannot read' };
++ attrs.d = d;
++ continue;
++ }
++ if (key === 'points') {
++ const pts = safePoints(value);
++ if (pts === null) return { ok: false, why: 'the drawing has points it cannot read' };
++ attrs.points = pts;
++ continue;
++ }
++ const val = safeValue(value);
++ if (!val.ok) {
++ if (val.why) return { ok: false, why: val.why };
++ continue;
++ }
++ attrs[key] = val.value;
++ }
++ return { ok: true, attrs };
++}
++
++/**
++ * Read one SVG. The markup is walked tag by tag; only the elements and attributes on the list are kept, and
++ * everything is written back out from scratch, so nothing the model wrote survives unless it was allowed.
++ */
++function sanitize(markup) {
++ const raw = String(markup == null ? '' : markup).trim();
++ if (!raw) return fail('the drawing is empty');
++ if (raw.length > MAX_BYTES) return fail('the drawing is too large');
++
++ // The things that are never in a drawing, refused before anything is read.
++ if (/<!\[CDATA\[|<!ENTITY|<!DOCTYPE|<\?/i.test(raw)) return fail('the drawing carries markup a drawing must not carry');
++ if (/javascript:|vbscript:|data:text\/html|<script|<foreignObject|<iframe|<embed|<object|<use\b|<image\b|<animate|<set\b|<audio|<video|<handler/i.test(raw))
++ return fail('the drawing carries something a drawing must not carry');
++
++ const out = [];
++ const stack = [];
++ let rootAttrs = null;
++ let viewBox = null;
++
++ for (const m of raw.matchAll(TAG)) {
++ const [full, closing, nameRaw, restRaw] = m;
++ const lower = nameRaw.toLowerCase();
++ const name = ELEMENTS.get(lower);
++ if (!name) return fail('a drawing is made of shapes, and this is not one');
++
++ if (closing) {
++ // The root's own close is not written out: the root tag is written once, at the end.
++ if (name === 'svg' && !stack.length) {
++ if (!rootAttrs) return fail('there is no drawing here');
++ continue;
++ }
++ if (!stack.length || stack[stack.length - 1] !== name) return fail('the drawing is not drawn properly');
++ stack.pop();
++ out.push('</' + name + '>');
++ continue;
++ }
++
++ const read = readAttrs(name, restRaw);
++ if (!read.ok) return read;
++ const selfClosing = /\/\s*$/.test(restRaw);
++ const parts = Object.keys(read.attrs).map((k) => ' ' + k + '="' + esc(read.attrs[k]) + '"');
++
++ if (name === 'svg') {
++ // The root is the one drawing; a second one inside it is not allowed.
++ if (rootAttrs) return fail('there is more than one drawing here');
++ rootAttrs = parts;
++ for (const a of restRaw.matchAll(ATTR)) {
++ if (a[1].toLowerCase() !== 'viewbox') continue;
++ const box = safeValue(a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]));
++ if (!box.ok) return fail(box.why || 'the drawing has no size');
++ viewBox = box.value;
++ }
++ // The root's own tag is written once, at the end, with its viewBox.
++ continue;
++ }
++
++ if (selfClosing) out.push('<' + name + parts.join('') + '/>');
++ else {
++ out.push('<' + name + parts.join('') + '>');
++ stack.push(name);
++ }
++ }
++
++ if (!rootAttrs) return fail('there is no drawing here');
++ if (stack.length) return fail('the drawing is not closed');
++ if (!viewBox) return fail('the drawing has no size');
++
++ // The root's attributes are read again, on their own, so the viewBox can be required.
++ const root = /<\s*svg\b([^>]*)>/i.exec(raw);
++ const keep = [];
++ for (const a of root[1].matchAll(ATTR)) {
++ const key = a[1].toLowerCase();
++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
++ if (key === 'viewbox') continue;
++ if (key === 'style') {
++ const style = safeStyle(value);
++ if (!style.ok) return fail(style.why);
++ if (style.value) keep.push('style="' + esc(style.value) + '"');
++ continue;
++ }
++ if (NEVER.test(key)) return fail('the drawing carries a ' + key + ' attribute');
++ if (!ATTRIBUTES.svg.has(key)) continue;
++ const val = safeValue(value);
++ if (!val.ok) {
++ if (val.why) return fail(val.why);
++ continue;
++ }
++ keep.push(key + '="' + esc(val.value) + '"');
++ }
++
++ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + esc(viewBox) + '"' + (keep.length ? ' ' + keep.join(' ') : '') + '>' + out.join('') + '</svg>';
++ return { ok: true, svg };
++}
++
++module.exports = { sanitize, ELEMENTS, ATTRIBUTES, NEVER, MAX_BYTES, safeValue, safeStyle, safePoints, safePathData };
ddiff --git a/server/views/typefaces.js b/server/views/typefaces.js
nnew file mode 100644
iindex 0000000..49716cb
---- /dev/null
++++ b/server/views/typefaces.js
@@@ -0,0 +1,135 @@
++'use strict';
++/**
++ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
++ *
++ * Two makers and two pages. Every page is server-rendered and shows its content: the form, the drawings,
++ * the specimen and the downloads are all in the HTML, and nothing is behind a tab.
++ *
++ * The specimen shows the face in its own voice: the font is written into the page as a data URL, so the
++ * letters on screen are the letters the person just made.
++ */
++const { layout, esc } = require('../render/layout');
++
++/** The stylesheet both pages share. */
++const STYLES = ['css/typefaces.css'];
++
++/** The font as a data URL the page can use: the specimen is drawn in the face it just made. */
++function fontFace(woff2) {
++ if (!woff2) return '';
++ return '@font-face{font-family:"OpenVibe Made";src:url(data:font/woff2;base64,' + woff2.toString('base64') + ') format("woff2");font-weight:400;font-style:normal;font-display:swap}';
++}
++
++/** "Aa" (or the first two letters it has) drawn from a typeface's own outlines, as one inline SVG. */
++function sample(glyphs) {
++ const list = Array.isArray(glyphs) ? glyphs.filter((g) => g && typeof g.d === 'string' && /^[MmLlHhVvCcSsQqTtZz0-9.,\s-]+$/.test(g.d)) : [];
++ const by = new Map(list.map((g) => [g.char, g]));
++ const picked = ['A', 'a'].every((c) => by.has(c)) ? [by.get('A'), by.get('a')] : list.slice(0, 2);
++ if (!picked.length) return '<span class="tf-sample is-empty">Not drawn yet</span>';
++ let x = 0;
++ const paths = picked.map((g) => { const p = '<path transform="translate(' + x + ' 0)" d="' + esc(g.d) + '"/>'; x += Number(g.advance) || 600; return p; }).join('');
++ return '<svg class="tf-sample" viewBox="0 -800 ' + x + ' 1000" aria-hidden="true"><g transform="scale(1 -1)" fill="currentColor">' + paths + '</g></svg>';
++}
++
++/** The maker: describe a face, start from an open one. */
++function make({ bases = [], mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {
++ const options = bases.map((b) => '<option value="' + esc(b.id) + '">' + esc(b.family) + ' — ' + esc(b.category) + '</option>').join('');
++ // Each typeface you made is shown in itself: "Aa" drawn from its own stored outlines (validated paths, font units,
++ // baseline at 0), so the list reads as a shelf of faces, not a list of names (Opus's design pass, 11-08).
++ const owned = mine.length
++ ? '<section class="tf-owned"><h2>Your typefaces <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-shelf">' + mine.map((f) => '<li><a href="/fonts/made/' + esc(f.id) + '">'
++ + sample(f.glyphs) + '<b>' + esc(f.name) + '</b><span>' + esc(f.glyphs.length) + ' letters</span></a></li>').join('') + '</ul></section>'
++ : '';
++ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI typefaces</p><h1>Describe a typeface.</h1>'
++ + '<p>The model draws the letters, one batch at a time. You get the outlines, the font files and the licence.</p></header>'
++ + (signedIn
++ ? '<form class="tf-form" method="post" action="/fonts/make"><label for="tf-prompt">What kind of typeface?</label>'
++ + '<textarea id="tf-prompt" name="prompt" required maxlength="600" placeholder="rounded geometric sans for a kids\' app, high x-height, open apertures"></textarea>'
++ + '<label for="tf-name">Name it <small>optional</small></label><input id="tf-name" name="name" maxlength="60" placeholder="Bubble Sans">'
++ + '<label for="tf-base">Start from an open font <small>optional</small></label><select id="tf-base" name="base" data-ov-native><option value="">No base, just the description</option>' + options + '</select>'
++ + '<button type="submit">Draw the typeface</button>'
++ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'
++ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a typeface. The runs are charged to your own allowance.</p>')
++ + owned
++ + '</div>';
++ return layout({ title: 'Make a typeface', description: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.',
++ path: '/fonts/make', active: '/fonts', styles: STYLES, body });
++}
++
++/** The specimen: the face in its own voice, the download, and the redraw. */
++function specimen({ face, compiled, chars = '' } = {}) {
++ const glyphs = Array.isArray(face.glyphs) ? face.glyphs : [];
++ const css = compiled && compiled.ok ? fontFace(compiled.woff2) : '';
++ const sample = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
++ const rows = chars.split('').map((c) => {
++ const g = glyphs.find((x) => x.char === c);
++ return '<option value="' + esc(c) + '"' + (g ? '' : ' disabled') + '>' + esc(c) + '</option>';
++ }).join('');
++ const letters = sample.split('').map((c) => {
++ const g = glyphs.find((x) => x.char === c);
++ return '<span class="tf-letter' + (g ? '' : ' tf-missing') + '">' + esc(c) + '</span>';
++ }).join('');
++ const body = '<div class="tf tf-specimen"><a class="tf-back" href="/fonts/make">← Make another</a>'
++ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(face.license) + (face.model ? ' · drawn by ' + esc(face.model) : '') + '</p>'
++ + '<h1 class="tf-name" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(face.name) + '</h1>'
++ + '<p>' + esc(face.prompt) + '</p></header>'
++ + '<p class="tf-sample" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(sample) + '</p>'
++ + '<p class="tf-sample tf-words" style="font-family:\'OpenVibe Made\',sans-serif">The quick brown fox jumps over the lazy dog.</p>'
++ + '<div class="tf-letters">' + letters + '</div>'
++ + '<section class="tf-download"><h2>Take it with you</h2><p>'
++ + (compiled && compiled.ok
++ ? '<a href="/api/v1/typefaces/' + esc(face.id) + '/font.woff2" download>Download the WOFF2</a> · <a href="/api/v1/typefaces/' + esc(face.id) + '/font.otf" download>Download the OTF</a>'
++ : 'The font files are being compiled. Try again in a moment.')
++ + '</p><p class="tf-note">The font is yours, offered under the ' + esc(face.license) + '. The name table says which model drew it.</p></section>'
++ + '<section class="tf-redraw"><h2>Redraw one letter</h2><p>Say what to change and the model draws that letter again, in the same style.</p>'
++ + '<form method="post" action="/fonts/made/' + esc(face.id) + '/glyph"><label for="tf-char">Letter</label>'
++ + '<select id="tf-char" name="char" data-ov-native>' + rows + '</select>'
++ + '<label for="tf-note">What to change</label><input id="tf-note" name="note" required maxlength="400" placeholder="make the tail longer and curl it up">'
++ + '<button type="submit">Redraw the letter</button></form>'
++ + (face.note ? '<p class="tf-lastnote">Last note: ' + esc(face.note) + '</p>' : '')
++ + '</section></div>';
++ return layout({ title: face.name + ' — a typeface you made', description: 'A typeface you described, drawn by the model, with the outlines and the font files.',
++ path: '/fonts/made/' + face.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow',
++ inlineStyles: css ? [css] : [], body });
++}
++
++/** The vector maker: one drawing from a prompt. */
++function vectorMake({ mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {
++ // Your drawings as drawings: each one's own SVG as a picture (an <img> runs nothing), with what it was asked to be.
++ const owned = mine.length
++ ? '<section class="tf-owned"><h2>Your drawings <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-gallery">' + mine.map((v) => '<li><a href="/vectors/made/' + esc(v.id) + '">'
++ + '<img src="/vectors/made/' + esc(v.id) + '.svg" alt="" loading="lazy" width="160" height="160"><span>' + esc(v.prompt.slice(0, 80)) + '</span><small>' + esc(v.kind) + '</small></a></li>').join('') + '</ul></section>'
++ : '';
++ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI vectors</p><h1>Describe a drawing.</h1>'
++ + '<p>One clean SVG: an icon, an illustration or a pattern. You can draw it again, change it with a note, and take it away.</p></header>'
++ + (signedIn
++ ? '<form class="tf-form" method="post" action="/vectors/make"><label for="tv-prompt">What should it be?</label>'
++ + '<textarea id="tv-prompt" name="prompt" required maxlength="600" placeholder="a paper plane folded from a single sheet, mid-flight, flat two-colour"></textarea>'
++ + '<label for="tv-kind">What kind</label><select id="tv-kind" name="kind" data-ov-native><option value="icon">Icon</option><option value="illustration">Illustration</option><option value="pattern">Pattern</option></select>'
++ + '<button type="submit">Draw it</button>'
++ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'
++ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a vector. The runs are charged to your own allowance.</p>')
++ + owned + '</div>';
++ return layout({ title: 'Make a vector', description: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.',
++ path: '/vectors/make', active: '/fonts', styles: STYLES, body });
++}
++
++/** The drawing: the SVG, the downloads and the notes. */
++function vector({ vector: v } = {}) {
++ const body = '<div class="tf tf-vector"><a class="tf-back" href="/vectors/make">← Make another</a>'
++ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(v.kind) + (v.model ? ' · drawn by ' + esc(v.model) : '') + '</p>'
++ + '<h1>' + esc(v.prompt) + '</h1></header>'
++ + '<figure class="tf-figure">' + v.svg + '</figure>'
++ + '<section class="tf-download"><h2>Take it with you</h2><p>'
++ + '<a href="/vectors/made/' + esc(v.id) + '.svg" download>Download the SVG</a> · <a href="/vectors/made/' + esc(v.id) + '.png" download>Download the PNG</a>'
++ + '</p><p class="tf-note">The drawing is yours, offered under the ' + esc(v.license) + '.</p></section>'
++ + '<section class="tf-redraw"><h2>Change it</h2><p>Say what to change, or leave the note empty to draw it again.</p>'
++ + '<form method="post" action="/vectors/made/' + esc(v.id) + '/edit"><label for="tv-note">What to change</label>'
++ + '<input id="tv-note" name="note" maxlength="400" placeholder="make the wings longer and add a shadow">'
++ + '<button type="submit" name="regenerate" value="1">Draw it again</button></form>'
++ + (v.note ? '<p class="tf-lastnote">Last note: ' + esc(v.note) + '</p>' : '')
++ + '</section></div>';
++ return layout({ title: 'A drawing you made', description: 'An SVG the model drew for you, clean and ready to use.',
++ path: '/vectors/made/' + v.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow', body });
++}
++
++module.exports = { make, specimen, vectorMake, vector };
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex 23ca347..cb1b419 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -24,8 +24,12 @@ const components = require('openvibe-shared/components');
'openvibe_design_validate_kit_document',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
'openvibe_design_list_fonts', 'openvibe_design_get_font',
++ 'openvibe_design_make_typeface', 'openvibe_design_make_vector',
]);
-- assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
++ assert.ok(listed.every(x => x.inputSchema && x.annotations));
++ // Every tool but the two that make something only reads.
++ assert.ok(listed.filter(x => x.annotations.readOnlyHint).length === listed.length - 2);
++ assert.ok(listed.filter(x => !x.annotations.readOnlyHint).every(x => x.name.startsWith('openvibe_design_make_')));
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
assert.strictEqual((await t.get('/mcp')).status, 405);
assert.strictEqual((await t.get('/mcp', { body: '{', headers: { 'content-type': 'application/json' } })).json().error.code, -32700);
ddiff --git a/test/typefaces.test.js b/test/typefaces.test.js
nnew file mode 100644
iindex 0000000..3d7ec8b
---- /dev/null
++++ b/test/typefaces.test.js
@@@ -0,0 +1,388 @@
++'use strict';
++/**
++ * The typefaces and vectors a person makes with the model (plan R1 item 8).
++ *
++ * Four things are proved here:
++ * - the sanitisers refuse hostile input: a script, an event handler, an external reference, a path that
++ * is not closed, an outline that leaves the em box
++ * - the font compiles: the same glyph fixtures always give the same font, with the metrics the compiler
++ * promises and the model named in the font's name table
++ * - the cap holds: a person runs out of runs for the day and is told so in plain words
++ * - the outputs stay private: another person cannot read them, and they are not in the sitemap
++ */
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { clean } = require('../server/typefaces/glyph');
++const { compile } = require('../server/typefaces/compile');
++const { sanitize } = require('../server/vectors/svg');
++
++/** The letters the fake model draws. Every one of them is a closed path inside the em box. */
++const LETTERS = {
++ A: 'M100 0 L300 700 L500 0 L420 0 L340 180 L160 180 L80 0 Z',
++ B: 'M100 0 L100 700 L350 700 L500 540 L500 390 L330 370 L500 0 Z M180 80 L180 620 L330 620 L420 540 L420 460 L330 460 L180 460 Z',
++ C: 'M450 0 L380 70 L340 30 L300 0 L200 0 L100 150 L100 550 L200 700 L300 700 L340 670 L380 630 L450 700 L330 800 L220 800 L100 720 L0 550 L0 150 L100 -20 L220 -100 L330 -100 Z',
++ D: 'M100 0 L100 700 L300 700 L450 570 L450 130 L300 0 Z M190 90 L190 610 L290 610 L360 540 L360 160 L290 90 Z',
++ E: 'M100 0 L100 700 L450 700 L450 610 L200 610 L200 400 L400 400 L400 310 L200 310 L200 90 L450 90 L450 0 Z',
++};
++/** The letters the fake model draws for everything else: a plain block, so every character has an outline. */
++const block = (x) => 'M100 0 L100 700 L400 700 L400 0 Z';
++
++const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
++
++/** The fake model: it draws letters when it is asked for a typeface, and a drawing when it is asked for a vector. */
++function fakeAi(overrides = {}) {
++ return {
++ enabled: true,
++ text: async ({ prompt }) => {
++ if (String(prompt).includes('Draw these characters')) {
++ const wanted = /Draw these characters, and only these: (.+)/.exec(String(prompt));
++ const chars = wanted ? wanted[1].trim() : CHARSET;
++ const glyphs = {};
++ for (const c of chars) glyphs[c] = LETTERS[c] || block(c);
++ return { text: JSON.stringify({ glyphs }), provider: 'anthropic', model: 'claude-opus-5-5' };
++ }
++ if (String(prompt).includes('Draw:') || String(prompt).includes('Now draw ONE letter')) {
++ const svg = '<svg viewBox="0 0 24 24"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></svg>';
++ return { text: svg, provider: 'anthropic', model: 'claude-opus-5-5' };
++ }
++ return { text: '{}', provider: 'anthropic', model: 'claude-opus-5-5' };
++ },
++ ...overrides,
++ };
++}
++
++(async () => {
++ // ── The sanitisers, on their own ──
++ await check('a glyph is one closed path inside the em box', () => {
++ const good = clean('M100 0 L400 0 L400 700 L100 700 Z');
++ assert.ok(good.ok);
++ assert.equal(good.glyph.d, 'M100 0 L400 0 L400 700 L100 700 Z');
++ assert.equal(good.glyph.advance, 420);
++ // An open contour is not an outline.
++ assert.equal(clean('M100 0 L400 0 L400 700').ok, false);
++ // An outline that leaves the em box is refused.
++ assert.equal(clean('M100 0 L4000 0 L4000 700 L100 700 Z').ok, false);
++ // A path with nothing in it is refused.
++ assert.equal(clean('M100 100 L101 100 L101 101 Z').ok, false);
++ });
++
++ await check('a glyph carries no script, handler or reference', () => {
++ for (const hostile of [
++ '<script>alert(1)</script>',
++ '<path onload="alert(1)" d="M0 0 L100 100 Z"/>',
++ '<path href="http://example.com/x" d="M0 0 L100 100 Z"/>',
++ '<path style="x" d="M0 0 L100 100 Z"/>',
++ '<foreignObject><b>x</b></foreignObject>',
++ '<image href="http://example.com/x.png"/>',
++ 'javascript:alert(1)',
++ 'M0 0 L10 10 url(http://example.com)',
++ '<path d="M0 0 L100 100 Z"/><path d="M0 0 L100 100 Z"/>',
++ ]) {
++ const r = clean(hostile);
++ assert.equal(r.ok, false, hostile);
++ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);
++ }
++ });
++
++ await check('a drawing is made of shapes and nothing else', () => {
++ const good = sanitize('<svg viewBox="0 0 24 24"><g fill="#333"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></g></svg>');
++ assert.ok(good.ok);
++ assert.ok(good.svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'));
++ assert.ok(good.svg.includes('<path d="M4 4 L20 4 L20 20 L4 20 Z"/>'));
++ assert.ok(!good.svg.includes('http://example.com'));
++ for (const hostile of [
++ '<svg viewBox="0 0 24 24"><script>alert(1)</script></svg>',
++ '<svg viewBox="0 0 24 24" onload="alert(1)"><path d="M0 0 L10 10 Z"/></svg>',
++ '<svg viewBox="0 0 24 24"><path onclick="x()" d="M0 0 L10 10 Z"/></svg>',
++ '<svg viewBox="0 0 24 24"><foreignObject><b>x</b></foreignObject></svg>',
++ '<svg viewBox="0 0 24 24"><image href="http://example.com/x.png"/></svg>',
++ '<svg viewBox="0 0 24 24"><use href="#x"/></svg>',
++ '<svg viewBox="0 0 24 24"><path href="http://example.com" d="M0 0 L10 10 Z"/></svg>',
++ '<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg viewBox="0 0 24 24"><path d="M0 0 L10 10 Z"/></svg>',
++ '<svg viewBox="0 0 24 24"><![CDATA[x]]></svg>',
++ '<svg viewBox="0 0 24 24"><path fill="url(http://example.com/x)" d="M0 0 L10 10 Z"/></svg>',
++ '<svg viewBox="0 0 24 24"><path d="M0 0 L10 10 javascript"/></svg>',
++ ]) {
++ const r = sanitize(hostile);
++ assert.equal(r.ok, false, hostile);
++ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);
++ }
++ // A drawing without a size cannot be shown.
++ assert.equal(sanitize('<svg><path d="M0 0 L10 10 Z"/></svg>').ok, false);
++ });
++
++ // ── The compiler, on fixed fixtures ──
++ await check('the same glyphs always compile to the same font', async () => {
++ const glyphs = Object.entries(LETTERS).map(([char, d]) => ({ char, d }));
++ const first = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });
++ const second = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });
++ assert.ok(first.ok, first.why);
++ assert.ok(second.ok);
++ assert.deepEqual(first.otf, second.otf);
++ assert.deepEqual(first.woff2, second.woff2);
++ // The files are what they say they are.
++ assert.equal(first.woff2.slice(0, 4).toString('latin1'), 'wOF2');
++ assert.equal(first.otf.slice(0, 4).toString('latin1'), 'OTTO');
++ // The metrics are the face's own.
++ assert.equal(first.metrics.unitsPerEm, 1000);
++ assert.equal(first.metrics.glyphs, 5);
++ assert.ok(first.metrics.ascender >= 400 && first.metrics.ascender <= 800);
++ assert.ok(first.metrics.descender >= -200 && first.metrics.descender <= -100);
++ });
++
++ await check('a font says which model drew it', async () => {
++ const out = await compile({ familyName: 'Named Face', glyphs: [{ char: 'A', d: LETTERS.A }], model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design', license: 'OFL-1.1' });
++ assert.ok(out.ok);
++ const names = out.font.names.unicode;
++ assert.equal(names.fontFamily.en, 'Named Face');
++ assert.equal(names.license.en, 'OFL-1.1');
++ assert.match(names.description.en, /claude-opus-5-5/);
++ });
++
++ await check('a font without a name or without glyphs is refused', async () => {
++ assert.equal((await compile({ familyName: '', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);
++ assert.equal((await compile({ familyName: '../../etc/passwd', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);
++ assert.equal((await compile({ familyName: 'Fine', glyphs: [] })).ok, false);
++ // A glyph that is not an outline does not become one on the way in.
++ assert.equal((await compile({ familyName: 'Fine', glyphs: [{ char: 'A', d: '<script>alert(1)</script>' }] })).ok, false);
++ });
++
++ // ── The service, over HTTP ──
++ const t = await boot({ ai: fakeAi() });
++ try {
++ const user = t.network.addUser('ada');
++ const other = t.network.addUser('bob');
++
++ await check('the maker page is public and the work is not', async () => {
++ const page = await t.get('/fonts/make');
++ assert.equal(page.status, 200);
++ assert.match(page.text, /Describe a typeface/);
++ assert.match(page.text, /Sign in/);
++ const vectorPage = await t.get('/vectors/make');
++ assert.equal(vectorPage.status, 200);
++ assert.match(vectorPage.text, /Describe a drawing/);
++ // The tools are pages a crawler may read; the drawings are not.
++ const sitemap = (await t.get('/sitemap.xml')).text;
++ assert.ok(sitemap.includes('/fonts/make'));
++ assert.ok(sitemap.includes('/vectors/make'));
++ assert.ok(!sitemap.includes('/fonts/made/'));
++ assert.ok(!sitemap.includes('/vectors/made/'));
++ const llms = (await t.get('/llms.txt')).text;
++ assert.ok(llms.includes('/fonts/make'));
++ assert.ok(llms.includes('/vectors/make'));
++ });
++
++ await check('a typeface is drawn, compiled and downloadable', async () => {
++ const made = await t.get('/fonts/make', { as: user, method: 'POST',
++ body: new URLSearchParams({ prompt: 'rounded geometric sans for a kids app', name: 'Bubble Sans' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
++ assert.equal(made.status, 303, made.text);
++ const id = made.headers.get('location').split('/').pop();
++ const row = (await t.store.db.many('SELECT * FROM ai_typefaces WHERE id=$1', [id]))[0];
++ assert.ok(row);
++ assert.equal(row.owner, user.subject);
++ assert.equal(row.name, 'Bubble Sans');
++ assert.equal(row.license, 'OFL-1.1');
++ assert.equal(row.model, 'claude-opus-5-5');
++ assert.ok(row.glyphs.length > 50, 'every letter is drawn');
++ assert.ok(row.glyphs.every(g => g.d && g.char && g.advance));
++
++ const specimen = await t.get('/fonts/made/' + id, { as: user });
++ assert.equal(specimen.status, 200);
++ assert.match(specimen.text, /Bubble Sans/);
++ assert.match(specimen.text, /font\/woff2;base64/);
++ assert.match(specimen.text, /Redraw one letter/);
++
++ const woff2 = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });
++ assert.equal(woff2.status, 200);
++ assert.equal(woff2.buffer.slice(0, 4).toString('latin1'), 'wOF2');
++ assert.match(woff2.headers.get('content-disposition') || '', /attachment/);
++ const otf = await t.get('/api/v1/typefaces/' + id + '/font.otf', { as: user });
++ assert.equal(otf.status, 200);
++ assert.equal(otf.buffer.slice(0, 4).toString('latin1'), 'OTTO');
++ });
++
++ await check('another person cannot read a typeface or its files', async () => {
++ const rows = await t.store.db.many('SELECT id FROM ai_typefaces LIMIT 1');
++ const id = rows[0].id;
++ assert.equal((await t.get('/api/v1/typefaces/' + id)).status, 401);
++ assert.equal((await t.get('/api/v1/typefaces/' + id, { as: other })).status, 404);
++ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: other })).status, 404);
++ assert.equal((await t.get('/fonts/made/' + id, { as: other })).status, 404);
++ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2')).status, 401);
++ });
++
++ await check('one letter is redrawn with a note', async () => {
++ const rows = await t.store.db.many('SELECT id, glyphs FROM ai_typefaces LIMIT 1');
++ const id = rows[0].id;
++ const before = rows[0].glyphs.length;
++ const redrawn = await t.get('/fonts/made/' + id + '/glyph', { as: user, method: 'POST',
++ body: new URLSearchParams({ char: 'A', note: 'make the apex rounder' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
++ assert.equal(redrawn.status, 303, redrawn.text);
++ const after = await t.store.db.many('SELECT glyphs, note FROM ai_typefaces WHERE id=$1', [id]);
++ assert.equal(after[0].glyphs.length, before);
++ assert.ok(after[0].glyphs.some(g => g.char === 'A'));
++ assert.equal(after[0].note, 'make the apex rounder');
++ // The font still compiles after the redraw.
++ const file = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });
++ assert.equal(file.status, 200);
++ });
++
++ await check('a vector is drawn, saved and downloadable as SVG and PNG', async () => {
++ const made = await t.get('/vectors/make', { as: user, method: 'POST',
++ body: new URLSearchParams({ prompt: 'a paper plane folded from a single sheet', kind: 'icon' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
++ assert.equal(made.status, 303, made.text);
++ const id = made.headers.get('location').split('/').pop();
++ const row = (await t.store.db.many('SELECT * FROM ai_vectors WHERE id=$1', [id]))[0];
++ assert.ok(row);
++ assert.equal(row.owner, user.subject);
++ assert.ok(row.svg.startsWith('<svg'));
++ assert.ok(!row.svg.includes('<script'));
++
++ const page = await t.get('/vectors/made/' + id, { as: user });
++ assert.equal(page.status, 200);
++ assert.match(page.text, /Download the SVG/);
++ const svg = await t.get('/vectors/made/' + id + '.svg', { as: user });
++ assert.equal(svg.status, 200);
++ assert.match(svg.headers.get('content-type') || '', /image\/svg\+xml/);
++ assert.match(svg.headers.get('content-security-policy') || '', /default-src 'none'.*sandbox/, 'a saved drawing runs nothing when opened on its own');
++ const png = await t.get('/vectors/made/' + id + '.png', { as: user });
++ assert.equal(png.status, 200);
++ assert.equal(png.buffer.slice(1, 4).toString('latin1'), 'PNG');
++ });
++
++ await check('another person cannot read a vector', async () => {
++ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');
++ const id = rows[0].id;
++ assert.equal((await t.get('/api/v1/vectors/' + id)).status, 401);
++ assert.equal((await t.get('/api/v1/vectors/' + id, { as: other })).status, 404);
++ assert.equal((await t.get('/vectors/made/' + id + '.svg', { as: other })).status, 404);
++ });
++
++ await check('the drawing is redrawn with a note', async () => {
++ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');
++ const id = rows[0].id;
++ const edited = await t.get('/vectors/made/' + id + '/edit', { as: user, method: 'POST',
++ body: new URLSearchParams({ note: 'make the wings longer' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
++ assert.equal(edited.status, 303, edited.text);
++ const after = await t.store.db.many('SELECT note FROM ai_vectors WHERE id=$1', [id]);
++ assert.equal(after[0].note, 'make the wings longer');
++ });
++
++ await check('MCP can make a typeface and a vector, and needs a signed-in person', async () => {
++ const anonymous = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 1, method: 'tools/call',
++ params: { name: 'openvibe_design_make_vector', arguments: { prompt: 'a small bird' } } } });
++ assert.equal(anonymous.json().result.isError, true);
++ assert.match(anonymous.json().result.content[0].text, /Sign in/);
++
++ const made = await t.get('/mcp', { as: user, method: 'POST', json: { jsonrpc: '2.0', id: 2, method: 'tools/call',
++ params: { name: 'openvibe_design_make_typeface', arguments: { prompt: 'a plain geometric sans', name: 'Grid Sans' } } } });
++ assert.equal(made.json().result.isError, false, made.text);
++ const out = made.json().result.structuredContent;
++ assert.equal(out.name, 'Grid Sans');
++ assert.ok(out.glyphs > 50);
++ assert.equal(out.model, 'claude-opus-5-5');
++ assert.ok(out.download.woff2.includes('/font.woff2'));
++
++ const list = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 3, method: 'tools/list' } });
++ const names = list.json().result.tools.map(x => x.name);
++ assert.ok(names.includes('openvibe_design_make_typeface'));
++ assert.ok(names.includes('openvibe_design_make_vector'));
++ // The tools that write are not marked read only.
++ const vector = list.json().result.tools.find(x => x.name === 'openvibe_design_make_vector');
++ assert.equal(vector.annotations.readOnlyHint, false);
++ });
++
++ await check('the API makes a typeface for a signed-in person', async () => {
++ const made = await t.get('/api/v1/typefaces', { as: user, method: 'POST', json: { prompt: 'a condensed sans' } });
++ assert.equal(made.status, 201, made.text);
++ assert.ok(made.json().glyphs.length > 50);
++ assert.equal(made.json().license, 'OFL-1.1');
++ const anon = await t.get('/api/v1/typefaces', { method: 'POST', json: { prompt: 'a condensed sans' } });
++ assert.equal(anon.status, 401);
++ });
++ } finally { await t.close(); }
++
++ // ── The cap ──
++ await check('a person runs out of runs for the day and is told in plain words', async () => {
++ const capped = await boot({ ai: fakeAi(), env: { DESIGN_AI_MAKE_PER_DAY: '1' } });
++ try {
++ const user = capped.network.addUser('cleo');
++ const first = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });
++ assert.equal(first.status, 201, first.text);
++ const second = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'another bird' } });
++ assert.equal(second.status, 429);
++ assert.match(second.json().error, /today|tomorrow/i);
++ // The cap is per person: somebody else may still make one.
++ const other = capped.network.addUser('dara');
++ const theirs = await capped.get('/api/v1/vectors', { as: other, method: 'POST', json: { prompt: 'a small bird' } });
++ assert.equal(theirs.status, 201, theirs.text);
++ // The runs are counted, so the cap is measured against something.
++ const runs = await capped.store.db.many('SELECT owner, kind FROM ai_make_runs');
++ assert.equal(runs.length, 2);
++ assert.deepEqual(new Set(runs.map(r => r.kind)), new Set(['vector']));
++ } finally { await capped.close(); }
++ });
++
++ // ── The account the work belongs to (ADR-033) ──
++ await check('an export carries the person\'s own typefaces and vectors; deleting the account takes them away', async () => {
++ const calls = [];
++ const secret = `fixture_${'design'.repeat(8)}`;
++ const t2 = await boot({ ai: fakeAi(), env: { DESIGN_EVENTS_SECRET: secret },
++ accountSend: async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201, json: async () => ({}) }; } });
++ try {
++ const owner = t2.network.addUser('ida');
++ const stranger = t2.network.addUser('eve');
++ await t2.get('/api/v1/typefaces', { as: owner, method: 'POST', json: { prompt: 'a plain geometric sans', name: 'Kept Sans' } });
++ await t2.get('/api/v1/vectors', { as: owner, method: 'POST', json: { prompt: 'a small bird' } });
++ await t2.get('/api/v1/vectors', { as: stranger, method: 'POST', json: { prompt: 'another bird' } });
++
++ const exportEvent = { event_id: 'evt_01JZ0000000000000000000E11', event_type: 'network.account.export_requested', source: 'network',
++ payload: { export_id: 'exp_01JZ0000000000000000000EX2', subject: owner.subject } };
++ const raw = JSON.stringify({ event: exportEvent, seq: 1 });
++ const res = await t2.get('/internal/events', { method: 'POST', body: raw,
++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, secret) } });
++ assert.equal(res.status, 200, res.text);
++ const part = calls.find((c) => c.path.startsWith('/internal/account-exports/'));
++ assert.ok(part, 'the part was pushed to Network');
++ const names = part.body.files.map((f) => f.name).sort();
++ assert.ok(names.includes('ai_typefaces.json'), names.join(', '));
++ assert.ok(names.includes('ai_vectors.json'), names.join(', '));
++ assert.ok(names.includes('ai_make_runs.json'), names.join(', '));
++ const all = JSON.stringify(part.body);
++ assert.ok(all.includes('Kept Sans'), 'the typeface travels with the person');
++ assert.ok(!all.includes(stranger.subject), 'another account is never named');
++
++ const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D11', event_type: 'network.account.deleted', source: 'network',
++ payload: { deletion_id: 'del_01JZ0000000000000000000DE2', subject: owner.subject, aliases: [] } };
++ const rawDelete = JSON.stringify({ event: deleteEvent, seq: 1 });
++ const gone = await t2.get('/internal/events', { method: 'POST', body: rawDelete,
++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(rawDelete, secret) } });
++ assert.equal(gone.status, 200, gone.text);
++ assert.equal(gone.json().outcome, 'erased');
++ const db = t2.ctx.s.db;
++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_typefaces WHERE owner = $1', [owner.subject])), 0);
++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [owner.subject])), 0);
++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_make_runs WHERE owner = $1', [owner.subject])), 0);
++ assert.ok(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [stranger.subject])) > 0, 'another person\'s drawing stays');
++ } finally { await t2.close(); }
++ });
++
++ // ── When the model is not there ──
++ await check('a plain word when the model is not configured', async () => {
++ const off = await boot({ ai: { enabled: false, text: async () => { throw new Error('no'); } } });
++ try {
++ const user = off.network.addUser('fay');
++ const made = await off.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });
++ assert.equal(made.status, 503);
++ assert.match(made.json().error, /not configured/i);
++ assert.ok(!/Error:|at Object/.test(made.json().error));
++ } finally { await off.close(); }
++ });
++
++ done();
++})();