spaps 0.7.6 → 0.7.7

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.
@@ -1,5 +1,6 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const axios = require('axios');
3
4
 
4
5
  const { DEFAULT_PORT } = require('./config');
5
6
  const { buildBaseUrl, getServerRuntime } = require('./local-runtime');
@@ -27,7 +28,11 @@ const COMPAT_STORAGE_KEYS = {
27
28
  legacy_user: 'spaps_user',
28
29
  };
29
30
 
30
- const DEFAULT_PERSONAS = [
31
+ function cloneValue(value) {
32
+ return JSON.parse(JSON.stringify(value));
33
+ }
34
+
35
+ const BASE_PERSONAS = [
31
36
  {
32
37
  code: 'user',
33
38
  display_name: 'Local User',
@@ -95,16 +100,521 @@ const DEFAULT_PERSONAS = [
95
100
  },
96
101
  ];
97
102
 
103
+ function getBasePersona(code) {
104
+ const persona = BASE_PERSONAS.find((entry) => entry.code === code);
105
+ if (!persona) {
106
+ throw new Error(`Unknown base persona "${code}"`);
107
+ }
108
+ return persona;
109
+ }
110
+
111
+ function makeDerivedPersona(code, displayName, fromCode, overrides = {}) {
112
+ const base = getBasePersona(fromCode);
113
+ const {
114
+ selector,
115
+ profile,
116
+ permissions,
117
+ browser,
118
+ ...rest
119
+ } = overrides;
120
+
121
+ return {
122
+ ...cloneValue(base),
123
+ ...cloneValue(rest),
124
+ code,
125
+ display_name: displayName,
126
+ selector: selector ? cloneValue(selector) : cloneValue(base.selector),
127
+ profile: {
128
+ ...cloneValue(base.profile),
129
+ ...(profile ? cloneValue(profile) : {}),
130
+ },
131
+ permissions: permissions ? [...permissions] : [...(base.permissions || [])],
132
+ browser: {
133
+ local_storage: {
134
+ ...(base.browser?.local_storage || {}),
135
+ ...((browser && browser.local_storage) || {}),
136
+ },
137
+ },
138
+ };
139
+ }
140
+
141
+ const DEFAULT_PERSONAS = [
142
+ ...BASE_PERSONAS,
143
+ makeDerivedPersona('dayrate-guest', 'Dayrate Guest', 'user', {
144
+ profile: {
145
+ username: 'dayrate-guest',
146
+ },
147
+ scenario: {
148
+ dayrate: {
149
+ mode: 'paid',
150
+ policy_key: null,
151
+ sample_request: {
152
+ date: '2026-05-01',
153
+ slot: 'AM',
154
+ clientEmail: 'guest-fixture@example.com',
155
+ clientName: 'Fixture Guest',
156
+ successUrl: 'https://example.com/dayrate/success',
157
+ cancelUrl: 'https://example.com/dayrate/cancel',
158
+ },
159
+ },
160
+ },
161
+ }),
162
+ makeDerivedPersona('dayrate-entitled', 'Dayrate Entitled', 'user', {
163
+ permissions: ['view_products', 'book_dayrate_free'],
164
+ profile: {
165
+ username: 'dayrate-entitled',
166
+ },
167
+ scenario: {
168
+ dayrate: {
169
+ mode: 'free',
170
+ policy_key: 'fixture-dayrate-free-{{global.run_id}}',
171
+ entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
172
+ free_bookings_remaining: 2,
173
+ sample_request: {
174
+ date: '2026-05-02',
175
+ slot: 'AM',
176
+ clientEmail: 'entitled-fixture@example.com',
177
+ clientName: 'Fixture Entitled',
178
+ successUrl: 'https://example.com/dayrate/success',
179
+ cancelUrl: 'https://example.com/dayrate/cancel',
180
+ policyKey: 'fixture-dayrate-free-{{global.run_id}}',
181
+ userId: '{{persona.profile.user_id}}',
182
+ },
183
+ },
184
+ },
185
+ seed: [
186
+ {
187
+ as: 'admin',
188
+ method: 'PUT',
189
+ path: '/api/dayrate/admin/config',
190
+ body: {
191
+ base_rate: 12000,
192
+ available_days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
193
+ horizon_weeks: 8,
194
+ min_notice_hours: 1,
195
+ hold_duration_minutes: 15,
196
+ timezone: 'America/Toronto',
197
+ currency: 'usd',
198
+ product_name: 'Fixture Dayrate',
199
+ slot_definitions: {
200
+ AM: { start: '09:00', end: '12:00' },
201
+ PM: { start: '13:00', end: '16:00' },
202
+ },
203
+ },
204
+ },
205
+ {
206
+ as: 'admin',
207
+ method: 'POST',
208
+ path: '/api/dayrate/admin/policies',
209
+ expected_statuses: [201, 409],
210
+ body: {
211
+ policyKey: 'fixture-dayrate-free-{{global.run_id}}',
212
+ entitlementKey: 'fixture.dayrate.free.{{global.run_id}}',
213
+ freeBookingsPerPeriod: 2,
214
+ periodDays: 28,
215
+ overageRate: null,
216
+ overageCurrency: 'usd',
217
+ overageProductName: 'Fixture Dayrate Overage',
218
+ rescheduleIsFree: true,
219
+ metadata: {
220
+ source: 'spaps-fixture-seed',
221
+ persona: 'dayrate-entitled',
222
+ },
223
+ },
224
+ },
225
+ {
226
+ as: 'admin',
227
+ method: 'POST',
228
+ path: '/api/entitlements/manual',
229
+ expected_statuses: [201, 409],
230
+ body: {
231
+ beneficiary_user_id: '{{persona.profile.user_id}}',
232
+ beneficiary_email: '{{persona.profile.email}}',
233
+ entitlement_key: 'fixture.dayrate.free.{{global.run_id}}',
234
+ resource_type: 'user',
235
+ reason: 'spaps fixture seed',
236
+ metadata: {
237
+ source: 'spaps-fixture-seed',
238
+ persona: 'dayrate-entitled',
239
+ },
240
+ },
241
+ },
242
+ ],
243
+ }),
244
+ makeDerivedPersona('dayrate-overage', 'Dayrate Overage', 'premium', {
245
+ permissions: ['view_products', 'book_dayrate_free'],
246
+ profile: {
247
+ username: 'dayrate-overage',
248
+ },
249
+ scenario: {
250
+ dayrate: {
251
+ mode: 'overage',
252
+ policy_key: 'fixture-dayrate-overage-{{global.run_id}}',
253
+ entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
254
+ overage_rate: 1500,
255
+ free_bookings_remaining: 0,
256
+ sample_request: {
257
+ date: '2026-05-03',
258
+ slot: 'PM',
259
+ clientEmail: 'overage-fixture@example.com',
260
+ clientName: 'Fixture Overage',
261
+ successUrl: 'https://example.com/dayrate/success',
262
+ cancelUrl: 'https://example.com/dayrate/cancel',
263
+ policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
264
+ userId: '{{persona.profile.user_id}}',
265
+ },
266
+ },
267
+ },
268
+ seed: [
269
+ {
270
+ as: 'admin',
271
+ method: 'POST',
272
+ path: '/api/dayrate/admin/policies',
273
+ expected_statuses: [201, 409],
274
+ body: {
275
+ policyKey: 'fixture-dayrate-overage-{{global.run_id}}',
276
+ entitlementKey: 'fixture.dayrate.overage.{{global.run_id}}',
277
+ freeBookingsPerPeriod: 0,
278
+ periodDays: 28,
279
+ overageRate: 1500,
280
+ overageCurrency: 'usd',
281
+ overageProductName: 'Fixture Dayrate Overage',
282
+ rescheduleIsFree: true,
283
+ metadata: {
284
+ source: 'spaps-fixture-seed',
285
+ persona: 'dayrate-overage',
286
+ },
287
+ },
288
+ },
289
+ {
290
+ as: 'admin',
291
+ method: 'POST',
292
+ path: '/api/entitlements/manual',
293
+ expected_statuses: [201, 409],
294
+ body: {
295
+ beneficiary_user_id: '{{persona.profile.user_id}}',
296
+ beneficiary_email: '{{persona.profile.email}}',
297
+ entitlement_key: 'fixture.dayrate.overage.{{global.run_id}}',
298
+ resource_type: 'user',
299
+ reason: 'spaps fixture seed',
300
+ metadata: {
301
+ source: 'spaps-fixture-seed',
302
+ persona: 'dayrate-overage',
303
+ },
304
+ },
305
+ },
306
+ ],
307
+ }),
308
+ makeDerivedPersona('email-local-safe', 'Email Local Safe', 'user', {
309
+ profile: {
310
+ username: 'email-local-safe',
311
+ },
312
+ scenario: {
313
+ email: {
314
+ template_key: 'fixture-email-safe-{{global.run_id}}',
315
+ send_request: {
316
+ template_key: 'fixture-email-safe-{{global.run_id}}',
317
+ to: 'recipient@example.com',
318
+ context: {
319
+ name: 'Fixture Recipient',
320
+ },
321
+ },
322
+ expected_status: 'short_circuited',
323
+ },
324
+ },
325
+ seed: [
326
+ {
327
+ as: 'admin',
328
+ method: 'POST',
329
+ path: '/api/email/templates',
330
+ expected_statuses: [201, 409],
331
+ body: {
332
+ name: 'Fixture Local Safe Email',
333
+ template_key: 'fixture-email-safe-{{global.run_id}}',
334
+ subject: 'Hello {{name}}',
335
+ html_body: '<p>Hello {{name}}</p>',
336
+ text_body: 'Hello {{name}}',
337
+ from_email: 'noreply@example.com',
338
+ category: 'fixture',
339
+ sample_context: {
340
+ name: 'Fixture Recipient',
341
+ },
342
+ },
343
+ },
344
+ ],
345
+ }),
346
+ makeDerivedPersona('webhook-signature-invalid', 'Webhook Signature Invalid', 'admin', {
347
+ profile: {
348
+ username: 'webhook-signature-invalid',
349
+ },
350
+ scenario: {
351
+ webhooks: {
352
+ invalid_request: {
353
+ path: '/api/webhooks/mailgun/events',
354
+ expected_status: 401,
355
+ body: {
356
+ signature: {
357
+ timestamp: '{{global.epoch}}',
358
+ token: 'fixture-invalid-token',
359
+ signature: 'not-a-real-signature',
360
+ },
361
+ 'event-data': {
362
+ event: 'delivered',
363
+ timestamp: '2026-05-04T09:00:00Z',
364
+ message: {
365
+ headers: {
366
+ 'message-id': 'fixture-invalid-msg',
367
+ },
368
+ },
369
+ },
370
+ },
371
+ },
372
+ },
373
+ },
374
+ }),
375
+ makeDerivedPersona('webhook-replay', 'Webhook Replay', 'admin', {
376
+ profile: {
377
+ username: 'webhook-replay',
378
+ },
379
+ scenario: {
380
+ webhooks: {
381
+ replay_request: {
382
+ path: '/api/webhooks/mailgun/events',
383
+ expected_status: 409,
384
+ signing_key: 'test-key',
385
+ token: 'fixture-replay-token',
386
+ body_template: {
387
+ signature: {
388
+ timestamp: '{{global.epoch}}',
389
+ token: 'fixture-replay-token',
390
+ signature: '{{scenario_signature}}',
391
+ },
392
+ 'event-data': {
393
+ event: 'delivered',
394
+ timestamp: '2026-05-04T09:05:00Z',
395
+ message: {
396
+ headers: {
397
+ 'message-id': 'fixture-replay-msg',
398
+ },
399
+ },
400
+ },
401
+ },
402
+ },
403
+ },
404
+ },
405
+ }),
406
+ makeDerivedPersona('policy-admin-allow', 'Policy Admin Allow', 'admin', {
407
+ scenario: {
408
+ policies: {
409
+ policy_name: 'fixture-admin-read-{{global.run_id}}',
410
+ authorize_request: {
411
+ policy_name: 'fixture-admin-read-{{global.run_id}}',
412
+ },
413
+ },
414
+ },
415
+ seed: [
416
+ {
417
+ as: 'admin',
418
+ method: 'POST',
419
+ path: '/api/policies',
420
+ expected_statuses: [201, 409],
421
+ body: {
422
+ name: 'fixture-admin-read-{{global.run_id}}',
423
+ description: 'Allow admin-only fixture policy',
424
+ effect: 'allow',
425
+ conditions: {
426
+ has_role: {
427
+ role: 'admin',
428
+ },
429
+ },
430
+ priority: 100,
431
+ metadata: {
432
+ source: 'spaps-fixture-seed',
433
+ persona: 'policy-admin-allow',
434
+ },
435
+ },
436
+ },
437
+ ],
438
+ }),
439
+ makeDerivedPersona('policy-denied-no-wallet', 'Policy Denied No Wallet', 'user', {
440
+ permissions: ['view_products'],
441
+ scenario: {
442
+ policies: {
443
+ policy_name: 'fixture-requires-wallet-{{global.run_id}}',
444
+ expected_decision: 'deny',
445
+ authorize_request: {
446
+ policy_name: 'fixture-requires-wallet-{{global.run_id}}',
447
+ },
448
+ },
449
+ },
450
+ seed: [
451
+ {
452
+ as: 'admin',
453
+ method: 'POST',
454
+ path: '/api/policies',
455
+ expected_statuses: [201, 409],
456
+ body: {
457
+ name: 'fixture-requires-wallet-{{global.run_id}}',
458
+ description: 'Require a Solana wallet for access',
459
+ effect: 'allow',
460
+ conditions: {
461
+ has_wallet: {
462
+ chain: 'solana',
463
+ },
464
+ },
465
+ priority: 100,
466
+ metadata: {
467
+ source: 'spaps-fixture-seed',
468
+ persona: 'policy-denied-no-wallet',
469
+ },
470
+ },
471
+ },
472
+ ],
473
+ }),
474
+ makeDerivedPersona('issue-reporter', 'Issue Reporter', 'user', {
475
+ permissions: ['view_products'],
476
+ scenario: {
477
+ issue_reporting: {
478
+ expected_status: 201,
479
+ create_request: {
480
+ target: {
481
+ component_key: 'feedback-button',
482
+ component_label: 'Feedback Button',
483
+ page_url: 'https://example.com/app',
484
+ surface_ref: 'feedback-fab',
485
+ metadata: {
486
+ screenshots: [
487
+ {
488
+ url: 'https://example.com/screenshots/fixture-1.png',
489
+ timestamp: '2026-05-05T09:00:00Z',
490
+ },
491
+ ],
492
+ },
493
+ },
494
+ note: 'Fixture issue report with screenshots for local issue-reporting flows.',
495
+ reporter_role_hint: 'member',
496
+ },
497
+ reply_request: {
498
+ note: 'Fixture follow-up after a support response.',
499
+ reporter_role_hint: 'member',
500
+ },
501
+ },
502
+ },
503
+ seed: [
504
+ {
505
+ as: 'persona',
506
+ method: 'POST',
507
+ path: '/api/v1/issue-reports',
508
+ expected_statuses: [201],
509
+ body: {
510
+ target: {
511
+ component_key: 'feedback-button',
512
+ component_label: 'Feedback Button',
513
+ page_url: 'https://example.com/app',
514
+ surface_ref: 'feedback-fab',
515
+ metadata: {
516
+ screenshots: [
517
+ {
518
+ url: 'https://example.com/screenshots/fixture-1.png',
519
+ timestamp: '2026-05-05T09:00:00Z',
520
+ },
521
+ ],
522
+ },
523
+ },
524
+ note: 'Fixture issue report with screenshots for local issue-reporting flows.',
525
+ reporter_role_hint: 'member',
526
+ },
527
+ capture: {
528
+ existing_issue_report_id: 'id',
529
+ existing_issue_case_id: 'linked_case.case_id',
530
+ },
531
+ },
532
+ ],
533
+ }),
534
+ makeDerivedPersona('issue-reporter-blocked', 'Issue Reporter Blocked', 'user', {
535
+ permissions: [],
536
+ selector: {
537
+ query_param: { _user: 'user' },
538
+ headers: {
539
+ 'X-Test-User': 'user',
540
+ 'X-API-Key': '{{seed.blocked_app_api_key}}',
541
+ },
542
+ },
543
+ application: {
544
+ application_id: '{{seed.blocked_app_id}}',
545
+ application_slug: '{{seed.blocked_app_slug}}',
546
+ api_key: '{{seed.blocked_app_api_key}}',
547
+ },
548
+ scenario: {
549
+ issue_reporting: {
550
+ expected_status: 403,
551
+ required_capability: 'issue_reporting',
552
+ create_request: {
553
+ target: {
554
+ component_key: 'feedback-button',
555
+ component_label: 'Feedback Button',
556
+ page_url: 'https://example.com/app',
557
+ surface_ref: 'feedback-fab',
558
+ metadata: {},
559
+ },
560
+ note: 'Blocked issue reporter fixture should fail until capability is granted.',
561
+ reporter_role_hint: 'member',
562
+ },
563
+ },
564
+ },
565
+ seed: [
566
+ {
567
+ as: 'admin',
568
+ method: 'POST',
569
+ path: '/api/admin/create-app',
570
+ expected_statuses: [201],
571
+ body: {
572
+ name: 'Fixture Issue Reporting Blocked {{global.run_id}}',
573
+ slug: 'fixture-ir-blocked-{{global.run_id}}',
574
+ settings: {
575
+ issue_reporting_required_capability: 'issue_reporting',
576
+ },
577
+ },
578
+ capture: {
579
+ blocked_app_api_key: 'api_key',
580
+ blocked_app_id: 'application.id',
581
+ blocked_app_slug: 'application.slug',
582
+ },
583
+ },
584
+ ],
585
+ }),
586
+ ];
587
+
98
588
  const DEFAULT_ROLE_GRANTS = {
99
589
  user: ['user'],
100
590
  admin: ['admin', 'super_admin'],
101
591
  premium: ['user'],
592
+ 'dayrate-guest': ['user'],
593
+ 'dayrate-entitled': ['user'],
594
+ 'dayrate-overage': ['user'],
595
+ 'email-local-safe': ['user'],
596
+ 'webhook-signature-invalid': ['admin', 'super_admin'],
597
+ 'webhook-replay': ['admin', 'super_admin'],
598
+ 'policy-admin-allow': ['admin', 'super_admin'],
599
+ 'policy-denied-no-wallet': ['user'],
600
+ 'issue-reporter': ['user'],
601
+ 'issue-reporter-blocked': ['user'],
102
602
  };
103
603
 
104
604
  const DEFAULT_ENTITLEMENT_GRANTS = {
105
605
  user: [],
106
606
  admin: ['paid_access', 'admin_console'],
107
607
  premium: ['paid_access'],
608
+ 'dayrate-guest': [],
609
+ 'dayrate-entitled': ['fixture.dayrate.free.{{global.run_id}}'],
610
+ 'dayrate-overage': ['fixture.dayrate.overage.{{global.run_id}}'],
611
+ 'email-local-safe': [],
612
+ 'webhook-signature-invalid': [],
613
+ 'webhook-replay': [],
614
+ 'policy-admin-allow': [],
615
+ 'policy-denied-no-wallet': [],
616
+ 'issue-reporter': [],
617
+ 'issue-reporter-blocked': [],
108
618
  };
109
619
 
110
620
  function createCliError(code, message) {
@@ -256,7 +766,7 @@ Repo-local SPAPS auth fixtures live here.
256
766
  What to edit:
257
767
 
258
768
  - \`app.json\`: local SPAPS server and browser target settings
259
- - \`users.json\`: personas, profile data, selector hints, and custom browser storage
769
+ - \`users.json\`: personas, profile data, scenario metadata, optional seed steps, selector hints, and custom browser storage
260
770
  - \`roles.json\`: persona-to-role grants
261
771
  - \`entitlements.json\`: persona-to-entitlement grants
262
772
 
@@ -272,8 +782,14 @@ Common workflow:
272
782
  1. Run \`npx spaps fixtures init\`
273
783
  2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
274
784
  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
785
+ 4. Run \`npx spaps fixtures apply --seed --persona <code>\` when a domain persona needs real local DB state
786
+ 5. Point Playwright or local scripts at the generated browser artifacts
787
+ 6. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
788
+
789
+ Notes:
790
+
791
+ - \`--seed\` only runs against a reachable SPAPS server with local mode active
792
+ - Seed steps use the persona metadata already in \`.spaps/users.json\`; no extra backend-only fixture routes are required
277
793
  `;
278
794
  }
279
795
 
@@ -405,6 +921,322 @@ function ensurePersona(usersConfig, code) {
405
921
  return persona;
406
922
  }
407
923
 
924
+ function buildFixtureRunState(appConfig, runtime) {
925
+ const now = new Date();
926
+ const generatedAt = now.toISOString();
927
+ return {
928
+ global: {
929
+ generated_at: generatedAt,
930
+ epoch: Math.floor(now.getTime() / 1000),
931
+ run_id: generatedAt
932
+ .replace(/[-:]/g, '')
933
+ .replace(/\.\d+Z$/, 'z')
934
+ .replace('T', 't'),
935
+ },
936
+ runtime: {
937
+ api_url: appConfig.server.api_url,
938
+ application_id: appConfig.server.application_id,
939
+ application_slug: appConfig.server.application_slug,
940
+ local_mode_active: appConfig.server.local_mode_active,
941
+ running: appConfig.server.running,
942
+ environment: runtime?.local_mode?.environment || appConfig.server.environment || null,
943
+ },
944
+ seed: {},
945
+ };
946
+ }
947
+
948
+ function getNestedValue(source, dottedPath) {
949
+ if (!source || typeof source !== 'object' || !dottedPath) {
950
+ return undefined;
951
+ }
952
+
953
+ return String(dottedPath)
954
+ .split('.')
955
+ .filter(Boolean)
956
+ .reduce((current, segment) => {
957
+ if (current === undefined || current === null) {
958
+ return undefined;
959
+ }
960
+
961
+ if (Array.isArray(current)) {
962
+ const index = Number(segment);
963
+ return Number.isInteger(index) ? current[index] : undefined;
964
+ }
965
+
966
+ return current[segment];
967
+ }, source);
968
+ }
969
+
970
+ function resolveTemplateLookup(context, expression) {
971
+ const lookup = String(expression || '').trim();
972
+ if (!lookup) {
973
+ return undefined;
974
+ }
975
+
976
+ if (!lookup.includes('.') && Object.prototype.hasOwnProperty.call(context, lookup)) {
977
+ return context[lookup];
978
+ }
979
+
980
+ return getNestedValue(context, lookup);
981
+ }
982
+
983
+ function renderTemplateValue(value, context) {
984
+ if (typeof value === 'string') {
985
+ const matches = [...value.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)];
986
+ if (matches.length === 0) {
987
+ return value;
988
+ }
989
+
990
+ if (matches.length === 1 && matches[0][0] === value) {
991
+ const resolved = resolveTemplateLookup(context, matches[0][1]);
992
+ return resolved === undefined ? value : cloneValue(resolved);
993
+ }
994
+
995
+ return value.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, expression) => {
996
+ const resolved = resolveTemplateLookup(context, expression);
997
+ return resolved === undefined ? match : String(resolved);
998
+ });
999
+ }
1000
+
1001
+ if (Array.isArray(value)) {
1002
+ return value.map((entry) => renderTemplateValue(entry, context));
1003
+ }
1004
+
1005
+ if (value && typeof value === 'object') {
1006
+ return Object.fromEntries(
1007
+ Object.entries(value).map(([key, entry]) => [key, renderTemplateValue(entry, context)])
1008
+ );
1009
+ }
1010
+
1011
+ return value;
1012
+ }
1013
+
1014
+ function unwrapEnvelope(payload) {
1015
+ if (
1016
+ payload &&
1017
+ typeof payload === 'object' &&
1018
+ payload.success === true &&
1019
+ Object.prototype.hasOwnProperty.call(payload, 'data')
1020
+ ) {
1021
+ return payload.data;
1022
+ }
1023
+
1024
+ return payload;
1025
+ }
1026
+
1027
+ function describeStatuses(statuses = []) {
1028
+ if (!Array.isArray(statuses) || statuses.length === 0) {
1029
+ return '2xx';
1030
+ }
1031
+ return statuses.join(', ');
1032
+ }
1033
+
1034
+ function buildApplicationSummary(appConfig, persona = {}) {
1035
+ const override = persona.application || {};
1036
+ return {
1037
+ api_url: override.api_url || appConfig.server.api_url,
1038
+ application_id: override.application_id || override.id || appConfig.server.application_id || null,
1039
+ application_slug: override.application_slug || override.slug || appConfig.server.application_slug || null,
1040
+ api_key: override.api_key || null,
1041
+ local_mode_active: appConfig.server.local_mode_active,
1042
+ };
1043
+ }
1044
+
1045
+ function buildPersonaTemplateContext(appConfig, persona, fixtureRunState) {
1046
+ return {
1047
+ global: fixtureRunState.global,
1048
+ runtime: fixtureRunState.runtime,
1049
+ seed: fixtureRunState.seed,
1050
+ persona,
1051
+ application: buildApplicationSummary(appConfig, persona),
1052
+ scenario: persona.scenario || {},
1053
+ };
1054
+ }
1055
+
1056
+ function resolvePersonaDefinition(appConfig, persona, fixtureRunState) {
1057
+ return renderTemplateValue(cloneValue(persona), buildPersonaTemplateContext(appConfig, persona, fixtureRunState));
1058
+ }
1059
+
1060
+ function resolveConfigTemplates(config, fixtureRunState) {
1061
+ return renderTemplateValue(cloneValue(config), fixtureRunState);
1062
+ }
1063
+
1064
+ function resolveSeedActor({ appConfig, usersConfig, persona, actor = 'persona', fixtureRunState }) {
1065
+ const actorCode = actor === 'persona' ? persona.code : actor;
1066
+ const actorPersona = ensurePersona(usersConfig, actorCode);
1067
+ return resolvePersonaDefinition(appConfig, actorPersona, fixtureRunState);
1068
+ }
1069
+
1070
+ function buildSeedUrl(apiUrl, requestPath, queryParams = {}) {
1071
+ const url = new URL(requestPath, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
1072
+ for (const [key, value] of Object.entries(queryParams || {})) {
1073
+ if (value === undefined || value === null || value === '') {
1074
+ continue;
1075
+ }
1076
+ url.searchParams.set(key, String(value));
1077
+ }
1078
+ return url.toString();
1079
+ }
1080
+
1081
+ function assertSeedingRuntime(runtime, port) {
1082
+ if (!runtime?.running) {
1083
+ throw createCliError(
1084
+ 'ESEED',
1085
+ `Fixture seeding requires a running SPAPS server on port ${port}. Start it with "npx spaps local --port ${port}" or omit --seed.`
1086
+ );
1087
+ }
1088
+
1089
+ if (runtime.local_mode?.active !== true) {
1090
+ throw createCliError(
1091
+ 'ESEED',
1092
+ 'Fixture seeding requires SPAPS local mode so persona routing can use X-Test-User headers. Enable local mode or omit --seed.'
1093
+ );
1094
+ }
1095
+ }
1096
+
1097
+ async function runSeedSequence({
1098
+ appConfig,
1099
+ usersConfig,
1100
+ persona,
1101
+ fixtureRunState,
1102
+ }) {
1103
+ const steps = Array.isArray(persona.seed) ? persona.seed : [];
1104
+ if (steps.length === 0) {
1105
+ return null;
1106
+ }
1107
+
1108
+ const requests = [];
1109
+ const captures = {};
1110
+
1111
+ for (const step of steps) {
1112
+ const resolvedPersona = resolvePersonaDefinition(appConfig, persona, fixtureRunState);
1113
+ const actor = resolveSeedActor({
1114
+ appConfig,
1115
+ usersConfig,
1116
+ persona,
1117
+ actor: step.as || 'persona',
1118
+ fixtureRunState,
1119
+ });
1120
+ const templateContext = buildPersonaTemplateContext(appConfig, resolvedPersona, fixtureRunState);
1121
+ const resolvedStep = renderTemplateValue(cloneValue(step), templateContext);
1122
+ const application = buildApplicationSummary(appConfig, actor);
1123
+ const headers = {
1124
+ Accept: 'application/json',
1125
+ ...(resolvedStep.body ? { 'Content-Type': 'application/json' } : {}),
1126
+ ...(actor.selector?.headers || {}),
1127
+ };
1128
+
1129
+ if (application.api_key && !headers['X-API-Key']) {
1130
+ headers['X-API-Key'] = application.api_key;
1131
+ }
1132
+
1133
+ const url = buildSeedUrl(
1134
+ appConfig.server.api_url,
1135
+ resolvedStep.path,
1136
+ actor.selector?.query_param || {}
1137
+ );
1138
+
1139
+ let response;
1140
+ try {
1141
+ response = await axios({
1142
+ method: resolvedStep.method || 'GET',
1143
+ url,
1144
+ data: resolvedStep.body || null,
1145
+ headers,
1146
+ timeout: 5000,
1147
+ validateStatus: () => true,
1148
+ });
1149
+ } catch (error) {
1150
+ throw createCliError(
1151
+ 'ESEED',
1152
+ `Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: ${error.message}`
1153
+ );
1154
+ }
1155
+
1156
+ const expectedStatuses =
1157
+ Array.isArray(resolvedStep.expected_statuses) && resolvedStep.expected_statuses.length > 0
1158
+ ? resolvedStep.expected_statuses
1159
+ : null;
1160
+ const ok = expectedStatuses
1161
+ ? expectedStatuses.includes(response.status)
1162
+ : response.status >= 200 && response.status < 300;
1163
+
1164
+ if (!ok) {
1165
+ throw createCliError(
1166
+ 'ESEED',
1167
+ `Fixture seed request for persona "${persona.code}" failed at ${resolvedStep.method || 'GET'} ${resolvedStep.path}: expected ${describeStatuses(expectedStatuses)}, received ${response.status}.`
1168
+ );
1169
+ }
1170
+
1171
+ const payload = unwrapEnvelope(response.data);
1172
+ const captured = {};
1173
+ for (const [key, lookup] of Object.entries(resolvedStep.capture || {})) {
1174
+ const capturedValue = getNestedValue(payload, lookup);
1175
+ if (capturedValue === undefined) {
1176
+ throw createCliError(
1177
+ 'ESEED',
1178
+ `Fixture seed request for persona "${persona.code}" could not capture "${key}" from "${lookup}" at ${resolvedStep.method || 'GET'} ${resolvedStep.path}.`
1179
+ );
1180
+ }
1181
+
1182
+ fixtureRunState.seed[key] = capturedValue;
1183
+ captures[key] = capturedValue;
1184
+ captured[key] = capturedValue;
1185
+ }
1186
+
1187
+ requests.push({
1188
+ as: actor.code,
1189
+ method: resolvedStep.method || 'GET',
1190
+ path: resolvedStep.path,
1191
+ status: response.status,
1192
+ expected_statuses: expectedStatuses,
1193
+ captured,
1194
+ });
1195
+ }
1196
+
1197
+ return {
1198
+ persona: persona.code,
1199
+ request_count: requests.length,
1200
+ captures,
1201
+ requests,
1202
+ };
1203
+ }
1204
+
1205
+ async function runFixtureSeeding({
1206
+ appConfig,
1207
+ runtime,
1208
+ usersConfig,
1209
+ personaCode = null,
1210
+ fixtureRunState,
1211
+ port,
1212
+ }) {
1213
+ assertSeedingRuntime(runtime, port);
1214
+
1215
+ const selectedPersonas = personaCode
1216
+ ? [ensurePersona(usersConfig, personaCode)]
1217
+ : usersConfig.personas;
1218
+ const seeded = [];
1219
+
1220
+ for (const persona of selectedPersonas) {
1221
+ const result = await runSeedSequence({
1222
+ appConfig,
1223
+ usersConfig,
1224
+ persona,
1225
+ fixtureRunState,
1226
+ });
1227
+
1228
+ if (result) {
1229
+ seeded.push(result);
1230
+ }
1231
+ }
1232
+
1233
+ return {
1234
+ enabled: true,
1235
+ personas: seeded,
1236
+ captures: { ...fixtureRunState.seed },
1237
+ };
1238
+ }
1239
+
408
1240
  function stringifyStorageValue(value) {
409
1241
  if (typeof value === 'string') {
410
1242
  return value;
@@ -440,6 +1272,7 @@ function base64UrlEncodeJson(value) {
440
1272
  function buildFixtureTokens(appConfig, persona, roles) {
441
1273
  const now = Math.floor(Date.now() / 1000);
442
1274
  const primaryRole = roles[0] || 'user';
1275
+ const application = buildApplicationSummary(appConfig, persona);
443
1276
  const header = base64UrlEncodeJson({ alg: 'none', typ: 'JWT' });
444
1277
  const payload = base64UrlEncodeJson({
445
1278
  sub: persona.profile?.user_id,
@@ -448,8 +1281,8 @@ function buildFixtureTokens(appConfig, persona, roles) {
448
1281
  role: primaryRole,
449
1282
  roles,
450
1283
  tier: persona.profile?.tier || 'free',
451
- app_id: appConfig.server.application_id,
452
- aud: appConfig.server.application_slug || 'spaps-fixture',
1284
+ app_id: application.application_id,
1285
+ aud: application.application_slug || 'spaps-fixture',
453
1286
  iss: 'spaps-fixture',
454
1287
  iat: now,
455
1288
  exp: now + (60 * 60 * 24 * 30),
@@ -465,10 +1298,11 @@ function buildFixtureTokens(appConfig, persona, roles) {
465
1298
 
466
1299
  function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
467
1300
  const entitlementKeys = entitlementsConfig.grants?.[persona.code] || [];
1301
+ const application = buildApplicationSummary(appConfig, persona);
468
1302
 
469
1303
  return entitlementKeys.map((entitlementKey, index) => ({
470
1304
  id: `fixture-${persona.code}-${index + 1}`,
471
- application_id: appConfig.server.application_id,
1305
+ application_id: application.application_id,
472
1306
  beneficiary_user_id: persona.profile?.user_id || null,
473
1307
  beneficiary_email: persona.profile?.email || null,
474
1308
  entitlement_key: entitlementKey,
@@ -490,6 +1324,7 @@ function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
490
1324
  const entitlements = entitlementsConfig.grants?.[persona.code] || [];
491
1325
  const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
492
1326
  const primaryRole = roles[0] || 'user';
1327
+ const application = buildApplicationSummary(appConfig, persona);
493
1328
 
494
1329
  return {
495
1330
  id: persona.profile?.user_id || null,
@@ -503,7 +1338,7 @@ function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
503
1338
  is_super_admin: roles.includes('super_admin'),
504
1339
  entitlements,
505
1340
  active_entitlements: entitlements.map((entitlementKey) => ({ key: entitlementKey })),
506
- application_id: appConfig.server.application_id || null,
1341
+ application_id: application.application_id || null,
507
1342
  fixture_persona: persona.code,
508
1343
  };
509
1344
  }
@@ -544,6 +1379,7 @@ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
544
1379
  display_name: persona.display_name,
545
1380
  selector: persona.selector || {},
546
1381
  route_hint: buildRouteHint(appConfig, persona),
1382
+ application: buildApplicationSummary(appConfig, persona),
547
1383
  user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
548
1384
  tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
549
1385
  entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
@@ -605,7 +1441,7 @@ function buildDevAuthBridgeScript(config) {
605
1441
  localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
606
1442
  localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
607
1443
  localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
608
- localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(CONFIG.application));
1444
+ localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(persona.application || CONFIG.application));
609
1445
  localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
610
1446
  auth_mode: CONFIG.auth_mode,
611
1447
  local_mode_active: CONFIG.local_mode_active
@@ -841,6 +1677,7 @@ function buildDevAuthBridgeScript(config) {
841
1677
  function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlementsConfig) {
842
1678
  const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
843
1679
  const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
1680
+ const application = buildApplicationSummary(appConfig, persona);
844
1681
  const localStorage = [
845
1682
  {
846
1683
  name: FIXTURE_KEYS.active_persona,
@@ -864,11 +1701,7 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
864
1701
  },
865
1702
  {
866
1703
  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
- }),
1704
+ value: stringifyStorageValue(application),
872
1705
  },
873
1706
  {
874
1707
  name: FIXTURE_KEYS.runtime,
@@ -921,6 +1754,7 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
921
1754
  function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig, paths) {
922
1755
  const user = buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig);
923
1756
  const tokens = buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []);
1757
+ const application = buildApplicationSummary(appConfig, persona);
924
1758
  return {
925
1759
  persona: persona.code,
926
1760
  display_name: persona.display_name,
@@ -928,16 +1762,12 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
928
1762
  route_hint: buildRouteHint(appConfig, persona),
929
1763
  selector: persona.selector || {},
930
1764
  profile: persona.profile || {},
1765
+ scenario: persona.scenario || {},
931
1766
  roles: rolesConfig.grants?.[persona.code] || [],
932
1767
  entitlements: entitlementsConfig.grants?.[persona.code] || [],
933
1768
  user,
934
1769
  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
- },
1770
+ application,
941
1771
  artifacts: {
942
1772
  storage_state_path: path.join(paths.browserDir, `${persona.code}.storage-state.json`),
943
1773
  headers_path: path.join(paths.browserDir, `${persona.code}.headers.json`),
@@ -946,7 +1776,16 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
946
1776
  };
947
1777
  }
948
1778
 
949
- function writePersonaArtifacts({ rootDir, paths, appConfig, users, roles, entitlements, personaCode = null }) {
1779
+ function writePersonaArtifacts({
1780
+ rootDir,
1781
+ paths,
1782
+ appConfig,
1783
+ users,
1784
+ roles,
1785
+ entitlements,
1786
+ personaCode = null,
1787
+ seedResultsByPersona = {},
1788
+ }) {
950
1789
  const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
951
1790
  const generated = [];
952
1791
 
@@ -955,6 +1794,7 @@ function writePersonaArtifacts({ rootDir, paths, appConfig, users, roles, entitl
955
1794
  const storageState = buildStorageStateArtifact(appConfig, persona, roles, entitlements);
956
1795
  const headers = buildHeaderArtifact(appConfig, persona);
957
1796
  const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
1797
+ context.seed = seedResultsByPersona[code] || null;
958
1798
 
959
1799
  writeJson(context.artifacts.storage_state_path, storageState);
960
1800
  writeJson(context.artifacts.headers_path, headers);
@@ -1000,6 +1840,8 @@ async function applyFixtures({
1000
1840
  baseUrl = null,
1001
1841
  version = '0.0.0',
1002
1842
  persona = null,
1843
+ seed = false,
1844
+ subcommand = 'apply',
1003
1845
  } = {}) {
1004
1846
  const rootDir = resolveRepoRoot(dir);
1005
1847
  const paths = resolveFixturePaths(rootDir);
@@ -1029,32 +1871,55 @@ async function applyFixtures({
1029
1871
  files_skipped: [],
1030
1872
  });
1031
1873
 
1874
+ const fixtureRunState = buildFixtureRunState(appConfig, runtime);
1875
+ const seeding = seed
1876
+ ? await runFixtureSeeding({
1877
+ appConfig,
1878
+ runtime,
1879
+ usersConfig: kernel.users,
1880
+ personaCode: persona,
1881
+ fixtureRunState,
1882
+ port,
1883
+ })
1884
+ : null;
1885
+ const resolvedUsers = {
1886
+ ...kernel.users,
1887
+ personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
1888
+ };
1889
+ const resolvedRoles = resolveConfigTemplates(kernel.roles, fixtureRunState);
1890
+ const resolvedEntitlements = resolveConfigTemplates(kernel.entitlements, fixtureRunState);
1891
+ const seedResultsByPersona = Object.fromEntries(
1892
+ (seeding?.personas || []).map((entry) => [entry.persona, entry])
1893
+ );
1894
+
1032
1895
  const generated = writePersonaArtifacts({
1033
1896
  rootDir,
1034
1897
  paths,
1035
1898
  appConfig,
1036
- users: kernel.users,
1037
- roles: kernel.roles,
1038
- entitlements: kernel.entitlements,
1899
+ users: resolvedUsers,
1900
+ roles: resolvedRoles,
1901
+ entitlements: resolvedEntitlements,
1039
1902
  personaCode: persona,
1903
+ seedResultsByPersona,
1040
1904
  });
1041
1905
  const bridge = writeDevAuthBridge({
1042
1906
  rootDir,
1043
1907
  paths,
1044
1908
  appConfig,
1045
- users: kernel.users,
1046
- roles: kernel.roles,
1047
- entitlements: kernel.entitlements,
1909
+ users: resolvedUsers,
1910
+ roles: resolvedRoles,
1911
+ entitlements: resolvedEntitlements,
1048
1912
  });
1049
1913
 
1050
1914
  return {
1051
1915
  success: true,
1052
1916
  command: 'fixtures',
1053
- subcommand: persona ? 'storage-state' : 'apply',
1917
+ subcommand,
1054
1918
  root_dir: rootDir,
1055
1919
  fixture_dir: paths.fixtureDir,
1056
1920
  bootstrapped,
1057
1921
  runtime,
1922
+ seeding,
1058
1923
  generated: {
1059
1924
  personas: generated,
1060
1925
  bridge,
@@ -1074,7 +1939,7 @@ async function applyFixtures({
1074
1939
  }
1075
1940
 
1076
1941
  async function exportStorageState(options = {}) {
1077
- const result = await applyFixtures(options);
1942
+ const result = await applyFixtures({ ...options, subcommand: 'storage-state' });
1078
1943
  const personaArtifact = result.generated.personas[0];
1079
1944
  return {
1080
1945
  success: true,
@@ -1089,6 +1954,7 @@ async function exportStorageState(options = {}) {
1089
1954
  route_hint: personaArtifact.route_hint,
1090
1955
  bridge: result.generated.bridge,
1091
1956
  runtime: result.runtime,
1957
+ seeding: result.seeding,
1092
1958
  next_steps: result.next_steps,
1093
1959
  };
1094
1960
  }
@@ -1098,6 +1964,7 @@ async function resetFixtures({
1098
1964
  port = DEFAULT_PORT,
1099
1965
  baseUrl = null,
1100
1966
  version = '0.0.0',
1967
+ seed = false,
1101
1968
  } = {}) {
1102
1969
  const rootDir = resolveRepoRoot(dir);
1103
1970
  const paths = resolveFixturePaths(rootDir);
@@ -1114,6 +1981,7 @@ async function resetFixtures({
1114
1981
  port,
1115
1982
  baseUrl,
1116
1983
  version,
1984
+ seed,
1117
1985
  });
1118
1986
 
1119
1987
  return {
@@ -1127,6 +1995,7 @@ async function resetFixtures({
1127
1995
  files_overwritten: initResult.files_overwritten,
1128
1996
  generated: applyResult.generated,
1129
1997
  runtime: applyResult.runtime,
1998
+ seeding: applyResult.seeding,
1130
1999
  next_steps: applyResult.next_steps,
1131
2000
  };
1132
2001
  }