spaps 0.8.2 → 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.
@@ -60,6 +60,35 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
60
60
  .option('--json', 'Output in JSON format');
61
61
  }
62
62
 
63
+ function addAccessDecisionOptions(command) {
64
+ return command
65
+ .option('--actor-type <type>', 'Actor type', 'user')
66
+ .option('--actor-ref <ref>', 'Stable actor reference')
67
+ .option('--user-id <id>', 'User UUID')
68
+ .option('--email <email>', 'Actor email address')
69
+ .option('--agent-id <id>', 'Agent UUID')
70
+ .option('--authenticated <bool>', 'Actor authenticated state')
71
+ .requiredOption('--action <action>', 'Action to check')
72
+ .requiredOption('--resource-type <type>', 'Resource type')
73
+ .requiredOption('--resource-ref <ref>', 'Stable resource reference')
74
+ .option('--resource-id <id>', 'Resource UUID')
75
+ .option('--resource-key <key>', 'Resource key')
76
+ .option('--entitlement-key <key>', 'Required entitlement key')
77
+ .option('--entitlement-resource-type <type>', 'Entitlement resource type')
78
+ .option('--entitlement-resource-id <id>', 'Entitlement resource UUID')
79
+ .option('--policy-name <name>', 'Policy name to evaluate')
80
+ .option('--policy-context <json>', 'Policy context JSON')
81
+ .option('--usage-feature-key <key>', 'Usage-control feature key')
82
+ .option('--usage-dimensions <json>', 'Usage dimensions JSON')
83
+ .option('--x402-resource-key <key>', 'x402 resource key')
84
+ .option('--approval-id <id>', 'Approval UUID')
85
+ .option('--approval-required', 'Require an approval before action execution', false)
86
+ .option('--authority-scope <scope>', 'Authority scope', 'application')
87
+ .option('--context <json>', 'Decision context JSON')
88
+ .option('--idempotency-key <key>', 'Decision idempotency key')
89
+ .option('--correlation-id <id>', 'Correlation id');
90
+ }
91
+
63
92
  // spaps home
64
93
  const cmdHome = program
65
94
  .command('home')
@@ -256,10 +285,18 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
256
285
  .command('doctor')
257
286
  .description('Diagnose local environment and config')
258
287
  .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT))
288
+ .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)')
289
+ .option('--origin <origin>', 'Browser origin to send with publishable-key auth diagnostics')
259
290
  .option('-s, --stripe <mode>', 'Stripe mode: mock|real')
260
291
  .option('--json', 'Output in JSON format')
261
292
  .action(
262
- makeAction('doctor', (opts, _cmd, isJson) => ({ port: Number(opts.port), stripe: opts.stripe || null, json: isJson }))
293
+ makeAction('doctor', (opts, _cmd, isJson) => ({
294
+ port: Number(opts.port),
295
+ serverUrl: opts.serverUrl || null,
296
+ origin: opts.origin || null,
297
+ stripe: opts.stripe || null,
298
+ json: isJson,
299
+ }))
263
300
  );
264
301
  allowDryRun(cmdDoctor);
265
302
 
@@ -330,6 +367,77 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
330
367
  );
331
368
  allowDryRun(cmdToken);
332
369
 
