diff --git a/AGENTS.md b/AGENTS.md index 8b5c66e2..ced2fb80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ curl -s -X POST https://axionlabsai-lumen.hf.space/gradio_api/v1/chat/completion # → should return JSON with choices, not 405 HTML # Also test via CF Worker (full stack): -curl -s -X POST https://api.amplifiedsmp.org/v1/chat/completions \ +curl -s -X POST https://api.sennoric.com/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"lumen","messages":[{"role":"user","content":"hi"}]}' ``` diff --git a/README.md b/README.md index 7c731e75..1f655cd2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The deployed public website is maintained separately in ### Install (recommended) ```bash -curl -fsSL https://axion.amplifiedsmp.org/install.sh | sh +curl -fsSL https://sennoric.com/install.sh | sh ``` Or directly via npm: diff --git a/api-proxy-cf/migrations/041_domain_migration_codes.sql b/api-proxy-cf/migrations/041_domain_migration_codes.sql new file mode 100644 index 00000000..ec9dd40c --- /dev/null +++ b/api-proxy-cf/migrations/041_domain_migration_codes.sql @@ -0,0 +1,13 @@ +-- Short-lived, one-time browser handoffs used only while moving authenticated +-- sessions from amplifiedsmp.org to sennoric.com. +CREATE TABLE domain_migration_codes ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + redeemed_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX idx_domain_migration_codes_expires + ON domain_migration_codes(expires_at); diff --git a/api-proxy-cf/src/chatGeneration.js b/api-proxy-cf/src/chatGeneration.js index 6513f0c5..04807e5d 100644 --- a/api-proxy-cf/src/chatGeneration.js +++ b/api-proxy-cf/src/chatGeneration.js @@ -1,6 +1,6 @@ import { ALLOWED_WEB_ORIGINS } from './webOrigins.js' -const COMPLETIONS_URL = 'https://api.amplifiedsmp.org/v1/chat/completions' +const COMPLETIONS_URL = 'https://api.sennoric.com/v1/chat/completions' // Partial text is written to storage at most this often. Frequent enough that a // reader attaching after an eviction sees almost everything, rare enough that a @@ -30,7 +30,7 @@ async function sendEmail(resendKey, { to, subject, html }) { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${resendKey}` }, - body: JSON.stringify({ from: 'Sennoric ', to: [to], subject, html }), + body: JSON.stringify({ from: 'Sennoric ', to: [to], subject, html }), }) if (!res.ok) { const body = await res.text().catch(() => '') @@ -63,7 +63,7 @@ async function notifyScheduledCompletion(env, job, { status, error }) { html: `

"${name}" ${ok ? 'finished' : 'failed'}

