spaps 0.7.8 → 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:
@@ -295,6 +313,38 @@ That bridge does three things:
295
313
  - intercepts `/api/auth/user`, `/api/auth/login`, `/api/auth/logout`, `/api/entitlements`, and `/api/entitlements/check`
296
314
  - renders a tiny persona switcher so you can flip between `user`, `admin`, and `premium`
297
315
 
316
+ Personas can also carry app-owned demo state in `.spaps/users.json`:
317
+
318
+ ```json
319
+ {
320
+ "code": "pds-initiator",
321
+ "display_name": "PDS Initiator",
322
+ "profile": { "user_id": "00000000-0000-0000-0000-000000000001" },
323
+ "scenario": {
324
+ "pds": {
325
+ "dashboard": "work",
326
+ "role": "initiator"
327
+ }
328
+ }
329
+ }
330
+ ```
331
+
332
+ `scenario` is opaque to SPAPS. It is copied into `browser/<persona>.context.json`, persisted as `spaps.fixture.scenario`, and exposed by the bridge:
333
+
334
+ ```js
335
+ const pdsScenario = window.__SPAPS_DEV_AUTH__.getScenario("pds");
336
+
337
+ const unsubscribe = window.__SPAPS_DEV_AUTH__.onPersonaChange((event) => {
338
+ console.log(event.persona.code, event.scenario.pds);
339
+ });
340
+
341
+ window.addEventListener("spaps:persona-change", (event) => {
342
+ console.log(event.detail.persona.code);
343
+ });
344
+ ```
345
+
346
+ Use this for persona-specific dashboard stories, queues, and UI fixture hints. Keep product-specific data in the consuming app; SPAPS only owns the persona/auth/role/entitlement envelope.
347
+
298
348
  ## Middleware Example
299
349
 
300
350
  The main module exports admin and permission helpers for Express-style apps.
@@ -12,12 +12,13 @@ RUN apt-get update && \
12
12
  curl \
