spaps 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AI_TOOLS.json +10 -11
  2. package/README.md +267 -110
  3. package/assets/local-runtime/Dockerfile +28 -0
  4. package/assets/local-runtime/alembic/env.py +101 -0
  5. package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
  6. package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
  7. package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
  8. package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
  9. package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
  10. package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
  11. package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
  12. package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
  13. package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
  14. package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
  15. package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
  16. package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
  17. package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
  18. package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
  19. package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
  20. package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
  21. package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
  22. package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
  23. package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
  24. package/assets/local-runtime/alembic.ini +47 -0
  25. package/assets/local-runtime/docker-compose.yml +61 -0
  26. package/assets/local-runtime/manifest.json +8 -0
  27. package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
  28. package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
  29. package/assets/local-runtime/scripts/run-migrations.sh +96 -0
  30. package/package.json +5 -4
  31. package/src/ai-helper.js +176 -234
  32. package/src/ai-tool-spec.js +52 -20
  33. package/src/auth/api-key.js +119 -0
  34. package/src/auth/client-id.js +136 -0
  35. package/src/auth/client.js +169 -0
  36. package/src/auth/credentials.js +110 -0
  37. package/src/auth/device-flow.js +159 -0
  38. package/src/auth/env.js +57 -0
  39. package/src/auth/handlers.js +462 -0
  40. package/src/auth/http.js +74 -0
  41. package/src/cli-dispatcher.js +155 -24
  42. package/src/docs-system.js +7 -7
  43. package/src/error-handler.js +42 -0
  44. package/src/fixture-kernel.js +1143 -0
  45. package/src/handlers.js +252 -15
  46. package/src/help-system.js +3 -1
  47. package/src/local-runtime.js +258 -0
  48. package/src/local-server.js +597 -199
  49. package/src/project-scaffolder.js +441 -0