370
+ // spaps auth <subcommand>
371
+ const cmdAuth = program
372
+ .command('auth')
373
+ .description('Auth discovery and local diagnostic commands')
374
+ .showHelpAfterError()
375
+ .showSuggestionAfterError();
376
+ if (!dryRun) {
377
+ cmdAuth.action(() => {
378
+ cmdAuth.outputHelp();
379
+ });
380
+ }
381
+ allowDryRun(cmdAuth);
382
+
383
+ function addAuthSubcommand(name, description, addOptions, shape) {
384
+ const command = cmdAuth.command(name).description(description);
385
+ addRemoteCommandOptions(command)
386
+ .option('--origin <origin>', 'Browser Origin header for publishable-key checks');
387
+ if (typeof addOptions === 'function') addOptions(command);
388
+ command.action(
389
+ makeAction('auth', (opts, _cmd, isJson) => ({
390
+ subcommand: name,
391
+ port: Number(opts.port) || DEFAULT_PORT,
392
+ serverUrl: opts.serverUrl || null,
393
+ origin: opts.origin || null,
394
+ ...shape(opts),
395
+ json: isJson,
396
+ }))
397
+ );
398
+ allowDryRun(command);
399
+ return command;
400
+ }
401
+
402
+ addAuthSubcommand(
403
+ 'methods',
404
+ 'Print the auth method matrix from GET /api/auth/methods',
405
+ null,
406
+ () => ({})
407
+ );
408
+
409
+ addAuthSubcommand(
410
+ 'mfa-test',
411
+ 'Exercise local TOTP MFA enrollment, activation, login challenge, verification, and cleanup',
412
+ (command) =>
413
+ command
414
+ .option('--email <email>', 'Local test user email (or SPAPS_TEST_EMAIL)')
415
+ .option('--password <password>', 'Local test user password (or SPAPS_TEST_PASSWORD)')
416
+ .option('--allow-remote', 'Allow running against a non-local server URL', false),
417
+ (opts) => ({
418
+ email: opts.email || null,
419
+ password: opts.password || null,
420
+ allowRemote: Boolean(opts.allowRemote),
421
+ })
422
+ );
423
+
424
+ addAuthSubcommand(
425
+ 'sms-test',
426
+ 'Request or verify an SMS OTP against local console SMS',
427
+ (command) =>
428
+ command
429
+ .option('--phone-number <phone>', 'E.164 phone number (or SPAPS_TEST_PHONE_NUMBER)')
430
+ .option('--challenge-id <id>', 'Existing SMS challenge id to verify')
431
+ .option('--code <code>', 'SMS code from local server logs')
432
+ .option('--allow-remote', 'Allow running against a non-local server URL', false),
433
+ (opts) => ({
434
+ phoneNumber: opts.phoneNumber || null,
435
+ challengeId: opts.challengeId || null,
436
+ code: opts.code || null,
437
+ allowRemote: Boolean(opts.allowRemote),
438
+ })
439
+ );
440
+
333
441
  // spaps dayrate <subcommand>
334
442
  const cmdDayrate = program
335
443
  .command('dayrate <subcommand>')
@@ -646,6 +754,242 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
646
754
  );
647
755
  allowDryRun(cmdIssueReports);
648
756
 
