spaps 0.9.0 → 0.9.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/src/handlers.js CHANGED
@@ -509,9 +509,16 @@ function createHandlers(version, logo) {
509
509
  },
510
510
  doctor: async ({ options }) => {
511
511
  const { runDoctor } = loadDoctor();
512
- await runDoctor({ port: options.port || DEFAULT_PORT, stripe: options.stripe || null, json: options.json });
512
+ await runDoctor({
513
+ port: options.port || DEFAULT_PORT,
514
+ serverUrl: options.serverUrl || null,
515
+ origin: options.origin || null,
516
+ stripe: options.stripe || null,
517
+ json: options.json,
518
+ });
513
519
  },
514
520
  test: verifyHandler,
521
+ auth: async (...args) => loadAuthCommandHandlers().authHandler(...args),
515
522
  login: async (...args) => loadAuthCommandHandlers().loginHandler(...args),
516
523
  logout: async (...args) => loadAuthCommandHandlers().logoutHandler(...args),
517
524
  whoami: async (...args) => loadAuthCommandHandlers().whoamiHandler(...args),
@@ -522,6 +529,11 @@ function createHandlers(version, logo) {
522
529
  policy: policyHandler,
523
530
  webhook: webhookHandler,
524
531
  'issue-reports': issueReportsHandler,
532
+ access: capabilityHandler,
533
+ journey: capabilityHandler,
534
+ graph: capabilityHandler,
535
+ explain: capabilityHandler,
536
+ contract: capabilityHandler,
525
537
  };
526
538
  }
527
539
 
@@ -593,6 +605,250 @@ function assignIfDefined(target, key, value) {
593
605
  }
594
606
  }
595
607
 
