spaps 0.7.8 → 0.8.0
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 +32 -0
- 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/package.json +1 -1
- package/src/fixture-kernel.js +87 -13
package/README.md
CHANGED
|
@@ -295,6 +295,38 @@ That bridge does three things:
|
|
|
295
295
|
- intercepts `/api/auth/user`, `/api/auth/login`, `/api/auth/logout`, `/api/entitlements`, and `/api/entitlements/check`
|
|
296
296
|
- renders a tiny persona switcher so you can flip between `user`, `admin`, and `premium`
|
|
297
297
|
|
|
298
|
+
Personas can also carry app-owned demo state in `.spaps/users.json`:
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{
|
|
302
|
+
"code": "pds-initiator",
|
|
303
|
+
"display_name": "PDS Initiator",
|
|
304
|
+
"profile": { "user_id": "00000000-0000-0000-0000-000000000001" },
|
|
305
|
+
"scenario": {
|
|
306
|
+
"pds": {
|
|
307
|
+
"dashboard": "work",
|
|
308
|
+
"role": "initiator"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`scenario` is opaque to SPAPS. It is copied into `browser/<persona>.context.json`, persisted as `spaps.fixture.scenario`, and exposed by the bridge:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
const pdsScenario = window.__SPAPS_DEV_AUTH__.getScenario("pds");
|
|
318
|
+
|
|
319
|
+
const unsubscribe = window.__SPAPS_DEV_AUTH__.onPersonaChange((event) => {
|
|
320
|
+
console.log(event.persona.code, event.scenario.pds);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
window.addEventListener("spaps:persona-change", (event) => {
|
|
324
|
+
console.log(event.detail.persona.code);
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
298
330
|
## Middleware Example
|
|
299
331
|
|
|
300
332
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spaps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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/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',
|
|
@@ -1384,6 +1385,7 @@ function buildBridgeConfig(appConfig, users, rolesConfig, entitlementsConfig) {
|
|
|
1384
1385
|
tokens: buildFixtureTokens(appConfig, persona, rolesConfig.grants?.[persona.code] || []),
|
|
1385
1386
|
entitlement_keys: entitlementsConfig.grants?.[persona.code] || [],
|
|
1386
1387
|
entitlements: buildEntitlementRecords(appConfig, persona, entitlementsConfig),
|
|
1388
|
+
scenario: persona.scenario || {},
|
|
1387
1389
|
browser: {
|
|
1388
1390
|
local_storage: persona.browser?.local_storage || {},
|
|
1389
1391
|
},
|
|
@@ -1407,8 +1409,14 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1407
1409
|
const PERSONA_LOCAL_STORAGE_KEYS = Array.from(new Set(
|
|
1408
1410
|
CONFIG.personas.flatMap((persona) => Object.keys(persona.browser?.local_storage || {}))
|
|
1409
1411
|
));
|
|
1412
|
+
const personaChangeListeners = [];
|
|
1410
1413
|
const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
|
|
1411
1414
|
|
|
1415
|
+
function clone(value) {
|
|
1416
|
+
if (value === undefined || value === null) return value;
|
|
1417
|
+
return JSON.parse(JSON.stringify(value));
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1412
1420
|
function toAbsoluteUrl(input) {
|
|
1413
1421
|
if (input instanceof Request) return new URL(input.url, window.location.origin);
|
|
1414
1422
|
return new URL(String(input), window.location.origin);
|
|
@@ -1427,6 +1435,63 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1427
1435
|
return getPersonaByCode(localStorage.getItem(ACTIVE_PERSONA_KEY)) || getPersonaByCode(getInitialPersonaCode()) || CONFIG.personas[0];
|
|
1428
1436
|
}
|
|
1429
1437
|
|
|
1438
|
+
function getScenario(namespace) {
|
|
1439
|
+
const scenario = getCurrentPersona().scenario || {};
|
|
1440
|
+
if (!namespace) return clone(scenario);
|
|
1441
|
+
return Object.prototype.hasOwnProperty.call(scenario, namespace) ? clone(scenario[namespace]) : null;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function publicPersona(persona) {
|
|
1445
|
+
if (!persona) return null;
|
|
1446
|
+
return {
|
|
1447
|
+
code: persona.code,
|
|
1448
|
+
display_name: persona.display_name,
|
|
1449
|
+
selector: clone(persona.selector || {}),
|
|
1450
|
+
route_hint: persona.route_hint || null,
|
|
1451
|
+
application: clone(persona.application || CONFIG.application),
|
|
1452
|
+
user: clone(persona.user),
|
|
1453
|
+
roles: clone(persona.user?.roles || []),
|
|
1454
|
+
entitlements: clone(persona.entitlement_keys || []),
|
|
1455
|
+
scenario: clone(persona.scenario || {})
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function buildPersonaChangeDetail(previousPersona, persona) {
|
|
1460
|
+
return {
|
|
1461
|
+
persona: publicPersona(persona),
|
|
1462
|
+
previous_persona: publicPersona(previousPersona),
|
|
1463
|
+
user: clone(persona?.user || null),
|
|
1464
|
+
scenario: clone(persona?.scenario || {})
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function emitPersonaChange(previousPersona, persona) {
|
|
1469
|
+
const detail = buildPersonaChangeDetail(previousPersona, persona);
|
|
1470
|
+
personaChangeListeners.slice().forEach((listener) => {
|
|
1471
|
+
try {
|
|
1472
|
+
listener(detail);
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
console.error('[spaps-dev-auth] persona change listener failed', error);
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
|
|
1479
|
+
window.dispatchEvent(new CustomEvent('spaps:persona-change', { detail }));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function onPersonaChange(callback) {
|
|
1484
|
+
if (typeof callback !== 'function') {
|
|
1485
|
+
return function noopUnsubscribe() {};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
personaChangeListeners.push(callback);
|
|
1489
|
+
return function unsubscribe() {
|
|
1490
|
+
const index = personaChangeListeners.indexOf(callback);
|
|
1491
|
+
if (index >= 0) personaChangeListeners.splice(index, 1);
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1430
1495
|
function clearPersonaBrowserStorage() {
|
|
1431
1496
|
PERSONA_LOCAL_STORAGE_KEYS.forEach((key) => {
|
|
1432
1497
|
localStorage.removeItem(key);
|
|
@@ -1441,6 +1506,7 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1441
1506
|
localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(persona.user));
|
|
1442
1507
|
localStorage.setItem(STORAGE_KEYS.roles, JSON.stringify(persona.user.roles || []));
|
|
1443
1508
|
localStorage.setItem(STORAGE_KEYS.entitlements, JSON.stringify(persona.entitlement_keys || []));
|
|
1509
|
+
localStorage.setItem(STORAGE_KEYS.scenario, JSON.stringify(persona.scenario || {}));
|
|
1444
1510
|
localStorage.setItem(STORAGE_KEYS.application, JSON.stringify(persona.application || CONFIG.application));
|
|
1445
1511
|
localStorage.setItem(STORAGE_KEYS.runtime, JSON.stringify({
|
|
1446
1512
|
auth_mode: CONFIG.auth_mode,
|
|
@@ -1458,11 +1524,21 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1458
1524
|
}
|
|
1459
1525
|
|
|
1460
1526
|
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) => {
|
|
1527
|
+
[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
1528
|
localStorage.removeItem(key);
|
|
1463
1529
|
});
|
|
1464
1530
|
}
|
|
1465
1531
|
|
|
1532
|
+
function switchPersona(code, options) {
|
|
1533
|
+
const nextPersona = getPersonaByCode(code);
|
|
1534
|
+
if (!nextPersona) return null;
|
|
1535
|
+
const previousPersona = getCurrentPersona();
|
|
1536
|
+
persistPersona(nextPersona);
|
|
1537
|
+
emitPersonaChange(previousPersona, nextPersona);
|
|
1538
|
+
if (!options || options.reload !== false) window.location.reload();
|
|
1539
|
+
return nextPersona;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1466
1542
|
function response(body, status) {
|
|
1467
1543
|
return Promise.resolve(new Response(JSON.stringify(body), {
|
|
1468
1544
|
status: status || 200,
|
|
@@ -1625,10 +1701,7 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1625
1701
|
button.textContent = 'Switch';
|
|
1626
1702
|
button.style.cssText = 'background:#f59e0b;color:#111827;border:0;border-radius:8px;padding:4px 8px;cursor:pointer;font-weight:600;';
|
|
1627
1703
|
button.onclick = function () {
|
|
1628
|
-
|
|
1629
|
-
if (!nextPersona) return;
|
|
1630
|
-
persistPersona(nextPersona);
|
|
1631
|
-
window.location.reload();
|
|
1704
|
+
switchPersona(select.value);
|
|
1632
1705
|
};
|
|
1633
1706
|
|
|
1634
1707
|
container.appendChild(label);
|
|
@@ -1648,20 +1721,17 @@ function buildDevAuthBridgeScript(config) {
|
|
|
1648
1721
|
code: persona.code,
|
|
1649
1722
|
display_name: persona.display_name,
|
|
1650
1723
|
entitlements: persona.entitlement_keys,
|
|
1651
|
-
roles: persona.user.roles || []
|
|
1724
|
+
roles: persona.user.roles || [],
|
|
1725
|
+
scenario: persona.scenario || {}
|
|
1652
1726
|
}));
|
|
1653
1727
|
},
|
|
1654
1728
|
getCurrentPersona: getCurrentPersona,
|
|
1655
1729
|
getCurrentUser: function () {
|
|
1656
1730
|
return getCurrentPersona().user;
|
|
1657
1731
|
},
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
persistPersona(nextPersona);
|
|
1662
|
-
if (!options || options.reload !== false) window.location.reload();
|
|
1663
|
-
return nextPersona;
|
|
1664
|
-
},
|
|
1732
|
+
getScenario: getScenario,
|
|
1733
|
+
onPersonaChange: onPersonaChange,
|
|
1734
|
+
switchPersona: switchPersona,
|
|
1665
1735
|
clearSession: clearSession,
|
|
1666
1736
|
installFetchBridge: installFetchBridge
|
|
1667
1737
|
};
|
|
@@ -1699,6 +1769,10 @@ function buildStorageStateArtifact(appConfig, persona, rolesConfig, entitlements
|
|
|
1699
1769
|
name: FIXTURE_KEYS.entitlements,
|
|
1700
1770
|
value: stringifyStorageValue(entitlementsConfig.grants?.[persona.code] || []),
|
|
1701
1771
|
},
|
|
1772
|
+
{
|
|
1773
|
+
name: FIXTURE_KEYS.scenario,
|
|
1774
|
+
value: stringifyStorageValue(persona.scenario || {}),
|
|
1775
|
+
},
|
|
1702
1776
|
{
|
|
1703
1777
|
name: FIXTURE_KEYS.application,
|
|
1704
1778
|
value: stringifyStorageValue(application),
|