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 +52 -2
- package/assets/local-runtime/Dockerfile +12 -1
- package/assets/local-runtime/alembic/versions/000000000024_app_links.py +55 -0
- package/assets/local-runtime/alembic/versions/000000000025_x402_svm_fee_payer.py +30 -0
- package/assets/local-runtime/docker-compose.yml +1 -1
- package/assets/local-runtime/manifest.json +1 -1
- package/client.js +18 -7
- package/package.json +1 -1
- package/src/cli-dispatcher.js +21 -0
- package/src/docs-html.js +3 -3
- package/src/docs-quick.js +1 -1
- package/src/docs-system.js +2 -2
- package/src/domains.js +19 -4
- package/src/fixture-kernel.js +434 -34
- package/src/handlers.js +60 -0
- package/src/help-system.js +1 -1
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.
|
|
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.
|
|
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"
|
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/
|
|
74
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/src/cli-dispatcher.js
CHANGED
|
@@ -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
|
|
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:'),
|
package/src/docs-system.js
CHANGED
|
@@ -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.
|
|
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/{
|
|
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/{
|
|
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/{
|
|
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;
|
package/src/fixture-kernel.js
CHANGED
|
@@ -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 --
|
|
786
|
-
5.
|
|
787
|
-
6.
|
|
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
|
|
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
|
|
1579
|
+
const entitlementGrants = (entitlementsConfig.grants?.[persona.code] || []).map(normalizeEntitlementGrant);
|
|
1301
1580
|
const application = buildApplicationSummary(appConfig, persona);
|
|
1302
1581
|
|
|
1303
|
-
return
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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:
|
|
2270
|
+
usersConfig: resolvedUsers,
|
|
1880
2271
|
personaCode: persona,
|
|
1881
2272
|
fixtureRunState,
|
|
1882
2273
|
port,
|
|
1883
2274
|
})
|
|
1884
2275
|
: null;
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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);
|
package/src/help-system.js
CHANGED
|
@@ -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
|
}
|