${body} - Open Sennoric → + Open Sennoric →
`, }) } catch (err) { diff --git a/api-proxy-cf/src/index.js b/api-proxy-cf/src/index.js index 4a924735..520e8490 100644 --- a/api-proxy-cf/src/index.js +++ b/api-proxy-cf/src/index.js @@ -29,7 +29,7 @@ import { import { reviewPendingMessages } from './messageReview.js' export { ChatGeneration } from './chatGeneration.js' import { avatarUrlForUser, installAvatarRoutes } from './avatar.js' -import { WEB_ORIGIN, ALLOWED_WEB_ORIGINS } from './webOrigins.js' +import { WEB_ORIGIN, LEGACY_WEB_ORIGIN, ALLOWED_WEB_ORIGINS } from './webOrigins.js' import { ModerationAdminError, banAccountFromModeration, @@ -43,11 +43,8 @@ import { } from './moderationAdmin.js' const app = new Hono() -// WEB_ORIGIN/NEW_WEB_ORIGIN/ALLOWED_WEB_ORIGINS live in webOrigins.js so this -// file and chatGeneration.js share one definition. Every WEB_ORIGIN use here -// that builds redirect/email links deliberately still points at the old -// domain until the sennoric.com cutover is confirmed, since changing those -// before the new domain resolves would hand out dead links. +// WEB_ORIGIN/LEGACY_WEB_ORIGIN/ALLOWED_WEB_ORIGINS live in webOrigins.js so this +// file and chatGeneration.js share one definition during the domain cutover. app.use('*', async (c, next) => { await next() @@ -64,6 +61,24 @@ app.use('*', cors({ allowHeaders: ['Content-Type', 'Authorization'], })) +// Keep every bookmarked old-site URL useful after GitHub Pages moves to the +// new custom domain. The account page deliberately detours through the old API: +// a top-level request is the only reliable way to send the old HttpOnly cookie, +// and /auth/domain-migrate converts it into a one-time handoff for the new API. +app.use('*', async (c, next) => { + const requestUrl = new URL(c.req.url) + if (requestUrl.origin !== LEGACY_WEB_ORIGIN) return next() + + const destination = new URL(`${requestUrl.pathname}${requestUrl.search}`, WEB_ORIGIN) + if (requestUrl.pathname === '/keys' || requestUrl.pathname === '/keys.html') { + destination.searchParams.set('domain_migration', 'checked') + const migrate = new URL('/auth/domain-migrate', 'https://api.amplifiedsmp.org') + migrate.searchParams.set('return', destination.href) + return noStoreRedirect(migrate.href) + } + return noStoreRedirect(destination.href) +}) + // ── Helpers ──────────────────────────────────────────────────────────────── // Constant-time string compare for secrets (webhook signatures, password @@ -180,6 +195,8 @@ function genKey() { const TOKEN_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days const SESSION_COOKIE_TTL = 30 * 24 * 60 * 60 * 1000 // 30 days const SESSION_COOKIE = 'axion_session' +const DOMAIN_MIGRATION_TTL = 60 * 1000 +const NEW_API_ORIGIN = 'https://api.sennoric.com' // `v` pins the token to the user's token_version at mint time so a password // reset (which bumps token_version) invalidates every session token issued @@ -258,7 +275,7 @@ function signupRequiredResponse() { message: 'An Sennoric account is required. Sign up or sign in, then use your session or an Sennoric API key.', type: 'authentication_error', signup_required: true, - signup_url: 'https://axion.amplifiedsmp.org/chat', + signup_url: 'https://sennoric.com/chat', }, }, 401) } @@ -288,14 +305,10 @@ async function requireKey(c) { } // ── Session cookie ─────────────────────────────────────────────────────── -// A parallel, longer-lived identity channel for requests that can't carry an -// Authorization header — namely GET /auth/link/:provider, which is a -// top-level browser navigation, not a fetch. Set on every successful -// browser-facing login (password, OAuth callback, email verify) as a -// Domain=.amplifiedsmp.org cookie so it's sent on both same-site XHR (with -// credentials:'include', already wired into the frontend's login/register -// calls) and top-level cross-subdomain navigations (SameSite=Lax allows -// top-level GET navigations regardless of site). +// A parallel, longer-lived identity channel for requests that cannot carry +// an Authorization header. The old and new API hosts coexist during cutover. +// A response can only set a parent-domain cookie for its own registrable +// domain, so derive the cookie domain from the request host. function getCookieValue(c, name) { const header = c.req.header('Cookie') || '' @@ -303,12 +316,17 @@ function getCookieValue(c, name) { return match ? decodeURIComponent(match[1]) : null } -function sessionCookieHeader(token) { - return `${SESSION_COOKIE}=${token}; Domain=.amplifiedsmp.org; Path=/; Max-Age=${SESSION_COOKIE_TTL / 1000}; HttpOnly; Secure; SameSite=Lax` +function sessionCookieDomain(c) { + const hostname = new URL(c.req.url).hostname + return hostname === 'api.sennoric.com' ? '.sennoric.com' : '.amplifiedsmp.org' } -function clearSessionCookieHeader() { - return `${SESSION_COOKIE}=; Domain=.amplifiedsmp.org; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` +function sessionCookieHeader(c, token) { + return `${SESSION_COOKIE}=${token}; Domain=${sessionCookieDomain(c)}; Path=/; Max-Age=${SESSION_COOKIE_TTL / 1000}; HttpOnly; Secure; SameSite=Lax` +} + +function clearSessionCookieHeader(c) { + return `${SESSION_COOKIE}=; Domain=${sessionCookieDomain(c)}; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` } async function sessionUserFromCookie(c) { @@ -322,6 +340,81 @@ async function sessionUserFromCookie(c) { return user } +function domainMigrationDestination(raw) { + try { + const candidate = new URL(raw || '/keys', WEB_ORIGIN) + if (!ALLOWED_WEB_ORIGINS.includes(candidate.origin)) throw new Error('untrusted origin') + return new URL(`${candidate.pathname}${candidate.search}${candidate.hash}`, WEB_ORIGIN).href + } catch { + return `${WEB_ORIGIN}/keys` + } +} + +function noStoreRedirect(location) { + return new Response(null, { + status: 302, + headers: { + Location: location, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }, + }) +} + +// Cross-domain session handoff. The old API verifies its HttpOnly cookie and +// issues a signed, 60-second, single-use code. The new API consumes that code +// atomically and sets its own HttpOnly cookie; the session token never enters +// a URL or page script. +app.get('/auth/domain-migrate', async (c) => { + const destination = domainMigrationDestination(c.req.query('return')) + const user = await sessionUserFromCookie(c) + if (!user) return noStoreRedirect(destination) + + const now = Date.now() + const code = bytesToHex(crypto.getRandomValues(new Uint8Array(32))) + await c.env.DB.prepare( + 'INSERT INTO domain_migration_codes (code, user_id, created_at, expires_at) VALUES (?,?,?,?)' + ).bind(code, user.id, now, now + DOMAIN_MIGRATION_TTL).run() + + const handoff = await signState({ + action: 'domain_migration', + code, + uid: user.id, + exp: now + DOMAIN_MIGRATION_TTL, + }, c.env.TOKEN_SECRET) + const accept = new URL('/auth/domain-migrate/accept', NEW_API_ORIGIN) + accept.searchParams.set('handoff', handoff) + accept.searchParams.set('return', destination) + return noStoreRedirect(accept.href) +}) + +app.get('/auth/domain-migrate/accept', async (c) => { + if (new URL(c.req.url).hostname !== 'api.sennoric.com') { + return new Response('Migration codes must be redeemed on api.sennoric.com.', { status: 400 }) + } + + const state = await parseToken(c.req.query('handoff'), c.env.TOKEN_SECRET) + if (state?.action !== 'domain_migration' || !/^[a-f0-9]{64}$/.test(state.code || '') || !state.uid) { + return new Response('This migration link is invalid or expired.', { status: 400 }) + } + + const now = Date.now() + const consumed = await c.env.DB.prepare( + 'UPDATE domain_migration_codes SET redeemed_at=? WHERE code=? AND user_id=? AND redeemed_at IS NULL AND expires_at>?' + ).bind(now, state.code, state.uid, now).run() + if (Number(consumed.meta?.changes || 0) !== 1) { + return new Response('This migration link was already used or expired.', { status: 400 }) + } + + const user = await c.env.DB.prepare('SELECT * FROM users WHERE id=?').bind(state.uid).first() + if (!user || user.banned) return new Response('This account cannot be migrated.', { status: 401 }) + + const res = noStoreRedirect(domainMigrationDestination(c.req.query('return'))) + const token = await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL) + res.headers.set('Set-Cookie', sessionCookieHeader(c, token)) + return res +}) + // 3 requests per account per 15 minutes — distinct from the per-IP // checkRateLimit above, so an account can't be spammed regardless of how // many IPs the request comes from (and vice versa, an IP can't spam many @@ -347,7 +440,7 @@ async function sendEmail(resendKey, { to, subject, html, from, replyTo }) { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${resendKey}` }, body: JSON.stringify({ - from: from || 'Sennoric ', + from: from || 'Sennoric ', to: [to], subject, html, @@ -377,12 +470,12 @@ function emailWrap(inner) { } async function sendVerificationEmail(email, token, resendKey) { - const link = `https://api.amplifiedsmp.org/auth/verify?token=${token}` + const link = `https://api.sennoric.com/auth/verify?token=${token}` await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${resendKey}` }, body: JSON.stringify({ - from: 'Sennoric ', + from: 'Sennoric ', to: [email], subject: 'Verify your Sennoric account', html: ` @@ -440,7 +533,7 @@ app.post('/auth/register', async (c) => { ).bind(appealId, id, email.toLowerCase(), token, 'pending', now).run() if (c.env.RESEND_API_KEY) { - const appealUrl = 'https://api.amplifiedsmp.org/appeal/' + token + const appealUrl = 'https://api.sennoric.com/appeal/' + token c.executionCtx.waitUntil(sendEmail(c.env.RESEND_API_KEY, { to: email.toLowerCase(), subject: 'Your Sennoric account has been suspended', @@ -477,9 +570,9 @@ app.get('/auth/verify', async (c) => { const sessionToken = await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0) const res = new Response(null, { status: 302, - headers: { Location: `https://axion.amplifiedsmp.org/keys#verified=${encodeURIComponent(sessionToken)}&email=${encodeURIComponent(user.email)}` }, + headers: { Location: `https://sennoric.com/keys#verified=${encodeURIComponent(sessionToken)}&email=${encodeURIComponent(user.email)}` }, }) - res.headers.set('Set-Cookie', sessionCookieHeader(await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) + res.headers.set('Set-Cookie', sessionCookieHeader(c, await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) return res }) @@ -583,6 +676,7 @@ async function purgeExpiredDesktopAuthCodes(db) { const cutoff = Date.now() - DESKTOP_CODE_TTL await db.prepare('DELETE FROM desktop_auth_codes WHERE expires_at < ?').bind(cutoff).run() await db.prepare('DELETE FROM desktop_integration_codes WHERE expires_at < ?').bind(cutoff).run() + await db.prepare('DELETE FROM domain_migration_codes WHERE expires_at < ?').bind(cutoff).run() } // ── Desktop integration OAuth broker ───────────────────────────────────── @@ -596,17 +690,17 @@ const DESKTOP_INTEGRATION_CODE_TTL = 5 * 60 * 1000 const DESKTOP_INTEGRATION_PROVIDERS = { github: { authURL: 'https://github.com/login/oauth/authorize', - redirectUri: 'https://api.amplifiedsmp.org/auth/github/callback', + redirectUri: 'https://api.sennoric.com/auth/github/callback', scopes: 'repo read:org read:user user:email', }, google: { authURL: 'https://accounts.google.com/o/oauth2/v2/auth', - redirectUri: 'https://api.amplifiedsmp.org/auth/google/callback', + redirectUri: 'https://api.sennoric.com/auth/google/callback', scopes: 'https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events openid email profile', }, notion: { authURL: 'https://api.notion.com/v1/oauth/authorize', - redirectUri: 'https://api.amplifiedsmp.org/auth/notion/callback', + redirectUri: 'https://api.sennoric.com/auth/notion/callback', scopes: '', }, } @@ -739,14 +833,14 @@ app.post('/auth/desktop/integrations/token', async (c) => { // ── OAuth shared helper ──────────────────────────────────────────────────── const RETURN_DESTINATIONS = { - admin: 'https://axion.amplifiedsmp.org/admin', - home: 'https://axion.amplifiedsmp.org', - keys: 'https://axion.amplifiedsmp.org/keys', - playground: 'https://axion.amplifiedsmp.org/playground', - chat: 'https://axion.amplifiedsmp.org/chat', + admin: 'https://sennoric.com/admin', + home: 'https://sennoric.com', + keys: 'https://sennoric.com/keys', + playground: 'https://sennoric.com/playground', + chat: 'https://sennoric.com/chat', // The consent page preserves the PKCE challenge across an OAuth round-trip // in sessionStorage, so this destination needs no query parameters. - desktop: 'https://axion.amplifiedsmp.org/desktop-auth', + desktop: 'https://sennoric.com/desktop-auth', } async function oauthFinish(c, { id_field, email, provider_id, return_to }) { @@ -793,7 +887,7 @@ async function oauthFinish(c, { id_field, email, provider_id, return_to }) { 'INSERT INTO appeals (id, user_id, email, token, status, created_at) VALUES (?,?,?,?,?,?)' ).bind(appealId, uid, email.toLowerCase(), appealToken, 'pending', now).run() if (c.env.RESEND_API_KEY) { - const appealUrl = 'https://api.amplifiedsmp.org/appeal/' + appealToken + const appealUrl = 'https://api.sennoric.com/appeal/' + appealToken c.executionCtx.waitUntil(sendEmail(c.env.RESEND_API_KEY, { to: email.toLowerCase(), subject: 'Your Sennoric account has been suspended', @@ -825,7 +919,7 @@ async function oauthFinish(c, { id_field, email, provider_id, return_to }) { status: 302, headers: { Location: `${base}#verified=${encodeURIComponent(token)}&email=${encodeURIComponent(email || '')}` }, }) - res.headers.set('Set-Cookie', sessionCookieHeader(await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) + res.headers.set('Set-Cookie', sessionCookieHeader(c, await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) return res } @@ -883,7 +977,7 @@ app.get('/auth/link/:provider', async (c) => { if (provider === 'google') { const params = new URLSearchParams({ client_id: c.env.GOOGLE_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/google/callback', + redirect_uri: 'https://api.sennoric.com/auth/google/callback', response_type: 'code', scope: 'openid email profile', prompt: 'select_account', @@ -894,7 +988,7 @@ app.get('/auth/link/:provider', async (c) => { if (provider === 'github') { const params = new URLSearchParams({ client_id: c.env.GITHUB_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/github/callback', + redirect_uri: 'https://api.sennoric.com/auth/github/callback', scope: 'user:email', state, }) @@ -902,7 +996,7 @@ app.get('/auth/link/:provider', async (c) => { } const params = new URLSearchParams({ client_id: c.env.DISCORD_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/discord/callback', + redirect_uri: 'https://api.sennoric.com/auth/discord/callback', response_type: 'code', scope: 'identify email', state, @@ -935,7 +1029,7 @@ function decodeState(state) { try { return JSON.parse(atob(state || '')).return_ app.get('/auth/google', (c) => { const params = new URLSearchParams({ client_id: c.env.GOOGLE_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/google/callback', + redirect_uri: 'https://api.sennoric.com/auth/google/callback', response_type: 'code', scope: 'openid email profile', prompt: 'select_account', @@ -964,7 +1058,7 @@ app.get('/auth/google/callback', async (c) => { code, client_id: c.env.GOOGLE_CLIENT_ID, client_secret: c.env.GOOGLE_CLIENT_SECRET, - redirect_uri: 'https://api.amplifiedsmp.org/auth/google/callback', + redirect_uri: 'https://api.sennoric.com/auth/google/callback', grant_type: 'authorization_code', }), }) @@ -999,7 +1093,7 @@ app.get('/auth/google/callback', async (c) => { app.get('/auth/github', (c) => { const params = new URLSearchParams({ client_id: c.env.GITHUB_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/github/callback', + redirect_uri: 'https://api.sennoric.com/auth/github/callback', scope: 'user:email', state: encodeState(c.req.query('return_to')), }) @@ -1024,7 +1118,7 @@ app.get('/auth/github/callback', async (c) => { client_id: c.env.GITHUB_CLIENT_ID, client_secret: c.env.GITHUB_CLIENT_SECRET, code, - redirect_uri: 'https://api.amplifiedsmp.org/auth/github/callback', + redirect_uri: 'https://api.sennoric.com/auth/github/callback', }), }) const githubTokens = await tokenRes.json() @@ -1072,7 +1166,7 @@ app.get('/auth/notion/callback', async (c) => { body: JSON.stringify({ grant_type: 'authorization_code', code, - redirect_uri: 'https://api.amplifiedsmp.org/auth/notion/callback', + redirect_uri: 'https://api.sennoric.com/auth/notion/callback', }), }) const tokens = await tokenRes.json() @@ -1089,7 +1183,7 @@ app.get('/auth/notion/callback', async (c) => { app.get('/auth/discord', (c) => { const params = new URLSearchParams({ client_id: c.env.DISCORD_CLIENT_ID, - redirect_uri: 'https://api.amplifiedsmp.org/auth/discord/callback', + redirect_uri: 'https://api.sennoric.com/auth/discord/callback', response_type: 'code', scope: 'identify email', state: encodeState(c.req.query('return_to')), @@ -1110,7 +1204,7 @@ app.get('/auth/discord/callback', async (c) => { client_secret: c.env.DISCORD_CLIENT_SECRET, grant_type: 'authorization_code', code, - redirect_uri: 'https://api.amplifiedsmp.org/auth/discord/callback', + redirect_uri: 'https://api.sennoric.com/auth/discord/callback', }), }) const { access_token } = await tokenRes.json() @@ -1142,7 +1236,7 @@ app.post('/auth/login', async (c) => { if (user.banned) return json({ error: 'Your account has been suspended. Check your email for an appeal link.', banned: true }, 403) if (upgradedHash) c.executionCtx.waitUntil(c.env.DB.prepare('UPDATE users SET pw_hash=? WHERE id=?').bind(upgradedHash, user.id).run()) const res = json({ token: await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0), email: user.email }) - res.headers.set('Set-Cookie', sessionCookieHeader(await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) + res.headers.set('Set-Cookie', sessionCookieHeader(c, await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) return res }) @@ -1154,14 +1248,14 @@ app.get('/auth/session', async (c) => { const user = await sessionUserFromCookie(c) if (!user) return json({ error: 'Not authenticated' }, 401) const res = json({ token: await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0), email: user.email }) - res.headers.set('Set-Cookie', sessionCookieHeader(await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) + res.headers.set('Set-Cookie', sessionCookieHeader(c, await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0, SESSION_COOKIE_TTL))) return res }) const RESET_TOKEN_TTL = 60 * 60 // 1 hour, in seconds (reset_token_expires is epoch seconds) async function sendPasswordResetEmail(email, token, resendKey) { - const link = `https://axion.amplifiedsmp.org/keys#reset=${token}` + const link = `https://sennoric.com/keys#reset=${token}` await sendEmail(resendKey, { to: email, subject: 'Reset your Sennoric password', @@ -1426,6 +1520,9 @@ app.delete('/dashboard/account', async (c) => { db.prepare('DELETE FROM cloud_tasks WHERE user_id=?').bind(user.id), db.prepare('DELETE FROM email_prefs WHERE user_id=?').bind(user.id), db.prepare('DELETE FROM device_codes WHERE user_id=?').bind(user.id), + db.prepare('DELETE FROM desktop_auth_codes WHERE user_id=?').bind(user.id), + db.prepare('DELETE FROM desktop_integration_codes WHERE user_id=?').bind(user.id), + db.prepare('DELETE FROM domain_migration_codes WHERE user_id=?').bind(user.id), db.prepare('DELETE FROM appeals WHERE user_id=?').bind(user.id), db.prepare('DELETE FROM rate_limits WHERE key LIKE ?').bind(`%:${user.id}`), db.prepare('DELETE FROM users WHERE id=?').bind(user.id), @@ -1433,7 +1530,7 @@ app.delete('/dashboard/account', async (c) => { await db.batch(stmts) const res = json({ ok: true }) - res.headers.set('Set-Cookie', clearSessionCookieHeader()) + res.headers.set('Set-Cookie', clearSessionCookieHeader(c)) return res }) @@ -1446,7 +1543,7 @@ app.delete('/dashboard/account', async (c) => { const SQUARE_PLAN_VARIATION_ID = 'YEXEI6A4P4NTO73GCAJANOGJ' const SQUARE_ITEM_VARIATION_ID = '5NSUWXYLVOOXSZXZB7SY6XPQ' // "Regular" $7/mo, backs the plan above const SQUARE_API = 'https://connect.squareup.com/v2' -const SQUARE_WEBHOOK_URL = 'https://api.amplifiedsmp.org/webhooks/square' +const SQUARE_WEBHOOK_URL = 'https://api.sennoric.com/webhooks/square' function squareApi(env, path, opts = {}) { return fetch(`${SQUARE_API}${path}`, { @@ -1487,7 +1584,7 @@ app.post('/billing/checkout', async (c) => { planVariationId: SQUARE_PLAN_VARIATION_ID, itemVariationId: SQUARE_ITEM_VARIATION_ID, buyerEmail: user.email, - redirectUrl: 'https://axion.amplifiedsmp.org/settings.html', + redirectUrl: 'https://sennoric.com/settings.html', })), }) const data = await res.json().catch(() => ({})) @@ -3545,8 +3642,8 @@ app.post('/v1/chat/completions', async (c) => {

Usage alert

Your account${keyRow ? ` (API key ${keyRow.label})` : ''} has used $${usedUsd} / $${budgetUsd} this week (80%).

Your usage resets ${resetLabel}. If you need more, reply to this email.

- View usage → -

To turn off these alerts, visit your account settings.

+ View usage → +

To turn off these alerts, visit your account settings.

`), }) } @@ -3746,7 +3843,7 @@ app.post('/admin/moderation/messages/:id/ban', async (c) => { adminEmail: user.email, }) if (c.env.RESEND_API_KEY) { - const appealUrl = `https://api.amplifiedsmp.org/appeal/${banned.appeal_token}` + const appealUrl = `https://api.sennoric.com/appeal/${banned.appeal_token}` c.executionCtx.waitUntil(sendEmail(c.env.RESEND_API_KEY, { to: banned.email, subject: 'Your Sennoric account has been suspended', @@ -4045,7 +4142,7 @@ app.get('/waitlist/accept', async (c) => { if (entry.invite_expires < Math.floor(Date.now() / 1000)) { return new Response('This invite link has expired. Contact support for a new one.', { status: 400, headers: { 'Content-Type': 'text/plain' } }) } - if (entry.status === 'accepted') return new Response(null, { status: 302, headers: { Location: 'https://axion.amplifiedsmp.org/keys' } }) + if (entry.status === 'accepted') return new Response(null, { status: 302, headers: { Location: 'https://sennoric.com/keys' } }) // Find or create user let user = await c.env.DB.prepare('SELECT * FROM users WHERE email=?').bind(entry.email).first() @@ -4062,7 +4159,7 @@ app.get('/waitlist/accept', async (c) => { const sessionToken = await makeToken(user.id, c.env.TOKEN_SECRET, user.token_version || 0) return new Response(null, { status: 302, - headers: { Location: `https://axion.amplifiedsmp.org/keys#verified=${encodeURIComponent(sessionToken)}&email=${encodeURIComponent(entry.email)}` }, + headers: { Location: `https://sennoric.com/keys#verified=${encodeURIComponent(sessionToken)}&email=${encodeURIComponent(entry.email)}` }, }) }) @@ -4091,7 +4188,7 @@ app.post('/admin/waitlist/:id/approve', async (c) => { ).bind(token, expires, user.email, entry.id).run() if (c.env.RESEND_API_KEY) { - const link = `https://api.amplifiedsmp.org/waitlist/accept?token=${token}` + const link = `https://api.sennoric.com/waitlist/accept?token=${token}` c.executionCtx.waitUntil(sendEmail(c.env.RESEND_API_KEY, { to: entry.email, subject: "You're in — your Sennoric invite is ready", @@ -4137,7 +4234,7 @@ app.get('/announcements/unsubscribe', async (c) => { const token = c.req.query('token') if (!token) return new Response('Missing token.', { status: 400, headers: { 'Content-Type': 'text/plain' } }) await c.env.DB.prepare('UPDATE subscribers SET active=0 WHERE unsub_token=?').bind(token).run() - return new Response(null, { status: 302, headers: { Location: 'https://axion.amplifiedsmp.org/announcements?unsubscribed=1' } }) + return new Response(null, { status: 302, headers: { Location: 'https://sennoric.com/announcements?unsubscribed=1' } }) }) // Called by GitHub Actions when announcements.html is updated — secret-protected, no login needed @@ -4183,15 +4280,15 @@ app.post('/webhook/announce', async (c) => { for (let i = 0; i < all.length; i += 10) { await Promise.all(all.slice(i, i + 10).map(r => { const unsubUrl = r.unsub_token - ? `https://api.amplifiedsmp.org/announcements/unsubscribe?token=${r.unsub_token}` - : `https://axion.amplifiedsmp.org/keys` + ? `https://api.sennoric.com/announcements/unsubscribe?token=${r.unsub_token}` + : `https://sennoric.com/keys` return sendEmail(c.env.RESEND_API_KEY, { to: r.email, subject: `Sennoric: ${titleStr}`, html: emailWrap(`

${titleStr}

${bodyStr}
- Read on site → + Read on site →

Unsubscribe

`), }) @@ -4265,12 +4362,12 @@ app.post('/admin/invite', async (c) => { ).bind(token, email.toLowerCase(), user.email, expires_at).run() if (c.env.RESEND_API_KEY) { - const link = `https://api.amplifiedsmp.org/admin/invite/accept?token=${token}` + const link = `https://api.sennoric.com/admin/invite/accept?token=${token}` c.executionCtx.waitUntil(fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${c.env.RESEND_API_KEY}` }, body: JSON.stringify({ - from: 'Sennoric ', + from: 'Sennoric ', to: [email], subject: `${user.email} invited you to the Sennoric admin panel`, html: `
@@ -4304,7 +4401,7 @@ app.get('/admin/invite/accept', async (c) => { return new Response(null, { status: 302, - headers: { Location: `https://axion.amplifiedsmp.org/admin#invited=1` }, + headers: { Location: `https://sennoric.com/admin#invited=1` }, }) }) @@ -4396,7 +4493,7 @@ app.post('/appeal/:token', async (c) => {

Email: ${escHtml(appeal.email)}

Reason:

${escHtml(reason.trim())}
- Review in admin panel → + Review in admin panel → `), })) } @@ -4437,7 +4534,7 @@ app.post('/admin/appeals/:token/accept', async (c) => { html: emailWrap(`

Appeal approved

Your account has been reinstated. You can now sign in and use the service normally.

- Sign in → + Sign in → `), })) } @@ -4754,12 +4851,12 @@ app.post('/orgs/:id/invite', async (c) => { ).bind(token, orgId, email.toLowerCase(), assignRole, ctx.user.email, expires_at).run() if (c.env.RESEND_API_KEY) { - const link = `https://axion.amplifiedsmp.org/keys#invite=${token}&org=${orgId}` + const link = `https://sennoric.com/keys#invite=${token}&org=${orgId}` c.executionCtx.waitUntil(fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${c.env.RESEND_API_KEY}` }, body: JSON.stringify({ - from: 'Sennoric ', + from: 'Sennoric ', to: [email], subject: `${ctx.user.email} invited you to ${org?.name || 'a team'} on Sennoric`, html: `
diff --git a/api-proxy-cf/src/status.js b/api-proxy-cf/src/status.js index 46678876..0d21d446 100644 --- a/api-proxy-cf/src/status.js +++ b/api-proxy-cf/src/status.js @@ -25,7 +25,7 @@ function labelFor(service) { // the request would normally go through, with no network hop at all. async function checkSennoricApi(env, appFetch) { try { - const res = await appFetch(new Request('https://api.amplifiedsmp.org/v1/models')) + const res = await appFetch(new Request('https://api.sennoric.com/v1/models')) return res.ok ? { service: 'axion_api', status: 'up', detail: '' } : { service: 'axion_api', status: 'down', detail: `HTTP ${res.status}` } @@ -45,7 +45,7 @@ async function checkLumen(env, fetchImpl) { async function checkWebsite(env, fetchImpl) { try { - const res = await fetchImpl('https://axion.amplifiedsmp.org/', { method: 'GET' }) + const res = await fetchImpl('https://sennoric.com/', { method: 'GET' }) return res.ok ? { service: 'website', status: 'up', detail: '' } : { service: 'website', status: 'down', detail: `HTTP ${res.status}` } @@ -74,7 +74,7 @@ async function alertAdmin(env, { subject, html }) { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.RESEND_API_KEY}` }, body: JSON.stringify({ - from: 'Sennoric Status ', + from: 'Sennoric Status ', to: [env.ADMIN_ALERT_EMAIL], subject, html, @@ -108,7 +108,7 @@ async function evaluateIncident(env, result, nowIso) { 'INSERT INTO status_incident_updates (id, incident_id, status, body, created_at) VALUES (?,?,?,?,?)' ).bind(crypto.randomUUID(), id, 'investigating', body, nowIso), ]) - const editLink = `https://axion.amplifiedsmp.org/admin#incident-${id}` + const editLink = `https://sennoric.com/admin#incident-${id}` await alertAdmin(env, { subject: `[Sennoric Status] ${title}`, html: emailWrap(` @@ -131,7 +131,7 @@ async function evaluateIncident(env, result, nowIso) { 'INSERT INTO status_incident_updates (id, incident_id, status, body, created_at) VALUES (?,?,?,?,?)' ).bind(crypto.randomUUID(), existing.id, 'resolved', body, nowIso), ]) - const editLink = `https://axion.amplifiedsmp.org/admin#incident-${existing.id}` + const editLink = `https://sennoric.com/admin#incident-${existing.id}` await alertAdmin(env, { subject: `[Sennoric Status] Resolved: ${existing.title}`, html: emailWrap(` diff --git a/api-proxy-cf/src/webOrigins.js b/api-proxy-cf/src/webOrigins.js index 19ae7151..d7c5bfa6 100644 --- a/api-proxy-cf/src/webOrigins.js +++ b/api-proxy-cf/src/webOrigins.js @@ -3,9 +3,6 @@ // chatGeneration.js can both import it without a circular dependency // (index.js is what imports ChatGeneration from chatGeneration.js). // -// NEW_WEB_ORIGIN (sennoric.com) is not live yet as of this change -- -// DNS/GitHub Pages cutover pending. Accepted here ahead of time so CORS -// doesn't need another deploy the moment it goes live. -export const WEB_ORIGIN = 'https://axion.amplifiedsmp.org' -export const NEW_WEB_ORIGIN = 'https://sennoric.com' -export const ALLOWED_WEB_ORIGINS = [WEB_ORIGIN, NEW_WEB_ORIGIN] +export const WEB_ORIGIN = 'https://sennoric.com' +export const LEGACY_WEB_ORIGIN = 'https://axion.amplifiedsmp.org' +export const ALLOWED_WEB_ORIGINS = [WEB_ORIGIN, LEGACY_WEB_ORIGIN] diff --git a/api-proxy-cf/test/billing.test.mjs b/api-proxy-cf/test/billing.test.mjs index 08d256d2..d512a2fd 100644 --- a/api-proxy-cf/test/billing.test.mjs +++ b/api-proxy-cf/test/billing.test.mjs @@ -148,6 +148,18 @@ class D1TestDatabase { CREATE TABLE cloud_task_events (id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES cloud_tasks(id)); CREATE TABLE email_prefs (user_id TEXT PRIMARY KEY REFERENCES users(id)); CREATE TABLE device_codes (code TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id)); + CREATE TABLE desktop_auth_codes ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) + ); + CREATE TABLE desktop_integration_codes ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) + ); + CREATE TABLE domain_migration_codes ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) + ); CREATE TABLE appeals ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), @@ -565,7 +577,7 @@ test('only an authenticated admin can manually run pending message review', asyn assert.match(runResult.run_id, /^[0-9a-f-]{36}$/) assert.equal( runResult.details_url, - `https://axion.amplifiedsmp.org/admin-moderation?run=${runResult.run_id}`, + `https://sennoric.com/admin-moderation?run=${runResult.run_id}`, ) const message = db.prepare( 'SELECT review_status, review_run_id FROM message_log WHERE id=1' @@ -944,6 +956,9 @@ test('account deletion removes audits, rate limits, and every key in an owned or db.prepare('INSERT INTO cloud_tasks (id, user_id) VALUES (?,?)').bind('member-cloud-task', 'member').run() db.prepare('INSERT INTO cloud_task_events (id, task_id) VALUES (?,?)').bind('member-cloud-task-event', 'member-cloud-task').run() db.prepare('INSERT INTO user_settings (user_id) VALUES (?)').bind('member').run() + db.prepare('INSERT INTO desktop_auth_codes (code, user_id) VALUES (?,?)').bind('desktop-code', 'member').run() + db.prepare('INSERT INTO desktop_integration_codes (code, user_id) VALUES (?,?)').bind('integration-code', 'member').run() + db.prepare('INSERT INTO domain_migration_codes (code, user_id) VALUES (?,?)').bind('migration-code', 'member').run() db.prepare( `INSERT INTO admin_account_edits (id, user_id, admin_email, previous_plan, new_plan, @@ -1009,6 +1024,9 @@ test('account deletion removes audits, rate limits, and every key in an owned or assert.equal(db.prepare('SELECT COUNT(*) AS count FROM cloud_tasks WHERE user_id=?').bind('member').first().count, 0) assert.equal(db.prepare('SELECT COUNT(*) AS count FROM cloud_task_events WHERE task_id=?').bind('member-cloud-task').first().count, 0) assert.equal(db.prepare('SELECT COUNT(*) AS count FROM user_settings WHERE user_id=?').bind('member').first().count, 0) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM desktop_auth_codes WHERE user_id=?').bind('member').first().count, 0) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM desktop_integration_codes WHERE user_id=?').bind('member').first().count, 0) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM domain_migration_codes WHERE user_id=?').bind('member').first().count, 0) assert.equal(db.prepare('SELECT COUNT(*) AS count FROM admin_account_edits WHERE user_id=?').bind('member').first().count, 0) assert.equal(db.prepare("SELECT COUNT(*) AS count FROM rate_limits WHERE key LIKE '%:member'").first().count, 0) assert.equal(db.prepare("SELECT COUNT(*) AS count FROM rate_limits WHERE key='free:unrelated-ip'").first().count, 1) @@ -1055,6 +1073,37 @@ function executionCtx() { } } +test('appeal notification links to the website admin panel', async () => { + const db = new D1TestDatabase() + addUser(db, 'appealing-user') + db.prepare( + 'INSERT INTO appeals (id, user_id, email, token, status, created_at) VALUES (?,?,?,?,?,?)' + ).bind('appeal-1', 'appealing-user', 'appealing-user@example.com', 'appeal-token', 'pending', Date.now()).run() + + let emailRequest = null + const realFetch = globalThis.fetch + globalThis.fetch = async (url, options) => { + emailRequest = { url: String(url), body: JSON.parse(options.body) } + return Response.json({ id: 'email-1' }) + } + try { + const { ctx, settle } = executionCtx() + const response = await app.request('/appeal/appeal-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'Please review this account.' }), + }, { DB: db, TOKEN_SECRET: 'appeal-secret', RESEND_API_KEY: 'resend-test' }, ctx) + + assert.equal(response.status, 200) + await settle() + assert.equal(emailRequest.url, 'https://api.resend.com/emails') + assert.match(emailRequest.body.html, /href="https:\/\/sennoric\.com\/admin"/) + assert.doesNotMatch(emailRequest.body.html, /https:\/\/api\.sennoric\.com\/admin/) + } finally { + globalThis.fetch = realFetch + } +}) + test('session-authenticated completions are charged to the account', async () => { const db = new D1TestDatabase() const secret = 'session-billing-secret' @@ -1322,7 +1371,7 @@ test('chat completions require an account and never call the model for anonymous const body = await response.json() assert.equal(body.error.type, 'authentication_error') assert.equal(body.error.signup_required, true) - assert.equal(body.error.signup_url, 'https://axion.amplifiedsmp.org/chat') + assert.equal(body.error.signup_url, 'https://sennoric.com/chat') assert.equal(upstreamCalls, 0) assert.equal(db.prepare('SELECT COUNT(*) AS count FROM message_log').first().count, 0) assert.equal(db.prepare("SELECT COUNT(*) AS count FROM rate_limits WHERE key LIKE 'free:%'").first().count, 0) diff --git a/api-proxy-cf/test/chat-generation.test.mjs b/api-proxy-cf/test/chat-generation.test.mjs index 635f6210..d336c1dc 100644 --- a/api-proxy-cf/test/chat-generation.test.mjs +++ b/api-proxy-cf/test/chat-generation.test.mjs @@ -273,7 +273,7 @@ test('the Durable Object appends the assistant reply and completes the job after globalThis.fetch = realFetch } - assert.equal(requestSeen.url, 'https://api.amplifiedsmp.org/v1/chat/completions') + assert.equal(requestSeen.url, 'https://api.sennoric.com/v1/chat/completions') assert.equal(requestSeen.options.headers.Authorization, 'Bearer signed-job-token') // The object owns the only model call, and it streams so watching tabs get // tokens as they arrive rather than one block at the end. diff --git a/api-proxy-cf/test/desktop-auth.test.mjs b/api-proxy-cf/test/desktop-auth.test.mjs index 39812d8a..b0431d85 100644 --- a/api-proxy-cf/test/desktop-auth.test.mjs +++ b/api-proxy-cf/test/desktop-auth.test.mjs @@ -24,6 +24,7 @@ class Statement { class D1TestDatabase { constructor() { this.database = new DatabaseSync(':memory:') + this.database.exec('PRAGMA foreign_keys=ON') this.database.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, @@ -45,6 +46,14 @@ class D1TestDatabase { expires_at INTEGER NOT NULL, redeemed_at INTEGER ); + CREATE TABLE domain_migration_codes ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + redeemed_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) + ); `) } prepare(sql) { return new Statement(this.database, sql) } @@ -221,3 +230,82 @@ test('a password reset invalidates a token minted before it', async () => { const response = await approve(env, minted, { code_challenge: (await pkcePair()).challenge }) assert.equal(response.status, 401) }) + +test('the old HttpOnly session migrates through a signed single-use handoff', async () => { + const { env } = makeEnv() + const token = await sessionToken('u1') + const start = await app.request( + 'https://api.amplifiedsmp.org/auth/domain-migrate?return=https%3A%2F%2Faxion.amplifiedsmp.org%2Fkeys%3Ftab%3Dusage', + { headers: { Cookie: `axion_session=${token}` } }, + env, + ) + + assert.equal(start.status, 302) + const acceptUrl = new URL(start.headers.get('location')) + assert.equal(acceptUrl.origin, 'https://api.sennoric.com') + assert.equal(acceptUrl.pathname, '/auth/domain-migrate/accept') + assert.ok(acceptUrl.searchParams.get('handoff')) + assert.equal(acceptUrl.searchParams.has('token'), false) + + const attempts = await Promise.all([ + app.request(acceptUrl.href, {}, env), + app.request(acceptUrl.href, {}, env), + ]) + assert.deepEqual(attempts.map(response => response.status).sort(), [302, 400]) + const accepted = attempts.find(response => response.status === 302) + assert.equal(accepted.status, 302) + assert.equal(accepted.headers.get('location'), 'https://sennoric.com/keys?tab=usage') + assert.match(accepted.headers.get('set-cookie'), /Domain=\.sennoric\.com/) + assert.match(accepted.headers.get('set-cookie'), /HttpOnly/) + assert.match(accepted.headers.get('set-cookie'), /;\s*Secure(?:;|$)/i) + + const replay = await app.request(acceptUrl.href, {}, env) + assert.equal(replay.status, 400) +}) + +test('an expired domain migration code cannot be accepted', async () => { + const { db, env } = makeEnv() + const token = await sessionToken('u1') + const start = await app.request( + 'https://api.amplifiedsmp.org/auth/domain-migrate?return=%2Fkeys', + { headers: { Cookie: `axion_session=${token}` } }, + env, + ) + const acceptUrl = new URL(start.headers.get('location')) + const signedState = acceptUrl.searchParams.get('handoff') + const state = JSON.parse(atob(signedState.split('.')[0])) + db.prepare('UPDATE domain_migration_codes SET expires_at=? WHERE code=?') + .bind(Date.now() - 1, state.code).run() + + const response = await app.request(acceptUrl.href, {}, env) + assert.equal(response.status, 400) +}) + +test('domain migration without an old session redirects without minting a handoff', async () => { + const { env } = makeEnv() + const response = await app.request( + 'https://api.amplifiedsmp.org/auth/domain-migrate?return=https%3A%2F%2Faxion.amplifiedsmp.org%2Fdocs', + {}, + env, + ) + assert.equal(response.status, 302) + assert.equal(response.headers.get('location'), 'https://sennoric.com/docs') +}) + +test('the old website preserves paths and routes account visits through the signed handoff', async () => { + const { env } = makeEnv() + + const docs = await app.request('https://axion.amplifiedsmp.org/docs?section=cli', {}, env) + assert.equal(docs.status, 302) + assert.equal(docs.headers.get('location'), 'https://sennoric.com/docs?section=cli') + + const keys = await app.request('https://axion.amplifiedsmp.org/keys?tab=usage', {}, env) + assert.equal(keys.status, 302) + const migrate = new URL(keys.headers.get('location')) + assert.equal(migrate.origin, 'https://api.amplifiedsmp.org') + assert.equal(migrate.pathname, '/auth/domain-migrate') + assert.equal( + migrate.searchParams.get('return'), + 'https://sennoric.com/keys?tab=usage&domain_migration=checked', + ) +}) diff --git a/api-proxy-cf/test/desktop-integrations.test.mjs b/api-proxy-cf/test/desktop-integrations.test.mjs index 9bf5d3c5..61f32d20 100644 --- a/api-proxy-cf/test/desktop-integrations.test.mjs +++ b/api-proxy-cf/test/desktop-integrations.test.mjs @@ -83,7 +83,7 @@ test('GitHub integration OAuth is brokered with PKCE and the provider token is s mock.method(globalThis, 'fetch', async (input, init) => { assert.equal(String(input), 'https://github.com/login/oauth/access_token') - assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/github/callback') + assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.sennoric.com/auth/github/callback') return Response.json({ access_token: 'gho_secret', scope: 'repo read:user', token_type: 'bearer' }) }) const callback = await app.request(`/auth/github/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env) @@ -151,7 +151,7 @@ test('Notion integration OAuth is brokered without exposing its client secret', mock.method(globalThis, 'fetch', async (input, init) => { assert.equal(String(input), 'https://api.notion.com/v1/oauth/token') assert.equal(init.headers.Authorization, `Basic ${btoa('notion-client:notion-secret')}`) - assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/notion/callback') + assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.sennoric.com/auth/notion/callback') return Response.json({ access_token: 'secret_notion', workspace_name: 'Sennoric' }) }) const callback = await app.request(`/auth/notion/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env) diff --git a/api-proxy-cf/test/migrations.test.mjs b/api-proxy-cf/test/migrations.test.mjs index 49d0fe80..681fb025 100644 --- a/api-proxy-cf/test/migrations.test.mjs +++ b/api-proxy-cf/test/migrations.test.mjs @@ -28,3 +28,18 @@ test('user_settings accepts the same TEXT ids used by users after migration 038' userId, ) }) + +test('domain migration codes insert with a user association and start unredeemed', () => { + const db = new DatabaseSync(':memory:') + db.exec('CREATE TABLE users (id TEXT PRIMARY KEY)') + db.exec(migration('041_domain_migration_codes.sql')) + db.prepare('INSERT INTO users (id) VALUES (?)').run('u1') + db.prepare(` + INSERT INTO domain_migration_codes (code, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?) + `).run('code', 'u1', 1, 2) + + const row = db.prepare('SELECT * FROM domain_migration_codes WHERE code=?').get('code') + assert.equal(row.user_id, 'u1') + assert.equal(row.redeemed_at, null) +}) diff --git a/api-proxy-cf/test/sandbox-route.test.mjs b/api-proxy-cf/test/sandbox-route.test.mjs index 4decacd0..9dee2374 100644 --- a/api-proxy-cf/test/sandbox-route.test.mjs +++ b/api-proxy-cf/test/sandbox-route.test.mjs @@ -109,7 +109,7 @@ test('anonymous requests (no auth header at all) require signup and are rejected const body = await response.json() assert.equal(body.error.type, 'authentication_error') assert.equal(body.error.signup_required, true) - assert.equal(body.error.signup_url, 'https://axion.amplifiedsmp.org/chat') + assert.equal(body.error.signup_url, 'https://sennoric.com/chat') }) test('a banned session-token account is rejected (requireAuth already filters banned users to null, same as /v1/chat/completions — so this is a 401, not a 403)', async () => { diff --git a/api-proxy-cf/test/status.test.mjs b/api-proxy-cf/test/status.test.mjs index f13d3ab8..db617430 100644 --- a/api-proxy-cf/test/status.test.mjs +++ b/api-proxy-cf/test/status.test.mjs @@ -63,7 +63,7 @@ function fetchStub({ axionApiUp = true, lumenUp = true, websiteUp = true } = {}) return async (url) => { const s = typeof url === 'string' ? url : url.url if (s.includes('runpod.ai')) return { ok: lumenUp } - if (s.includes('api.amplifiedsmp.org')) return { ok: axionApiUp } + if (s.includes('api.sennoric.com')) return { ok: axionApiUp } return { ok: websiteUp } } } diff --git a/api-proxy-cf/wrangler.toml b/api-proxy-cf/wrangler.toml index 032c4ba6..1d5981a0 100644 --- a/api-proxy-cf/wrangler.toml +++ b/api-proxy-cf/wrangler.toml @@ -30,9 +30,17 @@ new_sqlite_classes = ["BridgeRelay"] tag = "v2" new_sqlite_classes = ["ChatGeneration"] +[[routes]] +pattern = "api.sennoric.com/*" +zone_name = "sennoric.com" + [[routes]] pattern = "api.amplifiedsmp.org/*" zone_name = "amplifiedsmp.org" +[[routes]] +pattern = "axion.amplifiedsmp.org/*" +zone_name = "amplifiedsmp.org" + [triggers] crons = ["*/5 * * * *", "0 * * * *", "* * * * *"] diff --git a/models/Lumen/IPYNBs (COLAB RUNS)/In Use/lumen-1.3-sft.ipynb b/models/Lumen/IPYNBs (COLAB RUNS)/In Use/lumen-1.3-sft.ipynb index 3cd883fc..62c4b055 100644 --- a/models/Lumen/IPYNBs (COLAB RUNS)/In Use/lumen-1.3-sft.ipynb +++ b/models/Lumen/IPYNBs (COLAB RUNS)/In Use/lumen-1.3-sft.ipynb @@ -138,7 +138,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## After training\n\n- **Serve**: load `lumen-1.3-merged` in vLLM with `--enable-auto-tool-choice --tool-call-parser hermes`\n behind the existing lumen endpoint (`api.amplifiedsmp.org/v1`). Sennoric needs zero changes for text —\n `/model lumen` already points there.\n- **Multimodal serving is extra work, not automatic.** The weights support images/video, but the\n serving stack doesn't yet: vLLM needs multimodal flags, `api-proxy-cf` must accept OpenAI-style\n `image_url` content parts in `/v1/chat/completions`, `chat.html` needs upload UI, and image tokens\n need billing rates. Track as its own task.\n- **Eval**: run a SWE-bench-style held-out set through the deployed model and compare pass-rate\n against base Qwen3.5-9B served the same way. (Eval harness is a separate, not-yet-built piece.)\n- **Regression checks before shipping**:\n - plain chat (a few ordinary questions) — no catastrophic forgetting\n - `` still appears on complex prompts (thinking is ON by default in Qwen3.5)\n - **vision still works** — feed an image and ask for a description; compare against base model.\n This is the check that catches an accidental unfreezing of the vision tower." + "source": "## After training\n\n- **Serve**: load `lumen-1.3-merged` in vLLM with `--enable-auto-tool-choice --tool-call-parser hermes`\n behind the existing lumen endpoint (`api.sennoric.com/v1`). Sennoric needs zero changes for text —\n `/model lumen` already points there.\n- **Multimodal serving is extra work, not automatic.** The weights support images/video, but the\n serving stack doesn't yet: vLLM needs multimodal flags, `api-proxy-cf` must accept OpenAI-style\n `image_url` content parts in `/v1/chat/completions`, `chat.html` needs upload UI, and image tokens\n need billing rates. Track as its own task.\n- **Eval**: run a SWE-bench-style held-out set through the deployed model and compare pass-rate\n against base Qwen3.5-9B served the same way. (Eval harness is a separate, not-yet-built piece.)\n- **Regression checks before shipping**:\n - plain chat (a few ordinary questions) — no catastrophic forgetting\n - `` still appears on complex prompts (thinking is ON by default in Qwen3.5)\n - **vision still works** — feed an image and ask for a description; compare against base model.\n This is the check that catches an accidental unfreezing of the vision tower." } ], "metadata": { diff --git a/src/agent/agent.js b/src/agent/agent.js index f8d603ec..fe80c22b 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -2054,7 +2054,7 @@ export function classifyProviderError(err, modelAlias) { const status = err.data.status ?? err?.status ?? err?.response?.status; if (status === 401) { if (modelAlias === 'other') return { kind: 'account', message: `Auth failed for custom endpoint. Use /endpoint to set the API key.` }; - if (isAxionHostedProvider(resolveProvider(modelAlias))) return { kind: 'account', message: `Invalid or revoked Sennoric credentials. Use /login or /axion-key to authenticate.\n→ Sign up or get a key at axion.amplifiedsmp.org/keys` }; + if (isAxionHostedProvider(resolveProvider(modelAlias))) return { kind: 'account', message: `Invalid or revoked Sennoric credentials. Use /login or /axion-key to authenticate.\n→ Sign up or get a key at sennoric.com/keys` }; return { kind: 'account', message: `Invalid API key for "${modelAlias}". Use /api ${modelAlias} to set it.` }; } if (status === 429) return { kind: 'quota', message: `Rate limited by "${providerLabel}". Wait a moment and try again.` }; @@ -2081,7 +2081,7 @@ export function classifyProviderError(err, modelAlias) { if (status === 401 || /unauthorized|invalid.*key|api.?key/i.test(msg)) { if (modelAlias === 'other') return { kind: 'account', message: `Auth failed for custom endpoint. Use /endpoint to set the API key.` }; - if (isAxionHostedProvider(resolveProvider(modelAlias))) return { kind: 'account', message: `Invalid or revoked Sennoric credentials. Use /login or /axion-key to authenticate.\n→ Sign up or get a key at axion.amplifiedsmp.org/keys` }; + if (isAxionHostedProvider(resolveProvider(modelAlias))) return { kind: 'account', message: `Invalid or revoked Sennoric credentials. Use /login or /axion-key to authenticate.\n→ Sign up or get a key at sennoric.com/keys` }; return { kind: 'account', message: `Invalid API key for "${modelAlias}". Use /api ${modelAlias} to set it.` }; } if (status === 429 || /rate.?limit|quota/i.test(msg)) { diff --git a/src/agent/mcp-marketplace.js b/src/agent/mcp-marketplace.js index c1f37110..3483ea19 100644 --- a/src/agent/mcp-marketplace.js +++ b/src/agent/mcp-marketplace.js @@ -8,7 +8,7 @@ import { writeJsonAtomic } from '../tui/persistence.js' // this file, not process.cwd(), so installs work from any launch directory. const PKG_SERVER = (rel) => fileURLToPath(new URL(`../../mcp-servers/${rel}`, import.meta.url)) -const CATALOG_URL = 'https://axion.amplifiedsmp.org/mcp-catalog.json' +const CATALOG_URL = 'https://sennoric.com/mcp-catalog.json' const CACHE_TTL = 60 * 60 * 1000 // 1 hour // ── Local fallback catalog ───────────────────────────────────────────────── diff --git a/src/agent/models.js b/src/agent/models.js index b1f1932f..8511b41e 100644 --- a/src/agent/models.js +++ b/src/agent/models.js @@ -206,7 +206,7 @@ export function createClient(modelAlias) { apiKey: key, baseURL: BASE_URLS.openrouter, defaultHeaders: { - 'HTTP-Referer': 'https://axion.amplifiedsmp.org', + 'HTTP-Referer': 'https://sennoric.com', 'X-Title': 'Sennoric', }, }) }; diff --git a/src/agent/tools.js b/src/agent/tools.js index dee22e8e..0a3515ce 100644 --- a/src/agent/tools.js +++ b/src/agent/tools.js @@ -1603,7 +1603,7 @@ export async function executeTool(name, input, { let response; try { - response = await fetch('https://api.amplifiedsmp.org/artifacts', { + response = await fetch('https://api.sennoric.com/artifacts', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -1638,7 +1638,7 @@ export async function executeTool(name, input, { let response; try { - response = await fetch(`https://api.amplifiedsmp.org/artifacts/${encodeURIComponent(input.id)}`, { + response = await fetch(`https://api.sennoric.com/artifacts/${encodeURIComponent(input.id)}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -1663,7 +1663,7 @@ export async function executeTool(name, input, { let response; try { - response = await fetch(`https://api.amplifiedsmp.org/artifacts/${encodeURIComponent(input.id)}`, { + response = await fetch(`https://api.sennoric.com/artifacts/${encodeURIComponent(input.id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); diff --git a/src/bridge.js b/src/bridge.js index 4138f1ec..ebfa4f5c 100755 --- a/src/bridge.js +++ b/src/bridge.js @@ -23,7 +23,7 @@ for (const [name, ep] of Object.entries(getSavedCustomEndpoints())) { const PORT = Number(process.env.BRIDGE_PORT) || 3002; const TOKEN = process.env.BRIDGE_TOKEN || ''; -const RELAY_URL = process.env.AXION_BRIDGE_RELAY_URL || 'wss://api.amplifiedsmp.org/bridge/ws'; +const RELAY_URL = process.env.AXION_BRIDGE_RELAY_URL || 'wss://api.sennoric.com/bridge/ws'; const html = readFileSync(new URL('./assets/console.html', import.meta.url), 'utf-8'); const xtermJs = readFileSync(new URL('../vendor/xterm.js', import.meta.url), 'utf-8'); diff --git a/src/config.js b/src/config.js index f22665f1..9d2c0863 100644 --- a/src/config.js +++ b/src/config.js @@ -103,8 +103,8 @@ export const BASE_URLS = { // Worker dispatches on body.model. Veil is retiring 2026-08-17 with no // replacement; remove the model entirely around that date rather than // updating this URL again. - veil: 'https://api.amplifiedsmp.org/v1', - lumen: 'https://api.amplifiedsmp.org/v1', + veil: 'https://api.sennoric.com/v1', + lumen: 'https://api.sennoric.com/v1', 'axion-vision': 'https://axionlabsai-lumenvision.hf.space/v1', opencode: 'https://opencode.ai/zen/v1', zai: 'https://api.z.ai/api/paas/v4', diff --git a/src/tui/App.jsx b/src/tui/App.jsx index 58b1ce50..94488083 100644 --- a/src/tui/App.jsx +++ b/src/tui/App.jsx @@ -2149,13 +2149,13 @@ function Session({ const testKey = getAxionKey(); if (!testKey) { push({ type: 'error', text: 'No Sennoric key set.' }); return; } push({ type: 'info', text: 'Testing key…' }); - fetch('https://api.amplifiedsmp.org/v1/chat/completions', { + fetch('https://api.sennoric.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${testKey}` }, body: JSON.stringify({ model: 'lumen', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 }), }).then(async r => { if (r.status === 200) push({ type: 'info', text: 'Key is valid. Lumen is reachable.' }); - else if (r.status === 401) push({ type: 'error', text: 'Key rejected by server (401). Generate a fresh key at axion.amplifiedsmp.org/keys' }); + else if (r.status === 401) push({ type: 'error', text: 'Key rejected by server (401). Generate a fresh key at sennoric.com/keys' }); else if (r.status === 429) push({ type: 'info', text: 'Key is valid but rate-limited.' }); else push({ type: 'error', text: `Unexpected response: HTTP ${r.status}` }); }).catch(e => push({ type: 'error', text: `Network error: ${e.message}` })); @@ -2398,7 +2398,7 @@ function Session({ return; } case 'login': { - const AXION_API = 'https://api.amplifiedsmp.org'; + const AXION_API = 'https://api.sennoric.com'; push({ type: 'info', text: 'Opening browser to authorize your Sennoric account…' }); try { const res = await fetch(`${AXION_API}/auth/device`, { method: 'POST' }); @@ -2406,7 +2406,7 @@ function Session({ const { device_code, expires_in } = await res.json(); const deviceCode = String(device_code); if (!/^[A-Za-z0-9_-]+$/.test(deviceCode)) { push({ type: 'error', text: 'Invalid device code from server.' }); return; } - const loginUrl = `https://axion.amplifiedsmp.org/keys#device=${deviceCode}`; + const loginUrl = `https://sennoric.com/keys#device=${deviceCode}`; try { if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', loginUrl], { detached: true, stdio: 'ignore' }).unref(); else if (process.platform === 'darwin') spawn('open', [loginUrl], { detached: true, stdio: 'ignore' }).unref(); else spawn('xdg-open', [loginUrl], { detached: true, stdio: 'ignore' }).unref(); } catch { push({ type: 'info', text: `Open this URL in your browser:\n${loginUrl}` }); } push({ type: 'info', text: `Waiting for authorization… (expires in ${Math.floor(expires_in / 60)} min)` }); diff --git a/test/cloudArtifactTool.test.js b/test/cloudArtifactTool.test.js index 930c090f..6286604e 100644 --- a/test/cloudArtifactTool.test.js +++ b/test/cloudArtifactTool.test.js @@ -45,7 +45,7 @@ test('create_cloud_artifact posts to the Worker with the bearer token and defaul }); try { const result = await executeTool('create_cloud_artifact', { content: 'hello' }, {}); - assert.equal(seenUrl, 'https://api.amplifiedsmp.org/artifacts'); + assert.equal(seenUrl, 'https://api.sennoric.com/artifacts'); assert.equal(seenOptions.method, 'POST'); assert.equal(seenOptions.headers.Authorization, 'Bearer test-token'); assert.deepEqual(JSON.parse(seenOptions.body), { title: 'Untitled', kind: 'text', content: 'hello' }); @@ -172,7 +172,7 @@ test('update_cloud_artifact PUTs only the fields given, to the artifact\'s own U }); try { const result = await executeTool('update_cloud_artifact', { id: 'a1', content: 'new content' }, {}); - assert.equal(seenUrl, 'https://api.amplifiedsmp.org/artifacts/a1'); + assert.equal(seenUrl, 'https://api.sennoric.com/artifacts/a1'); assert.equal(seenOptions.method, 'PUT'); assert.equal(seenOptions.headers.Authorization, 'Bearer test-token'); assert.deepEqual(JSON.parse(seenOptions.body), { content: 'new content' }); @@ -229,7 +229,7 @@ test('delete_cloud_artifact DELETEs the artifact\'s own URL with the bearer toke }); try { const result = await executeTool('delete_cloud_artifact', { id: 'a1' }, {}); - assert.equal(seenUrl, 'https://api.amplifiedsmp.org/artifacts/a1'); + assert.equal(seenUrl, 'https://api.sennoric.com/artifacts/a1'); assert.equal(seenOptions.method, 'DELETE'); assert.equal(seenOptions.headers.Authorization, 'Bearer test-token'); assert.equal(result.success, true);