spaps 0.8.0 → 0.8.1

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.
package/README.md CHANGED
@@ -118,11 +118,12 @@ npm install spaps
118
118
  | `spaps quickstart` | Print quick-start instructions | `--port`, `--json` |
119
119
  | `spaps init` | Create a starter `.env.local` | `--json` |
120
120
  | `spaps create <name>` | Scaffold a SPAPS starter project directory and try local provisioning | `--template`, `--dir`, `--port`, `--force`, `--json` |
121
- | `spaps fixtures <subcommand>` | Manage repo-local `.spaps` auth fixtures | `--dir`, `--port`, `--base-url`, `--persona`, `--seed`, `--format`, `--force`, `--json` |
121
+ | `spaps fixtures <subcommand>` | Manage repo-local `.spaps` auth fixtures | `--dir`, `--port`, `--base-url`, `--persona`, `--seed`, `--sync-server`, `--format`, `--force`, `--json` |
122
122
  | `spaps docs` | Browse or search bundled docs | `--interactive`, `--search`, `--json` |
123
- | `spaps tools` | Emit the AI tool spec (covers auth, stripe, dayrate, email, webhooks, policies, issue-reporting) | `--port`, `--format`, `--json` |
123
+ | `spaps tools` | Emit the AI tool spec (covers auth, stripe, dayrate, email, webhooks, policies, billing, issue-reporting) | `--port`, `--format`, `--json` |
124
124
  | `spaps doctor` | Diagnose local environment problems and probe that every domain router is mounted | `--port`, `--stripe`, `--json` |
125
125
  | `spaps dayrate config` | Fetch the active dayrate admin config (requires admin JWT) | `--server-url`, `--port`, `--json` |
126
+ | `spaps billing status\|attach\|verify` | Inspect, bind, and verify the current app's billing-account resolution (admin/operator) | `--billing-account-id`, `--server-url`, `--port`, `--json` |
126
127
  | `spaps email <verb>` | Thin email control-plane commands over the existing SPAPS email API | `--server-url`, `--port`, `--json`; run `spaps email --help` for verb-specific flags |
127
128
  | `spaps policy list\|create\|delete` | List, create, or delete authorization policies (admin) | `--name`, `--effect`, `--conditions`, `--id`, `--is-active`, `--limit`, `--json` |
128
129
  | `spaps webhook list\|register` | List registered webhooks or register a new outbound webhook | `--url`, `--events`, `--json` |
@@ -165,12 +166,16 @@ spaps verify --json
165
166
  spaps create my-app --template react
166
167
  spaps connect --json
167
168
  spaps fixtures apply --base-url http://localhost:5173
169
+ spaps fixtures apply --sync-server --base-url http://localhost:5173
168
170
  spaps fixtures apply --seed --persona dayrate-entitled --base-url http://localhost:5173
169
171
  spaps fixtures storage-state --persona admin
170
172
  spaps docs --search secure-messages
171
173
  spaps doctor --stripe mock
172
174
  spaps local stop
173
175
  spaps dayrate config --json
176
+ spaps billing status --json
177
+ spaps billing attach --billing-account-id 11111111-1111-4111-8111-111111111111 --json
178
+ spaps billing verify --json
174
179
  spaps email --help
175
180
  spaps email send --help
176
181
  spaps email send --template-key welcome --to user@example.com --context '{"user_name":"Jane"}' --json
@@ -268,10 +273,23 @@ Then run:
268
273
 
269
274
  ```bash
270
275
  npx spaps fixtures apply --base-url http://localhost:5173
276
+ npx spaps fixtures apply --sync-server --base-url http://localhost:5173
271
277
  npx spaps fixtures apply --seed --persona issue-reporter-blocked --base-url http://localhost:5173
272
278
  npx spaps fixtures storage-state --persona admin
273
279
  ```
274
280
 
281
+ Use `--sync-server` when a local app needs real SPAPS DB state. It calls the
282
+ local-only `/api/dev/fixtures/apply` endpoint to upsert fixture users,
283
+ memberships, and entitlements, then writes ownership metadata to
284
+ `.spaps/fixtures.lock.json`. It refuses non-local targets and only runs when
285
+ `/health/local-mode` reports local mode active.
286
+
287
+ The default fixture pack includes shared personas (`user`, `admin`, `premium`),
288
+ CFO-style `cfo-billing-demo` company entitlements, and HTMA-style
289
+ `htma-local-auth` invite/template seed state. Browser artifacts keep
290
+ entitlements as plain keys, while `--sync-server` can send structured grants
291
+ with resource scope metadata to SPAPS.
292
+
275
293
  Use `--seed` when a persona declares backend seed steps in `.spaps/users.json`. Seeding is opt-in, only runs against a reachable local-mode SPAPS server, and reuses the persona selectors already defined in the fixture kernel instead of adding fixture-only backend routes.
276
294
 
277
295
  What `apply` emits:
package/client.js CHANGED
@@ -70,14 +70,22 @@ try {
70
70
  }
71
71
 