757
+ // spaps access check
758
+ const cmdAccess = program
759
+ .command('access')
760
+ .description('Access decision commands')
761
+ .showHelpAfterError()
762
+ .showSuggestionAfterError();
763
+ if (!dryRun) {
764
+ cmdAccess.action(() => {
765
+ cmdAccess.outputHelp();
766
+ });
767
+ }
768
+ allowDryRun(cmdAccess);
769
+
770
+ const cmdAccessCheck = addAccessDecisionOptions(cmdAccess.command('check').description('Check whether an actor can perform an action'));
771
+ addRemoteCommandOptions(cmdAccessCheck).action(
772
+ makeAction('access', (opts, _cmd, isJson) => ({
773
+ subcommand: 'check',
774
+ port: Number(opts.port) || DEFAULT_PORT,
775
+ serverUrl: opts.serverUrl || null,
776
+ actorType: opts.actorType || 'user',
777
+ actorRef: opts.actorRef || null,
778
+ userId: opts.userId || null,
779
+ email: opts.email || null,
780
+ agentId: opts.agentId || null,
781
+ authenticated: opts.authenticated === undefined ? null : opts.authenticated,
782
+ action: opts.action || null,
783
+ resourceType: opts.resourceType || null,
784
+ resourceRef: opts.resourceRef || null,
785
+ resourceId: opts.resourceId || null,
786
+ resourceKey: opts.resourceKey || null,
787
+ entitlementKey: opts.entitlementKey || null,
788
+ entitlementResourceType: opts.entitlementResourceType || null,
789
+ entitlementResourceId: opts.entitlementResourceId || null,
790
+ policyName: opts.policyName || null,
791
+ policyContext: opts.policyContext || null,
792
+ usageFeatureKey: opts.usageFeatureKey || null,
793
+ usageDimensions: opts.usageDimensions || null,
794
+ x402ResourceKey: opts.x402ResourceKey || null,
795
+ approvalId: opts.approvalId || null,
796
+ approvalRequired: Boolean(opts.approvalRequired),
797
+ authorityScope: opts.authorityScope || 'application',
798
+ context: opts.context || null,
799
+ idempotencyKey: opts.idempotencyKey || null,
800
+ correlationId: opts.correlationId || null,
801
+ json: isJson,
802
+ }))
803
+ );
804
+ allowDryRun(cmdAccessCheck);
805
+
806
+ // spaps journey run
807
+ const cmdJourney = program
808
+ .command('journey')
809
+ .description('Prepare agent-safe next actions for an application journey')
810
+ .showHelpAfterError()
811
+ .showSuggestionAfterError();
812
+ if (!dryRun) {
813
+ cmdJourney.action(() => {
814
+ cmdJourney.outputHelp();
815
+ });
816
+ }
817
+ allowDryRun(cmdJourney);
818
+
819
+ const cmdJourneyRun = addAccessDecisionOptions(cmdJourney.command('run').description('Run the access-to-next-action preparation flow'));
820
+ addRemoteCommandOptions(
821
+ cmdJourneyRun
822
+ .option('--include-command-templates', 'Include command templates for operator-gated actions', false)
823
+ .option('--operator-gated', 'Request server-authorized operator-gated templates', false)
824
+ .option('--operator-labels <csv>', 'Comma-separated operator labels')
825
+ .option('--environment <environment>', 'Execution environment', 'production')
826
+ ).action(
827
+ makeAction('journey', (opts, _cmd, isJson) => ({
828
+ subcommand: 'run',
829
+ port: Number(opts.port) || DEFAULT_PORT,
830
+ serverUrl: opts.serverUrl || null,
831
+ actorType: opts.actorType || 'user',
832
+ actorRef: opts.actorRef || null,
833
+ userId: opts.userId || null,
834
+ email: opts.email || null,
835
+ agentId: opts.agentId || null,
836
+ authenticated: opts.authenticated === undefined ? null : opts.authenticated,
837
+ action: opts.action || null,
838
+ resourceType: opts.resourceType || null,
839
+ resourceRef: opts.resourceRef || null,
840
+ resourceId: opts.resourceId || null,
841
+ resourceKey: opts.resourceKey || null,
842
+ entitlementKey: opts.entitlementKey || null,
843
+ entitlementResourceType: opts.entitlementResourceType || null,
844
+ entitlementResourceId: opts.entitlementResourceId || null,
845
+ policyName: opts.policyName || null,
846
+ policyContext: opts.policyContext || null,
847
+ usageFeatureKey: opts.usageFeatureKey || null,
848
+ usageDimensions: opts.usageDimensions || null,
849
+ x402ResourceKey: opts.x402ResourceKey || null,
850
+ approvalId: opts.approvalId || null,
851
+ approvalRequired: Boolean(opts.approvalRequired),
852
+ authorityScope: opts.authorityScope || 'application',
853
+ context: opts.context || null,
854
+ idempotencyKey: opts.idempotencyKey || null,
855
+ correlationId: opts.correlationId || null,
856
+ includeCommandTemplates: Boolean(opts.includeCommandTemplates),
857
+ operatorGated: Boolean(opts.operatorGated),
858
+ operatorLabels: opts.operatorLabels || null,
859
+ environment: opts.environment || 'production',
860
+ json: isJson,
861
+ }))
862
+ );
863
+ allowDryRun(cmdJourneyRun);
864
+
865
+ // spaps graph <nodes|paths|impact>
866
+ const cmdGraph = program
867
+ .command('graph')
868
+ .description('Inspect the materialized SPAPS capability graph')
869
+ .showHelpAfterError()
870
+ .showSuggestionAfterError();
871
+ if (!dryRun) {
872
+ cmdGraph.action(() => {
873
+ cmdGraph.outputHelp();
874
+ });
875
+ }
876
+ allowDryRun(cmdGraph);
877
+
878
+ addRemoteCommandOptions(
879
+ cmdGraph.command('nodes')
880
+ .description('List capability graph nodes')
881
+ .option('--application-id <id>', 'Explicit application id for super-admin reads')
882
+ .option('--node-type <type>', 'Filter by node type')
883
+ .option('--status <status>', 'Filter by row status', 'active')
884
+ .option('-q, --query <query>', 'Case-insensitive label search')
885
+ .option('--cursor <cursor>', 'Pagination cursor')
886
+ .option('--limit <n>', 'Pagination limit')
887
+ ).action(
888
+ makeAction('graph', (opts, _cmd, isJson) => ({
889
+ subcommand: 'nodes',
890
+ port: Number(opts.port) || DEFAULT_PORT,
891
+ serverUrl: opts.serverUrl || null,
892
+ applicationId: opts.applicationId || null,
893
+ nodeType: opts.nodeType || null,
894
+ status: opts.status || 'active',
895
+ query: opts.query || null,
896
+ cursor: opts.cursor || null,
897
+ limit: opts.limit ? Number(opts.limit) : null,
898
+ json: isJson,
899
+ }))
900
+ );
901
+
902
+ addRemoteCommandOptions(
903
+ cmdGraph.command('paths')
904
+ .description('Find bounded paths between two graph nodes')
905
+ .requiredOption('--from <node_key>', 'Starting node key')
906
+ .requiredOption('--to <node_key>', 'Target node key')
907
+ .option('--application-id <id>', 'Explicit application id for super-admin reads')
908
+ .option('--max-depth <n>', 'Maximum path depth')
909
+ .option('--limit <n>', 'Path limit')
910
+ .option('--include-stale', 'Include stale graph rows', false)
911
+ ).action(
912
+ makeAction('graph', (opts, _cmd, isJson) => ({
913
+ subcommand: 'paths',
914
+ port: Number(opts.port) || DEFAULT_PORT,
915
+ serverUrl: opts.serverUrl || null,
916
+ applicationId: opts.applicationId || null,
917
+ fromNodeKey: opts.from || null,
918
+ toNodeKey: opts.to || null,
919
+ maxDepth: opts.maxDepth ? Number(opts.maxDepth) : null,
920
+ limit: opts.limit ? Number(opts.limit) : null,
921
+ includeStale: Boolean(opts.includeStale),
922
+ json: isJson,
923
+ }))
924
+ );
925
+
926
+ addRemoteCommandOptions(
927
+ cmdGraph.command('impact')
928
+ .description('Traverse outward from one graph node')
929
+ .requiredOption('--node-key <node_key>', 'Starting node key')
930
+ .option('--application-id <id>', 'Explicit application id for super-admin reads')
931
+ .option('--max-depth <n>', 'Maximum traversal depth')
932
+ .option('--limit <n>', 'Result limit')
933
+ .option('--include-stale', 'Include stale graph rows', false)
934
+ ).action(
935
+ makeAction('graph', (opts, _cmd, isJson) => ({
936
+ subcommand: 'impact',
937
+ port: Number(opts.port) || DEFAULT_PORT,
938
+ serverUrl: opts.serverUrl || null,
939
+ applicationId: opts.applicationId || null,
940
+ nodeKey: opts.nodeKey || null,
941
+ maxDepth: opts.maxDepth ? Number(opts.maxDepth) : null,
942
+ limit: opts.limit ? Number(opts.limit) : null,
943
+ includeStale: Boolean(opts.includeStale),
944
+ json: isJson,
945
+ }))
946
+ );
947
+
948
+ addRemoteCommandOptions(
949
+ cmdGraph.command('refresh')
950
+ .description('Refresh the capability graph projection')
951
+ .option('--application-id <id>', 'Explicit application id for super-admin refresh')
952
+ .option('--correlation-id <id>', 'Correlation id for audit and projection diagnostics')
953
+ ).action(
954
+ makeAction('graph', (opts, _cmd, isJson) => ({
955
+ subcommand: 'refresh',
956
+ port: Number(opts.port) || DEFAULT_PORT,
957
+ serverUrl: opts.serverUrl || null,
958
+ applicationId: opts.applicationId || null,
959
+ correlationId: opts.correlationId || null,
960
+ json: isJson,
961
+ }))
962
+ );
963
+
964
+ // spaps explain <decision-id>
965
+ const cmdExplain = addRemoteCommandOptions(
966
+ program
967
+ .command('explain <decision-id>')
968
+ .description('Explain one persisted access decision trace')
969
+ ).action(
970
+ makeAction('explain', (opts, cmd, isJson) => ({
971
+ decisionId: cmd.args[0],
972
+ port: Number(opts.port) || DEFAULT_PORT,
973
+ serverUrl: opts.serverUrl || null,
974
+ json: isJson,
975
+ }))
976
+ );
977
+ allowDryRun(cmdExplain);
978
+
979
+ // spaps contract
980
+ const cmdContract = addRemoteCommandOptions(
981
+ program
982
+ .command('contract')
983
+ .description('Fetch the capability graph client contract')
984
+ ).action(
985
+ makeAction('contract', (opts, _cmd, isJson) => ({
986
+ port: Number(opts.port) || DEFAULT_PORT,
987
+ serverUrl: opts.serverUrl || null,
988
+ json: isJson,
989
+ }))
990
+ );
991
+ allowDryRun(cmdContract);
992
+
649
993
  return { program, getIntents: () => intents };
