spaps
Version:
Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware
2,413 lines • 75.5 kB
JavaScript
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
const { DEFAULT_PORT } = require('./config');
const { buildBaseUrl, getServerRuntime } = require('./local-runtime');
const FIXTURE_SCHEMA_VERSION = 1;
const FIXTURE_DIRNAME = '.spaps';
const BROWSER_DIRNAME = 'browser';
const DEFAULT_BROWSER_BASE_URL = 'http://localhost:3000';
const DEFAULT_PUBLIC_DIR = 'public';
const DEFAULT_BRIDGE_SCRIPT_NAME = 'spaps-dev-auth.js';
const FIXTURE_KEYS = {
active_persona: 'spaps.fixture.active_persona',
persona: 'spaps.fixture.persona',
profile: 'spaps.fixture.profile',
roles: 'spaps.fixture.roles',
entitlements: 'spaps.fixture.entitlements',
scenario: 'spaps.fixture.scenario',
application: 'spaps.fixture.application',
runtime: 'spaps.fixture.runtime',
selector: 'spaps.fixture.selector',
};
const COMPAT_STORAGE_KEYS = {
sdk_user: 'sweet_potato_user',
sdk_access_token: 'sweet_potato_access_token',
sdk_refresh_token: 'sweet_potato_refresh_token',
legacy_user: 'spaps_user',
};
function cloneValue(value) {
return JSON.parse(JSON.stringify(value));
}
const BASE_PERSONAS = [
{
code: 'user',
display_name: 'Local User',
selector: {
query_param: { _user: 'user' },
headers: { 'X-Test-User': 'user' },
},
profile: {
user_id: '00000000-0000-0000-0000-000000000001',
email: 'user@localhost',
username: 'dev-user',
tier: 'free',
},
permissions: ['view_products'],
browser: {
local_storage: {},
},
},
{
code: 'admin',
display_name: 'Local Admin',
selector: {
query_param: { _user: 'admin' },
headers: { 'X-Test-User': 'admin' },
},
profile: {
user_id: '5bdb0db2-5ab1-4e2c-999b-1153cc329477',
email: 'buildooor@gmail.com',
username: 'admin',
tier: 'enterprise',
},
permissions: [
'view_products',
'manage_products',
'view_users',
'manage_users',
'view_orders',
'manage_orders',
'access_admin',
'view_analytics',
'manage_subscriptions',
'manage_system_settings',
],
browser: {
local_storage: {},
},
},
{
code: 'premium',
display_name: 'Premium User',
selector: {
query_param: { _user: 'premium' },
headers: { 'X-Test-User': 'premium' },
},
profile: {
user_id: '00000000-0000-0000-0000-000000000002',
email: 'premium@localhost',
username: 'premium-user',
tier: 'premium',
},
permissions: ['view_products'],
browser: {
local_storage: {},
},
},
];
function getBasePersona(code) {
const persona = BASE_PERSONAS.find((entry) => entry.code === code);
if (!persona) {
throw new Error(`Unknown base persona "${code}"`);
}
return persona;
}
function makeDerivedPersona(code, displayName, fromCode, overrides = {}) {
const base = getBasePersona(fromCode);
const {
selector,
profile,
permissions,
browser,
...rest
} = overrides;
return {
...cloneValue(base),
...cloneValue(rest),
code,
display_name: displayName,
selector: selector ? cloneValue(selector) : cloneValue(base.selector),
profile: {
...cloneValue(base.profile),
...(profile ? cloneValue(profile) : {}),
},
permissions: permissions ? [...permissions] : [...(base.permissions || [])],
browser: {
local_storage: {
...(base.browser?.local_storage || {}),
...((browser && browser.local_storage) || {}),
},
},
};
}
const DEFAULT_PERSONAS = [
...BASE_PERSONAS,
makeDerivedPersona('dayrate-guest', 'Dayrate Guest', 'user', {
profile: {
username: 'dayrate-guest',
},
scenario: {
dayrate: {
mode: 'paid',
policy_key: null,
sample_request: {
date: '2026-05-01',
slot: 'AM',
clientEmail: 'guest-fixture@example.com',
clientName: 'Fixture Guest',
successUrl: 'https://example.com/dayrate/success',
cancelUrl: 'https://example.com/dayrate/cancel',
},
},
},
}),
makeDerivedPersona('dayrate-entitled', 'Dayrate Entitled', 'user', {
permissions: ['view_products', 'book_dayrate_free'],
profile: {
username: 'dayrate-entitled',
},
scenario: {
dayrate: {
mode: 'free',
policy_key: 'fixture-dayrate-free-{{global.run_id}}',
entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
free_bookings_remaining: 2,
sample_request: {
date: '2026-05-02',
slot: 'AM',
clientEmail: 'entitled-fixture@example.com',
clientName: 'Fixture Entitled',
successUrl: 'https://example.com/dayrate/success',
cancelUrl: 'https://example.com/dayrate/cancel',
policyKey: 'fixture-dayrate-free-{{global.run_id}}',
userId: '{{persona.profile.user_id}}',
},
},
},
seed: [
{
as: 'admin',
method: 'PUT',
path: '/api/dayrate/admin/config',
body: {
base_rate: 12000,
available_days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
horizon_weeks: 8,
min_notice_hours: 1,
hold_duration_minutes: 15,
timezone: 'America/Toronto',
currency: 'usd',
product_name: 'Fixture Dayrate',
slot_definitions: {
AM: { start: '09:00', end: '12:00' },
PM: { start: '13:00', end: '16:00' },
},
},
},
{
as: 'admin',
method: 'POST',
path: '/api/dayrate/admin/policies',
expected_statuses: [201, 409],
body: {
policyKey: 'fixture-dayrate-free-{{global.run_id}}',
entitlementKey: 'fixture.dayrate.free.{{global.run_id}}',
freeBookingsPerPeriod: 2,
periodDays: 28,
overageRate: null,
overageCurrency: 'usd',
overageProductName: 'Fixture Dayrate Overage',
rescheduleIsFree: true,
metadata: {
source: 'spaps-fixture-seed',
persona: 'dayrate-entitled',
},
},
},
{
as: 'admin',
method: 'POST',
path: '/api/entitlements/manual',
expected_statuses: [201, 409],
body: {
beneficiary_user_id: '{{persona.profile.user_id}}',
beneficiary_email: '{{persona.profile.email}}',
entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
resource_type: 'user',
reason: 'spaps fixture seed',
metadata: {
source: 'spaps-fixture-seed',
persona: 'dayrate-entitled',
},
},
},
],
}),
makeDerivedPersona('dayrate-overage', 'Dayrate Overage', 'premium', {
permissions: ['view_products', 'book_dayrate_free'],
profile: {
username: 'dayrate-overage',
},
scenario: {
dayrate: {
mode: 'overage',
policy_key: 'fixture-dayrate-overage-{{global.run_id}}',
entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
overage_rate: 1500,
free_bookings_remaining: 0,
sample_request: {
date: '2026-05-03',
slot: 'PM',
clientEmail: 'overage-fixture@example.com',
clientName: 'Fixture Overage',
successUrl: 'https://example.com/dayrate/success',
cancelUrl: 'https://example.com/dayrate/cancel',
policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
userId: '{{persona.profile.user_id}}',
},
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/dayrate/admin/policies',
expected_statuses: [201, 409],
body: {
policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
entitlementKey: 'fixture.dayrate.overage.{{global.run_id}}',
freeBookingsPerPeriod: 0,
periodDays: 28,
overageRate: 1500,
overageCurrency: 'usd',
overageProductName: 'Fixture Dayrate Overage',
rescheduleIsFree: true,
metadata: {
source: 'spaps-fixture-seed',
persona: 'dayrate-overage',
},
},
},
{
as: 'admin',
method: 'POST',
path: '/api/entitlements/manual',
expected_statuses: [201, 409],
body: {
beneficiary_user_id: '{{persona.profile.user_id}}',
beneficiary_email: '{{persona.profile.email}}',
entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
resource_type: 'user',
reason: 'spaps fixture seed',
metadata: {
source: 'spaps-fixture-seed',
persona: 'dayrate-overage',
},
},
},
],
}),
makeDerivedPersona('cfo-billing-demo', 'CFO Billing Demo', 'admin', {
permissions: ['view_products', 'manage_company_billing', 'view_company_reports'],
profile: {
username: 'cfo-billing-demo',
},
scenario: {
cfo: {
pack: 'cfo.billing_demo',
company_id: '00000000-0000-4000-8000-00000000c001',
company_slug: 'fixture-cfo-company',
expected_entitlements: [
'fixture.cfo.company.billing_admin',
'fixture.cfo.company.reports',
],
},
},
}),
makeDerivedPersona('htma-local-auth', 'HTMA Local Auth', 'user', {
permissions: ['view_products', 'manage_patient_protocols'],
profile: {
username: 'htma-local-auth',
},
scenario: {
htma: {
pack: 'htma.local_auth',
invite_template_key: 'fixture-htma-invite-{{global.run_id}}',
invite_action_url: 'http://localhost:5173/htma/invite/{{global.run_id}}',
expected_entitlements: ['fixture.htma.local_auth'],
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/email/templates',
expected_statuses: [201, 409],
body: {
name: 'Fixture HTMA Invite',
template_key: 'fixture-htma-invite-{{global.run_id}}',
subject: 'HTMA invite for {{name}}',
html_body: '<p><a href="{{action_url}}">Open HTMA invite</a></p>',
text_body: 'Open HTMA invite: {{action_url}}',
from_email: 'noreply@example.com',
category: 'fixture',
sample_context: {
name: 'Fixture HTMA User',
action_url: 'http://localhost:5173/htma/invite/{{global.run_id}}',
},
},
},
],
}),
makeDerivedPersona('email-local-safe', 'Email Local Safe', 'user', {
profile: {
username: 'email-local-safe',
},
scenario: {
email: {
template_key: 'fixture-email-safe-{{global.run_id}}',
send_request: {
template_key: 'fixture-email-safe-{{global.run_id}}',
to: 'recipient@example.com',
context: {
name: 'Fixture Recipient',
},
},
expected_status: 'short_circuited',
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/email/templates',
expected_statuses: [201, 409],
body: {
name: 'Fixture Local Safe Email',
template_key: 'fixture-email-safe-{{global.run_id}}',
subject: 'Hello {{name}}',
html_body: '<p>Hello {{name}}</p>',
text_body: 'Hello {{name}}',
from_email: 'noreply@example.com',
category: 'fixture',
sample_context: {
name: 'Fixture Recipient',
},
},
},
],
}),
makeDerivedPersona('webhook-signature-invalid', 'Webhook Signature Invalid', 'admin', {
profile: {
username: 'webhook-signature-invalid',
},
scenario: {
webhooks: {
invalid_request: {
path: '/api/webhooks/mailgun/events',
expected_status: 401,
body: {
signature: {
timestamp: '{{global.epoch}}',
token: 'fixture-invalid-token',
signature: 'not-a-real-signature',
},
'event-data': {
event: 'delivered',
timestamp: '2026-05-04T09:00:00Z',
message: {
headers: {
'message-id': 'fixture-invalid-msg',
},
},
},
},
},
},
},
}),
makeDerivedPersona('webhook-replay', 'Webhook Replay', 'admin', {
profile: {
username: 'webhook-replay',
},
scenario: {
webhooks: {
replay_request: {
path: '/api/webhooks/mailgun/events',
expected_status: 409,
signing_key: 'test-key',
token: 'fixture-replay-token',
body_template: {
signature: {
timestamp: '{{global.epoch}}',
token: 'fixture-replay-token',
signature: '{{scenario_signature}}',
},
'event-data': {
event: 'delivered',
timestamp: '2026-05-04T09:05:00Z',
message: {
headers: {
'message-id': 'fixture-replay-msg',
},
},
},
},
},
},
},
}),
makeDerivedPersona('policy-admin-allow', 'Policy Admin Allow', 'admin', {
scenario: {
policies: {
policy_name: 'fixture-admin-read-{{global.run_id}}',
authorize_request: {
policy_name: 'fixture-admin-read-{{global.run_id}}',
},
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/policies',
expected_statuses: [201, 409],
body: {
name: 'fixture-admin-read-{{global.run_id}}',
description: 'Allow admin-only fixture policy',
effect: 'allow',
conditions: {
has_role: {
role: 'admin',
},
},
priority: 100,
metadata: {
source: 'spaps-fixture-seed',
persona: 'policy-admin-allow',
},
},
},
],
}),
makeDerivedPersona('policy-denied-no-wallet', 'Policy Denied No Wallet', 'user', {
permissions: ['view_products'],
scenario: {
policies: {
policy_name: 'fixture-requires-wallet-{{global.run_id}}',
expected_decision: 'deny',
authorize_request: {
policy_name: 'fixture-requires-wallet-{{global.run_id}}',
},
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/policies',
expected_statuses: [201, 409],
body: {
name: 'fixture-requires-wallet-{{global.run_id}}',
description: 'Require a Solana wallet for access',
effect: 'allow',
conditions: {
has_wallet: {
chain: 'solana',
},
},
priority: 100,
metadata: {
source: 'spaps-fixture-seed',
persona: 'policy-denied-no-wallet',
},
},
},
],
}),
makeDerivedPersona('issue-reporter', 'Issue Reporter', 'user', {
permissions: ['view_products'],
scenario: {
issue_reporting: {
expected_status: 201,
create_request: {
target: {
component_key: 'feedback-button',
component_label: 'Feedback Button',
page_url: 'https://example.com/app',
surface_ref: 'feedback-fab',
metadata: {
screenshots: [
{
url: 'https://example.com/screenshots/fixture-1.png',
timestamp: '2026-05-05T09:00:00Z',
},
],
},
},
note: 'Fixture issue report with screenshots for local issue-reporting flows.',
reporter_role_hint: 'member',
},
reply_request: {
note: 'Fixture follow-up after a support response.',
reporter_role_hint: 'member',
},
},
},
seed: [
{
as: 'persona',
method: 'POST',
path: '/api/v1/issue-reports',
expected_statuses: [201],
body: {
target: {
component_key: 'feedback-button',
component_label: 'Feedback Button',
page_url: 'https://example.com/app',
surface_ref: 'feedback-fab',
metadata: {
screenshots: [
{
url: 'https://example.com/screenshots/fixture-1.png',
timestamp: '2026-05-05T09:00:00Z',
},
],
},
},
note: 'Fixture issue report with screenshots for local issue-reporting flows.',
reporter_role_hint: 'member',
},
capture: {
existing_issue_report_id: 'id',
existing_issue_case_id: 'linked_case.case_id',
},
},
],
}),
makeDerivedPersona('issue-reporter-blocked', 'Issue Reporter Blocked', 'user', {
permissions: [],
selector: {
query_param: { _user: 'user' },
headers: {
'X-Test-User': 'user',
'X-API-Key': '{{seed.blocked_app_api_key}}',
},
},
application: {
application_id: '{{seed.blocked_app_id}}',
application_slug: '{{seed.blocked_app_slug}}',
api_key: '{{seed.blocked_app_api_key}}',
},
scenario: {
issue_reporting: {
expected_status: 403,
required_capability: 'issue_reporting',
create_request: {
target: {
component_key: 'feedback-button',
component_label: 'Feedback Button',
page_url: 'https://example.com/app',
surface_ref: 'feedback-fab',
metadata: {},
},
note: 'Blocked issue reporter fixture should fail until capability is granted.',
reporter_role_hint: 'member',
},
},
},
seed: [
{
as: 'admin',
method: 'POST',
path: '/api/admin/create-app',
expected_statuses: [201],
body: {
name: 'Fixture Issue Reporting Blocked {{global.run_id}}',
slug: 'fixture-ir-blocked-{{global.run_id}}',
settings: {
issue_reporting_required_capability: 'issue_reporting',
},
},
capture: {
blocked_app_api_key: 'api_key',
blocked_app_id: 'application.id',
blocked_app_slug: 'application.slug',
},
},
],
}),
];
const DEFAULT_ROLE_GRANTS = {
user: ['user'],
admin: ['admin', 'super_admin'],
premium: ['user'],
'dayrate-guest': ['user'],
'dayrate-entitled': ['user'],
'dayrate-overage': ['user'],
'cfo-billing-demo': ['admin', 'super_admin'],
'htma-local-auth': ['user'],
'email-local-safe': ['user'],
'webhook-signature-invalid': ['admin', 'super_admin'],
'webhook-replay': ['admin', 'super_admin'],
'policy-admin-allow': ['admin', 'super_admin'],
'policy-denied-no-wallet': ['user'],
'issue-reporter': ['user'],
'issue-reporter-blocked': ['user'],
};
const DEFAULT_ENTITLEMENT_GRANTS = {
user: [],
admin: ['paid_access', 'admin_console'],
premium: ['paid_access'],
'dayrate-guest': [],
'dayrate-entitled': ['fixture.dayrate.free.{{global.run_id}}'],
'dayrate-overage': ['fixture.dayrate.overage.{{global.run_id}}'],
'cfo-billing-demo': [
{
key: 'fixture.cfo.company.billing_admin',
resource_type: 'company',
resource_id: '00000000-0000-4000-8000-00000000c001',
metadata: {
pack: 'cfo.billing_demo',
company_slug: 'fixture-cfo-company',
},
},
{
key: 'fixture.cfo.company.reports',
resource_type: 'company',
resource_id: '00000000-0000-4000-8000-00000000c001',
metadata: {
pack: 'cfo.billing_demo',
company_slug: 'fixture-cfo-company',
},
},
],
'htma-local-auth': ['fixture.htma.local_auth'],
'email-local-safe': [],
'webhook-signature-invalid': [],
'webhook-replay': [],
'policy-admin-allow': [],
'policy-denied-no-wallet': [],
'issue-reporter': [],
'issue-reporter-blocked': [],
};
function normalizeEntitlementGrant(grant) {
if (typeof grant === 'string') {
return { key: grant };
}
if (!grant || typeof grant !== 'object') {
return { key: String(grant || '') };
}
return {
key: grant.key || grant.entitlement_key || '',
entitlement_type: grant.entitlement_type || grant.type || 'access',
resource_type: grant.resource_type || 'user',
resource_id: grant.resource_id || null,
metadata: grant.metadata || {},
};
}
function entitlementGrantKey(grant) {
return normalizeEntitlementGrant(grant).key;
}
function entitlementGrantKeys(grants = []) {
return grants.map(entitlementGrantKey).filter(Boolean);
}
function serializeEntitlementGrantForSync(grant) {
if (typeof grant === 'string') {
return grant;
}
return normalizeEntitlementGrant(grant);
}
function createCliError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function resolveRepoRoot(dir = null) {
return path.resolve(dir || process.cwd());
}
function resolveFixturePaths(rootDir) {
const fixtureDir = path.join(rootDir, FIXTURE_DIRNAME);
const browserDir = path.join(fixtureDir, BROWSER_DIRNAME);
const publicDir = path.join(rootDir, DEFAULT_PUBLIC_DIR);
return {
rootDir,
fixtureDir,
browserDir,
publicDir,
app: path.join(fixtureDir, 'app.json'),
users: path.join(fixtureDir, 'users.json'),
roles: path.join(fixtureDir, 'roles.json'),
entitlements: path.join(fixtureDir, 'entitlements.json'),
readme: path.join(fixtureDir, 'README.md'),
browserGitignore: path.join(browserDir, '.gitignore'),
bridgeScript: path.join(publicDir, DEFAULT_BRIDGE_SCRIPT_NAME),
lock: path.join(fixtureDir, 'fixtures.lock.json'),
starterContract: path.join(rootDir, 'spaps.app.json'),
};
}
function normalizeBaseUrl(baseUrl = null) {
const candidate = String(baseUrl || process.env.SPAPS_BROWSER_BASE_URL || DEFAULT_BROWSER_BASE_URL).trim();
return candidate.replace(/\/+$/, '');
}
function resolveStorageOrigin(baseUrl) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
try {
return new URL(normalizedBaseUrl).origin;
} catch {
return normalizedBaseUrl;
}
}
function readJsonFile(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw createCliError('EINVAL', `Could not read ${label} at "${filePath}": ${error.message}`);
}
}
function readExistingStarterContract(paths) {
if (!fs.existsSync(paths.starterContract)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(paths.starterContract, 'utf8'));
} catch {
return null;
}
}
function buildRuntimeSummary(runtime, port, starterContract = null) {
const contractLocal = starterContract?.spaps?.local || {};
const contractApp = starterContract?.spaps?.application || {};
const apiUrl = runtime?.url || contractLocal.api_url || buildBaseUrl(port);
const docsUrl = runtime?.docs || contractLocal.docs_url || `${apiUrl}/docs`;
return {
api_url: apiUrl,
docs_url: docsUrl,
port,
running: Boolean(runtime?.running),
local_mode_active:
runtime?.running
? (runtime.local_mode?.known ? Boolean(runtime.local_mode.active) : null)
: (typeof contractLocal.local_mode_active === 'boolean' ? contractLocal.local_mode_active : null),
environment: runtime?.running ? runtime.local_mode?.environment || null : null,
application_id: contractApp.id || runtime?.local_mode?.test_application?.id || null,
application_slug: contractApp.slug || runtime?.local_mode?.test_application?.slug || null,
provisioning_status: contractApp.provisioning_status || null,
};
}
function buildAppConfig({ version, port, baseUrl, runtime, starterContract }) {
const server = buildRuntimeSummary(runtime, port, starterContract);
return {
schema_version: FIXTURE_SCHEMA_VERSION,
generated_by: `spaps@${version}`,
server,
browser: {
base_url: normalizeBaseUrl(baseUrl),
default_persona: 'user',
storage_format: 'playwright',
},
bridge: {
enabled: true,
public_dir: DEFAULT_PUBLIC_DIR,
script_name: DEFAULT_BRIDGE_SCRIPT_NAME,
query_param: 'spaps_persona',
ui: {
enabled: true,
},
},
auth: {
mode: server.local_mode_active === true ? 'local_mode' : runtime?.running ? 'application_key' : 'offline',
selector: {
query_param: '_user',
header: 'X-Test-User',
},
api_key_header: 'X-API-Key',
},
};
}
function buildUsersConfig() {
return {
schema_version: FIXTURE_SCHEMA_VERSION,
default_persona: 'user',
personas: DEFAULT_PERSONAS,
};
}
function buildRolesConfig() {
return {
schema_version: FIXTURE_SCHEMA_VERSION,
grants: DEFAULT_ROLE_GRANTS,
};
}
function buildEntitlementsConfig() {
return {
schema_version: FIXTURE_SCHEMA_VERSION,
grants: DEFAULT_ENTITLEMENT_GRANTS,
};
}
function buildFixtureReadme() {
return `# .spaps
Repo-local SPAPS auth fixtures live here.
What to edit:
- \`app.json\`: local SPAPS server and browser target settings
- \`users.json\`: personas, profile data, scenario metadata, optional seed steps, selector hints, and custom browser storage
- \`roles.json\`: persona-to-role grants
- \`entitlements.json\`: persona-to-entitlement grants
Generated files:
- \`browser/*.storage-state.json\`: Playwright storageState files
- \`browser/*.headers.json\`: extraHTTPHeaders companions for local-mode persona routing
- \`browser/*.context.json\`: merged persona/runtime summary for test harnesses
- \`public/${DEFAULT_BRIDGE_SCRIPT_NAME}\`: optional browser bridge for frontend-only auth/RBAC clicking around
Common workflow:
1. Run \`npx spaps fixtures init\`
2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
3. Run \`npx spaps fixtures apply\`
4. Run \`npx spaps fixtures apply --sync-server\` to reconcile users, memberships, and entitlements into local SPAPS
5. Run \`npx spaps fixtures apply --seed --persona <code>\` when a domain persona needs extra domain-specific DB state
6. Point Playwright or local scripts at the generated browser artifacts
7. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
Notes:
- \`--sync-server\` only runs against a reachable localhost SPAPS server with local mode active
- \`--seed\` only runs against a reachable SPAPS server with local mode active
- Seed steps use the persona metadata already in \`.spaps/users.json\`; no extra backend-only fixture routes are required
`;
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function writeManagedFile(filePath, content, bookkeeping, { overwrite = true } = {}) {
const relativePath = path.relative(bookkeeping.rootDir, filePath) || path.basename(filePath);
if (fs.existsSync(filePath) && !overwrite) {
bookkeeping.files_skipped.push(relativePath);
return false;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
if (fs.existsSync(filePath)) {
bookkeeping.files_overwritten.push(relativePath);
} else {
bookkeeping.files_created.push(relativePath);
}
fs.writeFileSync(filePath, content);
return true;
}
function cleanupGeneratedBrowserArtifacts(paths) {
if (!fs.existsSync(paths.browserDir)) {
return [];
}
const removed = [];
for (const entry of fs.readdirSync(paths.browserDir)) {
if (entry === '.gitignore') {
continue;
}
const fullPath = path.join(paths.browserDir, entry);
fs.rmSync(fullPath, { recursive: true, force: true });
removed.push(path.relative(paths.rootDir, fullPath));
}
if (fs.existsSync(paths.lock)) {
fs.rmSync(paths.lock, { force: true });
removed.push(path.relative(paths.rootDir, paths.lock));
}
return removed;
}
async function buildFixtureSeed({ rootDir, port, baseUrl, version }) {
const paths = resolveFixturePaths(rootDir);
const starterContract = readExistingStarterContract(paths);
const runtime = await getServerRuntime({ port });
return {
runtime,
appConfig: buildAppConfig({ version, port, baseUrl, runtime, starterContract }),
usersConfig: buildUsersConfig(),
rolesConfig: buildRolesConfig(),
entitlementsConfig: buildEntitlementsConfig(),
};
}
async function initFixtureKernel({
dir = null,
port = DEFAULT_PORT,
baseUrl = null,
version = '0.0.0',
force = false,
} = {}) {
const rootDir = resolveRepoRoot(dir);
const paths = resolveFixturePaths(rootDir);
const bookkeeping = {
rootDir,
files_created: [],
files_overwritten: [],
files_skipped: [],
};
const seed = await buildFixtureSeed({ rootDir, port, baseUrl, version });
writeManagedFile(paths.app, `${JSON.stringify(seed.appConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
writeManagedFile(paths.users, `${JSON.stringify(seed.usersConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
writeManagedFile(paths.roles, `${JSON.stringify(seed.rolesConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
writeManagedFile(paths.entitlements, `${JSON.stringify(seed.entitlementsConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
writeManagedFile(paths.readme, buildFixtureReadme(), bookkeeping, { overwrite: force });
writeManagedFile(paths.browserGitignore, '*.json\n!.gitignore\n', bookkeeping, { overwrite: force });
return {
success: true,
command: 'fixtures',
subcommand: force ? 'reset' : 'init',
root_dir: rootDir,
fixture_dir: paths.fixtureDir,
runtime: seed.runtime,
files_created: bookkeeping.files_created,
files_overwritten: bookkeeping.files_overwritten,
files_skipped: bookkeeping.files_skipped,
next_steps: [
'Edit .spaps/users.json, .spaps/roles.json, or .spaps/entitlements.json as needed',
'Run npx spaps fixtures apply',
],
};
}
function loadFixtureKernel(rootDir) {
const paths = resolveFixturePaths(rootDir);
if (!fs.existsSync(paths.fixtureDir)) {
throw createCliError('ENOENT', `No .spaps directory found in "${rootDir}". Run "npx spaps fixtures init" first.`);
}
const app = readJsonFile(paths.app, '.spaps/app.json');
const users = readJsonFile(paths.users, '.spaps/users.json');
const roles = readJsonFile(paths.roles, '.spaps/roles.json');
const entitlements = readJsonFile(paths.entitlements, '.spaps/entitlements.json');
if (!Array.isArray(users.personas) || users.personas.length === 0) {
throw createCliError('EINVAL', '.spaps/users.json must define at least one persona.');
}
return { paths, app, users, roles, entitlements };
}
function ensurePersona(usersConfig, code) {
const persona = usersConfig.personas.find((entry) => entry.code === code);
if (!persona) {
throw createCliError('EINVAL', `Unknown persona "${code}". Add it to .spaps/users.json or choose one of: ${usersConfig.personas.map((entry) => entry.code).join(', ')}.`);
}
return persona;
}
function buildFixtureRunState(appConfig, runtime) {
const now = new Date();
const generatedAt = now.toISOString();
return {
global: {
generated_at: generatedAt,
epoch: Math.floor(now.getTime() / 1000),
run_id: generatedAt
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'z')
.replace('T', 't'),
},
runtime: {
api_url: appConfig.server.api_url,
application_id: appConfig.server.application_id,
application_slug: appConfig.server.application_slug,
local_mode_active: appConfig.server.local_mode_active,
running: appConfig.server.running,
environment: runtime?.local_mode?.environment || appConfig.server.environment || null,
},
seed: {},
};
}
function getNestedValue(source, dottedPath) {
if (!source || typeof source !== 'object' || !dottedPath) {
return undefined;
}
return String(dottedPath)
.split('.')
.filter(Boolean)
.reduce((current, segment) => {
if (current === undefined || current === null) {
return undefined;
}
if (Array.isArray(current)) {
const index = Number(segment);
return Number.isInteger(index) ? current[index] : undefined;
}
return current[segment];
}, source);
}
function resolveTemplateLookup(context, expression) {
const lookup = String(expression || '').trim();
if (!lookup) {
return undefined;
}
if (!lookup.includes('.') && Object.prototype.hasOwnProperty.call(context, lookup)) {
return context[lookup];
}
return getNestedValue(context, lookup);
}
function renderTemplateValue(value, context) {
if (typeof value === 'string') {
const matches = [...value.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)];
if (matches.length === 0) {
return value;
}
if (matches.length === 1 && matches[0][0] === value) {
const resolved = resolveTemplateLookup(context, matches[0][1]);
return resolved === undefined ? value : cloneValue(resolved);
}
return value.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, expression) => {
const resolved = resolveTemplateLookup(context, expression);
return resolved === undefined ? match : String(resolved);
});
}
if (Array.isArray(value)) {
return value.map((entry) => renderTemplateValue(entry, context));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, renderTemplateValue(entry, context)])
);
}
return value;
}
function unwrapEnvelope(payload) {
if (
payload &&
typeof payload === 'object' &&
payload.success === true &&
Object.prototype.hasOwnProperty.call(payload, 'data')
) {
return payload.data;
}
return payload;
}
function describeStatuses(statuses = []) {
if (!Array.isArray(statuses) || statuses.length === 0) {
return '2xx';
}
return statuses.join(', ');
}
function buildApplicationSummary(appConfig, persona = {}) {
const override = persona.application || {};
return {
api_url: override.api_url || appConfig.server.api_url,
application_id: override.application_id || override.id || appConfig.server.application_id || null,
application_slug: override.application_slug || override.slug || appConfig.server.application_slug || null,
api_key: override.api_key || null,
local_mode_active: appConfig.server.local_mode_active,
};
}
function buildPersonaTemplateContext(appConfig, persona, fixtureRunState) {
return {
global: fixtureRunState.global,
runtime: fixtureRunState.runtime,
seed: fixtureRunState.seed,
persona,
application: buildApplicationSummary(appConfig, persona),
scenario: persona.scenario || {},
};
}
function resolvePersonaDefinition(appConfig, persona, fixtureRunState) {
return renderTemplateValue(cloneValue(persona), buildPersonaTemplateContext(appConfig, persona, fixtureRunState));
}
function resolveConfigTemplates(config, fixtureRunState) {
return renderTemplateValue(cloneValue(config), fixtureRunState);
}
function resolveSeedActor({ appConfig, usersConfig, persona, actor = 'persona', fixtureRunState }) {
const actorCode = actor === 'persona' ? persona.code : actor;
const actorPersona = ensurePersona(usersConfig, actorCode);
return resolvePersonaDefinition(appConfig, actorPersona, fixtureRunState);
}
function buildSeedUrl(apiUrl, requestPath, queryParams = {}) {
const url = new URL(requestPath, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
for (const [key, value] of Object.entries(queryParams || {})) {
if (value === undefined || value === null || value === '') {
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
function isLocalServerUrl(apiUrl) {
try {
const parsed = new URL(apiUrl);
return ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
} catch {
return false;
}
}
function assertSeedingRuntime(runtime, port) {
if (!runtime?.running) {
throw createCliError(
'ESEED',
`Fixture seeding requires a running SPAPS server on port ${port}. Start it with "npx spaps local --port ${port}" or omit --seed.`
);
}
if (runtime.local_mode?.active !== true) {
throw createCliError(
'ESEED',
'Fixture seeding requires SPAPS local mode so persona routing can use X-Test-User headers. Enable local mode or omit --seed.'
);
}
}
function assertServerSyncRuntime(runtime, appConfig, port) {
if (!runtime?.running) {
throw createCliError(
'ESYNC',
`Fixture server sync requires a running SPAPS server on port ${port}. Start it with "npx spaps local --port ${port}" or omit --sync-server.`
);
}
if (runtime.local_mode?.active !== true) {
throw createCliError(
'ESYNC',
'Fixture server sync requires SPAPS local mode. Refusing to sync fixtures into a non-local server.'
);
}
if (!isLocalServerUrl(appConfig.server.api_url)) {
throw createCliError(
'ESYNC',
`Fixture server sync refuses non-local SPAPS targets: ${appConfig.server.api_url}`
);
}
}
async function runSeedSequence({
appConfig,
usersConfig,
persona,
fixtureRunState,
}) {
const steps = Array.isArray(persona.seed) ? persona.seed : [];
if (steps.length === 0) {
return null;
}
const requests = [];
const captures = {};
for (const step of steps) {
const resolvedPersona = resolvePersonaDefinition(appConfig, persona, fixtureRunState);
const actor = resolveSeedActor({
appConfig,
usersConfig,
persona,
actor: step.as || 'persona',
fixtureRunState,
});
const templateContext = buildPersonaTemplateContext(appConfig, resolvedPersona, fixtureRunState);
const resolvedStep = renderTemplateValue(cloneValue(step), templateContext);
const application = buildApplicationSummary(appConfig, actor);
const headers = {
Accept: 'application/json',
...(resolvedStep.body ? { 'Content-Type': 'application/json' } : {}),
...(actor.selector?.headers || {}),
};
if (application.api_key && !headers['X-API-Key']) {
headers['X-API-Key'] = application.api_key;
}
const url = buildSeedUrl(
appConfig.server.api_url,
resolvedStep.path,
actor.selector?.query_param || {}
);
let response;
try {
response = await axios({
method: resolvedStep.method || 'GET',
url,
data: resolvedStep.body || null,
headers,
timeout: 5000,
validateStatus: () => true,
});
} catch (error) {
throw createCliError(
'ESEED',
`Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: ${error.message}`
);
}
const expectedStatuses =
Array.isArray(resolvedStep.expected_statuses) && resolvedStep.expected_statuses.length > 0
? resolvedStep.expected_statuses
: null;
const ok = expectedStatuses
? expectedStatuses.includes(response.status)
: response.status >= 200 && response.status < 300;
if (!ok) {
throw createCliError(
'ESEED',
`Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: expected ${describeStatuses(expectedStatuses)}, received ${response.status}.`
);
}
const payload = unwrapEnvelope(response.data);
const captured = {};
for (const [key, lookup] of Object.entries(resolvedStep.capture || {})) {
const capturedValue = getNestedValue(payload, lookup);
if (capturedValue === undefined) {
throw createCliError(
'ESEED',
`Fixture seed request for persona "${persona.code}" could not capture "${key}" from "${lookup}" at ${resolvedStep.method || 'GET'} ${resolvedStep.path}.`
);
}
fixtureRunState.seed[key] = capturedValue;
captures[key] = capturedValue;
captured[key] = capturedValue;
}
requests.push({
as: actor.code,
method: resolvedStep.method || 'GET',
path: resolvedStep.path,
status: response.status,
expected_statuses: expectedStatuses,
captured,
});
}
return {
persona: persona.code,
request_count: requests.length,
captures,
requests,
};
}
async function runFixtureSeeding({
appConfig,
runtime,
usersConfig,
personaCode = null,
fixtureRunState,
port,
}) {
assertSeedingRuntime(runtime, port);
const selectedPersonas = personaCode
? [ensurePersona(usersConfig, personaCode)]
: usersConfig.personas;
const seeded = [];
for (const persona of selectedPersonas) {
const result = await runSeedSequence({
appConfig,
usersConfig,
persona,
fixtureRunState,
});
if (result) {
seeded.push(result);
}
}
return {
enabled: true,
personas: seeded,
captures: { ...fixtureRunState.seed },
};
}
function buildFixtureServerSyncPayload({
appConfig,
usersConfig,
rolesConfig,
entitlementsConfig,
personaCode = null,
}) {
const selectedCodes = personaCode
? [ensurePersona(usersConfig, personaCode).code]
: usersConfig.personas.map((entry) => entry.code);
return {
source: 'spaps-fixtures',
application: {
id: appConfig.server.application_id || null,
slug: appConfig.server.application_slug || 'local-dev',
name: appConfig.server.application_slug || 'Local Development',
api_key: appConfig.server.api_key || null,
allowed_origins: [resolveStorageOrigin(appConfig.browser.base_url)],
settings: {
spaps_fixture_browser_base_url: appConfig.browser.base_url,
},
},
personas: selectedCodes.map((code) => {
const persona = ensurePersona(usersConfig, code);
return {
code,
display_name: persona.display_name || code,
profile: persona.profile || {},
roles: rolesConfig.grants?.[code] || [],
permissions: persona.permissions || [],
entitlements: (entitlementsConfig.grants?.[code] || []).map(serializeEntitlementGrantForSync),
metadata: {
selector: persona.selector || {},
scenario: persona.scenario || {},
},
};
}),
};
}
async function runFixtureServerSync({
appConfig,
runtime,
usersConfig,
rolesConfig,
entitlementsConfig,
personaCode = null,
port,
}) {
assertServerSyncRuntime(runtime, appConfig, port);
const payload = buildFixtureServerSyncPayload({
appConfig,
usersConfig,
rolesConfig,
entitlementsConfig,
personaCode,
});
let response;
try {
response = await axios({
method: 'POST',
url: `${appConfig.server.api_url.replace(/\/+$/, '')}/api/dev/fixtures/apply`,
data: payload,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
timeout: 10000,
validateStatus: () => true,
});
} catch (error) {
throw createCliError(
'ESYNC',
`Fixture server sync failed at /api/dev/fixtures/apply: ${error.message}`
);
}
const body = unwrapEnvelope(response.data);
if (response.status < 200 || response.status >= 300) {
const message = body?.message || body?.error?.message || `status ${response.status}`;
throw createCliError('ESYNC', `Fixture server sync failed: ${message}`);
}
return body;
}
function applyServerSnapshotToUsers(usersConfig, appConfig, serverSync) {
if (!serverSync?.application || !Array.isArray(serverSync.personas)) {
return usersConfig;
}
const byCode = new Map(serverSync.personas.map((entry) => [entry.code, entry]));
appConfig.server.application_id = serverSync.application.id;
appConfig.server.application_slug = serverSync.application.slug;
appConfig.server.api_key = serverSync.application.api_key;
return {
...usersConfig,
personas: usersConfig.personas.map((persona) => {
const synced = byCode.get(persona.code);
if (!synced) {
return persona;
}
return {
...persona,
profile: {
...(persona.profile || {}),
user_id: synced.user.id,
email: synced.user.email || persona.profile?.email,
username: synced.user.username || persona.profile?.username,
tier: synced.user.tier || persona.profile?.tier,
},
application: {
...(persona.application || {}),
application_id: serverSync.application.id,
application_slug: serverSync.application.slug,
api_key: serverSync.application.api_key,
},
server_sync: {
membership_created: synced.membership_created,
entitlements_created: synced.entitlements_created,
},
};
}),
};
}
function stringifyStorageValue(value) {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
function buildRouteHint(appConfig, persona) {
const userCode = persona?.selector?.query_param?._user;
if (appConfig.server.local_mode_active === true && userCode) {
return `/?_user=${encodeURIComponent(userCode)}`;
}
return null;
}
function buildHeaderArtifact(appConfig, persona) {
const application = buildApplicationSummary(appConfig, persona);
const headers = {
...(persona.selector?.headers || {}),
};
if (application.api_key && !headers['X-API-Key']) {
headers['X-API-Key'] = application.api_key;
}
return {
format: 'playwright-extra-http-headers',
local_mode_active: appConfig.server.local_mode_active === true,
headers,
route_hint: buildRouteHint(appConfig, persona),
note:
appConfig.server.local_mode_active === true
? 'Use these headers with Playwright context.extraHTTPHeaders when you want SPAPS local-mode persona routing.'
: 'SPAPS local mode is not active. These headers are still emitted so app-specific test harnesses can choose to consume them.',
};
}
function base64UrlEncodeJson(value) {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
function buildFixtureTokens(appConfig, persona, roles) {
const now = Math.floor(Date.now() / 1000);
const primaryRole = roles[0] || 'user';
const application = buildApplicationSummary(appConfig, persona);
const header = base64UrlEncodeJson({ alg: 'none', typ: 'JWT' });
const payload = base64UrlEncodeJson({
sub: persona.profile?.user_id,
user_id: persona.profile?.user_id,
email: persona.profile?.email,
role: primaryRole,
roles,
tier: persona.profile?.tier || 'free',
app_id: application.application_id,
aud: application.application_slug || 'spaps-fixture',
iss: 'spaps-fixture',
iat: now,
exp: now + (60 * 60 * 24 * 30),
});
return {
access_token: `${header}.${payload}.fixture`,
refresh_token: `fixture-refresh-${persona.code}`,
token_type: 'Bearer',
expires_in: 60 * 60 * 24 * 30,
};
}
function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
const entitlementGrants = (entitlementsConfig.grants?.[persona.code] || []).map(normalizeEntitlementGrant);
const application = buildApplicationSummary(appConfig, persona);
return entitlementGrants.map((grant, index) => ({
id: `fixture-${persona.code}-${index + 1}`,
application_id: application.application_id,
beneficiary_user_id: persona.profile?.user_id || null,
beneficiary_email: persona.profile?.email || null,
entitlement_key: grant.key,
entitlement_type: grant.entitlement_type || 'manual',
source: 'fixture',
resource_type: grant.resource_type || 'user',
resource_id: grant.resource_id || null,
starts_at: '2026-01-01T00:00:00.000Z',
ends_at: null,
revoked_at: null,
metadata: {
fixture_persona: persona.code,
...(grant.metadata || {}),
},
}));
}
function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
const roles = rolesConfig.grants?.[persona.code] || [];
const entitlements = entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []);
const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
const primaryRole = roles[0] || 'user';
const application = buildApplicationSummary(appConfig, persona);
return {
id: persona.profile?.user_id || null,
email: persona.profile?.email || null,
username: persona.profile?.username || null,
tier: persona.profile?.tier || 'free',
role: primaryRole,
roles,
permissions,
is_admin: roles.includes('admin') || roles.includes('super_admin'),
is_super_admin: roles.includes('super_admin'),
entitlements,
active_entitlements: entitlements.map((entitlementKey) => ({ key: entitlementKey })),
application_id: application.application_id || null,
fixture_persona: persona.code,
};
}
function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
const apiOrigin = (() => {
try {
return new URL(appConfig.server.api_url).origin;
} catch {
return appConfig.server.api_url;
}
})();
return {
generated_at: new Date().toISOString(),
api_url: appConfig.server.api_url,
api_origin: apiOrigin,
auth_mode: appConfig.auth.mode,
local_mode_active: appConfig.server.local_mode_active === true,
default_persona: users.default_persona || appConfig.browser.default_persona || 'user',
query_param: appConfig.bridge?.query_param || 'spaps_persona',
storage_keys: {
...FIXTURE_KEYS,
...COMPAT_STORAGE_KEYS,
},
bridge: {
script_name: appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME,
ui_enabled: appConfig.bridge?.ui?.enabled !== false,
},
application: {
api_url: appConfig.server.api_url,
application_id: appConfig.server.application_id,
application_slug: appConfig.server.application_slug,
local_mode_active: appConfig.server.local_mode_active,
},
personas: users.personas.map((persona) => ({
code: persona.code,
display_name: persona.display_name,
selector: persona.selector || {},
route_hint: buildRouteHint(appConfig, persona),
application: buildApplicationSummary(appConfig, persona),
user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
entitlement_keys: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
entitlements: buildEntitlementRecords(appConfig, persona, entitlementsConfig),
scenario: persona.scenario || {},
browser: {
local_storage: persona.browser?.local_storage || {},
},
})),
};
}
function buildDevAuthBridgeScript(config) {
const serializedConfig = JSON.stringify(config);
return `;(function () {
if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;
const CONFIG = ${serializedConfig};
const STORAGE_KEYS = CONFIG.storage_keys;
const ACTIVE_PERSONA_KEY = STORAGE_KEYS.active_persona;
const TOKEN_USER_KEY = STORAGE_KEYS.sdk_user;
const TOKEN_ACCESS_KEY = STORAGE_KEYS.sdk_access_token;
const TOKEN_REFRESH_KEY = STORAGE_KEYS.sdk_refresh_token;
const LEGACY_USER_KEY = STORAGE_KEYS.legacy_user;
const PERSONA_LOCAL_STORAGE_KEYS = Array.from(new Set(
CONFIG.personas.flatMap((persona) => Object.keys(persona.browser?.local_storage || {}))
));
const personaChangeListeners = [];
const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
function clone(value) {
if (value === undefined || value === null) return value;
return JSON.parse(JSON.stringify(value));
}
function toAbsoluteUrl(input) {
if (input instanceof Request) return new URL(input.url, window.location.origin);
return new URL(String(input), window.location.origin);
}
function getPersonaByCode(code) {
return CONFIG.personas.find((persona) => persona.code === code) || null;
}
function getInitialPersonaCode() {
const fromQuery = new URLSearchParams(window.location.search).get(CONFIG.query_param);
return fromQuery || localStorage.getItem(ACTIVE_PERSONA_KEY) || CONFIG.default_persona;
}
function getCurrentPersona() {
return getPersonaByCode(localStorage.getItem(ACTIVE_PERSONA_KEY)) || getPersonaByCode(getInitialPersonaCode()) || CONFIG.personas[0];
}
function getScenario(namespace) {
const scenario = getCurrentPersona().scenario || {};
if (!namespace) return clone(scenario);
return Object.prototype.hasOwnProperty.call(scenario, namespace) ? clone(scenario[namespace]) : null;
}
function publicPersona(persona) {
if (!persona) return null;
return {
code: persona.code,
display_name: persona.display_name,
selector: clone(persona.selector || {}),
route_hint: persona.route_hint || null,
application: clone(persona.application || CONFIG.application),
user: clone(persona.user),
roles: clone(persona.user?.roles || []),
entitlements: clone(persona.entitlement_keys || []),
scenario: clone(persona.scenario || {})
};
}
function buildPersonaChangeDetail(previousPersona, persona) {
return {
persona: publicPersona(persona),
previous_persona: publicPersona(previousPersona),
user: clone(persona?.user || null),
scenario: clone(persona?.scenario || {})
};
}
function emitPersonaChange(previousPersona, persona) {
const detail = buildPersonaChangeDetail(previousPersona, persona);
personaChangeListeners.slice().forEach((listener) => {
try {
listener(detail);
} catch (error) {
console.error('[spaps-dev-auth] persona change listener failed', error);
}
});
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent('spaps:persona-change', { detail }));
}
}
function onPersonaChange(callback) {
if (typeof callback !== 'function') {
return function noopUnsubscribe() {};
}
personaChangeListeners.push(callback);
return function unsubscribe() {
const index = personaChangeListeners.indexOf(callback);
if (index >= 0) personaChangeListeners.splice(index, 1);
};
}
function clearPersonaBrowserStorage() {
PERSONA_LOCAL_STORAGE_KEYS.forEach((key) => {
localStorage.removeItem(key);
});
}
function persistPersona(persona) {
if (!persona) return;
clearPersonaBrowserStorage();
localStorage.setItem(ACTIVE_PERSONA_KEY, persona.code);
localStorage.setItem(STORAGE_KEYS.persona, persona.code);
localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
localStorage.setItem(STORAGE_KEYS.scenario, JSON.stringify(persona.scenario || {}));
localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(persona.application || CONFIG.application));
localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
auth_mode: CONFIG.auth_mode,
local_mode_active: CONFIG.local_mode_active
}));
localStorage.setItem(STORAGE_KEYS.selector, JSON.stringify(persona.selector || {}));
localStorage.setItem(TOKEN_USER_KEY, JSON.stringify(persona.user));
localStorage.setItem(TOKEN_ACCESS_KEY, persona.tokens.access_token);
localStorage.setItem(TOKEN_REFRESH_KEY, persona.tokens.refresh_token);
localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(persona.user));
Object.entries(persona.browser?.local_storage || {}).forEach(([key, value]) => {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
});
}
function clearSession() {
[ACTIVE_PERSONA_KEY, STORAGE_KEYS.persona, STORAGE_KEYS.profile, STORAGE_KEYS.roles, STORAGE_KEYS.entitlements, STORAGE_KEYS.scenario, STORAGE_KEYS.application, STORAGE_KEYS.runtime, STORAGE_KEYS.selector, TOKEN_USER_KEY, TOKEN_ACCESS_KEY, TOKEN_REFRESH_KEY, LEGACY_USER_KEY, ...PERSONA_LOCAL_STORAGE_KEYS].forEach((key) => {
localStorage.removeItem(key);
});
}
function switchPersona(code, options) {
const nextPersona = getPersonaByCode(code);
if (!nextPersona) return null;
const previousPersona = getCurrentPersona();
persistPersona(nextPersona);
emitPersonaChange(previousPersona, nextPersona);
if (!options || options.reload !== false) window.location.reload();
return nextPersona;
}
function response(body, status) {
return Promise.resolve(new Response(JSON.stringify(body), {
status: status || 200,
headers: { 'Content-Type': 'application/json' }
}));
}
function authEnvelope(persona) {
return {
success: true,
data: {
access_token: persona.tokens.access_token,
refresh_token: persona.tokens.refresh_token,
expires_in: persona.tokens.expires_in,
token_type: persona.tokens.token_type,
user: persona.user
}
};
}
function userEnvelope(persona) {
return {
success: true,
data: {
user: persona.user
}
};
}
function entitlementsEnvelope(persona, requestedKey) {
const matching = requestedKey
? persona.entitlements.filter((entry) => entry.entitlement_key === requestedKey)
: persona.entitlements;
return {
success: true,
data: {
entitlements: matching,
count: matching.length
}
};
}
function entitlementCheckEnvelope(persona, requestedKey) {
const matching = requestedKey
? persona.entitlements.filter((entry) => entry.entitlement_key === requestedKey)
: persona.entitlements;
return {
success: true,
data: {
has_entitlement: matching.length > 0,
entitlements: matching,
entitlement_key: requestedKey || null
}
};
}
async function parseJsonBody(input, init) {
try {
const request = input instanceof Request ? input.clone() : new Request(String(input), init);
const text = await request.text();
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
function resolvePersonaForEmail(email) {
if (!email) return null;
return CONFIG.personas.find((persona) => persona.user.email && persona.user.email.toLowerCase() === String(email).toLowerCase()) || null;
}
function shouldIntercept(url) {
const path = url.pathname;
const supported = path === '/api/auth/login' ||
path === '/api/auth/register' ||
path === '/api/auth/logout' ||
path === '/api/auth/refresh' ||
path === '/api/auth/user' ||
path === '/api/entitlements' ||
path === '/api/entitlements/check';
if (!supported) return false;
return url.origin === window.location.origin || url.origin === CONFIG.api_origin;
}
function installFetchBridge() {
if (!originalFetch || window.__spapsDevAuthFetchInstalled) return;
window.fetch = async function (input, init) {
const url = toAbsoluteUrl(input);
if (!shouldIntercept(url)) {
return originalFetch(input, init);
}
const method = String((init && init.method) || (input instanceof Request && input.method) || 'GET').toUpperCase();
const currentPersona = getCurrentPersona();
if ((url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register') && method === 'POST') {
const body = await parseJsonBody(input, init);
const persona = resolvePersonaForEmail(body.email) || currentPersona;
persistPersona(persona);
return response(authEnvelope(persona), 200);
}
if (url.pathname === '/api/auth/refresh' && method === 'POST') {
persistPersona(currentPersona);
return response(authEnvelope(currentPersona), 200);
}
if (url.pathname === '/api/auth/logout' && method === 'POST') {
clearSession();
return response({ success: true, message: 'Signed out from dev auth bridge' }, 200);
}
if (url.pathname === '/api/auth/user' && method === 'GET') {
persistPersona(currentPersona);
return response(userEnvelope(currentPersona), 200);
}
if (url.pathname === '/api/entitlements' && method === 'GET') {
const requestedKey = url.searchParams.get('entitlement_key');
return response(entitlementsEnvelope(currentPersona, requestedKey), 200);
}
if (url.pathname === '/api/entitlements/check' && method === 'GET') {
const requestedKey = url.searchParams.get('key') || url.searchParams.get('entitlement_key');
return response(entitlementCheckEnvelope(currentPersona, requestedKey), 200);
}
return originalFetch(input, init);
};
window.__spapsDevAuthFetchInstalled = true;
}
function renderOverlay() {
if (CONFIG.bridge.ui_enabled === false || document.getElementById('spaps-dev-auth-overlay')) return;
const container = document.createElement('div');
container.id = 'spaps-dev-auth-overlay';
container.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#111827;color:#f9fafb;padding:10px 12px;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,0.25);font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;display:flex;gap:8px;align-items:center;';
const label = document.createElement('span');
label.textContent = 'SPAPS dev auth';
label.style.fontWeight = '600';
const select = document.createElement('select');
select.style.cssText = 'background:#1f2937;color:#f9fafb;border:1px solid #374151;border-radius:8px;padding:4px 8px;';
CONFIG.personas.forEach((persona) => {
const option = document.createElement('option');
option.value = persona.code;
option.textContent = persona.code;
select.appendChild(option);
});
select.value = getCurrentPersona().code;
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Switch';
button.style.cssText = 'background:#f59e0b;color:#111827;border:0;border-radius:8px;padding:4px 8px;cursor:pointer;font-weight:600;';
button.onclick = function () {
switchPersona(select.value);
};
container.appendChild(label);
container.appendChild(select);
container.appendChild(button);
document.body.appendChild(container);
}
const initialPersona = getCurrentPersona();
persistPersona(initialPersona);
installFetchBridge();
window.__SPAPS_DEV_AUTH__ = {
config: CONFIG,
listPersonas: function () {
return CONFIG.personas.map((persona) => ({
code: persona.code,
display_name: persona.display_name,
entitlements: persona.entitlement_keys,
roles: persona.user.roles || [],
scenario: persona.scenario || {}
}));
},
getCurrentPersona: getCurrentPersona,
getCurrentUser: function () {
return getCurrentPersona().user;
},
getScenario: getScenario,
onPersonaChange: onPersonaChange,
switchPersona: switchPersona,
clearSession: clearSession,
installFetchBridge: installFetchBridge
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderOverlay, { once: true });
} else {
renderOverlay();
}
})();\n`;
}
function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlementsConfig) {
const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
const application = buildApplicationSummary(appConfig, persona);
const localStorage = [
{
name: FIXTURE_KEYS.active_persona,
value: persona.code,
},
{
name: FIXTURE_KEYS.persona,
value: persona.code,
},
{
name: FIXTURE_KEYS.profile,
value: stringifyStorageValue(persona.profile || {}),
},
{
name: FIXTURE_KEYS.roles,
value: stringifyStorageValue(rolesConfig.grants?.[persona.code] || []),
},
{
name: FIXTURE_KEYS.entitlements,
value: stringifyStorageValue(entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || [])),
},
{
name: FIXTURE_KEYS.scenario,
value: stringifyStorageValue(persona.scenario || {}),
},
{
name: FIXTURE_KEYS.application,
value: stringifyStorageValue(application),
},
{
name: FIXTURE_KEYS.runtime,
value: stringifyStorageValue({
running: appConfig.server.running,
local_mode_active: appConfig.server.local_mode_active,
auth_mode: appConfig.auth.mode,
}),
},
{
name: FIXTURE_KEYS.selector,
value: stringifyStorageValue(persona.selector || {}),
},
{
name: COMPAT_STORAGE_KEYS.sdk_user,
value: stringifyStorageValue(user),
},
{
name: COMPAT_STORAGE_KEYS.sdk_access_token,
value: tokens.access_token,
},
{
name: COMPAT_STORAGE_KEYS.sdk_refresh_token,
value: tokens.refresh_token,
},
{
name: COMPAT_STORAGE_KEYS.legacy_user,
value: stringifyStorageValue(user),
},
];
for (const [key, value] of Object.entries(persona.browser?.local_storage || {})) {
localStorage.push({
name: key,
value: stringifyStorageValue(value),
});
}
return {
cookies: [],
origins: [
{
origin: resolveStorageOrigin(appConfig.browser.base_url),
localStorage,
},
],
};
}
function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig, paths) {
const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
const application = buildApplicationSummary(appConfig, persona);
return {
persona: persona.code,
display_name: persona.display_name,
base_url: appConfig.browser.base_url,
route_hint: buildRouteHint(appConfig, persona),
selector: persona.selector || {},
profile: persona.profile || {},
scenario: persona.scenario || {},
roles: rolesConfig.grants?.[persona.code] || [],
entitlements: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
user,
tokens,
application,
artifacts: {
storage_state_path: path.join(paths.browserDir, `${persona.code}.storage-state.json`),
headers_path: path.join(paths.browserDir, `${persona.code}.headers.json`),
context_path: path.join(paths.browserDir, `${persona.code}.context.json`),
},
};
}
function writePersonaArtifacts({
rootDir,
paths,
appConfig,
users,
roles,
entitlements,
personaCode = null,
seedResultsByPersona = {},
serverSync = null,
}) {
const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
const generated = [];
for (const code of selectedCodes) {
const persona = ensurePersona(users, code);
const storageState = buildStorageStateArtifact(appConfig, persona, roles, entitlements);
const headers = buildHeaderArtifact(appConfig, persona);
const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
context.seed = seedResultsByPersona[code] || null;
context.server_sync = serverSync?.personas?.find((entry) => entry.code === code) || null;
writeJson(context.artifacts.storage_state_path, storageState);
writeJson(context.artifacts.headers_path, headers);
writeJson(context.artifacts.context_path, context);
generated.push({
persona: code,
storage_state_path: context.artifacts.storage_state_path,
headers_path: context.artifacts.headers_path,
context_path: context.artifacts.context_path,
route_hint: context.route_hint,
});
}
writeJson(paths.lock, {
schema_version: FIXTURE_SCHEMA_VERSION,
applied_at: new Date().toISOString(),
personas: generated.map((entry) => entry.persona),
base_url: appConfig.browser.base_url,
api_url: appConfig.server.api_url,
local_mode_active: appConfig.server.local_mode_active,
ownership: serverSync
? {
source: serverSync.source || 'spaps-fixtures',
application: {
id: serverSync.application.id,
slug: serverSync.application.slug,
},
personas: serverSync.personas.map((entry) => ({
code: entry.code,
user_id: entry.user.id,
entitlements: entry.entitlements,
})),
}
: null,
});
return generated;
}
function writeDevAuthBridge({ rootDir, paths, appConfig, users, roles, entitlements }) {
const bridgeConfig = buildBridgeConfig(appConfig, users, roles, entitlements);
fs.mkdirSync(paths.publicDir, { recursive: true });
fs.writeFileSync(paths.bridgeScript, buildDevAuthBridgeScript(bridgeConfig));
return {
script_path: paths.bridgeScript,
public_dir: paths.publicDir,
script_name: appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME,
public_url: `/${appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME}`,
};
}
async function applyFixtures({
dir = null,
port = DEFAULT_PORT,
baseUrl = null,
version = '0.0.0',
persona = null,
seed = false,
syncServer = false,
subcommand = 'apply',
} = {}) {
const rootDir = resolveRepoRoot(dir);
const paths = resolveFixturePaths(rootDir);
let bootstrapped = false;
if (!fs.existsSync(paths.fixtureDir)) {
await initFixtureKernel({ dir: rootDir, port, baseUrl, version, force: false });
bootstrapped = true;
}
const kernel = loadFixtureKernel(rootDir);
const runtime = await getServerRuntime({ port });
const starterContract = readExistingStarterContract(paths);
const appConfig = buildAppConfig({
version,
port,
baseUrl: baseUrl || kernel.app.browser?.base_url,
runtime,
starterContract,
});
writeJson(paths.app, appConfig);
writeManagedFile(paths.browserGitignore, '*.json\n!.gitignore\n', {
rootDir,
files_created: [],
files_overwritten: [],
files_skipped: [],
});
const fixtureRunState = buildFixtureRunState(appConfig, runtime);
let resolvedUsers = {
...kernel.users,
personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
};
const resolvedRoles = resolveConfigTemplates(kernel.roles, fixtureRunState);
const resolvedEntitlements = resolveConfigTemplates(kernel.entitlements, fixtureRunState);
const serverSync = syncServer
? await runFixtureServerSync({
appConfig,
runtime,
usersConfig: resolvedUsers,
rolesConfig: resolvedRoles,
entitlementsConfig: resolvedEntitlements,
personaCode: persona,
port,
})
: null;
if (serverSync) {
resolvedUsers = applyServerSnapshotToUsers(resolvedUsers, appConfig, serverSync);
writeJson(paths.app, appConfig);
}
const seeding = seed
? await runFixtureSeeding({
appConfig,
runtime,
usersConfig: resolvedUsers,
personaCode: persona,
fixtureRunState,
port,
})
: null;
if (seeding) {
resolvedUsers = {
...kernel.users,
personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
};
if (serverSync) {
resolvedUsers = applyServerSnapshotToUsers(resolvedUsers, appConfig, serverSync);
}
}
const seedResultsByPersona = Object.fromEntries(
(seeding?.personas || []).map((entry) => [entry.persona, entry])
);
const generated = writePersonaArtifacts({
rootDir,
paths,
appConfig,
users: resolvedUsers,
roles: resolvedRoles,
entitlements: resolvedEntitlements,
personaCode: persona,
seedResultsByPersona,
serverSync,
});
const bridge = writeDevAuthBridge({
rootDir,
paths,
appConfig,
users: resolvedUsers,
roles: resolvedRoles,
entitlements: resolvedEntitlements,
});
return {
success: true,
command: 'fixtures',
subcommand,
root_dir: rootDir,
fixture_dir: paths.fixtureDir,
bootstrapped,
runtime,
server_sync: serverSync,
seeding,
generated: {
personas: generated,
bridge,
},
next_steps: persona
? [
`Use ${generated[0].storage_state_path} as Playwright storageState`,
`Use ${generated[0].headers_path} for extraHTTPHeaders when needed`,
`Include ${bridge.public_url} before your app boots when you want frontend-only persona switching`,
]
: [
'Wire the generated storage-state and headers files into Playwright or your local harness',
`Include ${bridge.public_url} before your app boots to get frontend-only auth/RBAC fixtures`,
'Re-run npx spaps fixtures apply after editing .spaps/*.json',
],
};
}
async function exportStorageState(options = {}) {
const result = await applyFixtures({ ...options, subcommand: 'storage-state' });
const personaArtifact = result.generated.personas[0];
return {
success: true,
command: 'fixtures',
subcommand: 'storage-state',
root_dir: result.root_dir,
fixture_dir: result.fixture_dir,
persona: personaArtifact.persona,
storage_state_path: personaArtifact.storage_state_path,
headers_path: personaArtifact.headers_path,
context_path: personaArtifact.context_path,
route_hint: personaArtifact.route_hint,
bridge: result.generated.bridge,
runtime: result.runtime,
server_sync: result.server_sync,
seeding: result.seeding,
next_steps: result.next_steps,
};
}
async function resetFixtures({
dir = null,
port = DEFAULT_PORT,
baseUrl = null,
version = '0.0.0',
seed = false,
syncServer = false,
} = {}) {
const rootDir = resolveRepoRoot(dir);
const paths = resolveFixturePaths(rootDir);
const removed = cleanupGeneratedBrowserArtifacts(paths);
const initResult = await initFixtureKernel({
dir: rootDir,
port,
baseUrl,
version,
force: true,
});
const applyResult = await applyFixtures({
dir: rootDir,
port,
baseUrl,
version,
seed,
syncServer,
});
return {
success: true,
command: 'fixtures',
subcommand: 'reset',
root_dir: rootDir,
fixture_dir: paths.fixtureDir,
removed,
files_created: initResult.files_created,
files_overwritten: initResult.files_overwritten,
generated: applyResult.generated,
runtime: applyResult.runtime,
server_sync: applyResult.server_sync,
seeding: applyResult.seeding,
next_steps: applyResult.next_steps,
};
}
module.exports = {
DEFAULT_BROWSER_BASE_URL,
FIXTURE_DIRNAME,
FIXTURE_KEYS,
FIXTURE_SCHEMA_VERSION,
applyFixtures,
exportStorageState,
initFixtureKernel,
resetFixtures,
};