72
72
  async createCheckoutSession(priceId, successUrl, cancelUrl) {
73
- return this.client.post('/api/stripe/create-checkout-session',
74
- { price_id: priceId, success_url: successUrl, cancel_url: cancelUrl },
73
+ return this.client.post('/api/stripe/checkout-sessions',
74
+ {
75
+ mode: 'payment',
76
+ line_items: [{ price_id: priceId, quantity: 1 }],
77
+ success_url: successUrl,
78
+ cancel_url: cancelUrl || successUrl
79
+ },
75
80
  { headers: { Authorization: `Bearer ${this.accessToken}` } }
76
81
  );
77
82
  }
78
83
 
79
- async getSubscription() {
80
- return this.client.get('/api/stripe/subscription', {
84
+ async getSubscription(subscriptionId) {
85
+ const path = subscriptionId
86
+ ? `/api/stripe/subscription/${encodeURIComponent(subscriptionId)}`
87
+ : '/api/stripe/subscriptions';
88
+ return this.client.get(path, {
81
89
  headers: { Authorization: `Bearer ${this.accessToken}` }
82
90
  });
83
91
  }
@@ -132,8 +140,11 @@ try {
132
140
  );
133
141
  }
134
142
 
135
- async cancelSubscription() {
136
- return this.client.delete('/api/stripe/subscription', {
143
+ async cancelSubscription(subscriptionId, options = {}) {
144
+ if (!subscriptionId) {
145
+ throw new Error('subscriptionId is required to cancel a subscription.');
146
+ }
147
+ return this.client.post(`/api/stripe/subscription/${encodeURIComponent(subscriptionId)}/cancel`, options, {
137
148
  headers: { Authorization: `Bearer ${this.accessToken}` }
138
149
  });
139
150
  }
@@ -144,4 +155,4 @@ try {
144
155
  module.exports.default = SPAPSClient;
145
156
  module.exports.SPAPS = SPAPSClient;
146
157
  module.exports.SweetPotatoSDK = SPAPSClient;
147
- }
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -229,6 +229,7 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
229
229
  .option('--base-url <url>', 'Browser app base URL for generated storage-state files')
230
230
  .option('--persona <persona>', 'Persona code to target for apply or storage-state export')
231
231
  .option('--seed', 'Run persona-declared seed requests against the local SPAPS server', false)
232
+ .option('--sync-server', 'Reconcile fixture users, memberships, and entitlements into a local SPAPS server', false)
232
233
  .option('-f, --format <format>', 'Artifact format (playwright)', 'playwright')
233
234
  .option('--force', 'Overwrite fixture files during init', false)
234
235
  .option('--json', 'Output in JSON format')
@@ -241,6 +242,7 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
241
242
  baseUrl: opts.baseUrl || null,
242
243
  persona: opts.persona || null,
243
244
  seed: Boolean(opts.seed),
245
+ syncServer: Boolean(opts.syncServer),
244
246
  format: String(opts.format || 'playwright'),
245
247
  force: Boolean(opts.force),
246
248
  json: isJson,
@@ -345,6 +347,25 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
345
347
  );
346
348
  allowDryRun(cmdDayrate);
347
349
 
350
+ // spaps billing <subcommand>
351
+ const cmdBilling = program
352
+ .command('billing <subcommand>')
353
+ .description('Billing-account operator commands (subcommand: status|attach|verify)')
354
+ .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT))
355
+ .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)')
356
+ .option('--billing-account-id <id>', 'Billing account id (attach)')
357
+ .option('--json', 'Output in JSON format')
358
+ .action(
359
+ makeAction('billing', (opts, cmd, isJson) => ({
360
+ subcommand: cmd.args[0],
361
+ port: Number(opts.port) || DEFAULT_PORT,
362
+ serverUrl: opts.serverUrl || null,
363
+ billingAccountId: opts.billingAccountId || null,
364
+ json: isJson,
365
+ }))
366
+ );
367
+ allowDryRun(cmdBilling);
368
+
348
369
  // spaps email
349
370
  const cmdEmail = program
350
371
  .command('email')
package/src/docs-html.js CHANGED
@@ -520,13 +520,13 @@ app.post('/webhook', (req, res) => {
520
520
  // Visit: http://localhost:${port}/api/stripe/webhooks/test</code></pre>
521
521
 
522
522
  <h3>Subscription Management</h3>
523
- <pre><code>// Get current subscription
524
- const subscription = await spaps.getSubscription();
523
+ <pre><code>// Get subscription by ID
524
+ const subscription = await spaps.getSubscription(subscriptionId);
525
525
  console.log('Status:', subscription.data.status);
526
526
  console.log('Plan:', subscription.data.plan);
527
527
 
528
528
  // Cancel subscription
529
- await spaps.cancelSubscription();</code></pre>
529
+ await spaps.cancelSubscription(subscriptionId);</code></pre>
530
530
 
531
531
  <h3>Usage Tracking</h3>
532
532
  <pre><code>// Check balance
package/src/docs-quick.js CHANGED
@@ -17,7 +17,7 @@ function showQuickReference() {
17
17
  ' await spaps.register(email, password)',
18
18
  ' await spaps.getUser()',
19
19
  ' await spaps.createCheckoutSession(priceId, successUrl)',
20
- ' await spaps.getSubscription()',
20
+ ' await spaps.getSubscription(subscriptionId)',
21
21
  ' await spaps.getUsageBalance()',
22
22
  '',
23
23
  chalk.green('Helper Methods:'),
@@ -72,7 +72,7 @@ ${chalk.green('Token Management:')}
72
72
  content: `
73
73
  ${chalk.green('Stripe Checkout:')}
74
74
  ${chalk.gray('// Create checkout session')}
75
- const session = await sdk.payments.createCheckoutSession({
75
+ const session = await sdk.payments.createPaymentCheckout({
76
76
  price_id: 'price_123abc',
77
77
  success_url: 'http://localhost:3000/success',
78
78
  cancel_url: 'http://localhost:3000/cancel'
@@ -86,7 +86,7 @@ ${chalk.green('Subscription Management:')}
86
86
  // Example subscription helpers would go here if enabled
87
87
 
88
88
  ${chalk.gray('// Cancel subscription')}
89
- await spaps.cancelSubscription()
89
+ await spaps.cancelSubscription(subscriptionId)
90
90
 
91
91
  ${chalk.green('Usage Tracking:')}
92
92
  ${chalk.gray('// Check balance')}
package/src/domains.js CHANGED
@@ -133,6 +133,21 @@ const POLICIES = {
133
133
  ],
134
134
  };
135
135
 
136
+ const BILLING = {
137
+ key: 'billing',
138
+ label: 'Billing Accounts',
139
+ // Admin-gated; 401/403 still proves the operator route is mounted.
140
+ probe: { method: 'GET', path: '/api/admin/billing/status', ok_on_auth_error: true },
141
+ tools: [
142
+ { name: 'billing_status', method: 'GET', path: '/api/admin/billing/status',
143
+ description: 'Show current application billing-account resolution and redacted Stripe diagnostics.', admin_required: true },
144
+ { name: 'billing_verify', method: 'GET', path: '/api/admin/billing/verify',
145
+ description: 'Verify that the current application has usable Stripe secret and webhook configuration.', admin_required: true },
146
+ { name: 'billing_attach', method: 'POST', path: '/api/admin/billing/attach',
147
+ description: 'Bind the current application to an existing active Stripe billing account.', admin_required: true },
148
+ ],
149
+ };
150
+
136
151
  const ISSUE_REPORTING = {
137
152
  key: 'issue_reporting',
138
153
  label: 'Issue Reporting',
@@ -145,16 +160,16 @@ const ISSUE_REPORTING = {
145
160
  description: 'List the caller\u2019s issue reports.' },
146
161
  { name: 'issue_report_status', method: 'GET', path: '/api/v1/issue-reports/status',
147
162
  description: 'Aggregate status for the caller\u2019s issues.' },
148
- { name: 'issue_report_get', method: 'GET', path: '/api/v1/issue-reports/{report_id}',
163
+ { name: 'issue_report_get', method: 'GET', path: '/api/v1/issue-reports/{issue_report_id}',
149
164
  description: 'Fetch a single issue report.' },
150
- { name: 'issue_report_update_note', method: 'PATCH', path: '/api/v1/issue-reports/{report_id}',
165
+ { name: 'issue_report_update_note', method: 'PATCH', path: '/api/v1/issue-reports/{issue_report_id}',
151
166
  description: 'Update the note on an existing report.' },
152
- { name: 'issue_report_reply', method: 'POST', path: '/api/v1/issue-reports/{report_id}/replies',
167
+ { name: 'issue_report_reply', method: 'POST', path: '/api/v1/issue-reports/{issue_report_id}/replies',
153
168
  description: 'Reply to an issue; creates a child report and reopens the linked support case.' },
154
169
  ],
155
170
  };
156
171
 
157
- const DOMAINS = [DAYRATE, EMAIL, WEBHOOKS, POLICIES, ISSUE_REPORTING];
172
+ const DOMAINS = [DAYRATE, EMAIL, WEBHOOKS, POLICIES, BILLING, ISSUE_REPORTING];
158
173
 
159
174
  function listDomains() {
160
175
  return DOMAINS;
@@ -306,6 +306,58 @@ const DEFAULT_PERSONAS = [
306
306
  },
307
307
  ],
308
308
  }),
309
+ makeDerivedPersona('cfo-billing-demo', 'CFO Billing Demo', 'admin', {
310
+ permissions: ['view_products', 'manage_company_billing', 'view_company_reports'],
311
+ profile: {
312
+ username: 'cfo-billing-demo',
313
+ },
314
+ scenario: {
315
+ cfo: {
316
+ pack: 'cfo.billing_demo',
317
+ company_id: '00000000-0000-4000-8000-00000000c001',
318
+ company_slug: 'fixture-cfo-company',
319
+ expected_entitlements: [
320
+ 'fixture.cfo.company.billing_admin',
321
+ 'fixture.cfo.company.reports',
322
+ ],
323
+ },
324
+ },
325
+ }),
326
+ makeDerivedPersona('htma-local-auth', 'HTMA Local Auth', 'user', {
327
+ permissions: ['view_products', 'manage_patient_protocols'],
328
+ profile: {
329
+ username: 'htma-local-auth',
330
+ },
331
+ scenario: {
332
+ htma: {
333
+ pack: 'htma.local_auth',
334
+ invite_template_key: 'fixture-htma-invite-{{global.run_id}}',
335
+ invite_action_url: 'http://localhost:5173/htma/invite/{{global.run_id}}',
336
+ expected_entitlements: ['fixture.htma.local_auth'],
337
+ },
338
+ },
339
+ seed: [
340
+ {
341
+ as: 'admin',
342
+ method: 'POST',
343
+ path: '/api/email/templates',
344
+ expected_statuses: [201, 409],
345
+ body: {
346
+ name: 'Fixture HTMA Invite',
347
+ template_key: 'fixture-htma-invite-{{global.run_id}}',
348
+ subject: 'HTMA invite for {{name}}',
349
+ html_body: '<p><a href="{{action_url}}">Open HTMA invite</a></p>',
350
+ text_body: 'Open HTMA invite: {{action_url}}',
351
+ from_email: 'noreply@example.com',
352
+ category: 'fixture',
353
+ sample_context: {
354
+ name: 'Fixture HTMA User',
355
+ action_url: 'http://localhost:5173/htma/invite/{{global.run_id}}',
356
+ },
357
+ },
358
+ },
359
+ ],
360
+ }),
309
361
  makeDerivedPersona('email-local-safe', 'Email Local Safe', 'user', {
310
362
  profile: {
311
363
  username: 'email-local-safe',
@@ -593,6 +645,8 @@ const DEFAULT_ROLE_GRANTS = {
593
645
  'dayrate-guest': ['user'],
594
646
  'dayrate-entitled': ['user'],
595
647
  'dayrate-overage': ['user'],
648
+ 'cfo-billing-demo': ['admin', 'super_admin'],
649
+ 'htma-local-auth': ['user'],
596
650
  'email-local-safe': ['user'],
597
651
  'webhook-signature-invalid': ['admin', 'super_admin'],
598
652
  'webhook-replay': ['admin', 'super_admin'],
@@ -609,6 +663,27 @@ const DEFAULT_ENTITLEMENT_GRANTS = {
609
663
  'dayrate-guest': [],
610
664
  'dayrate-entitled': ['fixture.dayrate.free.{{global.run_id}}'],
611
665
  'dayrate-overage': ['fixture.dayrate.overage.{{global.run_id}}'],
666
+ 'cfo-billing-demo': [
667
+ {
668
+ key: 'fixture.cfo.company.billing_admin',
669
+ resource_type: 'company',
670
+ resource_id: '00000000-0000-4000-8000-00000000c001',
671
+ metadata: {
672
+ pack: 'cfo.billing_demo',
673
+ company_slug: 'fixture-cfo-company',
674
+ },
675
+ },
676
+ {
677
+ key: 'fixture.cfo.company.reports',
678
+ resource_type: 'company',
679
+ resource_id: '00000000-0000-4000-8000-00000000c001',
680
+ metadata: {
681
+ pack: 'cfo.billing_demo',
682
+ company_slug: 'fixture-cfo-company',
683
+ },
684
+ },
685
+ ],
686
+ 'htma-local-auth': ['fixture.htma.local_auth'],
612
687
  'email-local-safe': [],
613
688
  'webhook-signature-invalid': [],
614
689
  'webhook-replay': [],
@@ -618,6 +693,37 @@ const DEFAULT_ENTITLEMENT_GRANTS = {
618
693
  'issue-reporter-blocked': [],
619
694
  };
620
695
 
696
+ function normalizeEntitlementGrant(grant) {
697
+ if (typeof grant === 'string') {
698
+ return { key: grant };
699
+ }
700
+ if (!grant || typeof grant !== 'object') {
701
+ return { key: String(grant || '') };
702
+ }
703
+ return {
704
+ key: grant.key || grant.entitlement_key || '',
705
+ entitlement_type: grant.entitlement_type || grant.type || 'access',
706
+ resource_type: grant.resource_type || 'user',
707
+ resource_id: grant.resource_id || null,
708
+ metadata: grant.metadata || {},
709
+ };
710
+ }
711
+
712
+ function entitlementGrantKey(grant) {
713
+ return normalizeEntitlementGrant(grant).key;
714
+ }
715
+
716
+ function entitlementGrantKeys(grants = []) {
717
+ return grants.map(entitlementGrantKey).filter(Boolean);
718
+ }
719
+
720
+ function serializeEntitlementGrantForSync(grant) {
721
+ if (typeof grant === 'string') {
722
+ return grant;
723
+ }
724
+ return normalizeEntitlementGrant(grant);
725
+ }
726
+
621
727
  function createCliError(code, message) {
622
728
  const error = new Error(message);
623
729
  error.code = code;
@@ -783,12 +889,14 @@ Common workflow:
783
889
  1. Run \`npx spaps fixtures init\`
784
890
  2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
785
891
  3. Run \`npx spaps fixtures apply\`
786
- 4. Run \`npx spaps fixtures apply --seed --persona <code>\` when a domain persona needs real local DB state
787
- 5. Point Playwright or local scripts at the generated browser artifacts
788
- 6. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
892
+ 4. Run \`npx spaps fixtures apply --sync-server\` to reconcile users, memberships, and entitlements into local SPAPS
893
+ 5. Run \`npx spaps fixtures apply --seed --persona <code>\` when a domain persona needs extra domain-specific DB state
894
+ 6. Point Playwright or local scripts at the generated browser artifacts
895
+ 7. Include \`/${DEFAULT_BRIDGE_SCRIPT_NAME}\` before your app boots if you want frontend-only persona switching
789
896
 
790
897
  Notes:
791
898
 
899
+ - \`--sync-server\` only runs against a reachable localhost SPAPS server with local mode active
792
900
  - \`--seed\` only runs against a reachable SPAPS server with local mode active
793
901
  - Seed steps use the persona metadata already in \`.spaps/users.json\`; no extra backend-only fixture routes are required
794
902
  `;
@@ -1079,6 +1187,15 @@ function buildSeedUrl(apiUrl, requestPath, queryParams = {}) {
1079
1187
  return url.toString();
1080
1188
  }
1081
1189
 
1190
+ function isLocalServerUrl(apiUrl) {
1191
+ try {
1192
+ const parsed = new URL(apiUrl);
1193
+ return ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
1194
+ } catch {
1195
+ return false;
1196
+ }
1197
+ }
1198
+
1082
1199
  function assertSeedingRuntime(runtime, port) {
1083
1200
  if (!runtime?.running) {
1084
1201
  throw createCliError(
@@ -1095,6 +1212,29 @@ function assertSeedingRuntime(runtime, port) {
1095
1212
  }
1096
1213
  }
1097
1214
 
1215
+ function assertServerSyncRuntime(runtime, appConfig, port) {
1216
+ if (!runtime?.running) {
1217
+ throw createCliError(
1218
+ 'ESYNC',
1219
+ `Fixture server sync requires a running SPAPS server on port ${port}. Start it with "npx spaps local --port ${port}" or omit --sync-server.`
1220
+ );
1221
+ }
1222
+
1223
+ if (runtime.local_mode?.active !== true) {
1224
+ throw createCliError(
1225
+ 'ESYNC',
1226
+ 'Fixture server sync requires SPAPS local mode. Refusing to sync fixtures into a non-local server.'
1227
+ );
1228
+ }
1229
+
1230
+ if (!isLocalServerUrl(appConfig.server.api_url)) {
1231
+ throw createCliError(
1232
+ 'ESYNC',
1233
+ `Fixture server sync refuses non-local SPAPS targets: ${appConfig.server.api_url}`
1234
+ );
1235
+ }
1236
+ }
1237
+
1098
1238
  async function runSeedSequence({
1099
1239
  appConfig,
1100
1240
  usersConfig,
@@ -1238,6 +1378,136 @@ async function runFixtureSeeding({
1238
1378
  };
1239
1379
  }
1240
1380
 
1381
+ function buildFixtureServerSyncPayload({
1382
+ appConfig,
1383
+ usersConfig,
1384
+ rolesConfig,
1385
+ entitlementsConfig,
1386
+ personaCode = null,
1387
+ }) {
1388
+ const selectedCodes = personaCode
1389
+ ? [ensurePersona(usersConfig, personaCode).code]
1390
+ : usersConfig.personas.map((entry) => entry.code);
1391
+
1392
+ return {
1393
+ source: 'spaps-fixtures',
1394
+ application: {
1395
+ id: appConfig.server.application_id || null,
1396
+ slug: appConfig.server.application_slug || 'local-dev',
1397
+ name: appConfig.server.application_slug || 'Local Development',
1398
+ api_key: appConfig.server.api_key || null,
1399
+ allowed_origins: [resolveStorageOrigin(appConfig.browser.base_url)],
1400
+ settings: {
1401
+ spaps_fixture_browser_base_url: appConfig.browser.base_url,
1402
+ },
1403
+ },
1404
+ personas: selectedCodes.map((code) => {
1405
+ const persona = ensurePersona(usersConfig, code);
1406
+ return {
1407
+ code,
1408
+ display_name: persona.display_name || code,
1409
+ profile: persona.profile || {},
1410
+ roles: rolesConfig.grants?.[code] || [],
1411
+ permissions: persona.permissions || [],
1412
+ entitlements: (entitlementsConfig.grants?.[code] || []).map(serializeEntitlementGrantForSync),
1413
+ metadata: {
1414
+ selector: persona.selector || {},
1415
+ scenario: persona.scenario || {},
1416
+ },
1417
+ };
1418
+ }),
1419
+ };
1420
+ }
1421
+
1422
+ async function runFixtureServerSync({
1423
+ appConfig,
1424
+ runtime,
1425
+ usersConfig,
1426
+ rolesConfig,
1427
+ entitlementsConfig,
1428
+ personaCode = null,
1429
+ port,
1430
+ }) {
1431
+ assertServerSyncRuntime(runtime, appConfig, port);
1432
+ const payload = buildFixtureServerSyncPayload({
1433
+ appConfig,
1434
+ usersConfig,
1435
+ rolesConfig,
1436
+ entitlementsConfig,
1437
+ personaCode,
1438
+ });
1439
+
1440
+ let response;
1441
+ try {
1442
+ response = await axios({
1443
+ method: 'POST',
1444
+ url: `${appConfig.server.api_url.replace(/\/+$/, '')}/api/dev/fixtures/apply`,
1445
+ data: payload,
1446
+ headers: {
1447
+ Accept: 'application/json',
1448
+ 'Content-Type': 'application/json',
1449
+ },
1450
+ timeout: 10000,
1451
+ validateStatus: () => true,
1452
+ });
1453
+ } catch (error) {
1454
+ throw createCliError(
1455
+ 'ESYNC',
1456
+ `Fixture server sync failed at /api/dev/fixtures/apply: ${error.message}`
1457
+ );
1458
+ }
1459
+
1460
+ const body = unwrapEnvelope(response.data);
1461
+ if (response.status < 200 || response.status >= 300) {
1462
+ const message = body?.message || body?.error?.message || `status ${response.status}`;
1463
+ throw createCliError('ESYNC', `Fixture server sync failed: ${message}`);
1464
+ }
1465
+
1466
+ return body;
1467
+ }
1468
+
1469
+ function applyServerSnapshotToUsers(usersConfig, appConfig, serverSync) {
1470
+ if (!serverSync?.application || !Array.isArray(serverSync.personas)) {
1471
+ return usersConfig;
1472
+ }
1473
+
1474
+ const byCode = new Map(serverSync.personas.map((entry) => [entry.code, entry]));
1475
+ appConfig.server.application_id = serverSync.application.id;
1476
+ appConfig.server.application_slug = serverSync.application.slug;
1477
+ appConfig.server.api_key = serverSync.application.api_key;
1478
+
1479
+ return {
1480
+ ...usersConfig,
1481
+ personas: usersConfig.personas.map((persona) => {
1482
+ const synced = byCode.get(persona.code);
1483
+ if (!synced) {
1484
+ return persona;
1485
+ }
1486
+
1487
+ return {
1488
+ ...persona,
1489
+ profile: {
1490
+ ...(persona.profile || {}),
1491
+ user_id: synced.user.id,
1492
+ email: synced.user.email || persona.profile?.email,
1493
+ username: synced.user.username || persona.profile?.username,
1494
+ tier: synced.user.tier || persona.profile?.tier,
1495
+ },
1496
+ application: {
1497
+ ...(persona.application || {}),
1498
+ application_id: serverSync.application.id,
1499
+ application_slug: serverSync.application.slug,
1500
+ api_key: serverSync.application.api_key,
1501
+ },
1502
+ server_sync: {
1503
+ membership_created: synced.membership_created,
1504
+ entitlements_created: synced.entitlements_created,
1505
+ },
1506
+ };
1507
+ }),
1508
+ };
1509
+ }
1510
+
1241
1511
  function stringifyStorageValue(value) {
1242
1512
  if (typeof value === 'string') {
1243
1513
  return value;
@@ -1254,10 +1524,18 @@ function buildRouteHint(appConfig, persona) {
1254
1524
  }
1255
1525
 
1256
1526
  function buildHeaderArtifact(appConfig, persona) {
1527
+ const application = buildApplicationSummary(appConfig, persona);
1528
+ const headers = {
1529
+ ...(persona.selector?.headers || {}),
1530
+ };
1531
+ if (application.api_key && !headers['X-API-Key']) {
1532
+ headers['X-API-Key'] = application.api_key;
1533
+ }
1534
+
1257
1535
  return {
1258
1536
  format: 'playwright-extra-http-headers',
1259
1537
  local_mode_active: appConfig.server.local_mode_active === true,
1260
- headers: persona.selector?.headers || {},
1538
+ headers,
1261
1539
  route_hint: buildRouteHint(appConfig, persona),
1262
1540
  note:
1263
1541
  appConfig.server.local_mode_active === true
@@ -1298,31 +1576,32 @@ function buildFixtureTokens(appConfig, persona, roles) {
1298
1576
  }
1299
1577
 
1300
1578
  function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
1301
- const entitlementKeys = entitlementsConfig.grants?.[persona.code] || [];
1579
+ const entitlementGrants = (entitlementsConfig.grants?.[persona.code] || []).map(normalizeEntitlementGrant);
1302
1580
  const application = buildApplicationSummary(appConfig, persona);
1303
1581
 
1304
- return entitlementKeys.map((entitlementKey, index) => ({
1582
+ return entitlementGrants.map((grant, index) => ({
1305
1583
  id: `fixture-${persona.code}-${index + 1}`,
1306
1584
  application_id: application.application_id,
1307
1585
  beneficiary_user_id: persona.profile?.user_id || null,
1308
1586
  beneficiary_email: persona.profile?.email || null,
1309
- entitlement_key: entitlementKey,
1310
- entitlement_type: 'manual',
1587
+ entitlement_key: grant.key,
1588
+ entitlement_type: grant.entitlement_type || 'manual',
1311
1589
  source: 'fixture',
1312
- resource_type: 'user',
1313
- resource_id: null,
1590
+ resource_type: grant.resource_type || 'user',
1591
+ resource_id: grant.resource_id || null,
1314
1592
  starts_at: '2026-01-01T00:00:00.000Z',
1315
1593
  ends_at: null,
1316
1594
  revoked_at: null,
1317
1595
  metadata: {
1318
1596
  fixture_persona: persona.code,
1597
+ ...(grant.metadata || {}),
1319
1598
  },
1320
1599
  }));
1321
1600
  }
1322
1601
 
1323
1602
  function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
1324
1603
  const roles = rolesConfig.grants?.[persona.code] || [];
1325
- const entitlements = entitlementsConfig.grants?.[persona.code] || [];
1604
+ const entitlements = entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []);
1326
1605
  const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
1327
1606
  const primaryRole = roles[0] || 'user';
1328
1607
  const application = buildApplicationSummary(appConfig, persona);
@@ -1383,7 +1662,7 @@ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
1383
1662
  application: buildApplicationSummary(appConfig, persona),
1384
1663
  user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
1385
1664
  tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
1386
- entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
1665
+ entitlement_keys: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
1387
1666
  entitlements: buildEntitlementRecords(appConfig, persona, entitlementsConfig),
1388
1667
  scenario: persona.scenario || {},
1389
1668
  browser: {
@@ -1767,7 +2046,7 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
1767
2046
  },
1768
2047
  {
1769
2048
  name: FIXTURE_KEYS.entitlements,
1770
- value: stringifyStorageValue(entitlementsConfig.grants?.[persona.code] || []),
2049
+ value: stringifyStorageValue(entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || [])),
1771
2050
  },
1772
2051
  {
1773
2052
  name: FIXTURE_KEYS.scenario,
@@ -1838,7 +2117,7 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
1838
2117
  profile: persona.profile || {},
1839
2118
  scenario: persona.scenario || {},
1840
2119
  roles: rolesConfig.grants?.[persona.code] || [],
1841
- entitlements: entitlementsConfig.grants?.[persona.code] || [],
2120
+ entitlements: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
1842
2121
  user,
1843
2122
  tokens,
1844
2123
  application,
@@ -1859,6 +2138,7 @@ function writePersonaArtifacts({
1859
2138
  entitlements,
1860
2139
  personaCode = null,
1861
2140
  seedResultsByPersona = {},
2141
+ serverSync = null,
1862
2142
  }) {
1863
2143
  const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
1864
2144
  const generated = [];
@@ -1869,6 +2149,7 @@ function writePersonaArtifacts({
1869
2149
  const headers = buildHeaderArtifact(appConfig, persona);
1870
2150
  const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
1871
2151
  context.seed = seedResultsByPersona[code] || null;
2152
+ context.server_sync = serverSync?.personas?.find((entry) => entry.code === code) || null;
1872
2153
 
1873
2154
  writeJson(context.artifacts.storage_state_path, storageState);
1874
2155
  writeJson(context.artifacts.headers_path, headers);
@@ -1890,6 +2171,20 @@ function writePersonaArtifacts({
1890
2171
  base_url: appConfig.browser.base_url,
1891
2172
  api_url: appConfig.server.api_url,
1892
2173
  local_mode_active: appConfig.server.local_mode_active,
2174
+ ownership: serverSync
2175
+ ? {
2176
+ source: serverSync.source || 'spaps-fixtures',
2177
+ application: {
2178
+ id: serverSync.application.id,
2179
+ slug: serverSync.application.slug,
2180
+ },
2181
+ personas: serverSync.personas.map((entry) => ({
2182
+ code: entry.code,
2183
+ user_id: entry.user.id,
2184
+ entitlements: entry.entitlements,
2185
+ })),
2186
+ }
2187
+ : null,
1893
2188
  });
1894
2189
 
1895
2190
  return generated;
@@ -1915,6 +2210,7 @@ async function applyFixtures({
1915
2210
  version = '0.0.0',
1916
2211
  persona = null,
1917
2212
  seed = false,
2213
+ syncServer = false,
1918
2214
  subcommand = 'apply',
1919
2215
  } = {}) {
1920
2216
  const rootDir = resolveRepoRoot(dir);
@@ -1946,22 +2242,46 @@ async function applyFixtures({
1946
2242
  });
1947
2243
 
1948
2244
  const fixtureRunState = buildFixtureRunState(appConfig, runtime);
2245
+ let resolvedUsers = {
2246
+ ...kernel.users,
2247
+ personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
2248
+ };
2249
+ const resolvedRoles = resolveConfigTemplates(kernel.roles, fixtureRunState);
2250
+ const resolvedEntitlements = resolveConfigTemplates(kernel.entitlements, fixtureRunState);
2251
+ const serverSync = syncServer
2252
+ ? await runFixtureServerSync({
2253
+ appConfig,
2254
+ runtime,
2255
+ usersConfig: resolvedUsers,
2256
+ rolesConfig: resolvedRoles,
2257
+ entitlementsConfig: resolvedEntitlements,
2258
+ personaCode: persona,
2259
+ port,
2260
+ })
2261
+ : null;
2262
+ if (serverSync) {
2263
+ resolvedUsers = applyServerSnapshotToUsers(resolvedUsers, appConfig, serverSync);
2264
+ writeJson(paths.app, appConfig);
2265
+ }
1949
2266
  const seeding = seed
1950
2267
  ? await runFixtureSeeding({
1951
2268
  appConfig,
1952
2269
  runtime,
1953
- usersConfig: kernel.users,
2270
+ usersConfig: resolvedUsers,
1954
2271
  personaCode: persona,
1955
2272
  fixtureRunState,
1956
2273
  port,
1957
2274
  })
1958
2275
  : null;
1959
- const resolvedUsers = {
1960
- ...kernel.users,
1961
- personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
1962
- };
1963
- const resolvedRoles = resolveConfigTemplates(kernel.roles, fixtureRunState);
1964
- const resolvedEntitlements = resolveConfigTemplates(kernel.entitlements, fixtureRunState);
2276
+ if (seeding) {
2277
+ resolvedUsers = {
2278
+ ...kernel.users,
2279
+ personas: kernel.users.personas.map((entry) => resolvePersonaDefinition(appConfig, entry, fixtureRunState)),
2280
+ };
2281
+ if (serverSync) {
2282
+ resolvedUsers = applyServerSnapshotToUsers(resolvedUsers, appConfig, serverSync);
2283
+ }
2284
+ }
1965
2285
  const seedResultsByPersona = Object.fromEntries(
1966
2286
  (seeding?.personas || []).map((entry) => [entry.persona, entry])
1967
2287
  );
@@ -1975,6 +2295,7 @@ async function applyFixtures({
1975
2295
  entitlements: resolvedEntitlements,
1976
2296
  personaCode: persona,
1977
2297
  seedResultsByPersona,
2298
+ serverSync,
1978
2299
  });
1979
2300
  const bridge = writeDevAuthBridge({
1980
2301
  rootDir,
@@ -1993,6 +2314,7 @@ async function applyFixtures({
1993
2314
  fixture_dir: paths.fixtureDir,
1994
2315
  bootstrapped,
1995
2316
  runtime,
2317
+ server_sync: serverSync,
1996
2318
  seeding,
1997
2319
  generated: {
1998
2320
  personas: generated,
@@ -2028,6 +2350,7 @@ async function exportStorageState(options = {}) {
2028
2350
  route_hint: personaArtifact.route_hint,
2029
2351
  bridge: result.generated.bridge,
2030
2352
  runtime: result.runtime,
2353
+ server_sync: result.server_sync,
2031
2354
  seeding: result.seeding,
2032
2355
  next_steps: result.next_steps,
2033
2356
  };
@@ -2039,6 +2362,7 @@ async function resetFixtures({
2039
2362
  baseUrl = null,
2040
2363
  version = '0.0.0',
2041
2364
  seed = false,
2365
+ syncServer = false,
2042
2366
  } = {}) {
2043
2367
  const rootDir = resolveRepoRoot(dir);
2044
2368
  const paths = resolveFixturePaths(rootDir);
@@ -2056,6 +2380,7 @@ async function resetFixtures({
2056
2380
  baseUrl,
2057
2381
  version,
2058
2382
  seed,
2383
+ syncServer,
2059
2384
  });
2060
2385
 
2061
2386
  return {
@@ -2069,6 +2394,7 @@ async function resetFixtures({
2069
2394
  files_overwritten: initResult.files_overwritten,
2070
2395
  generated: applyResult.generated,
2071
2396
  runtime: applyResult.runtime,
2397
+ server_sync: applyResult.server_sync,
2072
2398
  seeding: applyResult.seeding,
2073
2399
  next_steps: applyResult.next_steps,
2074
2400
  };
package/src/handlers.js CHANGED
@@ -385,6 +385,7 @@ function createHandlers(version, logo) {
385
385
  version,
386
386
  persona: options.persona,
387
387
  seed: options.seed,
388
+ syncServer: options.syncServer,
388
389
  });
389
390
  break;
390
391
  case 'reset':
@@ -394,6 +395,7 @@ function createHandlers(version, logo) {
394
395
  baseUrl: options.baseUrl,
395
396
  version,
396
397
  seed: options.seed,
398
+ syncServer: options.syncServer,
397
399
  });
398
400
  break;
399
401
  case 'storage-state':
@@ -407,6 +409,7 @@ function createHandlers(version, logo) {
407
409
  version,
408
410
  persona: options.persona,
409
411
  seed: options.seed,
412
+ syncServer: options.syncServer,
410
413
  });
411
414
  break;
412
415
  default:
@@ -451,6 +454,14 @@ function createHandlers(version, logo) {
451
454
  });
452
455
  }
453
456
 
457
+ if (Array.isArray(result.server_sync?.personas) && result.server_sync.personas.length > 0) {
458
+ console.log(chalk.green('\nSynced server fixtures:'));
459
+ console.log(chalk.gray(` app: ${result.server_sync.application.slug}`));
460
+ result.server_sync.personas.forEach((entry) => {
461
+ console.log(chalk.gray(` • ${entry.code} (${entry.user.id})`));
462
+ });
463
+ }
464
+
454
465
  if (result.generated?.personas?.length) {
455
466
  console.log(chalk.green('\nGenerated personas:'));
456
467
  result.generated.personas.forEach((entry) => {
@@ -505,6 +516,7 @@ function createHandlers(version, logo) {
505
516
  logout: async (...args) => loadAuthCommandHandlers().logoutHandler(...args),
506
517
  whoami: async (...args) => loadAuthCommandHandlers().whoamiHandler(...args),
507
518
  token: async (...args) => loadAuthCommandHandlers().tokenHandler(...args),
519
+ billing: billingHandler,
508
520
  dayrate: dayrateHandler,
509
521
  email: emailHandler,
510
522
  policy: policyHandler,
@@ -600,6 +612,54 @@ async function dayrateHandler({ options }) {
600
612
  }
601
613
  }
602
614
 
615
+ async function billingHandler({ options }) {
616
+ const { callEndpoint, emit, emitAuthError } = loadDomainCli();
617
+ const isJson = Boolean(options.json);
618
+ const sub = options.subcommand;
619
+ try {
620
+ if (sub === 'status') {
621
+ const result = await callEndpoint({
622
+ options,
623
+ method: 'GET',
624
+ path: '/api/admin/billing/status',
625
+ });
626
+ emit({ intent: 'billing.status', result, isJson });
627
+ if (!result.ok) process.exitCode = 1;
628
+ return;
629
+ }
630
+
631
+ if (sub === 'verify') {
632
+ const result = await callEndpoint({
633
+ options,
634
+ method: 'GET',
635
+ path: '/api/admin/billing/verify',
636
+ });
637
+ emit({ intent: 'billing.verify', result, isJson });
638
+ if (!result.ok || result.data?.valid === false) process.exitCode = 1;
639
+ return;
640
+ }
641
+
642
+ if (sub === 'attach') {
643
+ if (!requireOption(options.billingAccountId, 'billing attach: --billing-account-id is required')) return;
644
+ const result = await callEndpoint({
645
+ options,
646
+ method: 'POST',
647
+ path: '/api/admin/billing/attach',
648
+ body: { billing_account_id: options.billingAccountId },
649
+ });
650
+ emit({ intent: 'billing.attach', result, isJson });
651
+ if (!result.ok) process.exitCode = 1;
652
+ return;
653
+ }
654
+
655
+ console.error('billing: unknown subcommand. Supported: status, attach, verify');
656
+ process.exitCode = 2;
657
+ } catch (err) {
658
+ emitAuthError(`billing.${sub || 'unknown'}`, err, isJson);
659
+ process.exitCode = 1;
660
+ }
661
+ }
662
+
603
663
  async function emailHandler({ options }) {
604
664
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
605
665
  const isJson = Boolean(options.json);
@@ -320,7 +320,7 @@ SPAPS handles two payment types:
320
320
  const session = await spaps.createCheckoutSession(priceId, successUrl)
321
321
 
322
322
  // Check subscription
323
- const sub = await spaps.getSubscription()
323
+ const sub = await spaps.getSubscription(subscriptionId)
324
324
  \`\`\`
325
325
  `
326
326
  }