spaps
Version:
Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware
1,532 lines • 69.7 kB
JavaScript
const fs = require('node:fs');
const path = require('node:path');
const { DEFAULT_PORT } = require('./config');
const { provisionStarterApplication } = require('./local-runtime');
const {
DSR_DEPENDENCIES,
DSR_DEV_DEPENDENCIES,
DSR_THEME,
componentsJsonSource,
designSystemManifestSource,
reactStylesSource,
themeAdapterSource,
uiPrimitiveSources,
utilsSource,
} = require('./starter-design-system');
const PASSKEY_BROWSER_SDK_RANGE = '^1.14.0';
function browserControllerSource() {
return `import { PasskeyBrowserError } from 'spaps-sdk';
import { passkeyFlow, spaps } from './spaps';
let activeCeremony: AbortController | undefined;
let activeConditional: ReturnType<typeof passkeyFlow.createConditionalSignIn> | undefined;
async function runCeremony<T>(run: (signal: AbortSignal) => Promise<T>): Promise<T> {
activeCeremony?.abort();
const controller = new AbortController();
activeCeremony = controller;
try { return await run(controller.signal); }
finally { if (activeCeremony === controller) activeCeremony = undefined; }
}
export function cancelPasskeyAction() { activeCeremony?.abort(); activeConditional?.abort(); }
export function bootstrapPasskeyAccount(email?: string, username?: string) { return runCeremony((signal) => passkeyFlow.bootstrap({ email: email || undefined, username: username || undefined, signal })); }
export function beginPasskeySignIn(email?: string) { return runCeremony((signal) => passkeyFlow.signIn({ email: email || undefined, signal })); }
export function startConditionalSignIn() {
activeConditional?.abort();
const controller = passkeyFlow.createConditionalSignIn();
activeConditional = controller;
return controller.start().finally(() => {
controller.dispose();
if (activeConditional === controller) activeConditional = undefined;
});
}
export function enrollPasskey(label?: string) { return runCeremony((signal) => passkeyFlow.register({ label: label || undefined, signal })); }
export function requestRecovery(email: string) { return passkeyFlow.requestRecoveryEmail({ email }); }
export function verifyRecoveryProof(token: string, code?: string) { return passkeyFlow.verifyRecoveryEmail({ token, code: code || undefined }); }
export function registerRecoveryPasskey(recovery_grant: string, label?: string) { return runCeremony((signal) => passkeyFlow.registerRecoveryPasskey({ recovery_grant, label: label || undefined, signal })); }
export function completeRecoveryAssertion(recovery_grant: string) { return runCeremony((signal) => passkeyFlow.completeRecoveryAssertion({ recovery_grant, signal })); }
export function listCredentials() { return passkeyFlow.listCredentials(); }
export function renameCredential(id: string, label: string) { return passkeyFlow.renameCredential(id, label); }
export function revokeCredential(id: string) { return passkeyFlow.revokeCredential(id); }
export function completeMfa(challenge: { challenge_id: string; challenge: string }, code: string) { return spaps.auth.mfa.verify({ challenge_id: challenge.challenge_id, challenge: challenge.challenge, code }); }
export function stepUp(action: string) { return runCeremony((signal) => passkeyFlow.stepUp({ action, intent: { source: 'generated-reference-ui' }, signal })); }
export function handlePasskeyResult(result: unknown) {
if (result && typeof result === 'object') {
const value = result as Record<string, unknown>;
if (value.mfa_required === true || value.status === 'mfa-required') return 'Complete the additional verification step.';
if (value.status === 'fallback') return 'Passkey autofill is unavailable. Use explicit sign-in or another enabled method.';
if (value.recovery_grant) return 'Recovery proof accepted. Continue every re-hardening step before treating the session as restored.';
if (Array.isArray(value.credentials)) return 'Credential list refreshed.';
if (value.step_up_grant) return 'Step-up verified. Send the bound grant only to the protected server action.';
}
return 'Passkey action complete.';
}
export function passkeyStatus(error: unknown) {
if (error instanceof PasskeyBrowserError && error.code === 'PASSKEY_CANCELLED') return 'Passkey action cancelled.';
if (error instanceof PasskeyBrowserError && error.code === 'PASSKEY_NOT_ALLOWED') return 'The browser or authenticator did not complete the passkey prompt. Try again and approve the browser dialog.';
if (error instanceof PasskeyBrowserError && error.code === 'PASSKEY_UNSUPPORTED') return 'This browser does not support the required passkey APIs.';
if (error instanceof PasskeyBrowserError && error.code === 'PASSKEY_INVALID_STATE') return 'That passkey is already registered or cannot be used in this state.';
if (error && typeof error === 'object' && 'code' in error && error.code === 'LAST_AUTH_FACTOR') return 'Keep this passkey: add another primary factor before revoking it.';
if (error instanceof Error && error.message) return \`Passkey action failed: \${error.message}\`;
return 'Passkey action failed. Check the browser console for details.';
}
export const recoveryGuidance = 'Recovery stays restricted until a replacement passkey is registered and freshly asserted.';
`;
}
function authControllerSource() {
return `import type { AuthMethodResponse, AuthResponse, User } from 'spaps-sdk';
import { TokenManager } from 'spaps-sdk';
import { spaps } from './spaps';
export function rememberAuth(result: AuthMethodResponse | unknown): User | undefined {
if (!result || typeof result !== 'object' || (result as Record<string, unknown>).mfa_required === true) return undefined;
const auth = result as Partial<AuthResponse>;
if (!auth.access_token || !auth.refresh_token || !auth.user) return undefined;
TokenManager.storeTokens(auth as AuthResponse);
return auth.user;
}
export async function restoreSession(): Promise<User | undefined> {
const restored = await TokenManager.autoRefreshToken(spaps);
return restored ? spaps.auth.getCurrentUser() : undefined;
}
export async function signOut(): Promise<void> {
await spaps.auth.logout();
TokenManager.clearTokens();
}
export function errorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : 'The authentication request failed.';
}
`;
}
function onboardingControllerSource() {
return `import { spapsApiUrl } from './spaps';
export type LoginMethodChoice = 'password' | 'passkeys';
export type ProvisionedApplication = {
environment: 'development' | 'production';
id: string;
name: string;
slug: string;
publishable_key: string;
secret_key: string;
capability_graph: 'completed' | 'refresh_failed';
};
export type ProvisionedProject = {
name: string;
entitlement_key: string;
login_methods: LoginMethodChoice[];
applications: ProvisionedApplication[];
};
type Envelope<T> = { success?: boolean; data?: T; error?: { code?: string; message?: string } };
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
export function isValidEntitlementKey(key: string): boolean {
return /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(key);
}
async function selfServiceRequest<T>(path: string, token: string | undefined, body: unknown): Promise<T> {
const response = await fetch(spapsApiUrl + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: JSON.stringify(body),
});
const payload = (await response.json().catch(() => undefined)) as Envelope<T> | undefined;
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error?.message || 'Self-service request failed with HTTP ' + response.status);
}
return (payload && 'data' in payload ? payload.data : payload) as T;
}
// Mirrors the browser_auth blueprint scopes plus entitlements_read, and seeds an
// allow policy so the chosen entitlement is server-enforced from day one.
function entitlementBlueprint(entitlementKey: string, environment: string) {
return {
publishable_scopes: ['auth', 'webauthn_ceremonies', 'sessions', 'capability_decisions', 'marketing_events_ingest', 'docs', 'entitlements_read'],
default_policies: [
{
name: 'access-' + entitlementKey.replace(/[^a-z0-9]+/g, '-'),
description: 'Allow admins and users holding the ' + entitlementKey + ' entitlement into gated surfaces.',
effect: 'allow',
conditions: {
any: [
{ has_role: { role: 'admin' } },
{ has_entitlement: { key: entitlementKey } },
],
},
priority: 100,
metadata: { source: 'auth-reference-onboarding', environment },
},
],
};
}
export async function provisionEnvironmentPair(options: {
name: string;
password: string;
entitlementKey: string;
loginMethods: LoginMethodChoice[];
}): Promise<ProvisionedProject> {
const baseSlug = slugify(options.name);
if (!baseSlug) throw new Error('Application name must contain letters or numbers.');
const auth = await selfServiceRequest<{ token: string }>('/api/self-service/auth', undefined, { password: options.password });
// Application names are globally unique server-side, so the mirrored pair
// carries the environment in the name while sharing the product name.
const environments: Array<{ environment: 'development' | 'production'; slug: string; name: string }> = [
{ environment: 'development', slug: baseSlug + '-dev', name: options.name + ' (Development)' },
{ environment: 'production', slug: baseSlug, name: options.name + ' (Production)' },
];
const applications: ProvisionedApplication[] = [];
for (const target of environments) {
const created = await selfServiceRequest<{
application: { id: string; name: string; slug: string };
publishable_key: string;
secret_key: string;
}>('/api/self-service/applications', auth.token, {
name: target.name,
slug: target.slug,
description: 'Mirrored ' + target.environment + ' environment for ' + options.name + ' (auth reference onboarding).',
allowed_origins: [window.location.origin],
blueprint_key: 'browser_auth',
blueprint: entitlementBlueprint(options.entitlementKey, target.environment),
});
// access.decide fails closed until the application has a completed
// capability graph projection, so build it now while we hold the one-time
// secret key. A refresh failure degrades to a documented follow-up, not a
// failed wizard.
let capabilityGraph: 'completed' | 'refresh_failed' = 'refresh_failed';
try {
const refresh = await fetch(spapsApiUrl + '/api/graph/refresh', {
method: 'POST',
headers: { 'X-API-Key': created.secret_key },
});
const refreshPayload = (await refresh.json().catch(() => undefined)) as
| Envelope<{ status?: string }>
| undefined;
if (refresh.ok && refreshPayload?.data?.status === 'completed') capabilityGraph = 'completed';
} catch {
capabilityGraph = 'refresh_failed';
}
applications.push({
environment: target.environment,
id: created.application.id,
name: created.application.name,
slug: created.application.slug,
publishable_key: created.publishable_key,
secret_key: created.secret_key,
capability_graph: capabilityGraph,
});
}
return {
name: options.name,
entitlement_key: options.entitlementKey,
login_methods: options.loginMethods,
applications,
};
}
export function buildAgentInstructions(project: ProvisionedProject, apiUrl: string): string {
const dev = project.applications.find((app) => app.environment === 'development');
const prod = project.applications.find((app) => app.environment === 'production');
const methodLines = [
project.login_methods.includes('password')
? '- Email & password: spaps.auth.register({ email, password }) to create accounts, spaps.auth.signInWithPassword to sign in.'
: null,
project.login_methods.includes('passkeys')
? '- Passkeys: createBrowserClient(publishableKey).auth.passkeys owns every ceremony. Before scaffolding run: npx spaps auth passkeys show, passkeys validate, passkeys doctor, passkeys test.'
: null,
].filter((line): line is string => line !== null);
const appLines = (label: string, app?: ProvisionedApplication) =>
app
? [
'### ' + label,
'- application_id: ' + app.id,
'- slug: ' + app.slug,
'- publishable key (browser-safe): ' + app.publishable_key,
'- secret key (server only, shown once — store in a secrets manager): ' + app.secret_key,
'- capability graph projection: ' +
(app.capability_graph === 'completed'
? 'completed (access.decide is ready)'
: 'NOT built — run POST ' + apiUrl + '/api/graph/refresh with X-API-Key: <secret key> before any access.decide call'),
]
: ['### ' + label, '- provisioning incomplete'];
return [
'# SPAPS integration handoff for ' + project.name,
'',
'You are an AI coding agent wiring an app to SPAPS (Sweet Potato Authentication & Payment Service).',
'The applications and keys below already exist on the SPAPS server at ' + apiUrl + ' — do not create new ones.',
'',
'## Applications (same product, mirrored dev/prod environments)',
...appLines('Development', dev),
...appLines('Production', prod),
'',
'Use the development application for local work and the production application for the deployed app.',
'They are the same product — one name with an environment suffix — but separate application IDs and keys, so promoting to production is an env-var swap.',
'',
'## Install',
'- npm install spaps-sdk # browser + server SDK',
'- npm install --save-dev spaps # CLI: local server, passkey doctor, scaffolding',
'',
'## Environment variables',
'- The browser bundle gets ONLY the publishable key (VITE_SPAPS_PUBLISHABLE_KEY / NEXT_PUBLIC_SPAPS_PUBLISHABLE_KEY).',
'- Secret keys live in server-side env files only. Never ship a spaps_sec_ key to a browser.',
'- Point SPAPS_API_URL / VITE_SPAPS_API_URL at ' + apiUrl + ' for development.',
'',
'## Login methods to implement',
...methodLines,
'',
'## Gated access (entitlement)',
'- entitlement_key: ' + project.entitlement_key,
'- Both applications carry an allow policy for users holding this entitlement (or the admin role).',
"- Gate premium UI and routes with spaps.access.decide({ actor, action, resource, controls: { entitlement_key: '" + project.entitlement_key + "' } }).",
"- Grant it server-side: POST /api/entitlements/manual with X-API-Key (secret) + an admin JWT, body { beneficiary_email or beneficiary_user_id, entitlement_key: '" + project.entitlement_key + "' }. Stripe entitlement mappings project it automatically on checkout. Clients can only read entitlements.",
'- access.decide fails closed without a completed capability graph projection; after changing policies, entitlement mappings, or blueprints, re-run POST /api/graph/refresh (X-API-Key: secret key).',
'',
'## Verify',
'- If the spaps MCP server or the spaps-integrate / sweet-potato-usage-audit skills are available in your environment, use them to route the integration and audit the result.',
'- Otherwise: sign in with each login method above, then confirm spaps.access.decide denies before the entitlement is granted and allows after.',
].join('\\n');
}
`;
}
function reactAppSource() {
return `import { useEffect, useState } from 'react';
import type { AccessDecisionResponse, SessionContext, User } from 'spaps-sdk';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input, Select, Textarea } from '@/components/ui/input';
import { ACTIVE_REGISTRY_ITEM, ACTIVE_REGISTRY_VERSION } from '@/design-system/theme-adapter';
import { errorMessage, rememberAuth, restoreSession, signOut } from '@/lib/auth-controller';
import type { LoginMethodChoice, ProvisionedProject } from '@/lib/onboarding-controller';
import { buildAgentInstructions, isValidEntitlementKey, provisionEnvironmentPair, slugify } from '@/lib/onboarding-controller';
import * as passkeys from '@/lib/passkey-controller';
import { spaps, spapsApiUrl } from '@/lib/spaps';
type Method = { method: string; enabled: boolean; config: Record<string, unknown> };
type Chain = 'solana' | 'ethereum' | 'base' | 'bitcoin';
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-1 rounded-sm border border-border/60 px-3 py-2">
<span className="text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">{label}</span>
<span className="text-sm text-foreground">{value}</span>
</div>
);
}
export default function App() {
const [methods, setMethods] = useState<Method[]>([]);
const [status, setStatus] = useState('Loading authentication capabilities…');
const [busy, setBusy] = useState(false);
const [user, setUser] = useState<User>();
const [context, setContext] = useState<SessionContext>();
const [decision, setDecision] = useState<AccessDecisionResponse>();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [magicToken, setMagicToken] = useState('');
const [chain, setChain] = useState<Chain>('ethereum');
const [walletAddress, setWalletAddress] = useState('');
const [walletMessage, setWalletMessage] = useState('');
const [walletSignature, setWalletSignature] = useState('');
const [token, setToken] = useState('');
const [grant, setGrant] = useState('');
const [credentialId, setCredentialId] = useState('');
const [label, setLabel] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [mfa, setMfa] = useState<{ challenge_id: string; challenge: string }>();
const [setupStep, setSetupStep] = useState(0);
const [appName, setAppName] = useState('');
const [selfServicePassword, setSelfServicePassword] = useState('');
const [entitlementKey, setEntitlementKey] = useState('premium.access');
const [loginMethods, setLoginMethods] = useState<LoginMethodChoice[]>(['password', 'passkeys']);
const [project, setProject] = useState<ProvisionedProject>();
const [copied, setCopied] = useState(false);
const enabled = (name: string) => methods.some((method) => method.method === name && method.enabled);
const toggleLoginMethod = (method: LoginMethodChoice) =>
setLoginMethods((current) => (current.includes(method) ? current.filter((entry) => entry !== method) : [...current, method]));
const instructions = project ? buildAgentInstructions(project, spapsApiUrl) : '';
useEffect(() => {
Promise.all([spaps.auth.getMethods(), restoreSession()])
.then(async ([discovery, restored]) => {
setMethods(discovery.methods);
setUser(restored);
if (restored) setContext(await spaps.auth.getSessionContext());
setStatus(restored ? 'Session restored.' : 'Choose an available authentication method.');
})
.catch((error) => setStatus(errorMessage(error)));
}, []);
const run = async (name: string, action: () => Promise<unknown>, isPasskey = false) => {
setBusy(true);
setStatus(isPasskey ? 'Waiting for the browser passkey prompt…' : name + '…');
try {
const result = await action();
const authenticated = rememberAuth(result);
if (authenticated) {
setUser(authenticated);
setContext(await spaps.auth.getSessionContext());
}
const value = result as Record<string, unknown>;
if (typeof value?.recovery_grant === 'string') setGrant(value.recovery_grant);
if (value?.mfa_required === true) setMfa(value as unknown as { challenge_id: string; challenge: string });
setStatus(isPasskey ? passkeys.handlePasskeyResult(result) : name + ' complete.');
return result;
} catch (error) {
setStatus(isPasskey ? passkeys.passkeyStatus(error) : name + ' failed: ' + errorMessage(error));
} finally {
setBusy(false);
}
};
const requestWalletChallenge = async () => {
const challenge = await spaps.auth.getNonce(walletAddress);
setWalletMessage(String(challenge.message));
return challenge;
};
const verifyWallet = () =>
spaps.auth.signInWithWallet({ wallet_address: walletAddress, signature: walletSignature, message: walletMessage, chain_type: chain });
const checkDeveloperAccess = () =>
spaps.access.decide({
actor: { actor_type: 'user', user_id: user?.id },
action: 'developer.application.create',
resource: { resource_type: 'developer_platform', resource_ref: 'application_provisioning' },
controls: { entitlement_key: 'developer.application.create' },
});
return (
<div className="relative min-h-svh">
<div aria-hidden="true" className="vs-scanfield pointer-events-none fixed inset-0 opacity-50" />
<main className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 pb-24 pt-12">
<header className="flex flex-col gap-3">
<p className="vs-eyebrow">SPAPS browser integration</p>
<h1 className="vs-glow font-display text-4xl font-semibold tracking-tight text-[var(--vs-text-bright)] sm:text-5xl">
Authentication reference
</h1>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Exercise every configured authentication contract from one browser-safe reference.
</p>
<div className="vs-rule mt-2" />
</header>
<section className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="flex items-center gap-3">
<span className={'size-2 rounded-full ' + (user ? 'bg-signal shadow-[0_0_8px_var(--vs-mesh-green)]' : 'bg-muted-foreground')} />
<strong className="text-xs uppercase tracking-[0.16em]">
{user ? 'Signed in as ' + (user.email || user.wallet_address || user.id) : 'Signed out'}
</strong>
</div>
<Button
size="sm"
disabled={!user || busy}
onClick={() =>
run('Sign out', async () => {
await signOut();
setUser(undefined);
setContext(undefined);
setDecision(undefined);
})
}
>
Sign out
</Button>
</section>
<p role="status" className="rounded-sm border border-primary/40 bg-signal-faint px-4 py-3 font-mono text-xs text-primary">
{status}
</p>
{user && (
<Card>
<CardHeader>
<CardTitle>Developer platform access</CardTitle>
<CardDescription>
This evaluates <code className="text-primary">developer.application.create</code>. Actual application provisioning and one-time
secret-key reveal remain privileged server operations.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Stat label="Tier" value={context?.tier || 'default'} />
<Stat label="Entitlements" value={String(context?.entitlements_count ?? '—')} />
<Stat label="Wallets" value={String(context?.wallets.length ?? '—')} />
<Stat label="MFA" value={context?.mfa_enrolled ? 'enrolled' : 'not enrolled'} />
</div>
<div className="flex flex-wrap gap-2">
{context?.entitlements.length ? (
context.entitlements.map((entitlement) => (
<Badge key={entitlement.id} variant="focal">
{entitlement.entitlement_key}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">No active entitlements for this application.</span>
)}
</div>
{decision && (
<p className={'font-mono text-xs ' + (decision.allowed ? 'text-signal' : 'text-destructive')}>
<strong>{decision.allowed ? 'Allowed' : 'Denied'}:</strong> {decision.outcome}.{' '}
{decision.reasons.map((reason) => reason.message).join(' ')}
</p>
)}
</CardContent>
<CardFooter>
<Button
variant="default"
disabled={busy}
onClick={() =>
run('Developer access check', async () => {
const result = await checkDeveloperAccess();
setDecision(result);
return result;
})
}
>
Check access
</Button>
</CardFooter>
</Card>
)}
{user && (
<Card className="border-primary/60">
<CardHeader>
<CardTitle>Set up your application</CardTitle>
<CardDescription>
Three questions, then SPAPS provisions mirrored development and production applications and writes the handoff for your coding
agent.
</CardDescription>
</CardHeader>
<CardContent>
{setupStep === 0 && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="setup-app-name">Application name</FieldLabel>
<Input id="setup-app-name" value={appName} onChange={(event) => setAppName(event.target.value)} />
{slugify(appName) && (
<p className="text-xs text-muted-foreground">
Creates two mirrored apps: <code className="text-primary">{slugify(appName)}-dev</code> and{' '}
<code className="text-primary">{slugify(appName)}</code> — one product, separate application IDs and keys, so you can
test in development and promote with an env swap.
</p>
)}
</Field>
<Field>
<FieldLabel htmlFor="setup-password">Self-service password</FieldLabel>
<Input
id="setup-password"
type="password"
value={selfServicePassword}
onChange={(event) => setSelfServicePassword(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
SELF_SERVICE_PASSWORD from your SPAPS server environment. Provisioning applications is a privileged operation.
</p>
</Field>
</FieldGroup>
)}
{setupStep === 1 && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="setup-entitlement">Entitlement key</FieldLabel>
<Input id="setup-entitlement" value={entitlementKey} onChange={(event) => setEntitlementKey(event.target.value)} />
<p className="text-xs text-muted-foreground">
An entitlement is gated access a user can hold in your app — for example{' '}
<code className="text-primary">premium.access</code> unlocking paid features. Both applications are created with a policy
that allows users holding this key (lowercase letters, numbers, and . _ - separators).
</p>
</Field>
</FieldGroup>
)}
{setupStep === 2 && (
<FieldGroup>
{(
[
['password', 'Email & password'],
['passkeys', 'Passkeys'],
] as Array<[LoginMethodChoice, string]>
).map(([method, methodLabel]) => (
<label key={method} className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={loginMethods.includes(method)}
onChange={() => toggleLoginMethod(method)}
/>
{methodLabel}
</label>
))}
<p className="text-xs text-muted-foreground">
Email & password or passkeys is plenty to start. Every other method on this page stays available once you integrate.
</p>
</FieldGroup>
)}
{setupStep === 3 && project && (
<div className="flex flex-col gap-3">
<div className="grid gap-2 sm:grid-cols-2">
{project.applications.map((app) => (
<Stat key={app.id} label={app.environment} value={app.slug + ' · ' + app.id} />
))}
</div>
<Field>
<FieldLabel htmlFor="agent-handoff">Copy this to your coding agent</FieldLabel>
<Textarea id="agent-handoff" readOnly rows={16} className="font-mono text-xs" value={instructions} />
</Field>
<p className="text-xs text-muted-foreground">
Secret keys appear once — store them in a secrets manager. Paste the block into Claude (or any coding agent) and it has
everything: keys, packages, login methods, and the entitlement gate.
</p>
</div>
)}
</CardContent>
<CardFooter>
{setupStep > 0 && setupStep < 3 && (
<Button variant="ghost" disabled={busy} onClick={() => setSetupStep(setupStep - 1)}>
Back
</Button>
)}
{setupStep === 0 && (
<Button variant="default" disabled={!slugify(appName) || !selfServicePassword || busy} onClick={() => setSetupStep(1)}>
Next: entitlement
</Button>
)}
{setupStep === 1 && (
<Button variant="default" disabled={!isValidEntitlementKey(entitlementKey) || busy} onClick={() => setSetupStep(2)}>
Next: login methods
</Button>
)}
{setupStep === 2 && (
<Button
variant="default"
disabled={loginMethods.length === 0 || busy}
onClick={() =>
run('Application provisioning', async () => {
const result = await provisionEnvironmentPair({
name: appName,
password: selfServicePassword,
entitlementKey,
loginMethods,
});
setProject(result);
setSetupStep(3);
return result;
})
}
>
Create development + production apps
</Button>
)}
{setupStep === 3 && (
<>
<Button
variant="default"
disabled={!instructions}
onClick={() => navigator.clipboard.writeText(instructions).then(() => setCopied(true))}
>
{copied ? 'Copied' : 'Copy agent instructions'}
</Button>
<Button
variant="ghost"
onClick={() => {
setSetupStep(0);
setProject(undefined);
setCopied(false);
}}
>
Start over
</Button>
</>
)}
</CardFooter>
</Card>
)}
<nav aria-label="Discovered authentication methods" className="flex flex-wrap gap-2">
{methods.map((method) => (
<Badge key={method.method} variant={method.enabled ? 'default' : 'muted'}>
{method.method}
</Badge>
))}
</nav>
<section className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Email & password</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="pw-email">Email</FieldLabel>
<Input id="pw-email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="pw-password">Password</FieldLabel>
<Input id="pw-password" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</Field>
</FieldGroup>
</CardContent>
<CardFooter>
<Button variant="default" disabled={!enabled('password') || busy} onClick={() => run('Create account', () => spaps.auth.register({ email, password }))}>
Create account
</Button>
<Button disabled={!enabled('password') || busy} onClick={() => run('Password sign-in', () => spaps.auth.signInWithPassword({ email, password }))}>
Sign in
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Magic link</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="magic-email">Email</FieldLabel>
<Input id="magic-email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="magic-token">Token from link</FieldLabel>
<Input id="magic-token" value={magicToken} onChange={(event) => setMagicToken(event.target.value)} />
</Field>
</FieldGroup>
</CardContent>
<CardFooter>
<Button
variant="default"
disabled={!enabled('magic_link') || busy}
onClick={() => run('Magic link request', () => spaps.auth.requestMagicLink({ email, redirect_url: window.location.origin }))}
>
Send magic link
</Button>
<Button disabled={!magicToken || busy} onClick={() => run('Magic link verification', () => spaps.auth.verifyMagicLink({ token: magicToken }))}>
Verify magic link
</Button>
</CardFooter>
</Card>
<Card className="border-primary/60 shadow-elevated md:col-span-2">
<CardHeader>
<CardTitle className="text-primary">Passkeys</CardTitle>
<CardDescription>The browser owns every ceremony. This page only transports the SDK result.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="sm:flex-row sm:gap-4">
<Field className="flex-1">
<FieldLabel htmlFor="passkey-email">Email</FieldLabel>
<Input id="passkey-email" type="email" autoComplete="username webauthn" value={email} onChange={(event) => setEmail(event.target.value)} />
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="passkey-label">Passkey label</FieldLabel>
<Input id="passkey-label" value={label} onChange={(event) => setLabel(event.target.value)} />
</Field>
</FieldGroup>
</CardContent>
<CardFooter>
<Button variant="default" disabled={!enabled('webauthn') || busy} onClick={() => run('Create passkey account', () => passkeys.bootstrapPasskeyAccount(email), true)}>
Create account with passkey
</Button>
<Button disabled={!enabled('webauthn') || busy} onClick={() => run('Passkey sign-in', () => passkeys.beginPasskeySignIn(email), true)}>
Sign in with passkey
</Button>
<Button disabled={!enabled('webauthn') || busy} onClick={() => run('Passkey autofill', () => passkeys.startConditionalSignIn(), true)}>
Try passkey autofill
</Button>
<Button disabled={!user || busy} onClick={() => run('Passkey enrollment', () => passkeys.enrollPasskey(label), true)}>
Enroll passkey
</Button>
<Button variant="ghost" onClick={passkeys.cancelPasskeyAction}>
Cancel passkey action
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>SMS code</CardTitle>
<CardDescription>SMS endpoints require a secret credential and must be called through your application server.</CardDescription>
</CardHeader>
<CardFooter>
<Button disabled>Server adapter required</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>OIDC providers</CardTitle>
</CardHeader>
<CardContent>
{['google', 'apple', 'github'].map((provider) => (
<p key={provider} className="text-xs leading-relaxed text-muted-foreground">
<Badge variant={enabled('oidc:' + provider) ? 'focal' : 'muted'}>{provider}</Badge>{' '}
{enabled('oidc:' + provider)
? 'Configured; connect the provider browser SDK with a nonce-bound ID token.'
: 'No provider credentials configured.'}
</p>
))}
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Wallet authentication</CardTitle>
<CardDescription>Sign the exact challenge message in your wallet, then paste the signature back.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="sm:flex-row sm:gap-4">
<Field className="sm:w-48">
<FieldLabel htmlFor="wallet-chain">Chain</FieldLabel>
<Select id="wallet-chain" value={chain} onChange={(event) => setChain(event.target.value as Chain)}>
<option value="ethereum">Ethereum</option>
<option value="base">Base</option>
<option value="solana">Solana</option>
<option value="bitcoin">Bitcoin</option>
</Select>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="wallet-address">Wallet address</FieldLabel>
<Input id="wallet-address" value={walletAddress} onChange={(event) => setWalletAddress(event.target.value)} />
</Field>
</FieldGroup>
<FieldGroup className="md:flex-row md:gap-4">
<Field className="flex-1">
<FieldLabel htmlFor="wallet-message">Exact message</FieldLabel>
<Textarea id="wallet-message" readOnly value={walletMessage} />
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="wallet-signature">Signature</FieldLabel>
<Textarea id="wallet-signature" value={walletSignature} onChange={(event) => setWalletSignature(event.target.value)} />
</Field>
</FieldGroup>
</CardContent>
<CardFooter>
<Button variant="default" disabled={!walletAddress || busy} onClick={() => run('Wallet challenge request', requestWalletChallenge)}>
Request challenge
</Button>
<Button disabled={!walletMessage || !walletSignature || busy} onClick={() => run('Wallet sign-in', verifyWallet)}>
Verify signature
</Button>
</CardFooter>
</Card>
</section>
<details className="group rounded-md border border-border bg-card/60">
<summary className="cursor-pointer list-none px-4 py-3 text-xs uppercase tracking-[0.18em] text-muted-foreground hover:text-foreground">
Advanced security and recovery
</summary>
<div className="grid gap-4 border-t border-border p-4 md:grid-cols-3">
<Card className="bg-transparent shadow-none">
<CardHeader>
<CardTitle>Recovery</CardTitle>
</CardHeader>
<CardContent>
<Field>
<FieldLabel htmlFor="recovery-token">Recovery token</FieldLabel>
<Input id="recovery-token" value={token} onChange={(event) => setToken(event.target.value)} />
</Field>
</CardContent>
<CardFooter>
<Button size="sm" onClick={() => run('Recovery request', () => passkeys.requestRecovery(email), true)}>
Request recovery email
</Button>
<Button size="sm" onClick={() => run('Recovery verification', () => passkeys.verifyRecoveryProof(token), true)}>
Verify recovery proof
</Button>
<Button size="sm" onClick={() => run('Replacement registration', () => passkeys.registerRecoveryPasskey(grant, label), true)}>
Register replacement passkey
</Button>
<Button size="sm" onClick={() => run('Recovery assertion', () => passkeys.completeRecoveryAssertion(grant), true)}>
Complete recovery assertion
</Button>
</CardFooter>
</Card>
<Card className="bg-transparent shadow-none">
<CardHeader>
<CardTitle>Credentials</CardTitle>
</CardHeader>
<CardContent>
<Field>
<FieldLabel htmlFor="credential-id">Credential ID</FieldLabel>
<Input id="credential-id" value={credentialId} onChange={(event) => setCredentialId(event.target.value)} />
</Field>
</CardContent>
<CardFooter>
<Button size="sm" onClick={() => run('Credential list', passkeys.listCredentials, true)}>
List credentials
</Button>
<Button size="sm" onClick={() => run('Credential rename', () => passkeys.renameCredential(credentialId, label), true)}>
Rename credential
</Button>
<Button size="sm" variant="destructive" onClick={() => run('Credential revoke', () => passkeys.revokeCredential(credentialId), true)}>
Revoke credential
</Button>
</CardFooter>
</Card>
<Card className="bg-transparent shadow-none">
<CardHeader>
<CardTitle>MFA and step-up</CardTitle>
</CardHeader>
<CardContent>
<Field>
<FieldLabel htmlFor="mfa-code">MFA code</FieldLabel>
<Input id="mfa-code" inputMode="numeric" value={mfaCode} onChange={(event) => setMfaCode(event.target.value)} />
</Field>
</CardContent>
<CardFooter>
<Button size="sm" disabled={!mfa} onClick={() => mfa && run('MFA verification', () => passkeys.completeMfa(mfa, mfaCode), true)}>
Verify MFA
</Button>
<Button size="sm" onClick={() => run('Step-up', () => passkeys.stepUp('account.security.review'), true)}>
Run step-up
</Button>
</CardFooter>
</Card>
</div>
</details>
<footer className="flex flex-col gap-2 pt-2">
<div className="vs-rule" />
<p className="text-xs leading-relaxed text-muted-foreground">{passkeys.recoveryGuidance}</p>
<p className="font-mono text-[0.625rem] uppercase tracking-[0.18em] text-muted-foreground/70">
Design System Registry · {ACTIVE_REGISTRY_ITEM}@{ACTIVE_REGISTRY_VERSION}
</p>
</footer>
</main>
</div>
);
}
`;
}
function nextPageSource() {
return `'use client';
import { useState } from 'react';
import * as passkeys from '../lib/passkey-controller';
export default function Page() {
const [status, setStatus] = useState('Ready for a passkey.'); const [email, setEmail] = useState(''); const [token, setToken] = useState(''); const [grant, setGrant] = useState(''); const [credentialId, setCredentialId] = useState(''); const [label, setLabel] = useState(''); const [mfaCode, setMfaCode] = useState(''); const [mfa, setMfa] = useState<{ challenge_id: string; challenge: string }>();
const run = (action: () => Promise<unknown>) => { setStatus('Waiting for the browser passkey prompt…'); return action().then((result) => { const value = result as Record<string, unknown>; const nested = value?.status === 'mfa-required' ? value.result as Record<string, unknown> : value; if (typeof value?.recovery_grant === 'string') setGrant(value.recovery_grant); if (nested?.mfa_required === true) setMfa(nested as unknown as { challenge_id: string; challenge: string }); setStatus(passkeys.handlePasskeyResult(result)); }).catch((error) => setStatus(passkeys.passkeyStatus(error))); };
return <main><h1>Passkey reference app</h1><p role="status">{status}</p><label>Email<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></label><label>Passkey label<input value={label} onChange={(e) => setLabel(e.target.value)} /></label><button onClick={() => run(() => passkeys.bootstrapPasskeyAccount(email))}>Create account with passkey</button><button onClick={() => run(() => passkeys.startConditionalSignIn())}>Try passkey autofill</button><button onClick={() => run(() => passkeys.beginPasskeySignIn(email))}>Sign in with passkey</button><button onClick={() => run(() => passkeys.enrollPasskey(label))}>Enroll passkey</button><button onClick={passkeys.cancelPasskeyAction}>Cancel passkey action</button><fieldset><legend>Recovery</legend><button onClick={() => run(() => passkeys.requestRecovery(email))}>Request recovery email</button><label>Recovery token<input value={token} onChange={(e) => setToken(e.target.value)} /></label><button onClick={() => run(() => passkeys.verifyRecoveryProof(token))}>Verify recovery proof</button><button onClick={() => run(() => passkeys.registerRecoveryPasskey(grant, label))}>Register replacement passkey</button><button onClick={() => run(() => passkeys.completeRecoveryAssertion(grant))}>Complete recovery assertion</button></fieldset><fieldset><legend>Credential management</legend><button onClick={() => run(passkeys.listCredentials)}>List credentials</button><label>Credential ID<input value={credentialId} onChange={(e) => setCredentialId(e.target.value)} /></label><button onClick={() => run(() => passkeys.renameCredential(credentialId, label))}>Rename credential</button><button onClick={() => run(() => passkeys.revokeCredential(credentialId))}>Revoke credential</button></fieldset><label>MFA code<input inputMode="numeric" value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} /></label><button disabled={!mfa} onClick={() => mfa && run(() => passkeys.completeMfa(mfa, mfaCode))}>Verify MFA</button><button onClick={() => run(() => passkeys.stepUp('account.security.review'))}>Run step-up</button><p>{passkeys.recoveryGuidance}</p></main>;
}
`;
}
function tsconfigSource(template) {
const compilerOptions = { target: 'ES2022', lib: ['DOM', 'DOM.Iterable', 'ESNext'], strict: true, module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', noEmit: true, isolatedModules: true, esModuleInterop: true, types: ['node'] };
const config = template === 'nextjs'
? { compilerOptions: { ...compilerOptions, allowJs: true, skipLibCheck: true, incremental: true, resolveJsonModule: true, plugins: [{ name: 'next' }] }, include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts', '.next/dev/types/**/*.ts'], exclude: ['node_modules'] }
: {
compilerOptions: { ...compilerOptions, baseUrl: '.', paths: { '@/*': ['./src/*'] } },
include: ['src/**/*.ts', 'src/**/*.tsx', 'vite-env.d.ts', 'vite.config.ts'],
};
return JSON.stringify(config, null, 2) + '\n';
}
function validateBrowserConfig(apiUrl, expectedOrigin, actualOrigin = expectedOrigin) {
if (typeof expectedOrigin !== 'string' || !expectedOrigin.trim()) throw new Error('SPAPS browser origin is required');
if (typeof actualOrigin !== 'string' || !actualOrigin.trim()) throw new Error('Runtime browser origin is required');
const api = new URL(apiUrl); const expected = new URL(expectedOrigin); const actual = new URL(actualOrigin);
// Plain HTTP is allowed only for development hosts that are not routable on
// the public internet: loopback, RFC1918 private ranges, and the CGNAT range
// (100.64.0.0/10) that Tailscale and similar mesh VPNs assign. Anything else
// must use HTTPS so publishable keys and tokens are never sent in the clear.
const localHost = (hostname) =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '::1' ||
hostname === '[::1]' ||
/^127\./.test(hostname) ||
/^10\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(hostname);
if (api.protocol !== 'https:' && !(api.protocol === 'http:' && localHost(api.hostname))) throw new Error('SPAPS API URL must use HTTPS except local development hosts');
if (expected.origin !== actual.origin) throw new Error('Configured browser origin must exactly match window.location.origin');
if (expected.protocol !== 'https:' && !(expected.protocol === 'http:' && localHost(expected.hostname))) throw new Error('Browser origin must use HTTPS except local development hosts');
return { api, origin: expected.origin };
}
function browserConfigSource() {
const typedSource = validateBrowserConfig
.toString()
.replace(
'function validateBrowserConfig(apiUrl, expectedOrigin, actualOrigin = expectedOrigin)',
'function validateBrowserConfig(apiUrl: string, expectedOrigin: string, actualOrigin: string = expectedOrigin)'
)
.replace(
'const localHost = (hostname) =>',
'const localHost = (hostname: string) =>'
);
return `export ${typedSource}`;
}
const SUPPORTED_TEMPLATES = {
nextjs: {
label: 'Next.js starter',
blueprintKey: 'browser_auth',
allowedOrigins: ['http://localhost:3000'],
publicApiUrlEnv: 'NEXT_PUBLIC_SPAPS_API_URL',
publicApiKeyEnv: 'NEXT_PUBLIC_SPAPS_PUBLISHABLE_KEY',
files: ({ apiUrl }) => ({
'lib/spaps.ts': `import { createBrowserClient } from 'spaps-sdk';
${browserConfigSource()}
const apiUrl = process.env.NEXT_PUBLIC_SPAPS_API_URL || '${apiUrl}';
const apiKey = process.env.NEXT_PUBLIC_SPAPS_PUBLISHABLE_KEY;
const browserOrigin = process.env.NEXT_PUBLIC_SPAPS_BROWSER_ORIGIN;
if (!apiKey || !apiKey.startsWith('spaps_pub_')) throw new Error('NEXT_PUBLIC_SPAPS_PUBLISHABLE_KEY must be a spaps_pub_ key');
if (!browserOrigin) throw new Error('NEXT_PUBLIC_SPAPS_BROWSER_ORIGIN is required');
validateBrowserConfig(apiUrl, browserOrigin, typeof window === 'undefined' ? browserOrigin : window.location.origin);
export const spaps = createBrowserClient(apiKey, { apiUrl });
export const passkeyFlow = spaps.auth.passkeys;
`,
'lib/passkey-controller.ts': browserControllerSource(),
'app/page.tsx': nextPageSource(),
'app/layout.tsx': "import { PropsWithChildren } from 'react';\nexport default function RootLayout({ children }: PropsWithChildren) { return <html lang=\"en\"><body>{children}</body></html>; }\n",
'tsconfig.json': tsconfigSource('nextjs'),
'next-env.d.ts': '/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
'app/providers.tsx': `'use client';
import { PropsWithChildren } from 'react';
import { spaps } from '../lib/spaps';
export function Providers({ children }: PropsWithChildren) {
void spaps;
return children;
}
`,
}),
},
react: {
label: 'React + Vite starter',
blueprintKey: 'browser_auth',
allowedOrigins: ['http://localhost:5173'],
publicApiUrlEnv: 'VITE_SPAPS_API_URL',
publicApiKeyEnv: 'VITE_SPAPS_PUBLISHABLE_KEY',
files: ({ apiUrl, installedAt }) => ({
'src/lib/spaps.ts': `import { createBrowserClient } from 'spaps-sdk';
${browserConfigSource()}
const apiUrl = import.meta.env.VITE_SPAPS_API_URL || '${apiUrl}';
const apiKey = import.meta.env.VITE_SPAPS_PUBLISHABLE_KEY;
const browserOrigin = import.meta.env.VITE_SPAPS_BROWSER_ORIGIN;
if (!apiKey || !apiKey.startsWith('spaps_pub_')) throw new Error('VITE_SPAPS_PUBLISHABLE_KEY must be a spaps_pub_ key');
if (!browserOrigin) throw new Error('VITE_SPAPS_BROWSER_ORIGIN is required');
validateBrowserConfig(apiUrl, browserOrigin, typeof window === 'undefined' ? browserOrigin : window.location.origin);
export const spapsApiUrl = apiUrl;
export const spaps = createBrowserClient(apiKey, { apiUrl });
export const passkeyFlow = spaps.auth.passkeys;
`,
'src/lib/auth-controller.ts': authControllerSource(),
'src/lib/onboarding-controller.ts': onboardingControllerSource(),
'src/lib/passkey-controller.ts': browserControllerSource(),
'src/lib/utils.ts': utilsSource(),
'src/design-system/theme-adapter.ts': themeAdapterSource(),
...uiPrimitiveSources(),
'src/App.tsx': reactAppSource(),
'src/main.tsx': "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App';\nimport './app.css';\ncreateRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);\n",
'src/app.css': reactStylesSource(),
'components.json': componentsJsonSource(),
'design-system.registry.json': designSystemManifestSource({ installedAt }),
'index.html': `<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SPAPS authentication reference</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
`,
'tsconfig.json': tsconfigSource('react'),
'vite.config.ts': `import { fileURLToPath, URL } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
});
`,
'vite-env.d.ts': '/// <reference types="vite/client" />\n',
}),
},
node: {
label: 'Node.js starter',
blueprintKey: 'default',
allowedOrigins: [],
publicApiUrlEnv: 'SPAPS_API_URL',
publicApiKeyEnv: 'SPAPS_API_KEY',
files: ({ apiUrl }) => ({
'src/spaps.js': `const { createServerClient, SPAPSClient } = require('spaps-sdk');
const apiUrl = process.env.SPAPS_API_URL || '${apiUrl}';
const apiKey = process.env.SPAPS_API_KEY;
const spaps = apiKey
? createServerClient(apiKey, { apiUrl })
: new SPAPSClient({ apiUrl });
module.exports = { spaps };
`,
}),
},
vanilla: {
label: 'Vanilla JavaScript starter',
blueprintKey: 'browser_auth',
allowedOrigins: ['http://localhost:8080'],
publicApiUrlEnv: 'SPAPS_API_URL',
publicApiKeyEnv: 'SPAPS_API_KEY',
files: ({ apiUrl }) => ({
'src/spaps.js': `import { createBrowserClient, SPAPSClient } from 'spaps-sdk';
const apiUrl = window.SPAPS_API_URL || '${apiUrl}';
const apiKey = window.SPAPS_API_KEY;
export const spaps = apiKey && apiKey.startsWith('spaps_pub_')
? createBrowserClient(apiKey, { apiUrl })
: new SPAPSClient({
apiUrl,
...(apiKey ? { apiKey } : {}),
});
`,
}),
},
};
function createCliError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function slugifyProjectName(name) {
return String(name)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function ensureSupportedTemplate(template) {
if (!template) {
throw createCliError(
'EINVAL',
`The --template flag is required. Supported templates: ${Object.keys(SUPPORTED_TEMPLATES).join(', ')}.`
);
}
if (!SUPPORTED_TEMPLATES[template]) {
throw createCliError(
'EINVAL',
`Unsupported template "${template}". Supported templates: ${Object.keys(SUPPORTED_TEMPLATES).join(', ')}.`
);
}
}
function ensureWritableTarget(targetDir, force) {
if (!fs.existsSync(targetDir)) {
return;
}
const entries = fs.readdirSync(targetDir);
if (entries.length > 0 && !force) {
throw createCliError(
'EEXIST',
`Target directory "${targetDir}" is not empty. Re-run with --force to overwrite managed files.`
);
}
}
function prepareWritableTarget(targetDir, force) {
ensureWritableTarget(targetDir, force);
fs.mkdirSync(targetDir, { recursive: true });
fs.accessSync(targetDir, fs.constants.W_OK);
}
function writeManagedFile(targetDir, relativePath, content, bookkeeping) {
const fullPath = path.join(targetDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
if (fs.existsSync(fullPath)) {
bookkeeping.files_overwritten.push(relativePath);
} else {
bookkeeping.files_created.push(relativePath);
}
fs.writeFileSync(fullPath, content);
}
function buildPackageJson(name, template) {
const pkg = {
name,
private: true,
version: '0.0.0',
description: `SPAPS ${SUPPORTED_TEMPLATES[template].label.toLowerCase()} for ${name}`,
dependencies: {
'spaps-sdk': template === 'react' || template === 'nextjs'
? PASSKEY_BROWSER_SDK_RANGE
: 'latest',
},
};
if (template === 'react') {
pkg.scripts = { dev: 'vite', build: 'tsc -b && vite build', typecheck: 'tsc --noEmit', 'test:passkeys': 'spaps auth passkeys test --launch --json' };
pkg.dependencies.react = '^19.2.0';
pkg.dependencies['react-dom'] = '^19.2.0';
// Design System Registry runtime: the starter renders registry-owned tokens
// through shadcn primitives. See design-system.registry.json.
Object.assign(pkg.dependencies, DSR_DEPENDENCIES);
pkg.devDependencies = { '@types/node': '^22.0.0', '@types/react': '^19.2.0', '@types/react-dom': '^19.2.0', '@vitejs/plugin-react': '^5.1.0', spaps: '^0.10.0', typescript: '^5.9.2', vite: '^7.2.0', ...DSR_DEV_DEPENDENCIES };
} else if (template === 'nextjs') {
pkg.scripts = { dev: 'next dev', build: 'next build', typecheck: 'tsc --noEmit', 'test:passkeys': 'spaps auth passkeys test --launch --json' };
pkg.dependencies.next = '^16.1.0';
pkg.dependencies.react = '^19.2.0';
pkg.dependencies['react-dom'] = '^19.2.0';
pkg.devDependencies = { '@types/node': '^22.0.0', '@types/react': '^19.2.0', '@types/react-dom': '^19.2.0', spaps: '^0.10.0', typescript: '^5.9.2' };
}
if (template === 'node') {
pkg.type = 'commonjs';
}
return `${JSON.stringify(pkg, null, 2)}\n`;
}
function buildContract({ name, slug, template, version, apiUrl, docsUrl, provisioning }) {
const templateDef = SUPPORTED_TEMPLATES[template];
const contract = {
name,
slug,
template,
created_with: `spaps@${version}`,
spaps: {
local: {
api_url: apiUrl,
docs_url: docsUrl,
local_mode_active: provisioning.runtime?.local_mode?.active ?? null,
},
application: {
id: provisioning.application?.id || null,
slug,
blueprint_key: templateDef.blueprintKey,
allowed_origins: templateDef.allowedOrigins,
provisioning_status: provisioning.status,
provisioned_via:
provisioning.status === 'provisioned'
? 'self_service'
: provisioning.status === 'local_mode'
? 'local_mode'
: null,
},
},
};
return `${JSON.stringify(contract, null, 2)}\n`;
}
function buildEnvFile(template, apiUrl, provisioning) {
const templateDef = SUPPORTED_TEMPLATES[template];
const lines = [
'# SPAPS local development',
`SPAPS_API_URL=${apiUrl}`,
];
if (templateDef.publicApiUrlEnv !== 'SPAPS_API_URL') {
lines.push(`${templateDef.publicApiUrlEnv}=${apiUrl}`);
}
if (template === 'react') lines.push('VITE_SPAPS_BROWSER_ORIGIN=http://localhost:5173');
if (template === 'nextjs') lines.push('NEXT_PUBLIC_SPAPS_BROWSER_ORIGIN=http://localhost:3000');
if (provisioning.status === 'provisioned') {
const keyValue = template === 'node' ? provisioning.keys?.secret : provisioning.keys?.publishable;
if (keyValue) {
lines.push(`${templateDef.publicApiKeyEnv}=${keyValue}`);
} else {
lines.push(`# ${templateDef.publicApiKeyEnv}=`);
}
} else {
lines.push(`# ${templateDef.publicApiKeyEnv}=`);
}
if (template !== 'node' && templateDef.publicApiKeyEnv !== 'SPAPS_API_KEY') {
lines.push('# SPAPS_API_KEY=');
}
return `${lines.join('\n')}\n`;
}
function buildProvisioningNote({ provisioning, targetDir, template }) {
if (provisioning.status === 'provisioned') {
return [
'## Provisioning Status',
'',
`This starter was provisioned against \`${provisioning.runtime.url}\`. The generated \`.env.local\` includes a working ${template === 'node' ? 'server' : 'browser'} key for this template.`,
'',
].join('\n');
}
if (provisioning.status === 'local_mode') {
return [
'## Provisioning Status',
'',
`The server at \`${provisioning.runtime.url}\` is currently in local mode, so authenticated flows can run without provisioning while that mode stays enabled.`,
'',
'Use `npx spaps quickstart --json` if you need the current local-mode hints and test personas.',
'',
].join('\n');
}
const rerunCommand = `SELF_SERVICE_PASSWORD=your-password npx spaps create ${path.basename(targetDir)} --template ${template} --dir ${targetDir} --force`;
return [
'## Provisioning Status',
'',
'This run only scaffolded files. Authenticated flows will not work until you provision a real SPAPS application or enable server local mode.',
'',
'To provision automatically:',
'',
'```bash',
rerunCommand,
'```',
'',
].join('\n');
}
function buildDesignSystemNote() {
return `- \`components.json\`: shadcn project config, including the \`@design-system\` registry namespace
- \`design-system.registry.json\`: Design System Registry adoption contract for this app
- \`src/app.css\`: Tailwind v4 entry carrying the pinned \`${DSR_THEME.item}\` token payload
- \`src/design-system/theme-adapter.ts\`: the only place the active registry item is named
## Design System
The authentication reference page is styled by **${DSR_THEME.title}**
(\`${DSR_THEME.item}@${DSR_THEME.version}\`) from the
[Design System Registry](${DSR_THEME.sourceRepo}).
Page components consume shadcn semantic tokens only. Do not add a local palette,
radius scale, or shadow system: change the theme instead.
Re-pull or swap the active item:
\`\`\`bash
${DSR_THEME.installCommand}
\`\`\`
Approved alternates carrying the same \`ds-*\` token contract:
${DSR_THEME.switchableItems.map((item) => `- \`${item}\``).join('\n')}
After swapping, update \`activeItem\` in \`design-system.registry.json\` and
\`ACTIVE_REGISTRY_ITEM\` in \`src/design-system/theme-adapter.ts\`.
`;
}
function buildReadme({ name, template, apiUrl, targetDir, provisioning }) {
const templateDef = SUPPORTED_TEMPLATES[template];
return `# ${name}
This directory is a SPAPS ${templateDef.label.toLowerCase()}.
It gives you three things immediately:
- a machine-readable SPAPS app contract in \`spaps.app.json\`
- local env wiring in \`.env.local\`
- a small template-specific integration starter you can drop into a real app
This is not a full framework generator. It does not run \`create-next-app\`, Vite, or Express setup for you.
${buildProvisioningNote({ provisioning, targetDir, template })}## Next Steps
1. Install dependencies in this project with \`npm install\`
2. Copy the generated starter files into your real ${templateDef.label.toLowerCase()} or keep extending this directory
3. Point your app at \`${apiUrl}\`
4. Run \`npm run test:passkeys\` locally to launch the managed Chromium virtual-authenticator proof
## Generated Files
- \`spaps.app.json\`: local app contract and provisioning metadata
- \`.env.local\`: SPAPS API URL wiring and, when available, a working key for this template
- \`package.json\`: minimal dependency declaration for \`spaps-sdk\`
${template === 'react' ? buildDesignSystemNote() : ''}`;
}
function buildNextSteps({ targetDir, provisioning, port, name, template }) {
const steps = [
`cd ${targetDir}`,
'npm install',
];
if (provisioning.status === 'provisioned') {
steps.push('Review .env.local and start your app');
return steps;
}
if (provisioning.status === 'local_mode') {
steps.push(`Use the starter against http://localhost:${port} while local mode stays enabled`);
return steps;
}
if (provisioning.reason === 'server_unreachable') {
steps.push(`npx spaps local --port ${port}`);
} else {
steps.push(
`SELF_SERVICE_PASSWORD=your-password npx spaps create ${name} --template ${template} --dir ${targetDir} --force`
);
}
return steps;
}
async function createProjectStarter({
name,
template,
dir = null,
force = false,
version = '0.0.0',
port = DEFAULT_PORT,
}) {
if (!name || !String(name).trim()) {
throw createCliError('EINVAL', 'Project name is required.');
}
ensureSupportedTemplate(template);
const normalizedName = String(name).trim();
const slug = slugifyProjectName(normalizedName);
const targetDir = path.resolve(dir || path.join(process.cwd(), slug));
const apiUrl = `http://localhost:${port}`;
const docsUrl = `${apiUrl}/docs`;
const bookkeeping = {
files_created: [],
files_overwritten: [],
};
const templateDef = SUPPORTED_TEMPLATES[template];
prepareWritableTarget(targetDir, force);
const provisioning = await provisionStarterApplication({
port,
name: normalizedName,
slug,
blueprintKey: templateDef.blueprintKey,
allowedOrigins: templateDef.allowedOrigins,
});
writeManagedFile(
targetDir,
'spaps.app.json',
buildContract({
name: normalizedName,
slug,
template,
version,
apiUrl,
docsUrl,
provisioning,
}),
bookkeeping
);
writeManagedFile(targetDir, '.env.local', buildEnvFile(template, apiUrl, provisioning), bookkeeping);
writeManagedFile(
targetDir,
'README.md',
buildReadme({ name: normalizedName, template, apiUrl, targetDir, provisioning }),
bookkeeping
);
writeManagedFile(targetDir, 'package.json', buildPackageJson(normalizedName, template), bookkeeping);
writeManagedFile(
targetDir,
'.gitignore',
'node_modules\n.env\n.env.local\n',
bookkeeping
);
const templateFiles = templateDef.files({
name: normalizedName,
slug,
apiUrl,
installedAt: new Date().toISOString().slice(0, 10),
});
for (const [relativePath, content] of Object.entries(templateFiles)) {
writeManagedFile(targetDir, relativePath, content, bookkeeping);
}
return {
success: true,
command: 'create',
project_name: normalizedName,
template,
target_dir: targetDir,
contract_path: path.join(targetDir, 'spaps.app.json'),
files_created: bookkeeping.files_created,
files_overwritten: bookkeeping.files_overwritten,
provisioning: {
status: provisioning.status,
reason: provisioning.reason || null,
application_id: provisioning.application?.id || null,
application_slug: provisioning.application?.slug || slug,
local_mode_active: provisioning.runtime?.local_mode?.active ?? null,
server_url: provisioning.runtime?.url || apiUrl,
},
warnings: provisioning.warnings || [],
next_steps: buildNextSteps({
targetDir,
provisioning,
port,
name: normalizedName,
template,
}),
};
}
module.exports = {
PASSKEY_BROWSER_SDK_RANGE,
SUPPORTED_TEMPLATES,
createProjectStarter,
validateBrowserConfig,
};