@@ -0,0 +1,1143 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const { DEFAULT_PORT } = require('./config');
5
+ const { buildBaseUrl, getServerRuntime } = require('./local-runtime');
6
+
7
+ const FIXTURE_SCHEMA_VERSION = 1;
8
+ const FIXTURE_DIRNAME = '.spaps';
9
+ const BROWSER_DIRNAME = 'browser';
10
+ const DEFAULT_BROWSER_BASE_URL = 'http://localhost:3000';
11
+ const DEFAULT_PUBLIC_DIR = 'public';
12
+ const DEFAULT_BRIDGE_SCRIPT_NAME = 'spaps-dev-auth.js';
13
+ const FIXTURE_KEYS = {
14
+ active_persona: 'spaps.fixture.active_persona',
15
+ persona: 'spaps.fixture.persona',
16
+ profile: 'spaps.fixture.profile',
17
+ roles: 'spaps.fixture.roles',
18
+ entitlements: 'spaps.fixture.entitlements',
19
+ application: 'spaps.fixture.application',
20
+ runtime: 'spaps.fixture.runtime',
21
+ selector: 'spaps.fixture.selector',
22
+ };
23
+ const COMPAT_STORAGE_KEYS = {
24
+ sdk_user: 'sweet_potato_user',
25
+ sdk_access_token: 'sweet_potato_access_token',
26
+ sdk_refresh_token: 'sweet_potato_refresh_token',
27
+ legacy_user: 'spaps_user',
28
+ };
29
+
30
+ const DEFAULT_PERSONAS = [
31
+ {
32
+ code: 'user',
33
+ display_name: 'Local User',
34
+ selector: {
35
+ query_param: { _user: 'user' },
36
+ headers: { 'X-Test-User': 'user' },
37
+ },
38
+ profile: {
39
+ user_id: '00000000-0000-0000-0000-000000000001',
40
+ email: 'user@localhost',
41
+ username: 'dev-user',
42
+ tier: 'free',
43
+ },
44
+ permissions: ['view_products'],
45
+ browser: {
46
+ local_storage: {},
47
+ },
48
+ },
49
+ {
50
+ code: 'admin',
51
+ display_name: 'Local Admin',
52
+ selector: {
53
+ query_param: { _user: 'admin' },
54
+ headers: { 'X-Test-User': 'admin' },
55
+ },
56
+ profile: {
57
+ user_id: '5bdb0db2-5ab1-4e2c-999b-1153cc329477',
58
+ email: 'buildooor@gmail.com',
59
+ username: 'admin',
60
+ tier: 'enterprise',
61
+ },
62
+ permissions: [
63
+ 'view_products',
64
+ 'manage_products',
65
+ 'view_users',
66
+ 'manage_users',
67
+ 'view_orders',
68
+ 'manage_orders',
69
+ 'access_admin',
70
+ 'view_analytics',
71
+ 'manage_subscriptions',
72
+ 'manage_system_settings',
73
+ ],
74
+ browser: {
75
+ local_storage: {},
76
+ },
77
+ },
78
+ {
79
+ code: 'premium',
80
+ display_name: 'Premium User',
81
+ selector: {
82
+ query_param: { _user: 'premium' },
83
+ headers: { 'X-Test-User': 'premium' },
84
+ },
85
+ profile: {
86
+ user_id: '00000000-0000-0000-0000-000000000002',
87
+ email: 'premium@localhost',
88
+ username: 'premium-user',
89
+ tier: 'premium',
90
+ },
91
+ permissions: ['view_products'],
92
+ browser: {
93
+ local_storage: {},
94
+ },
95
+ },
96
+ ];
97
+
98
+ const DEFAULT_ROLE_GRANTS = {
99
+ user: ['user'],
100
+ admin: ['admin', 'super_admin'],
101
+ premium: ['user'],
102
+ };
103
+
104
+ const DEFAULT_ENTITLEMENT_GRANTS = {
105
+ user: [],
106
+ admin: ['paid_access', 'admin_console'],
107
+ premium: ['paid_access'],
108
+ };
109
+
110
+ function createCliError(code, message) {
111
+ const error = new Error(message);
112
+ error.code = code;
113
+ return error;
114
+ }
115
+
116
+ function resolveRepoRoot(dir = null) {
117
+ return path.resolve(dir || process.cwd());
118
+ }
119
+
120
+ function resolveFixturePaths(rootDir) {
121
+ const fixtureDir = path.join(rootDir, FIXTURE_DIRNAME);
122
+ const browserDir = path.join(fixtureDir, BROWSER_DIRNAME);
123
+ const publicDir = path.join(rootDir, DEFAULT_PUBLIC_DIR);
124
+ return {
125
+ rootDir,
126
+ fixtureDir,
127
+ browserDir,
128
+ publicDir,
129
+ app: path.join(fixtureDir, 'app.json'),
130
+ users: path.join(fixtureDir, 'users.json'),
131
+ roles: path.join(fixtureDir, 'roles.json'),
132
+ entitlements: path.join(fixtureDir, 'entitlements.json'),
133
+ readme: path.join(fixtureDir, 'README.md'),
134
+ browserGitignore: path.join(browserDir, '.gitignore'),
135
+ bridgeScript: path.join(publicDir, DEFAULT_BRIDGE_SCRIPT_NAME),
136
+ lock: path.join(fixtureDir, 'fixtures.lock.json'),
137
+ starterContract: path.join(rootDir, 'spaps.app.json'),
138
+ };
139
+ }
140
+
141
+ function normalizeBaseUrl(baseUrl = null) {
142
+ const candidate = String(baseUrl || process.env.SPAPS_BROWSER_BASE_URL || DEFAULT_BROWSER_BASE_URL).trim();
143
+ return candidate.replace(/\/+$/, '');
144
+ }
145
+
146
+ function resolveStorageOrigin(baseUrl) {
147
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
148
+ try {
149
+ return new URL(normalizedBaseUrl).origin;
150
+ } catch {
151
+ return normalizedBaseUrl;
152
+ }
153
+ }
154
+
155
+ function readJsonFile(filePath, label) {
156
+ try {
157
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
158
+ } catch (error) {
159
+ throw createCliError('EINVAL', `Could not read ${label} at "${filePath}": ${error.message}`);
160
+ }
161
+ }
162
+
163
+ function readExistingStarterContract(paths) {
164
+ if (!fs.existsSync(paths.starterContract)) {
165
+ return null;
166
+ }
167
+
168
+ try {
169
+ return JSON.parse(fs.readFileSync(paths.starterContract, 'utf8'));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ function buildRuntimeSummary(runtime, port, starterContract = null) {
176
+ const contractLocal = starterContract?.spaps?.local || {};
177
+ const contractApp = starterContract?.spaps?.application || {};
178
+ const apiUrl = runtime?.url || contractLocal.api_url || buildBaseUrl(port);
179
+ const docsUrl = runtime?.docs || contractLocal.docs_url || `${apiUrl}/docs`;
180
+
181
+ return {
182
+ api_url: apiUrl,
183
+ docs_url: docsUrl,
184
+ port,
185
+ running: Boolean(runtime?.running),
186
+ local_mode_active:
187
+ runtime?.running
188
+ ? (runtime.local_mode?.known ? Boolean(runtime.local_mode.active) : null)
189
+ : (typeof contractLocal.local_mode_active === 'boolean' ? contractLocal.local_mode_active : null),
190
+ environment: runtime?.running ? runtime.local_mode?.environment || null : null,
191
+ application_id: contractApp.id || runtime?.local_mode?.test_application?.id || null,
192
+ application_slug: contractApp.slug || runtime?.local_mode?.test_application?.slug || null,
193
+ provisioning_status: contractApp.provisioning_status || null,
194
+ };
195
+ }
196
+
197
+ function buildAppConfig({ version, port, baseUrl, runtime, starterContract }) {
198
+ const server = buildRuntimeSummary(runtime, port, starterContract);
199
+
200
+ return {
201
+ schema_version: FIXTURE_SCHEMA_VERSION,
202
+ generated_by: `spaps@${version}`,
203
+ server,
204
+ browser: {
205
+ base_url: normalizeBaseUrl(baseUrl),
206
+ default_persona: 'user',
207
+ storage_format: 'playwright',
208
+ },
209
+ bridge: {
210
+ enabled: true,
211
+ public_dir: DEFAULT_PUBLIC_DIR,
212
+ script_name: DEFAULT_BRIDGE_SCRIPT_NAME,
213
+ query_param: 'spaps_persona',
214
+ ui: {
215
+ enabled: true,
216
+ },
217
+ },
218
+ auth: {
219
+ mode: server.local_mode_active === true ? 'local_mode' : runtime?.running ? 'application_key' : 'offline',
220
+ selector: {
221
+ query_param: '_user',
222
+ header: 'X-Test-User',
223
+ },
224
+ api_key_header: 'X-API-Key',
225
+ },
226
+ };
227
+ }
228
+
229
+ function buildUsersConfig() {
230
+ return {
231
+ schema_version: FIXTURE_SCHEMA_VERSION,
232
+ default_persona: 'user',
233
+ personas: DEFAULT_PERSONAS,
234
+ };
235
+ }
236
+
237
+ function buildRolesConfig() {
238
+ return {
239
+ schema_version: FIXTURE_SCHEMA_VERSION,
240
+ grants: DEFAULT_ROLE_GRANTS,
241
+ };
242
+ }
243
+
244
+ function buildEntitlementsConfig() {
245
+ return {
246
+ schema_version: FIXTURE_SCHEMA_VERSION,
247
+ grants: DEFAULT_ENTITLEMENT_GRANTS,
248
+ };
249
+ }
250
+
251
+ function buildFixtureReadme() {
252
+ return `# .spaps
253
+
254
+ Repo-local SPAPS auth fixtures live here.
255
+
256
+ What to edit:
257
+
258
+ - \`app.json\`: local SPAPS server and browser target settings
259
+ - \`users.json\`: personas, profile data, selector hints, and custom browser storage
260
+ - \`roles.json\`: persona-to-role grants
261
+ - \`entitlements.json\`: persona-to-entitlement grants
262
+
263
+ Generated files:
264
+
265
+ - \`browser/*.storage-state.json\`: Playwright storageState files
266
+ - \`browser/*.headers.json\`: extraHTTPHeaders companions for local-mode persona routing
267
+ - \`browser/*.context.json\`: merged persona/runtime summary for test harnesses
268
+ - \`public/${DEFAULT_BRIDGE_SCRIPT_NAME}\`: optional browser bridge for frontend-only auth/RBAC clicking around
269
+
270
+ Common workflow:
271
+
272
+ 1. Run \`npx spaps fixtures init\`
273
+ 2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
274
+ 3. Run \`npx spaps fixtures apply\`
275
+ 4. Point Playwright or local scripts at the generated browser artifacts
276
+ 5. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
277
+ `;
278
+ }
279
+
280
+ function writeJson(filePath, value) {
281
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
282
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
283
+ }
284
+
285
+ function writeManagedFile(filePath, content, bookkeeping, { overwrite = true } = {}) {
286
+ const relativePath = path.relative(bookkeeping.rootDir, filePath) || path.basename(filePath);
287
+ if (fs.existsSync(filePath) && !overwrite) {
288
+ bookkeeping.files_skipped.push(relativePath);
289
+ return false;
290
+ }
291
+
292
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
293
+
294
+ if (fs.existsSync(filePath)) {
295
+ bookkeeping.files_overwritten.push(relativePath);
296
+ } else {
297
+ bookkeeping.files_created.push(relativePath);
298
+ }
299
+
300
+ fs.writeFileSync(filePath, content);
301
+ return true;
302
+ }
303
+
304
+ function cleanupGeneratedBrowserArtifacts(paths) {
305
+ if (!fs.existsSync(paths.browserDir)) {
306
+ return [];
307
+ }
308
+
309
+ const removed = [];
310
+ for (const entry of fs.readdirSync(paths.browserDir)) {
311
+ if (entry === '.gitignore') {
312
+ continue;
313
+ }
314
+ const fullPath = path.join(paths.browserDir, entry);
315
+ fs.rmSync(fullPath, { recursive: true, force: true });
316
+ removed.push(path.relative(paths.rootDir, fullPath));
317
+ }
318
+
319
+ if (fs.existsSync(paths.lock)) {
320
+ fs.rmSync(paths.lock, { force: true });
321
+ removed.push(path.relative(paths.rootDir, paths.lock));
322
+ }
323
+
324
+ return removed;
325
+ }
326
+
327
+ async function buildFixtureSeed({ rootDir, port, baseUrl, version }) {
328
+ const paths = resolveFixturePaths(rootDir);
329
+ const starterContract = readExistingStarterContract(paths);
330
+ const runtime = await getServerRuntime({ port });
331
+
332
+ return {
333
+ runtime,
334
+ appConfig: buildAppConfig({ version, port, baseUrl, runtime, starterContract }),
335
+ usersConfig: buildUsersConfig(),
336
+ rolesConfig: buildRolesConfig(),
337
+ entitlementsConfig: buildEntitlementsConfig(),
338
+ };
339
+ }
340
+
341
+ async function initFixtureKernel({
342
+ dir = null,
343
+ port = DEFAULT_PORT,
344
+ baseUrl = null,
345
+ version = '0.0.0',
346
+ force = false,
347
+ } = {}) {
348
+ const rootDir = resolveRepoRoot(dir);
349
+ const paths = resolveFixturePaths(rootDir);
350
+ const bookkeeping = {
351
+ rootDir,
352
+ files_created: [],
353
+ files_overwritten: [],
354
+ files_skipped: [],
355
+ };
356
+ const seed = await buildFixtureSeed({ rootDir, port, baseUrl, version });
357
+
358
+ writeManagedFile(paths.app, `${JSON.stringify(seed.appConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
359
+ writeManagedFile(paths.users, `${JSON.stringify(seed.usersConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
360
+ writeManagedFile(paths.roles, `${JSON.stringify(seed.rolesConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
361
+ writeManagedFile(paths.entitlements, `${JSON.stringify(seed.entitlementsConfig, null, 2)}\n`, bookkeeping, { overwrite: force });
362
+ writeManagedFile(paths.readme, buildFixtureReadme(), bookkeeping, { overwrite: force });
363
+ writeManagedFile(paths.browserGitignore, '*.json\n!.gitignore\n', bookkeeping, { overwrite: force });
364
+
365
+ return {
366
+ success: true,
367
+ command: 'fixtures',
368
+ subcommand: force ? 'reset' : 'init',
369
+ root_dir: rootDir,
370
+ fixture_dir: paths.fixtureDir,
371
+ runtime: seed.runtime,
372
+ files_created: bookkeeping.files_created,
373
+ files_overwritten: bookkeeping.files_overwritten,
374
+ files_skipped: bookkeeping.files_skipped,
375
+ next_steps: [
376
+ 'Edit .spaps/users.json, .spaps/roles.json, or .spaps/entitlements.json as needed',
377
+ 'Run npx spaps fixtures apply',
378
+ ],
379
+ };
380
+ }
381
+
382
+ function loadFixtureKernel(rootDir) {
383
+ const paths = resolveFixturePaths(rootDir);
384
+ if (!fs.existsSync(paths.fixtureDir)) {
385
+ throw createCliError('ENOENT', `No .spaps directory found in "${rootDir}". Run "npx spaps fixtures init" first.`);
386
+ }
387
+
388
+ const app = readJsonFile(paths.app, '.spaps/app.json');
389
+ const users = readJsonFile(paths.users, '.spaps/users.json');
390
+ const roles = readJsonFile(paths.roles, '.spaps/roles.json');
391
+ const entitlements = readJsonFile(paths.entitlements, '.spaps/entitlements.json');
392
+
393
+ if (!Array.isArray(users.personas) || users.personas.length === 0) {
394
+ throw createCliError('EINVAL', '.spaps/users.json must define at least one persona.');
395
+ }
396
+
397
+ return { paths, app, users, roles, entitlements };
398
+ }
399
+
400
+ function ensurePersona(usersConfig, code) {
401
+ const persona = usersConfig.personas.find((entry) => entry.code === code);
402
+ if (!persona) {
403
+ throw createCliError('EINVAL', `Unknown persona "${code}". Add it to .spaps/users.json or choose one of: ${usersConfig.personas.map((entry) => entry.code).join(', ')}.`);
404
+ }
405
+ return persona;
406
+ }
407
+
408
+ function stringifyStorageValue(value) {
409
+ if (typeof value === 'string') {
410
+ return value;
411
+ }
412
+ return JSON.stringify(value);
413
+ }
414
+
415
+ function buildRouteHint(appConfig, persona) {
416
+ const userCode = persona?.selector?.query_param?._user;
417
+ if (appConfig.server.local_mode_active === true && userCode) {
418
+ return `/?_user=${encodeURIComponent(userCode)}`;
419
+ }
420
+ return null;
421
+ }
422
+
423
+ function buildHeaderArtifact(appConfig, persona) {
424
+ return {
425
+ format: 'playwright-extra-http-headers',
426
+ local_mode_active: appConfig.server.local_mode_active === true,
427
+ headers: persona.selector?.headers || {},
428
+ route_hint: buildRouteHint(appConfig, persona),
429
+ note:
430
+ appConfig.server.local_mode_active === true
431
+ ? 'Use these headers with Playwright context.extraHTTPHeaders when you want SPAPS local-mode persona routing.'
432
+ : 'SPAPS local mode is not active. These headers are still emitted so app-specific test harnesses can choose to consume them.',
433
+ };
434
+ }
435
+
436
+ function base64UrlEncodeJson(value) {
437
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
438
+ }
439
+
440
+ function buildFixtureTokens(appConfig, persona, roles) {
441
+ const now = Math.floor(Date.now() / 1000);
442
+ const primaryRole = roles[0] || 'user';
443
+ const header = base64UrlEncodeJson({ alg: 'none', typ: 'JWT' });
444
+ const payload = base64UrlEncodeJson({
445
+ sub: persona.profile?.user_id,
446
+ user_id: persona.profile?.user_id,
447
+ email: persona.profile?.email,
448
+ role: primaryRole,
449
+ roles,
450
+ tier: persona.profile?.tier || 'free',
451
+ app_id: appConfig.server.application_id,
452
+ aud: appConfig.server.application_slug || 'spaps-fixture',
453
+ iss: 'spaps-fixture',
454
+ iat: now,
455
+ exp: now + (60 * 60 * 24 * 30),
456
+ });
457
+
458
+ return {
459
+ access_token: `${header}.${payload}.fixture`,
460
+ refresh_token: `fixture-refresh-${persona.code}`,
461
+ token_type: 'Bearer',
462
+ expires_in: 60 * 60 * 24 * 30,
463
+ };
464
+ }
465
+
466
+ function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
467
+ const entitlementKeys = entitlementsConfig.grants?.[persona.code] || [];
468
+
469
+ return entitlementKeys.map((entitlementKey, index) => ({
470
+ id: `fixture-${persona.code}-${index + 1}`,
471
+ application_id: appConfig.server.application_id,
472
+ beneficiary_user_id: persona.profile?.user_id || null,
473
+ beneficiary_email: persona.profile?.email || null,
474
+ entitlement_key: entitlementKey,
475
+ entitlement_type: 'manual',
476
+ source: 'fixture',
477
+ resource_type: 'user',
478
+ resource_id: null,
479
+ starts_at: '2026-01-01T00:00:00.000Z',
480
+ ends_at: null,
481
+ revoked_at: null,
482
+ metadata: {
483
+ fixture_persona: persona.code,
484
+ },
485
+ }));
486
+ }
487
+
488
+ function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
489
+ const roles = rolesConfig.grants?.[persona.code] || [];
490
+ const entitlements = entitlementsConfig.grants?.[persona.code] || [];
491
+ const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
492
+ const primaryRole = roles[0] || 'user';
493
+
494
+ return {
495
+ id: persona.profile?.user_id || null,
496
+ email: persona.profile?.email || null,
497
+ username: persona.profile?.username || null,
498
+ tier: persona.profile?.tier || 'free',
499
+ role: primaryRole,
500
+ roles,
501
+ permissions,
502
+ is_admin: roles.includes('admin') || roles.includes('super_admin'),
503
+ is_super_admin: roles.includes('super_admin'),
504
+ entitlements,
505
+ active_entitlements: entitlements.map((entitlementKey) => ({ key: entitlementKey })),
506
+ application_id: appConfig.server.application_id || null,
507
+ fixture_persona: persona.code,
508
+ };
509
+ }
510
+
511
+ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
512
+ const apiOrigin = (() => {
513
+ try {
514
+ return new URL(appConfig.server.api_url).origin;
515
+ } catch {
516
+ return appConfig.server.api_url;
517
+ }
518
+ })();
519
+
520
+ return {
521
+ generated_at: new Date().toISOString(),
522
+ api_url: appConfig.server.api_url,
523
+ api_origin: apiOrigin,
524
+ auth_mode: appConfig.auth.mode,
525
+ local_mode_active: appConfig.server.local_mode_active === true,
526
+ default_persona: users.default_persona || appConfig.browser.default_persona || 'user',
527
+ query_param: appConfig.bridge?.query_param || 'spaps_persona',
528
+ storage_keys: {
529
+ ...FIXTURE_KEYS,
530
+ ...COMPAT_STORAGE_KEYS,
531
+ },
532
+ bridge: {
533
+ script_name: appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME,
534
+ ui_enabled: appConfig.bridge?.ui?.enabled !== false,
535
+ },
536
+ application: {
537
+ api_url: appConfig.server.api_url,
538
+ application_id: appConfig.server.application_id,
539
+ application_slug: appConfig.server.application_slug,
540
+ local_mode_active: appConfig.server.local_mode_active,
541
+ },
542
+ personas: users.personas.map((persona) => ({
543
+ code: persona.code,
544
+ display_name: persona.display_name,
545
+ selector: persona.selector || {},
546
+ route_hint: buildRouteHint(appConfig, persona),
547
+ user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
548
+ tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
549
+ entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
550
+ entitlements: buildEntitlementRecords(appConfig, persona, entitlementsConfig),
551
+ browser: {
552
+ local_storage: persona.browser?.local_storage || {},
553
+ },
554
+ })),
555
+ };
556
+ }
557
+
558
+ function buildDevAuthBridgeScript(config) {
559
+ const serializedConfig = JSON.stringify(config);
560
+
561
+ return `;(function () {
562
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;
563
+
564
+ const CONFIG = ${serializedConfig};
565
+ const STORAGE_KEYS = CONFIG.storage_keys;
566
+ const ACTIVE_PERSONA_KEY = STORAGE_KEYS.active_persona;
567
+ const TOKEN_USER_KEY = STORAGE_KEYS.sdk_user;
568
+ const TOKEN_ACCESS_KEY = STORAGE_KEYS.sdk_access_token;
569
+ const TOKEN_REFRESH_KEY = STORAGE_KEYS.sdk_refresh_token;
570
+ const LEGACY_USER_KEY = STORAGE_KEYS.legacy_user;
571
+ const PERSONA_LOCAL_STORAGE_KEYS = Array.from(new Set(
572
+ CONFIG.personas.flatMap((persona) => Object.keys(persona.browser?.local_storage || {}))
573
+ ));
574
+ const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
575
+
576
+ function toAbsoluteUrl(input) {
577
+ if (input instanceof Request) return new URL(input.url, window.location.origin);
578
+ return new URL(String(input), window.location.origin);
579
+ }
580
+
581
+ function getPersonaByCode(code) {
582
+ return CONFIG.personas.find((persona) => persona.code === code) || null;
583
+ }
584
+
585
+ function getInitialPersonaCode() {
586
+ const fromQuery = new URLSearchParams(window.location.search).get(CONFIG.query_param);
587
+ return fromQuery || localStorage.getItem(ACTIVE_PERSONA_KEY) || CONFIG.default_persona;
588
+ }
589
+
590
+ function getCurrentPersona() {
591
+ return getPersonaByCode(localStorage.getItem(ACTIVE_PERSONA_KEY)) || getPersonaByCode(getInitialPersonaCode()) || CONFIG.personas[0];
592
+ }
593
+
594
+ function clearPersonaBrowserStorage() {
595
+ PERSONA_LOCAL_STORAGE_KEYS.forEach((key) => {
596
+ localStorage.removeItem(key);
597
+ });
598
+ }
599
+
600
+ function persistPersona(persona) {
601
+ if (!persona) return;
602
+ clearPersonaBrowserStorage();
603
+ localStorage.setItem(ACTIVE_PERSONA_KEY, persona.code);
604
+ localStorage.setItem(STORAGE_KEYS.persona, persona.code);
605
+ localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
606
+ localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
607
+ localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
608
+ localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(CONFIG.application));
609
+ localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
610
+ auth_mode: CONFIG.auth_mode,
611
+ local_mode_active: CONFIG.local_mode_active
612
+ }));
613
+ localStorage.setItem(STORAGE_KEYS.selector, JSON.stringify(persona.selector || {}));
614
+ localStorage.setItem(TOKEN_USER_KEY, JSON.stringify(persona.user));
615
+ localStorage.setItem(TOKEN_ACCESS_KEY, persona.tokens.access_token);
616
+ localStorage.setItem(TOKEN_REFRESH_KEY, persona.tokens.refresh_token);
617
+ localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(persona.user));
618
+
619
+ Object.entries(persona.browser?.local_storage || {}).forEach(([key, value]) => {
620
+ localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
621
+ });
622
+ }
623
+
624
+ function clearSession() {
625
+ [ACTIVE_PERSONA_KEY, STORAGE_KEYS.persona, STORAGE_KEYS.profile, STORAGE_KEYS.roles, STORAGE_KEYS.entitlements, 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) => {
626
+ localStorage.removeItem(key);
627
+ });
628
+ }
629
+
630
+ function response(body, status) {
631
+ return Promise.resolve(new Response(JSON.stringify(body), {
632
+ status: status || 200,
633
+ headers: { 'Content-Type': 'application/json' }
634
+ }));
635
+ }
636
+
637
+ function authEnvelope(persona) {
638
+ return {
639
+ success: true,
640
+ data: {
641
+ access_token: persona.tokens.access_token,
642
+ refresh_token: persona.tokens.refresh_token,
643
+ expires_in: persona.tokens.expires_in,
644
+ token_type: persona.tokens.token_type,
645
+ user: persona.user
646
+ }
647
+ };
648
+ }
649
+
650
+ function userEnvelope(persona) {
651
+ return {
652
+ success: true,
653
+ data: {
654
+ user: persona.user
655
+ }
656
+ };
657
+ }
658
+
659
+ function entitlementsEnvelope(persona, requestedKey) {
660
+ const matching = requestedKey
661
+ ? persona.entitlements.filter((entry) => entry.entitlement_key === requestedKey)
662
+ : persona.entitlements;
663
+
664
+ return {
665
+ success: true,
666
+ data: {
667
+ entitlements: matching,
668
+ count: matching.length
669
+ }
670
+ };
671
+ }
672
+
673
+ function entitlementCheckEnvelope(persona, requestedKey) {
674
+ const matching = requestedKey
675
+ ? persona.entitlements.filter((entry) => entry.entitlement_key === requestedKey)
676
+ : persona.entitlements;
677
+
678
+ return {
679
+ success: true,
680
+ data: {
681
+ has_entitlement: matching.length > 0,
682
+ entitlements: matching,
683
+ entitlement_key: requestedKey || null
684
+ }
685
+ };
686
+ }
687
+
688
+ async function parseJsonBody(input, init) {
689
+ try {
690
+ const request = input instanceof Request ? input.clone() : new Request(String(input), init);
691
+ const text = await request.text();
692
+ return text ? JSON.parse(text) : {};
693
+ } catch {
694
+ return {};
695
+ }
696
+ }
697
+
698
+ function resolvePersonaForEmail(email) {
699
+ if (!email) return null;
700
+ return CONFIG.personas.find((persona) => persona.user.email && persona.user.email.toLowerCase() === String(email).toLowerCase()) || null;
701
+ }
702
+
703
+ function shouldIntercept(url) {
704
+ const path = url.pathname;
705
+ const supported = path === '/api/auth/login' ||
706
+ path === '/api/auth/register' ||
707
+ path === '/api/auth/logout' ||
708
+ path === '/api/auth/refresh' ||
709
+ path === '/api/auth/user' ||
710
+ path === '/api/entitlements' ||
711
+ path === '/api/entitlements/check';
712
+
713
+ if (!supported) return false;
714
+ return url.origin === window.location.origin || url.origin === CONFIG.api_origin;
715
+ }
716
+
717
+ function installFetchBridge() {
718
+ if (!originalFetch || window.__spapsDevAuthFetchInstalled) return;
719
+
720
+ window.fetch = async function (input, init) {
721
+ const url = toAbsoluteUrl(input);
722
+ if (!shouldIntercept(url)) {
723
+ return originalFetch(input, init);
724
+ }
725
+
726
+ const method = String((init && init.method) || (input instanceof Request && input.method) || 'GET').toUpperCase();
727
+ const currentPersona = getCurrentPersona();
728
+
729
+ if ((url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register') && method === 'POST') {
730
+ const body = await parseJsonBody(input, init);
731
+ const persona = resolvePersonaForEmail(body.email) || currentPersona;
732
+ persistPersona(persona);
733
+ return response(authEnvelope(persona), 200);
734
+ }
735
+
736
+ if (url.pathname === '/api/auth/refresh' && method === 'POST') {
737
+ persistPersona(currentPersona);
738
+ return response(authEnvelope(currentPersona), 200);
739
+ }
740
+
741
+ if (url.pathname === '/api/auth/logout' && method === 'POST') {
742
+ clearSession();
743
+ return response({ success: true, message: 'Signed out from dev auth bridge' }, 200);
744
+ }
745
+
746
+ if (url.pathname === '/api/auth/user' && method === 'GET') {
747
+ persistPersona(currentPersona);
748
+ return response(userEnvelope(currentPersona), 200);
749
+ }
750
+
751
+ if (url.pathname === '/api/entitlements' && method === 'GET') {
752
+ const requestedKey = url.searchParams.get('entitlement_key');
753
+ return response(entitlementsEnvelope(currentPersona, requestedKey), 200);
754
+ }
755
+
756
+ if (url.pathname === '/api/entitlements/check' && method === 'GET') {
757
+ const requestedKey = url.searchParams.get('key') || url.searchParams.get('entitlement_key');
758
+ return response(entitlementCheckEnvelope(currentPersona, requestedKey), 200);
759
+ }
760
+
761
+ return originalFetch(input, init);
762
+ };
763
+
764
+ window.__spapsDevAuthFetchInstalled = true;
765
+ }
766
+
767
+ function renderOverlay() {
768
+ if (CONFIG.bridge.ui_enabled === false || document.getElementById('spaps-dev-auth-overlay')) return;
769
+ const container = document.createElement('div');
770
+ container.id = 'spaps-dev-auth-overlay';
771
+ 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;';
772
+
773
+ const label = document.createElement('span');
774
+ label.textContent = 'SPAPS dev auth';
775
+ label.style.fontWeight = '600';
776
+
777
+ const select = document.createElement('select');
778
+ select.style.cssText = 'background:#1f2937;color:#f9fafb;border:1px solid #374151;border-radius:8px;padding:4px 8px;';
779
+ CONFIG.personas.forEach((persona) => {
780
+ const option = document.createElement('option');
781
+ option.value = persona.code;
782
+ option.textContent = persona.code;
783
+ select.appendChild(option);
784
+ });
785
+ select.value = getCurrentPersona().code;
786
+
787
+ const button = document.createElement('button');
788
+ button.type = 'button';
789
+ button.textContent = 'Switch';
790
+ button.style.cssText = 'background:#f59e0b;color:#111827;border:0;border-radius:8px;padding:4px 8px;cursor:pointer;font-weight:600;';
791
+ button.onclick = function () {
792
+ const nextPersona = getPersonaByCode(select.value);
793
+ if (!nextPersona) return;
794
+ persistPersona(nextPersona);
795
+ window.location.reload();
796
+ };
797
+
798
+ container.appendChild(label);
799
+ container.appendChild(select);
800
+ container.appendChild(button);
801
+ document.body.appendChild(container);
802
+ }
803
+
804
+ const initialPersona = getCurrentPersona();
805
+ persistPersona(initialPersona);
806
+ installFetchBridge();
807
+
808
+ window.__SPAPS_DEV_AUTH__ = {
809
+ config: CONFIG,
810
+ listPersonas: function () {
811
+ return CONFIG.personas.map((persona) => ({
812
+ code: persona.code,
813
+ display_name: persona.display_name,
814
+ entitlements: persona.entitlement_keys,
815
+ roles: persona.user.roles || []
816
+ }));
817
+ },
818
+ getCurrentPersona: getCurrentPersona,
819
+ getCurrentUser: function () {
820
+ return getCurrentPersona().user;
821
+ },
822
+ switchPersona: function (code, options) {
823
+ const nextPersona = getPersonaByCode(code);
824
+ if (!nextPersona) return null;
825
+ persistPersona(nextPersona);
826
+ if (!options || options.reload !== false) window.location.reload();
827
+ return nextPersona;
828
+ },
829
+ clearSession: clearSession,
830
+ installFetchBridge: installFetchBridge
831
+ };
832
+
833
+ if (document.readyState === 'loading') {
834
+ document.addEventListener('DOMContentLoaded', renderOverlay, { once: true });
835
+ } else {
836
+ renderOverlay();
837
+ }
838
+ })();\n`;
839
+ }
840
+
841
+ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlementsConfig) {
842
+ const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
843
+ const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
844
+ const localStorage = [
845
+ {
846
+ name: FIXTURE_KEYS.active_persona,
847
+ value: persona.code,
848
+ },
849
+ {
850
+ name: FIXTURE_KEYS.persona,
851
+ value: persona.code,
852
+ },
853
+ {
854
+ name: FIXTURE_KEYS.profile,
855
+ value: stringifyStorageValue(persona.profile || {}),
856
+ },
857
+ {
858
+ name: FIXTURE_KEYS.roles,
859
+ value: stringifyStorageValue(rolesConfig.grants?.[persona.code] || []),
860
+ },
861
+ {
862
+ name: FIXTURE_KEYS.entitlements,
863
+ value: stringifyStorageValue(entitlementsConfig.grants?.[persona.code] || []),
864
+ },
865
+ {
866
+ name: FIXTURE_KEYS.application,
867
+ value: stringifyStorageValue({
868
+ api_url: appConfig.server.api_url,
869
+ application_id: appConfig.server.application_id,
870
+ application_slug: appConfig.server.application_slug,
871
+ }),
872
+ },
873
+ {
874
+ name: FIXTURE_KEYS.runtime,
875
+ value: stringifyStorageValue({
876
+ running: appConfig.server.running,
877
+ local_mode_active: appConfig.server.local_mode_active,
878
+ auth_mode: appConfig.auth.mode,
879
+ }),
880
+ },
881
+ {
882
+ name: FIXTURE_KEYS.selector,
883
+ value: stringifyStorageValue(persona.selector || {}),
884
+ },
885
+ {
886
+ name: COMPAT_STORAGE_KEYS.sdk_user,
887
+ value: stringifyStorageValue(user),
888
+ },
889
+ {
890
+ name: COMPAT_STORAGE_KEYS.sdk_access_token,
891
+ value: tokens.access_token,
892
+ },
893
+ {
894
+ name: COMPAT_STORAGE_KEYS.sdk_refresh_token,
895
+ value: tokens.refresh_token,
896
+ },
897
+ {
898
+ name: COMPAT_STORAGE_KEYS.legacy_user,
899
+ value: stringifyStorageValue(user),
900
+ },
901
+ ];
902
+
903
+ for (const [key, value] of Object.entries(persona.browser?.local_storage || {})) {
904
+ localStorage.push({
905
+ name: key,
906
+ value: stringifyStorageValue(value),
907
+ });
908
+ }
909
+
910
+ return {
911
+ cookies: [],
912
+ origins: [
913
+ {
914
+ origin: resolveStorageOrigin(appConfig.browser.base_url),
915
+ localStorage,
916
+ },
917
+ ],
918
+ };
919
+ }
920
+
921
+ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig, paths) {
922
+ const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
923
+ const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
924
+ return {
925
+ persona: persona.code,
926
+ display_name: persona.display_name,
927
+ base_url: appConfig.browser.base_url,
928
+ route_hint: buildRouteHint(appConfig, persona),
929
+ selector: persona.selector || {},
930
+ profile: persona.profile || {},
931
+ roles: rolesConfig.grants?.[persona.code] || [],
932
+ entitlements: entitlementsConfig.grants?.[persona.code] || [],
933
+ user,
934
+ tokens,
935
+ application: {
936
+ api_url: appConfig.server.api_url,
937
+ application_id: appConfig.server.application_id,
938
+ application_slug: appConfig.server.application_slug,
939
+ local_mode_active: appConfig.server.local_mode_active,
940
+ },
941
+ artifacts: {
942
+ storage_state_path: path.join(paths.browserDir, `${persona.code}.storage-state.json`),
943
+ headers_path: path.join(paths.browserDir, `${persona.code}.headers.json`),
944
+ context_path: path.join(paths.browserDir, `${persona.code}.context.json`),
945
+ },
946
+ };
947
+ }
948
+
949
+ function writePersonaArtifacts({ rootDir, paths, appConfig, users, roles, entitlements, personaCode = null }) {
950
+ const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
951
+ const generated = [];
952
+
953
+ for (const code of selectedCodes) {
954
+ const persona = ensurePersona(users, code);
955
+ const storageState = buildStorageStateArtifact(appConfig, persona, roles, entitlements);
956
+ const headers = buildHeaderArtifact(appConfig, persona);
957
+ const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
958
+
959
+ writeJson(context.artifacts.storage_state_path, storageState);
960
+ writeJson(context.artifacts.headers_path, headers);
961
+ writeJson(context.artifacts.context_path, context);
962
+
963
+ generated.push({
964
+ persona: code,
965
+ storage_state_path: context.artifacts.storage_state_path,
966
+ headers_path: context.artifacts.headers_path,
967
+ context_path: context.artifacts.context_path,
968
+ route_hint: context.route_hint,
969
+ });
970
+ }
971
+
972
+ writeJson(paths.lock, {
973
+ schema_version: FIXTURE_SCHEMA_VERSION,
974
+ applied_at: new Date().toISOString(),
975
+ personas: generated.map((entry) => entry.persona),
976
+ base_url: appConfig.browser.base_url,
977
+ api_url: appConfig.server.api_url,
978
+ local_mode_active: appConfig.server.local_mode_active,
979
+ });
980
+
981
+ return generated;
982
+ }
983
+
984
+ function writeDevAuthBridge({ rootDir, paths, appConfig, users, roles, entitlements }) {
985
+ const bridgeConfig = buildBridgeConfig(appConfig, users, roles, entitlements);
986
+ fs.mkdirSync(paths.publicDir, { recursive: true });
987
+ fs.writeFileSync(paths.bridgeScript, buildDevAuthBridgeScript(bridgeConfig));
988
+
989
+ return {
990
+ script_path: paths.bridgeScript,
991
+ public_dir: paths.publicDir,
992
+ script_name: appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME,
993
+ public_url: `/${appConfig.bridge?.script_name || DEFAULT_BRIDGE_SCRIPT_NAME}`,
994
+ };
995
+ }
996
+
997
+ async function applyFixtures({
998
+ dir = null,
999
+ port = DEFAULT_PORT,
1000
+ baseUrl = null,
1001
+ version = '0.0.0',
1002
+ persona = null,
1003
+ } = {}) {
1004
+ const rootDir = resolveRepoRoot(dir);
1005
+ const paths = resolveFixturePaths(rootDir);
1006
+ let bootstrapped = false;
1007
+
1008
+ if (!fs.existsSync(paths.fixtureDir)) {
1009
+ await initFixtureKernel({ dir: rootDir, port, baseUrl, version, force: false });
1010
+ bootstrapped = true;
1011
+ }
1012
+
1013
+ const kernel = loadFixtureKernel(rootDir);
1014
+ const runtime = await getServerRuntime({ port });
1015
+ const starterContract = readExistingStarterContract(paths);
1016
+ const appConfig = buildAppConfig({
1017
+ version,
1018
+ port,
1019
+ baseUrl: baseUrl || kernel.app.browser?.base_url,
1020
+ runtime,
1021
+ starterContract,
1022
+ });
1023
+
1024
+ writeJson(paths.app, appConfig);
1025
+ writeManagedFile(paths.browserGitignore, '*.json\n!.gitignore\n', {
1026
+ rootDir,
1027
+ files_created: [],
1028
+ files_overwritten: [],
1029
+ files_skipped: [],
1030
+ });
1031
+
1032
+ const generated = writePersonaArtifacts({
1033
+ rootDir,
1034
+ paths,
1035
+ appConfig,
1036
+ users: kernel.users,
1037
+ roles: kernel.roles,
1038
+ entitlements: kernel.entitlements,
1039
+ personaCode: persona,
1040
+ });
1041
+ const bridge = writeDevAuthBridge({
1042
+ rootDir,
1043
+ paths,
1044
+ appConfig,
1045
+ users: kernel.users,
1046
+ roles: kernel.roles,
1047
+ entitlements: kernel.entitlements,
1048
+ });
1049
+
1050
+ return {
1051
+ success: true,
1052
+ command: 'fixtures',
1053
+ subcommand: persona ? 'storage-state' : 'apply',
1054
+ root_dir: rootDir,
1055
+ fixture_dir: paths.fixtureDir,
1056
+ bootstrapped,
1057
+ runtime,
1058
+ generated: {
1059
+ personas: generated,
1060
+ bridge,
1061
+ },
1062
+ next_steps: persona
1063
+ ? [
1064
+ `Use ${generated[0].storage_state_path} as Playwright storageState`,
1065
+ `Use ${generated[0].headers_path} for extraHTTPHeaders when needed`,
1066
+ `Include ${bridge.public_url} before your app boots when you want frontend-only persona switching`,
1067
+ ]
1068
+ : [
1069
+ 'Wire the generated storage-state and headers files into Playwright or your local harness',
1070
+ `Include ${bridge.public_url} before your app boots to get frontend-only auth/RBAC fixtures`,
1071
+ 'Re-run npx spaps fixtures apply after editing .spaps/*.json',
1072
+ ],
1073
+ };
1074
+ }
1075
+
1076
+ async function exportStorageState(options = {}) {
1077
+ const result = await applyFixtures(options);
1078
+ const personaArtifact = result.generated.personas[0];
1079
+ return {
1080
+ success: true,
1081
+ command: 'fixtures',
1082
+ subcommand: 'storage-state',
1083
+ root_dir: result.root_dir,
1084
+ fixture_dir: result.fixture_dir,
1085
+ persona: personaArtifact.persona,
1086
+ storage_state_path: personaArtifact.storage_state_path,
1087
+ headers_path: personaArtifact.headers_path,
1088
+ context_path: personaArtifact.context_path,
1089
+ route_hint: personaArtifact.route_hint,
1090
+ bridge: result.generated.bridge,
1091
+ runtime: result.runtime,
1092
+ next_steps: result.next_steps,
1093
+ };
1094
+ }
1095
+
1096
+ async function resetFixtures({
1097
+ dir = null,
1098
+ port = DEFAULT_PORT,
1099
+ baseUrl = null,
1100
+ version = '0.0.0',
1101
+ } = {}) {
1102
+ const rootDir = resolveRepoRoot(dir);
1103
+ const paths = resolveFixturePaths(rootDir);
1104
+ const removed = cleanupGeneratedBrowserArtifacts(paths);
1105
+ const initResult = await initFixtureKernel({
1106
+ dir: rootDir,
1107
+ port,
1108
+ baseUrl,
1109
+ version,
1110
+ force: true,
1111
+ });
1112
+ const applyResult = await applyFixtures({
1113
+ dir: rootDir,
1114
+ port,
1115
+ baseUrl,
1116
+ version,
1117
+ });
1118
+
1119
+ return {
1120
+ success: true,
1121
+ command: 'fixtures',
1122
+ subcommand: 'reset',
1123
+ root_dir: rootDir,
1124
+ fixture_dir: paths.fixtureDir,
1125
+ removed,
1126
+ files_created: initResult.files_created,
1127
+ files_overwritten: initResult.files_overwritten,
1128
+ generated: applyResult.generated,
1129
+ runtime: applyResult.runtime,
1130
+ next_steps: applyResult.next_steps,
1131
+ };
1132
+ }
1133
+
1134
+ module.exports = {
1135
+ DEFAULT_BROWSER_BASE_URL,
1136
+ FIXTURE_DIRNAME,
1137
+ FIXTURE_KEYS,
1138
+ FIXTURE_SCHEMA_VERSION,
1139
+ applyFixtures,
1140
+ exportStorageState,
1141
+ initFixtureKernel,
1142
+ resetFixtures,
1143
+ };