13
13
  && rm -rf /var/lib/apt/lists/*
14
14
 
15
- ARG SPAPS_SERVER_QUICKSTART_VERSION=0.5.0
15
+ ARG SPAPS_SERVER_QUICKSTART_VERSION=0.5.1
16
16
  RUN pip install --no-cache-dir "spaps-server-quickstart==${SPAPS_SERVER_QUICKSTART_VERSION}"
17
17
  RUN python - <<'PY'
18
18
  from pathlib import Path
19
19
 
20
20
  from spaps_server_quickstart.domains.auth import schemas
21
+ from spaps_server_quickstart.middleware import local_mode
21
22
 
22
23
  schema_path = Path(schemas.__file__)
23
24
  text = schema_path.read_text(encoding="utf-8")
@@ -26,6 +27,16 @@ text = text.replace(
26
27
  " refresh_token: str | None = None\n",
27
28
  )
28
29
  schema_path.write_text(text, encoding="utf-8")
30
+
31
+ local_mode_path = Path(local_mode.__file__)
32
+ text = local_mode_path.read_text(encoding="utf-8")
33
+ text = text.replace(
34
+ 'default_factory=lambda: {"issue_reporting_required_capability": "view_products"}',
35
+ 'default_factory=lambda: {"issue_reporting_required_capability": "view_products", "issue_reporting_input_modes": ["text", "voice"]}',
36
+ )
37
+ if '"issue_reporting_input_modes"' not in text:
38
+ raise RuntimeError("Failed to patch local-mode issue reporting input modes")
39
+ local_mode_path.write_text(text, encoding="utf-8")
29
40
  PY
30
41
 
31
42
  COPY alembic.ini /app/alembic.ini
@@ -0,0 +1,55 @@
1
+ """application-scoped short links
2
+
3
+ Revision ID: 000000000024
4
+ Revises: 000000000018
5
+ Create Date: 2026-05-03
6
+
7
+ Adds app_links, a reusable per-application short-link table for browser
8
+ applications that need stable public URLs for compressed state.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000024"
14
+ down_revision = "000000000018"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ CREATE TABLE IF NOT EXISTS app_links (
22
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ application_id UUID NOT NULL
24
+ REFERENCES applications(id) ON DELETE CASCADE,
25
+ owner_user_id UUID NOT NULL
26
+ REFERENCES users(id) ON DELETE CASCADE,
27
+ owner_username VARCHAR(64) NOT NULL,
28
+ slug VARCHAR(48) NOT NULL,
29
+ app_slug VARCHAR(64) NOT NULL,
30
+ resource_kind VARCHAR(64) NOT NULL,
31
+ title VARCHAR(160),
32
+ target_path TEXT NOT NULL,
33
+ metadata JSONB DEFAULT '{}'::jsonb,
34
+ click_count INTEGER NOT NULL DEFAULT 0,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
36
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
37
+ CONSTRAINT uq_app_links_application_owner_slug
38
+ UNIQUE (application_id, owner_username, slug)
39
+ );
40
+ """)
41
+ op.execute("""
42
+ CREATE INDEX IF NOT EXISTS ix_app_links_application_owner_created
43
+ ON app_links (application_id, owner_user_id, created_at DESC);
44
+ """)
45
+ op.execute("""
46
+ CREATE INDEX IF NOT EXISTS ix_app_links_public_lookup
47
+ ON app_links (application_id, owner_username, slug);
48
+ """)
49
+
50
+
51
+ def downgrade() -> None:
52
+ op.execute("DROP INDEX IF EXISTS ix_app_links_public_lookup;")
53
+ op.execute("DROP INDEX IF EXISTS ix_app_links_application_owner_created;")
54
+ op.execute("DROP TABLE IF EXISTS app_links;")
55
+
@@ -0,0 +1,30 @@
1
+ """x402 SVM fee payer billing account ref
2
+
3
+ Revision ID: 000000000025
4
+ Revises: 000000000024
5
+ Create Date: 2026-05-03
6
+
7
+ Adds the trusted Solana fee-payer wallet reference required by exact SVM x402
8
+ payment requirements.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000025"
14
+ down_revision = "000000000024"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ ALTER TABLE billing_accounts
22
+ ADD COLUMN IF NOT EXISTS x402_fee_payer_wallet_ref TEXT;
23
+ """)
24
+
25
+
26
+ def downgrade() -> None:
27
+ op.execute("""
28
+ ALTER TABLE billing_accounts
29
+ DROP COLUMN IF EXISTS x402_fee_payer_wallet_ref;
30
+ """)
@@ -4,7 +4,7 @@ services:
4
4
  context: .
5
5
  dockerfile: Dockerfile
6
6
  args:
7
- SPAPS_SERVER_QUICKSTART_VERSION: ${SPAPS_SERVER_QUICKSTART_VERSION:-0.5.0}
7
+ SPAPS_SERVER_QUICKSTART_VERSION: ${SPAPS_SERVER_QUICKSTART_VERSION:-0.5.1}
8
8
  restart: unless-stopped
9
9
  ports:
10
10
  - "${SPAPS_LOCAL_PORT:-3301}:8000"
@@ -1,5 +1,5 @@
1
1
  {
2
- "spaps_server_quickstart_version": "0.5.0",
2
+ "spaps_server_quickstart_version": "0.5.1",
3
3
  "portable_runtime": {
4
4
  "default_local_mode": true,
5
5
  "default_auth_base_url": "http://localhost:5173",
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.7.8",
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;
@@ -17,6 +17,7 @@ const FIXTURE_KEYS = {
17
17
  profile: 'spaps.fixture.profile',
18
18
  roles: 'spaps.fixture.roles',
19
19
  entitlements: 'spaps.fixture.entitlements',
20
+ scenario: 'spaps.fixture.scenario',
20
21
  application: 'spaps.fixture.application',
21
22
  runtime: 'spaps.fixture.runtime',
22
23
  selector: 'spaps.fixture.selector',
@@ -305,6 +306,58 @@ const DEFAULT_PERSONAS = [
305
306
  },
306
307
  ],
307
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
+ }),
308
361
  makeDerivedPersona('email-local-safe', 'Email Local Safe', 'user', {
309
362
  profile: {
310
363
  username: 'email-local-safe',
@@ -592,6 +645,8 @@ const DEFAULT_ROLE_GRANTS = {
592
645
  'dayrate-guest': ['user'],
593
646
  'dayrate-entitled': ['user'],
594
647
  'dayrate-overage': ['user'],
648
+ 'cfo-billing-demo': ['admin', 'super_admin'],
649
+ 'htma-local-auth': ['user'],
595
650
  'email-local-safe': ['user'],
596
651
  'webhook-signature-invalid': ['admin', 'super_admin'],
597
652
  'webhook-replay': ['admin', 'super_admin'],
@@ -608,6 +663,27 @@ const DEFAULT_ENTITLEMENT_GRANTS = {
608
663
  'dayrate-guest': [],
609
664
  'dayrate-entitled': ['fixture.dayrate.free.{{global.run_id}}'],
610
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'],
611
687
  'email-local-safe': [],
612
688
  'webhook-signature-invalid': [],
613
689
  'webhook-replay': [],
@@ -617,6 +693,37 @@ const DEFAULT_ENTITLEMENT_GRANTS = {
617
693
  'issue-reporter-blocked': [],
618
694
  };
619
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
+
620
727
  function createCliError(code, message) {
621
728
  const error = new Error(message);
622
729
  error.code = code;
@@ -782,12 +889,14 @@ Common workflow:
782
889
  1. Run \`npx spaps fixtures init\`
783
890
  2. Edit \`.spaps/users.json\`, \`.spaps/roles.json\`, or \`.spaps/entitlements.json\`
784
891
  3. Run \`npx spaps fixtures apply\`
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
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
788
896
 
789
897
  Notes:
790
898
 
899
+ - \`--sync-server\` only runs against a reachable localhost SPAPS server with local mode active
791
900
  - \`--seed\` only runs against a reachable SPAPS server with local mode active
792
901
  - Seed steps use the persona metadata already in \`.spaps/users.json\`; no extra backend-only fixture routes are required
793
902
  `;
@@ -1078,6 +1187,15 @@ function buildSeedUrl(apiUrl, requestPath, queryParams = {}) {
1078
1187
  return url.toString();
1079
1188
  }
1080
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
+
1081
1199
  function assertSeedingRuntime(runtime, port) {
1082
1200
  if (!runtime?.running) {
1083
1201
  throw createCliError(
@@ -1094,6 +1212,29 @@ function assertSeedingRuntime(runtime, port) {
1094
1212
  }
1095
1213
  }
1096
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
+
1097
1238
  async function runSeedSequence({
1098
1239
  appConfig,
1099
1240
  usersConfig,
@@ -1237,6 +1378,136 @@ async function runFixtureSeeding({
1237
1378
  };
1238
1379
  }
1239
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
+
1240
1511
  function stringifyStorageValue(value) {
1241
1512
  if (typeof value === 'string') {
1242
1513
  return value;
@@ -1253,10 +1524,18 @@ function buildRouteHint(appConfig, persona) {
1253
1524
  }
1254
1525
 
1255
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
+
1256
1535
  return {
1257
1536
  format: 'playwright-extra-http-headers',
1258
1537
  local_mode_active: appConfig.server.local_mode_active === true,
1259
- headers: persona.selector?.headers || {},
1538
+ headers,
1260
1539
  route_hint: buildRouteHint(appConfig, persona),
1261
1540
  note:
1262
1541
  appConfig.server.local_mode_active === true
@@ -1297,31 +1576,32 @@ function buildFixtureTokens(appConfig, persona, roles) {
1297
1576
  }
1298
1577
 
1299
1578
  function buildEntitlementRecords(appConfig, persona, entitlementsConfig) {
1300
- const entitlementKeys = entitlementsConfig.grants?.[persona.code] || [];
1579
+ const entitlementGrants = (entitlementsConfig.grants?.[persona.code] || []).map(normalizeEntitlementGrant);
1301
1580
  const application = buildApplicationSummary(appConfig, persona);
1302
1581
 
1303
- return entitlementKeys.map((entitlementKey, index) => ({
1582
+ return entitlementGrants.map((grant, index) => ({
1304
1583
  id: `fixture-${persona.code}-${index + 1}`,
1305
1584
  application_id: application.application_id,
1306
1585
  beneficiary_user_id: persona.profile?.user_id || null,
1307
1586
  beneficiary_email: persona.profile?.email || null,
1308
- entitlement_key: entitlementKey,
1309
- entitlement_type: 'manual',
1587
+ entitlement_key: grant.key,
1588
+ entitlement_type: grant.entitlement_type || 'manual',
1310
1589
  source: 'fixture',
1311
- resource_type: 'user',
1312
- resource_id: null,
1590
+ resource_type: grant.resource_type || 'user',
1591
+ resource_id: grant.resource_id || null,
1313
1592
  starts_at: '2026-01-01T00:00:00.000Z',
1314
1593
  ends_at: null,
1315
1594
  revoked_at: null,
1316
1595
  metadata: {
1317
1596
  fixture_persona: persona.code,
1597
+ ...(grant.metadata || {}),
1318
1598
  },
1319
1599
  }));
1320
1600
  }
1321
1601
 
1322
1602
  function buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig) {
1323
1603
  const roles = rolesConfig.grants?.[persona.code] || [];
1324
- const entitlements = entitlementsConfig.grants?.[persona.code] || [];
1604
+ const entitlements = entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []);
1325
1605
  const permissions = Array.isArray(persona.permissions) ? persona.permissions : [];
1326
1606
  const primaryRole = roles[0] || 'user';
1327
1607
  const application = buildApplicationSummary(appConfig, persona);
@@ -1382,8 +1662,9 @@ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
1382
1662
  application: buildApplicationSummary(appConfig, persona),
1383
1663
  user: buildCurrentUser(appConfig, persona, rolesConfig, entitlementsConfig),
1384
1664
  tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
1385
- entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
1665
+ entitlement_keys: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
1386
1666
  entitlements: buildEntitlementRecords(appConfig, persona, entitlementsConfig),
1667
+ scenario: persona.scenario || {},
1387
1668
  browser: {
1388
1669
  local_storage: persona.browser?.local_storage || {},
1389
1670
  },
@@ -1407,8 +1688,14 @@ function buildDevAuthBridgeScript(config) {
1407
1688
  const PERSONA_LOCAL_STORAGE_KEYS = Array.from(new Set(
1408
1689
  CONFIG.personas.flatMap((persona) => Object.keys(persona.browser?.local_storage || {}))
1409
1690
  ));
1691
+ const personaChangeListeners = [];
1410
1692
  const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
1411
1693
 
1694
+ function clone(value) {
1695
+ if (value === undefined || value === null) return value;
1696
+ return JSON.parse(JSON.stringify(value));
1697
+ }
1698
+
1412
1699
  function toAbsoluteUrl(input) {
1413
1700
  if (input instanceof Request) return new URL(input.url, window.location.origin);
1414
1701
  return new URL(String(input), window.location.origin);
@@ -1427,6 +1714,63 @@ function buildDevAuthBridgeScript(config) {
1427
1714
  return getPersonaByCode(localStorage.getItem(ACTIVE_PERSONA_KEY)) || getPersonaByCode(getInitialPersonaCode()) || CONFIG.personas[0];
1428
1715
  }
1429
1716
 
1717
+ function getScenario(namespace) {
1718
+ const scenario = getCurrentPersona().scenario || {};
1719
+ if (!namespace) return clone(scenario);
1720
+ return Object.prototype.hasOwnProperty.call(scenario, namespace) ? clone(scenario[namespace]) : null;
1721
+ }
1722
+
1723
+ function publicPersona(persona) {
1724
+ if (!persona) return null;
1725
+ return {
1726
+ code: persona.code,
1727
+ display_name: persona.display_name,
1728
+ selector: clone(persona.selector || {}),
1729
+ route_hint: persona.route_hint || null,
1730
+ application: clone(persona.application || CONFIG.application),
1731
+ user: clone(persona.user),
1732
+ roles: clone(persona.user?.roles || []),
1733
+ entitlements: clone(persona.entitlement_keys || []),
1734
+ scenario: clone(persona.scenario || {})
1735
+ };
1736
+ }
1737
+
1738
+ function buildPersonaChangeDetail(previousPersona, persona) {
1739
+ return {
1740
+ persona: publicPersona(persona),
1741
+ previous_persona: publicPersona(previousPersona),
1742
+ user: clone(persona?.user || null),
1743
+ scenario: clone(persona?.scenario || {})
1744
+ };
1745
+ }
1746
+
1747
+ function emitPersonaChange(previousPersona, persona) {
1748
+ const detail = buildPersonaChangeDetail(previousPersona, persona);
1749
+ personaChangeListeners.slice().forEach((listener) => {
1750
+ try {
1751
+ listener(detail);
1752
+ } catch (error) {
1753
+ console.error('[spaps-dev-auth] persona change listener failed', error);
1754
+ }
1755
+ });
1756
+
1757
+ if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
1758
+ window.dispatchEvent(new CustomEvent('spaps:persona-change', { detail }));
1759
+ }
1760
+ }
1761
+
1762
+ function onPersonaChange(callback) {
1763
+ if (typeof callback !== 'function') {
1764
+ return function noopUnsubscribe() {};
1765
+ }
1766
+
1767
+ personaChangeListeners.push(callback);
1768
+ return function unsubscribe() {
1769
+ const index = personaChangeListeners.indexOf(callback);
1770
+ if (index >= 0) personaChangeListeners.splice(index, 1);
1771
+ };
1772
+ }
1773
+
1430
1774
  function clearPersonaBrowserStorage() {
1431
1775
  PERSONA_LOCAL_STORAGE_KEYS.forEach((key) => {
1432
1776
  localStorage.removeItem(key);
@@ -1441,6 +1785,7 @@ function buildDevAuthBridgeScript(config) {
1441
1785
  localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
1442
1786
  localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
1443
1787
  localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
1788
+ localStorage.setItem(STORAGE_KEYS.scenario, JSON.stringify(persona.scenario || {}));
1444
1789
  localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(persona.application || CONFIG.application));
1445
1790
  localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
1446
1791
  auth_mode: CONFIG.auth_mode,
@@ -1458,11 +1803,21 @@ function buildDevAuthBridgeScript(config) {
1458
1803
  }
1459
1804
 
1460
1805
  function clearSession() {
1461
- [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) => {
1806
+ [ACTIVE_PERSONA_KEY, STORAGE_KEYS.persona, STORAGE_KEYS.profile, STORAGE_KEYS.roles, STORAGE_KEYS.entitlements, STORAGE_KEYS.scenario, STORAGE_KEYS.application, STORAGE_KEYS.runtime, STORAGE_KEYS.selector, TOKEN_USER_KEY, TOKEN_ACCESS_KEY, TOKEN_REFRESH_KEY, LEGACY_USER_KEY, ...PERSONA_LOCAL_STORAGE_KEYS].forEach((key) => {
1462
1807
  localStorage.removeItem(key);
1463
1808
  });
1464
1809
  }
1465
1810
 
1811
+ function switchPersona(code, options) {
1812
+ const nextPersona = getPersonaByCode(code);
1813
+ if (!nextPersona) return null;
1814
+ const previousPersona = getCurrentPersona();
1815
+ persistPersona(nextPersona);
1816
+ emitPersonaChange(previousPersona, nextPersona);
1817
+ if (!options || options.reload !== false) window.location.reload();
1818
+ return nextPersona;
1819
+ }
1820
+
1466
1821
  function response(body, status) {
1467
1822
  return Promise.resolve(new Response(JSON.stringify(body), {
1468
1823
  status: status || 200,
@@ -1625,10 +1980,7 @@ function buildDevAuthBridgeScript(config) {
1625
1980
  button.textContent = 'Switch';
1626
1981
  button.style.cssText = 'background:#f59e0b;color:#111827;border:0;border-radius:8px;padding:4px 8px;cursor:pointer;font-weight:600;';
1627
1982
  button.onclick = function () {
1628
- const nextPersona = getPersonaByCode(select.value);
1629
- if (!nextPersona) return;
1630
- persistPersona(nextPersona);
1631
- window.location.reload();
1983
+ switchPersona(select.value);
1632
1984
  };
1633
1985
 
1634
1986
  container.appendChild(label);
@@ -1648,20 +2000,17 @@ function buildDevAuthBridgeScript(config) {
1648
2000
  code: persona.code,
1649
2001
  display_name: persona.display_name,
1650
2002
  entitlements: persona.entitlement_keys,
1651
- roles: persona.user.roles || []
2003
+ roles: persona.user.roles || [],
2004
+ scenario: persona.scenario || {}
1652
2005
  }));
1653
2006
  },
1654
2007
  getCurrentPersona: getCurrentPersona,
1655
2008
  getCurrentUser: function () {
1656
2009
  return getCurrentPersona().user;
1657
2010
  },
1658
- switchPersona: function (code, options) {
1659
- const nextPersona = getPersonaByCode(code);
1660
- if (!nextPersona) return null;
1661
- persistPersona(nextPersona);
1662
- if (!options || options.reload !== false) window.location.reload();
1663
- return nextPersona;
1664
- },
2011
+ getScenario: getScenario,
2012
+ onPersonaChange: onPersonaChange,
2013
+ switchPersona: switchPersona,
1665
2014
  clearSession: clearSession,
1666
2015
  installFetchBridge: installFetchBridge
1667
2016
  };
@@ -1697,7 +2046,11 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
1697
2046
  },
1698
2047
  {
1699
2048
  name: FIXTURE_KEYS.entitlements,
1700
- value: stringifyStorageValue(entitlementsConfig.grants?.[persona.code] || []),
2049
+ value: stringifyStorageValue(entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || [])),
2050
+ },
2051
+ {
2052
+ name: FIXTURE_KEYS.scenario,
2053
+ value: stringifyStorageValue(persona.scenario || {}),
1701
2054
  },
1702
2055
  {
1703
2056
  name: FIXTURE_KEYS.application,
@@ -1764,7 +2117,7 @@ function buildPersonaContext(appConfig, persona, rolesConfig, entitlementsConfig
1764
2117
  profile: persona.profile || {},
1765
2118
  scenario: persona.scenario || {},
1766
2119
  roles: rolesConfig.grants?.[persona.code] || [],
1767
- entitlements: entitlementsConfig.grants?.[persona.code] || [],
2120
+ entitlements: entitlementGrantKeys(entitlementsConfig.grants?.[persona.code] || []),
1768
2121
  user,
1769
2122
  tokens,
1770
2123
  application,
@@ -1785,6 +2138,7 @@ function writePersonaArtifacts({
1785
2138
  entitlements,
1786
2139
  personaCode = null,
1787
2140
  seedResultsByPersona = {},
2141
+ serverSync = null,
1788
2142
  }) {
1789
2143
  const selectedCodes = personaCode ? [ensurePersona(users, personaCode).code] : users.personas.map((persona) => persona.code);
1790
2144
  const generated = [];
@@ -1795,6 +2149,7 @@ function writePersonaArtifacts({
1795
2149
  const headers = buildHeaderArtifact(appConfig, persona);
1796
2150
  const context = buildPersonaContext(appConfig, persona, roles, entitlements, paths);
1797
2151
  context.seed = seedResultsByPersona[code] || null;
2152
+ context.server_sync = serverSync?.personas?.find((entry) => entry.code === code) || null;
1798
2153
 
1799
2154
  writeJson(context.artifacts.storage_state_path, storageState);
1800
2155
  writeJson(context.artifacts.headers_path, headers);
@@ -1816,6 +2171,20 @@ function writePersonaArtifacts({
1816
2171
  base_url: appConfig.browser.base_url,
1817
2172
  api_url: appConfig.server.api_url,
1818
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,
1819
2188
  });
1820
2189
 
1821
2190
  return generated;
@@ -1841,6 +2210,7 @@ async function applyFixtures({
1841
2210
  version = '0.0.0',
1842
2211
  persona = null,
1843
2212
  seed = false,
2213
+ syncServer = false,
1844
2214
  subcommand = 'apply',
1845
2215
  } = {}) {
1846
2216
  const rootDir = resolveRepoRoot(dir);
@@ -1872,22 +2242,46 @@ async function applyFixtures({
1872
2242
  });
1873
2243
 
1874
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
+ }
1875
2266
  const seeding = seed
1876
2267
  ? await runFixtureSeeding({
1877
2268
  appConfig,
1878
2269
  runtime,
1879
- usersConfig: kernel.users,
2270
+ usersConfig: resolvedUsers,
1880
2271
  personaCode: persona,
1881
2272
  fixtureRunState,
1882
2273
  port,
1883
2274
  })
1884
2275
  : 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);
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
+ }
1891
2285
  const seedResultsByPersona = Object.fromEntries(
1892
2286
  (seeding?.personas || []).map((entry) => [entry.persona, entry])
1893
2287
  );
@@ -1901,6 +2295,7 @@ async function applyFixtures({
1901
2295
  entitlements: resolvedEntitlements,
1902
2296
  personaCode: persona,
1903
2297
  seedResultsByPersona,
2298
+ serverSync,
1904
2299
  });
1905
2300
  const bridge = writeDevAuthBridge({
1906
2301
  rootDir,
@@ -1919,6 +2314,7 @@ async function applyFixtures({
1919
2314
  fixture_dir: paths.fixtureDir,
1920
2315
  bootstrapped,
1921
2316
  runtime,
2317
+ server_sync: serverSync,
1922
2318
  seeding,
1923
2319
  generated: {
1924
2320
  personas: generated,
@@ -1954,6 +2350,7 @@ async function exportStorageState(options = {}) {
1954
2350
  route_hint: personaArtifact.route_hint,
1955
2351
  bridge: result.generated.bridge,
1956
2352
  runtime: result.runtime,
2353
+ server_sync: result.server_sync,
1957
2354
  seeding: result.seeding,
1958
2355
  next_steps: result.next_steps,
1959
2356
  };
@@ -1965,6 +2362,7 @@ async function resetFixtures({
1965
2362
  baseUrl = null,
1966
2363
  version = '0.0.0',
1967
2364
  seed = false,
2365
+ syncServer = false,
1968
2366
  } = {}) {
1969
2367
  const rootDir = resolveRepoRoot(dir);
1970
2368
  const paths = resolveFixturePaths(rootDir);
@@ -1982,6 +2380,7 @@ async function resetFixtures({
1982
2380
  baseUrl,
1983
2381
  version,
1984
2382
  seed,
2383
+ syncServer,
1985
2384
  });
1986
2385
 
1987
2386
  return {
@@ -1995,6 +2394,7 @@ async function resetFixtures({
1995
2394
  files_overwritten: initResult.files_overwritten,
1996
2395
  generated: applyResult.generated,
1997
2396
  runtime: applyResult.runtime,
2397
+ server_sync: applyResult.server_sync,
1998
2398
  seeding: applyResult.seeding,
1999
2399
  next_steps: applyResult.next_steps,
2000
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
  }