650
994
  }
651
995
 
package/src/doctor.js CHANGED
@@ -7,6 +7,11 @@ const chalk = require('chalk');
7
7
  const { getServerStatus } = require('./ai-helper');
8
8
  const { DEFAULT_PORT } = require('./config');
9
9
  const { listDomains } = require('./domains');
10
+ const {
11
+ buildAuthDoctorChecks,
12
+ fetchAuthMethods,
13
+ resolveDiagnosticOrigin,
14
+ } = require('./auth/surface');
10
15
 
11
16
  function checkNodeVersion() {
12
17
  const version = process.versions.node || '0.0.0';
@@ -20,9 +25,9 @@ function checkNodeVersion() {
20
25
  };
21
26
  }
22
27
 
23
- async function checkPort(port) {
28
+ async function checkPort(port, serverUrl = null) {
24
29
  // If server is running, we consider port check OK
25
- const status = await getServerStatus(port);
30
+ const status = await getServerStatus({ port, serverUrl });
26
31
  if (status.running) {
27
32
  return {
28
33
  check: 'port',
@@ -31,6 +36,14 @@ async function checkPort(port) {
31
36
  fix: null
32
37
  };
33
38
  }
39
+ if (serverUrl) {
40
+ return {
41
+ check: 'port',
42
+ success: false,
43
+ details: { port, running: false, url: status.url, error: status.error || null },
44
+ fix: `Check SPAPS_API_URL or --server-url (${status.url})`,
45
+ };
46
+ }
34
47
  // Otherwise ensure port is free to bind
35
48
  const free = await new Promise((resolve) => {
36
49
  const tester = net.createServer()
@@ -224,22 +237,99 @@ async function checkDomainMounts(port) {
224
237
  return Promise.all(listDomains().map((d) => probeDomain(port, d)));
225
238
  }
226
239
 
240
+ async function checkAuthSurface({
241
+ port = DEFAULT_PORT,
242
+ serverUrl = null,
243
+ origin = null,
244
+ cwd = process.cwd(),
245
+ env = process.env,
246
+ } = {}) {
247
+ const runtime = await getServerStatus({ port, serverUrl });
248
+ if (!runtime.running) {
249
+ return [
250
+ {
251
+ check: 'auth_methods',
252
+ success: false,
253
+ details: { running: false, url: runtime.url, error: runtime.error || null },
254
+ fix: `Start the SPAPS server or pass --server-url to a reachable server before checking /api/auth/methods.`,
255
+ },
256
+ ];
257
+ }
258
+
259
+ const resolvedOrigin = resolveDiagnosticOrigin({ origin });
260
+ try {
261
+ const discovery = await fetchAuthMethods({
262
+ port,
263
+ serverUrl: runtime.url,
264
+ origin: resolvedOrigin,
265
+ cwd,
266
+ env,
267
+ });
268
+ const checks = await buildAuthDoctorChecks({
269
+ methods: discovery.methods,
270
+ runtime,
271
+ origin: resolvedOrigin,
272
+ });
273
+ const matrix = checks.find((check) => check.check === 'auth_methods');
274
+ if (matrix) {
275
+ matrix.details = {
276
+ ...matrix.details,
277
+ server_url: discovery.serverUrl,
278
+ origin: resolvedOrigin,
279
+ api_key_source: discovery.apiKeySource,
280
+ };
281
+ }
282
+ return checks;
283
+ } catch (err) {
284
+ const status = err.status || null;
285
+ return [
286
+ {
287
+ check: 'auth_methods',
288
+ success: false,
289
+ details: {
290
+ server_url: runtime.url,
291
+ origin: resolvedOrigin,
292
+ status,
293
+ code: err.code || 'AUTH_METHODS_FAILED',
294
+ error: err.message || String(err),
295
+ },
296
+ fix:
297
+ status === 401 || status === 403
298
+ ? 'Set SPAPS_API_KEY and SPAPS_ORIGIN (or pass --origin) for the application whose auth methods you are diagnosing.'
299
+ : 'Ensure GET /api/auth/methods is mounted and reachable on the configured SPAPS server.',
300
+ },
301
+ ];
302
+ }
303
+ }
304
+
227
305
  function formatHuman(results) {
228
306
  const ok = results.every(r => r.success);
229
307
  console.log(chalk.yellow('\nšŸ  SPAPS Doctor\n'));
230
308
  results.forEach(r => {
231
309
  const icon = r.success ? chalk.green('āœ”') : chalk.red('āœ–');
232
310
  console.log(`${icon} ${r.check} ${chalk.gray(JSON.stringify(r.details))}`);
311
+ if (r.check === 'auth_methods' && Array.isArray(r.details?.methods)) {
312
+ r.details.methods.forEach((method) => {
313
+ const state = method.enabled ? chalk.green('enabled') : chalk.gray('disabled');
314
+ console.log(` ${method.method}: ${state}`);
315
+ });
316
+ }
233
317
  if (!r.success && r.fix) console.log(chalk.cyan(` fix: ${r.fix}`));
234
318
  });
235
319
  console.log();
236
320
  console.log(ok ? chalk.green('All checks passed!') : chalk.red('Some checks failed. See fixes above.'));
237
321
  }
238
322
 
239
- async function runDoctor({ port = DEFAULT_PORT, stripe = null, json = false } = {}) {
323
+ async function runDoctor({
324
+ port = DEFAULT_PORT,
325
+ serverUrl = null,
326
+ stripe = null,
327
+ json = false,
328
+ origin = null,
329
+ } = {}) {
240
330
  const results = [];
241
331
  results.push(checkNodeVersion());
242
- results.push(await checkPort(port));
332
+ results.push(await checkPort(port, serverUrl));
243
333
  // Warn if using 3000 which often collides with Next.js
244
334
  if (port === 3000) {
245
335
  results.push({
@@ -260,6 +350,8 @@ async function runDoctor({ port = DEFAULT_PORT, stripe = null, json = false } =
260
350
  results.push(await checkWebhook(port));
261
351
  const domainResults = await checkDomainMounts(port);
262
352
  results.push(...domainResults);
353
+ const authResults = await checkAuthSurface({ port, serverUrl, origin });
354
+ results.push(...authResults);
263
355
 
264
356
  const ok = results.every(r => r.success);
265
357
  const payload = { success: ok, results, next_steps: ok ? [] : ['Apply suggested fixes and re-run: npx spaps doctor --json'] };
@@ -271,4 +363,4 @@ async function runDoctor({ port = DEFAULT_PORT, stripe = null, json = false } =
271
363
  return payload;
272
364
  }
273
365
 
274
- module.exports = { runDoctor, checkDomainMounts };
366
+ module.exports = { runDoctor, checkDomainMounts, checkAuthSurface };
package/src/domain-cli.js CHANGED
@@ -29,6 +29,7 @@ async function callEndpoint({ options = {}, method = 'GET', path, body = null, q
29
29
  return {
30
30
  status: res.status,
31
31
  data: res.data,
32
+ raw: res.raw,
32
33
  ok: res.status >= 200 && res.status < 300,
33
34
  };
34
35
  } catch (err) {