608
+ function splitCsv(raw) {
609
+ if (!raw) return [];
610
+ return String(raw)
611
+ .split(',')
612
+ .map((value) => value.trim())
613
+ .filter(Boolean);
614
+ }
615
+
616
+ function extractErrorObject(result) {
617
+ const candidates = [result?.raw, result?.data];
618
+ for (const candidate of candidates) {
619
+ if (!candidate || typeof candidate !== 'object') continue;
620
+ if (candidate.error && typeof candidate.error === 'object') return candidate.error;
621
+ if (typeof candidate.code === 'string' || typeof candidate.message === 'string') {
622
+ return candidate;
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ function extractErrorMessage(result) {
629
+ const error = extractErrorObject(result);
630
+ return (
631
+ error?.message ||
632
+ result?.data?.detail ||
633
+ (result?.status ? `HTTP ${result.status}` : 'Request failed')
634
+ );
635
+ }
636
+
637
+ function extractErrorCode(result) {
638
+ const error = extractErrorObject(result);
639
+ return error?.code || (result?.status ? `http_${result.status}` : 'request_failed');
640
+ }
641
+
642
+ function extractRequestId(result) {
643
+ const raw = result?.raw;
644
+ if (!raw || typeof raw !== 'object') return null;
645
+ return raw.request_id || raw.metadata?.request_id || null;
646
+ }
647
+
648
+ function extractSources(data) {
649
+ if (Array.isArray(data?.sources)) return data.sources;
650
+ if (Array.isArray(data?.decision?.sources)) return data.decision.sources;
651
+ return [];
652
+ }
653
+
654
+ function normalizeDiagnostic(item, fallbackStatus = null) {
655
+ const diagnostic = {
656
+ code: item?.code || (fallbackStatus ? `http_${fallbackStatus}` : 'request_failed'),
657
+ message: item?.message || 'Request failed',
658
+ status: item?.status ?? fallbackStatus,
659
+ };
660
+ assignIfDefined(diagnostic, 'details', item?.details);
661
+ return diagnostic;
662
+ }
663
+
664
+ function extractPayloadDiagnostics(result) {
665
+ const raw = result?.raw;
666
+ if (Array.isArray(raw?.diagnostics)) {
667
+ return raw.diagnostics.map((item) => normalizeDiagnostic(item, result?.status || null));
668
+ }
669
+ return [];
670
+ }
671
+
672
+ function extractRemediations(data) {
673
+ const actions = Array.isArray(data?.next_actions) ? data.next_actions : [];
674
+ return actions.map((action) => {
675
+ const remediation = {
676
+ kind: action.kind,
677
+ label: action.label || null,
678
+ method: action.method || null,
679
+ path: action.path || null,
680
+ cli: action.cli || null,
681
+ requires: Array.isArray(action.requires) ? action.requires : [],
682
+ };
683
+ assignIfDefined(remediation, 'command_template', action.command_template);
684
+ assignIfDefined(remediation, 'safe_to_execute', action.safe_to_execute);
685
+ assignIfDefined(remediation, 'operator_gate_required', action.operator_gate_required);
686
+ return remediation;
687
+ });
688
+ }
689
+
690
+ function normalizeRemediation(item) {
691
+ const remediation = {
692
+ kind: item.kind,
693
+ label: item.label || null,
694
+ method: item.method || null,
695
+ path: item.path || null,
696
+ cli: item.cli || null,
697
+ requires: Array.isArray(item.requires) ? item.requires : [],
698
+ };
699
+ assignIfDefined(remediation, 'details', item.details);
700
+ assignIfDefined(remediation, 'command_template', item.command_template);
701
+ assignIfDefined(remediation, 'safe_to_execute', item.safe_to_execute);
702
+ assignIfDefined(remediation, 'operator_gate_required', item.operator_gate_required);
703
+ return remediation;
704
+ }
705
+
706
+ function extractPayloadRemediations(result) {
707
+ const raw = result?.raw;
708
+ if (!Array.isArray(raw?.remediations)) return [];
709
+ return raw.remediations
710
+ .filter((item) => item && typeof item === 'object' && item.kind)
711
+ .map((item) => normalizeRemediation(item));
712
+ }
713
+
714
+ function capabilityEnvelope({ command, result, data = null, success = null, diagnostics = [] }) {
715
+ const resolvedData = data === null && result ? result.data : data;
716
+ const ok = success === null ? Boolean(result?.ok) : Boolean(success);
717
+ const envelopeDiagnostics = Array.isArray(diagnostics) ? diagnostics.slice() : [];
718
+ envelopeDiagnostics.push(...extractPayloadDiagnostics(result));
719
+ if (result && !result.ok) {
720
+ const hasServerDiagnostic = envelopeDiagnostics.length > 0;
721
+ if (!hasServerDiagnostic) {
722
+ const error = extractErrorObject(result);
723
+ const diagnostic = {
724
+ code: extractErrorCode(result),
725
+ message: extractErrorMessage(result),
726
+ status: result.status || null,
727
+ };
728
+ assignIfDefined(diagnostic, 'details', error?.details);
729
+ envelopeDiagnostics.push(diagnostic);
730
+ }
731
+ }
732
+ const payloadRemediations = extractPayloadRemediations(result);
733
+ const envelope = {
734
+ schema_version: 'spaps.cli.capability.v1',
735
+ command,
736
+ success: ok,
737
+ status: result?.status || null,
738
+ data: resolvedData,
739
+ diagnostics: envelopeDiagnostics,
740
+ remediations: payloadRemediations.length ? payloadRemediations : extractRemediations(resolvedData),
741
+ sources: extractSources(resolvedData),
742
+ };
743
+ assignIfDefined(envelope, 'request_id', extractRequestId(result));
744
+ assignIfDefined(envelope, 'timestamp', result?.raw?.timestamp);
745
+ return envelope;
746
+ }
747
+
748
+ function emitCapability({ command, result, isJson, exitCodeOnFailure = 10 }) {
749
+ if (isJson) {
750
+ console.log(JSON.stringify(capabilityEnvelope({ command, result }), null, 2));
751
+ } else if (!result.ok) {
752
+ console.error(`\u2717 ${command}: ${extractErrorMessage(result)}`);
753
+ } else {
754
+ const data = result.data || {};
755
+ if (typeof data.allowed === 'boolean') {
756
+ console.log(`${command}: ${data.allowed ? 'allowed' : data.outcome || 'not allowed'}`);
757
+ (data.reasons || []).forEach((reason) => {
758
+ console.log(` - ${reason.message || reason.code}`);
759
+ });
760
+ } else if (typeof data.status === 'string' && typeof data.allowed === 'boolean') {
761
+ console.log(`${command}: ${data.status}`);
762
+ } else {
763
+ console.log(JSON.stringify(data, null, 2));
764
+ }
765
+ }
766
+ if (!result.ok) {
767
+ process.exitCode = exitCodeOnFailure;
768
+ }
769
+ }
770
+
771
+ function emitCapabilityError({ command, err, isJson, exitCode = 10 }) {
772
+ const message = err && err.message ? err.message : String(err);
773
+ const code = err && err.code ? err.code : 'ERROR';
774
+ if (isJson) {
775
+ console.log(JSON.stringify({
776
+ schema_version: 'spaps.cli.capability.v1',
777
+ command,
778
+ success: false,
779
+ status: err?.status || null,
780
+ data: null,
781
+ diagnostics: [{ code, message, status: err?.status || null }],
782
+ remediations: code === 'NOT_AUTHENTICATED' || code === 'SESSION_EXPIRED'
783
+ ? [{ kind: 'authenticate', label: 'Run spaps login', cli: 'spaps login', requires: ['operator'] }]
784
+ : [],
785
+ sources: [],
786
+ }, null, 2));
787
+ } else {
788
+ const hint = code === 'NOT_AUTHENTICATED' || code === 'SESSION_EXPIRED'
789
+ ? ' (run `spaps login` first)'
790
+ : '';
791
+ console.error(`\u2717 ${command}: ${message}${hint}`);
792
+ }
793
+ process.exitCode = exitCode;
794
+ }
795
+
796
+ function buildAccessDecisionPayload(options, prefix) {
797
+ if (!requireOption(options.action, `${prefix}: --action is required`)) return null;
798
+ if (!requireOption(options.resourceType, `${prefix}: --resource-type is required`)) return null;
799
+ if (!requireOption(options.resourceRef, `${prefix}: --resource-ref is required`)) return null;
800
+
801
+ const context = parseJsonFlag(options.context, `${prefix}: --context`);
802
+ if (!context.ok) return null;
803
+ const policyContext = parseJsonFlag(options.policyContext, `${prefix}: --policy-context`);
804
+ if (!policyContext.ok) return null;
805
+ const usageDimensions = parseJsonFlag(options.usageDimensions, `${prefix}: --usage-dimensions`);
806
+ if (!usageDimensions.ok) return null;
807
+ const authenticated = parseBooleanFlag(options.authenticated, `${prefix}: --authenticated`);
808
+ if (!authenticated.ok) return null;
809
+
810
+ const actor = {
811
+ actor_type: options.actorType || 'user',
812
+ };
813
+ assignIfDefined(actor, 'actor_ref', options.actorRef);
814
+ assignIfDefined(actor, 'user_id', options.userId);
815
+ assignIfDefined(actor, 'email', options.email);
816
+ assignIfDefined(actor, 'agent_id', options.agentId);
817
+ assignIfDefined(actor, 'authenticated', authenticated.value);
818
+
819
+ const resource = {
820
+ resource_type: options.resourceType,
821
+ resource_ref: options.resourceRef,
822
+ };
823
+ assignIfDefined(resource, 'resource_id', options.resourceId);
824
+ assignIfDefined(resource, 'resource_key', options.resourceKey);
825
+
826
+ const controls = {
827
+ approval_required: Boolean(options.approvalRequired),
828
+ authority_scope: options.authorityScope || 'application',
829
+ };
830
+ assignIfDefined(controls, 'entitlement_key', options.entitlementKey);
831
+ assignIfDefined(controls, 'entitlement_resource_type', options.entitlementResourceType);
832
+ assignIfDefined(controls, 'entitlement_resource_id', options.entitlementResourceId);
833
+ assignIfDefined(controls, 'policy_name', options.policyName);
834
+ assignIfDefined(controls, 'policy_context', policyContext.value);
835
+ assignIfDefined(controls, 'usage_feature_key', options.usageFeatureKey);
836
+ assignIfDefined(controls, 'usage_dimensions', usageDimensions.value);
837
+ assignIfDefined(controls, 'x402_resource_key', options.x402ResourceKey);
838
+ assignIfDefined(controls, 'approval_id', options.approvalId);
839
+
840
+ const payload = {
841
+ actor,
842
+ action: options.action,
843
+ resource,
844
+ controls,
845
+ context: context.value || {},
846
+ };
847
+ assignIfDefined(payload, 'idempotency_key', options.idempotencyKey);
848
+ assignIfDefined(payload, 'correlation_id', options.correlationId);
849
+ return payload;
850
+ }
851
+
596
852
  async function dayrateHandler({ options }) {
597
853
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
598
854
  const isJson = Boolean(options.json);
@@ -983,4 +1239,137 @@ async function issueReportsHandler({ options }) {
983
1239
  }
984
1240
  }
985
1241
 
1242
+ async function capabilityHandler({ name, options }) {
1243
+ const { callEndpoint } = loadDomainCli();
1244
+ const isJson = Boolean(options.json);
1245
+ const command = name === 'access'
1246
+ ? `access.${options.subcommand || 'unknown'}`
1247
+ : name === 'journey'
1248
+ ? `journey.${options.subcommand || 'unknown'}`
1249
+ : name === 'graph'
1250
+ ? `graph.${options.subcommand || 'unknown'}`
1251
+ : name;
1252
+
1253
+ try {
1254
+ if (name === 'access') {
1255
+ if (options.subcommand !== 'check') {
1256
+ console.error('access: unknown subcommand. Supported: check');
1257
+ process.exitCode = 2;
1258
+ return;
1259
+ }
1260
+ const body = buildAccessDecisionPayload(options, 'access check');
1261
+ if (!body) return;
1262
+ const result = await callEndpoint({
1263
+ options,
1264
+ method: 'POST',
1265
+ path: '/api/access/decide',
1266
+ body,
1267
+ });
1268
+ emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
1269
+ return;
1270
+ }
1271
+
1272
+ if (name === 'journey') {
1273
+ if (options.subcommand !== 'run') {
1274
+ console.error('journey: unknown subcommand. Supported: run');
1275
+ process.exitCode = 2;
1276
+ return;
1277
+ }
1278
+ const access = buildAccessDecisionPayload(options, 'journey run');
1279
+ if (!access) return;
1280
+ const operatorLabels = splitCsv(options.operatorLabels)
1281
+ .filter((label) => label.toLowerCase() !== 'operator-gated');
1282
+ const body = {
1283
+ access,
1284
+ include_command_templates: Boolean(options.includeCommandTemplates),
1285
+ operator_labels: operatorLabels,
1286
+ environment: options.environment || 'production',
1287
+ };
1288
+ const result = await callEndpoint({
1289
+ options,
1290
+ method: 'POST',
1291
+ path: '/api/actions/prepare',
1292
+ body,
1293
+ });
1294
+ emitCapability({ command, result, isJson, exitCodeOnFailure: 30 });
1295
+ return;
1296
+ }
1297
+
1298
+ if (name === 'graph') {
1299
+ let result;
1300
+ if (options.subcommand === 'nodes') {
1301
+ const query = {};
1302
+ assignIfDefined(query, 'node_type', options.nodeType);
1303
+ assignIfDefined(query, 'status', options.status);
1304
+ assignIfDefined(query, 'q', options.query);
1305
+ assignIfDefined(query, 'cursor', options.cursor);
1306
+ assignIfDefined(query, 'limit', options.limit);
1307
+ assignIfDefined(query, 'application_id', options.applicationId);
1308
+ result = await callEndpoint({ options, method: 'GET', path: '/api/graph/nodes', query });
1309
+ } else if (options.subcommand === 'paths') {
1310
+ const query = {
1311
+ from_node_key: options.fromNodeKey,
1312
+ to_node_key: options.toNodeKey,
1313
+ };
1314
+ assignIfDefined(query, 'max_depth', options.maxDepth);
1315
+ assignIfDefined(query, 'limit', options.limit);
1316
+ assignIfDefined(query, 'include_stale', options.includeStale);
1317
+ assignIfDefined(query, 'application_id', options.applicationId);
1318
+ result = await callEndpoint({ options, method: 'GET', path: '/api/graph/paths', query });
1319
+ } else if (options.subcommand === 'impact') {
1320
+ const query = {
1321
+ node_key: options.nodeKey,
1322
+ };
1323
+ assignIfDefined(query, 'max_depth', options.maxDepth);
1324
+ assignIfDefined(query, 'limit', options.limit);
1325
+ assignIfDefined(query, 'include_stale', options.includeStale);
1326
+ assignIfDefined(query, 'application_id', options.applicationId);
1327
+ result = await callEndpoint({ options, method: 'GET', path: '/api/graph/impact', query });
1328
+ } else if (options.subcommand === 'refresh') {
1329
+ const query = {};
1330
+ assignIfDefined(query, 'application_id', options.applicationId);
1331
+ assignIfDefined(query, 'correlation_id', options.correlationId);
1332
+ result = await callEndpoint({ options, method: 'POST', path: '/api/graph/refresh', query });
1333
+ } else {
1334
+ console.error('graph: unknown subcommand. Supported: nodes, paths, impact, refresh');
1335
+ process.exitCode = 2;
1336
+ return;
1337
+ }
1338
+ emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
1339
+ return;
1340
+ }
1341
+
1342
+ if (name === 'explain') {
1343
+ if (!requireOption(options.decisionId, 'explain: <decision-id> is required')) return;
1344
+ const result = await callEndpoint({
1345
+ options,
1346
+ method: 'GET',
1347
+ path: `/api/graph/explain/${encodeURIComponent(options.decisionId)}`,
1348
+ });
1349
+ emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
1350
+ return;
1351
+ }
1352
+
1353
+ if (name === 'contract') {
1354
+ const result = await callEndpoint({
1355
+ options,
1356
+ method: 'GET',
1357
+ path: '/api/contract',
1358
+ });
1359
+ emitCapability({ command, result, isJson, exitCodeOnFailure: 20 });
1360
+ return;
1361
+ }
1362
+
1363
+ console.error('capability: unsupported command');
1364
+ process.exitCode = 2;
1365
+ } catch (err) {
1366
+ emitCapabilityError({
1367
+ command,
1368
+ err,
1369
+ isJson,
1370
+ exitCode: name === 'contract' ? 20 : name === 'journey' ? 30 : 10,
1371
+ });
1372
+ }
1373
+ }
1374
+
986
1375
  module.exports = { createHandlers };
@@ -236,7 +236,7 @@ const HELP_TREE = {
236
236
  SPAPS (Sweet Potato Authentication & Payment Service) provides:
237
237
 
238
238
  1. **Authentication**: Email/password, magic links, and wallet-based auth
239
- 2. **Payments**: Stripe subscriptions and wallet-based payments
239
+ 2. **Payments**: Stripe subscriptions and x402 paid-resource receipts
240
240
  3. **Local Development**: Zero-config local mode with auto-auth
241
241
  4. **Type Safety**: Full TypeScript support with generated types
242
242
  5. **Multi-tenant**: API key-based app isolation