spaps 0.7.2 → 0.7.4
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/AI_TOOLS.json +10 -11
- package/README.md +267 -110
- package/assets/local-runtime/Dockerfile +28 -0
- package/assets/local-runtime/alembic/env.py +101 -0
- package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
- package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
- package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
- package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
- package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
- package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
- package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
- package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
- package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
- package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
- package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
- package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
- package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
- package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
- package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
- package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
- package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
- package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
- package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
- package/assets/local-runtime/alembic.ini +47 -0
- package/assets/local-runtime/docker-compose.yml +61 -0
- package/assets/local-runtime/manifest.json +8 -0
- package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
- package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
- package/assets/local-runtime/scripts/run-migrations.sh +96 -0
- package/package.json +5 -4
- package/src/ai-helper.js +176 -234
- package/src/ai-tool-spec.js +52 -20
- package/src/auth/api-key.js +119 -0
- package/src/auth/client-id.js +136 -0
- package/src/auth/client.js +169 -0
- package/src/auth/credentials.js +110 -0
- package/src/auth/device-flow.js +159 -0
- package/src/auth/env.js +57 -0
- package/src/auth/handlers.js +462 -0
- package/src/auth/http.js +74 -0
- package/src/cli-dispatcher.js +155 -24
- package/src/docs-system.js +7 -7
- package/src/error-handler.js +42 -0
- package/src/fixture-kernel.js +1143 -0
- package/src/handlers.js +252 -15
- package/src/help-system.js +3 -1
- package/src/local-runtime.js +258 -0
- package/src/local-server.js +597 -199
- package/src/project-scaffolder.js +441 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const API_KEY_ENV_VARS = [
|
|
5
|
+
'SPAPS_API_KEY',
|
|
6
|
+
'NEXT_PUBLIC_SPAPS_API_KEY',
|
|
7
|
+
'VITE_SPAPS_API_KEY',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const API_KEY_ENV_FILES = ['.env.local', '.env'];
|
|
11
|
+
|
|
12
|
+
function stripWrappingQuotes(value) {
|
|
13
|
+
if (value.length >= 2) {
|
|
14
|
+
const first = value[0];
|
|
15
|
+
const last = value[value.length - 1];
|
|
16
|
+
if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) {
|
|
17
|
+
return value.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseEnvAssignments(content) {
|
|
24
|
+
const values = {};
|
|
25
|
+
|
|
26
|
+
for (const rawLine of String(content || '').split(/\r?\n/)) {
|
|
27
|
+
const line = rawLine.trim();
|
|
28
|
+
if (!line || line.startsWith('#')) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const separator = line.indexOf('=');
|
|
33
|
+
if (separator <= 0) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const key = line.slice(0, separator).trim();
|
|
38
|
+
const value = stripWrappingQuotes(line.slice(separator + 1).trim());
|
|
39
|
+
values[key] = value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readApiKeyFromFile(filePath) {
|
|
46
|
+
if (!fs.existsSync(filePath)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const values = parseEnvAssignments(fs.readFileSync(filePath, 'utf8'));
|
|
52
|
+
for (const envVar of API_KEY_ENV_VARS) {
|
|
53
|
+
if (typeof values[envVar] === 'string' && values[envVar].trim()) {
|
|
54
|
+
return {
|
|
55
|
+
apiKey: values[envVar].trim(),
|
|
56
|
+
envVar,
|
|
57
|
+
path: filePath,
|
|
58
|
+
source: path.basename(filePath),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findUpApiKey(startDir = process.cwd()) {
|
|
70
|
+
let current = path.resolve(startDir);
|
|
71
|
+
|
|
72
|
+
while (true) {
|
|
73
|
+
for (const envFile of API_KEY_ENV_FILES) {
|
|
74
|
+
const hit = readApiKeyFromFile(path.join(current, envFile));
|
|
75
|
+
if (hit) {
|
|
76
|
+
return hit;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const parent = path.dirname(current);
|
|
81
|
+
if (parent === current) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
current = parent;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveAuthApiKey({ cwd = process.cwd(), env = process.env } = {}) {
|
|
89
|
+
for (const envVar of API_KEY_ENV_VARS) {
|
|
90
|
+
if (typeof env[envVar] === 'string' && env[envVar].trim()) {
|
|
91
|
+
return {
|
|
92
|
+
apiKey: env[envVar].trim(),
|
|
93
|
+
envVar,
|
|
94
|
+
path: null,
|
|
95
|
+
source: envVar,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fileHit = findUpApiKey(cwd);
|
|
101
|
+
if (fileHit) {
|
|
102
|
+
return fileHit;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
apiKey: null,
|
|
107
|
+
envVar: null,
|
|
108
|
+
path: null,
|
|
109
|
+
source: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
API_KEY_ENV_FILES,
|
|
115
|
+
API_KEY_ENV_VARS,
|
|
116
|
+
findUpApiKey,
|
|
117
|
+
parseEnvAssignments,
|
|
118
|
+
resolveAuthApiKey,
|
|
119
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { requestJson } = require('../local-runtime');
|
|
5
|
+
|
|
6
|
+
const CONTRACT_FILES = [
|
|
7
|
+
{
|
|
8
|
+
relativePath: 'spaps.app.json',
|
|
9
|
+
label: 'spaps.app.json',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
relativePath: path.join('.spaps', 'app.json'),
|
|
13
|
+
label: '.spaps/app.json',
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function readJsonIfPresent(filePath) {
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractClientIdFromContract(contract) {
|
|
30
|
+
const candidates = [
|
|
31
|
+
contract?.spaps?.application?.slug,
|
|
32
|
+
contract?.server?.application_slug,
|
|
33
|
+
contract?.application_slug,
|
|
34
|
+
contract?.slug,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
39
|
+
return candidate.trim();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findUpContract(startDir = process.cwd()) {
|
|
47
|
+
let current = path.resolve(startDir);
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
for (const contractFile of CONTRACT_FILES) {
|
|
51
|
+
const filePath = path.join(current, contractFile.relativePath);
|
|
52
|
+
const payload = readJsonIfPresent(filePath);
|
|
53
|
+
const clientId = extractClientIdFromContract(payload);
|
|
54
|
+
if (clientId) {
|
|
55
|
+
return {
|
|
56
|
+
clientId,
|
|
57
|
+
source: contractFile.label,
|
|
58
|
+
path: filePath,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parent = path.dirname(current);
|
|
64
|
+
if (parent === current) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
current = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function findRuntimeClientId(serverUrl) {
|
|
72
|
+
if (!serverUrl) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await requestJson({
|
|
77
|
+
method: 'GET',
|
|
78
|
+
url: `${String(serverUrl).replace(/\/+$/, '')}/health/local-mode`,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const clientId = result.ok && result.data && typeof result.data === 'object'
|
|
82
|
+
? result.data.test_application?.slug || null
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
if (!clientId) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
clientId,
|
|
91
|
+
source: '/health/local-mode',
|
|
92
|
+
path: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveLoginClientId({ options = {}, serverUrl, cwd = process.cwd() } = {}) {
|
|
97
|
+
if (typeof options.clientId === 'string' && options.clientId.trim()) {
|
|
98
|
+
return {
|
|
99
|
+
clientId: options.clientId.trim(),
|
|
100
|
+
source: '--client-id',
|
|
101
|
+
path: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof process.env.SPAPS_CLI_CLIENT_ID === 'string' && process.env.SPAPS_CLI_CLIENT_ID.trim()) {
|
|
106
|
+
return {
|
|
107
|
+
clientId: process.env.SPAPS_CLI_CLIENT_ID.trim(),
|
|
108
|
+
source: 'SPAPS_CLI_CLIENT_ID',
|
|
109
|
+
path: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contractHit = findUpContract(cwd);
|
|
114
|
+
if (contractHit) {
|
|
115
|
+
return contractHit;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const runtimeHit = await findRuntimeClientId(serverUrl);
|
|
119
|
+
if (runtimeHit) {
|
|
120
|
+
return runtimeHit;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
clientId: null,
|
|
125
|
+
source: null,
|
|
126
|
+
path: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
CONTRACT_FILES,
|
|
132
|
+
extractClientIdFromContract,
|
|
133
|
+
findRuntimeClientId,
|
|
134
|
+
findUpContract,
|
|
135
|
+
resolveLoginClientId,
|
|
136
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Authenticated HTTP client for the SPAPS CLI.
|
|
2
|
+
//
|
|
3
|
+
// Wraps axios with:
|
|
4
|
+
// - bearer token injection from stored credentials
|
|
5
|
+
// - SPAPS_ACCESS_TOKEN env-var bypass (CI / machine use)
|
|
6
|
+
// - automatic one-shot refresh on 401 via POST /auth/refresh, writing the
|
|
7
|
+
// rotated pair back to the credentials store
|
|
8
|
+
|
|
9
|
+
const axios = require('axios');
|
|
10
|
+
|
|
11
|
+
const { DEFAULT_PORT } = require('../config');
|
|
12
|
+
const {
|
|
13
|
+
getCredentials,
|
|
14
|
+
setCredentials,
|
|
15
|
+
} = require('./credentials');
|
|
16
|
+
const { resolveAuthApiKey } = require('./api-key');
|
|
17
|
+
const { buildApiUrl, extractApiError, unwrapApiData } = require('./http');
|
|
18
|
+
|
|
19
|
+
function resolveServerUrl(options = {}) {
|
|
20
|
+
if (options.serverUrl) return String(options.serverUrl).replace(/\/+$/, '');
|
|
21
|
+
if (process.env.SPAPS_API_URL) return String(process.env.SPAPS_API_URL).replace(/\/+$/, '');
|
|
22
|
+
const port = options.port || DEFAULT_PORT;
|
|
23
|
+
return `http://localhost:${port}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildRequestHeaders(headers = {}, cwd = process.cwd()) {
|
|
27
|
+
const resolved = resolveAuthApiKey({ cwd });
|
|
28
|
+
if (
|
|
29
|
+
!resolved.apiKey ||
|
|
30
|
+
Object.prototype.hasOwnProperty.call(headers, 'X-API-Key') ||
|
|
31
|
+
Object.prototype.hasOwnProperty.call(headers, 'x-api-key')
|
|
32
|
+
) {
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...headers,
|
|
38
|
+
'X-API-Key': resolved.apiKey,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function refreshAccessToken({
|
|
43
|
+
serverUrl,
|
|
44
|
+
refreshToken,
|
|
45
|
+
axiosInstance = axios,
|
|
46
|
+
headers = {},
|
|
47
|
+
cwd = process.cwd(),
|
|
48
|
+
}) {
|
|
49
|
+
const res = await axiosInstance.post(
|
|
50
|
+
buildApiUrl(serverUrl, '/auth/refresh'),
|
|
51
|
+
{ refresh_token: refreshToken },
|
|
52
|
+
{
|
|
53
|
+
headers: buildRequestHeaders(
|
|
54
|
+
{ 'Content-Type': 'application/json', ...headers },
|
|
55
|
+
cwd
|
|
56
|
+
),
|
|
57
|
+
validateStatus: () => true,
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
if (res.status >= 400) {
|
|
61
|
+
const apiError = extractApiError(res.data || {}, res.status);
|
|
62
|
+
const err = new Error(apiError.message || `refresh failed (HTTP ${res.status})`);
|
|
63
|
+
err.code = apiError.code || 'REFRESH_FAILED';
|
|
64
|
+
err.status = res.status;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
return unwrapApiData(res.data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function authFetch(
|
|
71
|
+
pathOrUrl,
|
|
72
|
+
{
|
|
73
|
+
serverUrl,
|
|
74
|
+
method = 'GET',
|
|
75
|
+
body = null,
|
|
76
|
+
headers = {},
|
|
77
|
+
axiosInstance = axios,
|
|
78
|
+
allowRefresh = true,
|
|
79
|
+
cwd = process.cwd(),
|
|
80
|
+
} = {}
|
|
81
|
+
) {
|
|
82
|
+
const base = serverUrl || resolveServerUrl();
|
|
83
|
+
const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : buildApiUrl(base, pathOrUrl);
|
|
84
|
+
|
|
85
|
+
// CI / machine bypass: an explicit env-var token skips the credentials file
|
|
86
|
+
// entirely and opts out of refresh.
|
|
87
|
+
let accessToken = process.env.SPAPS_ACCESS_TOKEN || null;
|
|
88
|
+
let storedCreds = null;
|
|
89
|
+
if (!accessToken) {
|
|
90
|
+
storedCreds = getCredentials(base);
|
|
91
|
+
if (!storedCreds || !storedCreds.access_token) {
|
|
92
|
+
const err = new Error('Not authenticated. Run `spaps login` first.');
|
|
93
|
+
err.code = 'NOT_AUTHENTICATED';
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
accessToken = storedCreds.access_token;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const doRequest = (tokenToUse) =>
|
|
100
|
+
axiosInstance({
|
|
101
|
+
url,
|
|
102
|
+
method,
|
|
103
|
+
data: body,
|
|
104
|
+
headers: buildRequestHeaders({
|
|
105
|
+
...headers,
|
|
106
|
+
Authorization: `Bearer ${tokenToUse}`,
|
|
107
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
108
|
+
}, cwd),
|
|
109
|
+
validateStatus: () => true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let res = await doRequest(accessToken);
|
|
113
|
+
const initialError = res.status === 401 ? extractApiError(res.data || {}, res.status) : null;
|
|
114
|
+
const shouldAttemptRefresh = !(
|
|
115
|
+
initialError &&
|
|
116
|
+
typeof initialError.code === 'string' &&
|
|
117
|
+
initialError.code.toUpperCase() === 'INVALID_APPLICATION'
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
res.status === 401 &&
|
|
122
|
+
storedCreds &&
|
|
123
|
+
storedCreds.refresh_token &&
|
|
124
|
+
allowRefresh &&
|
|
125
|
+
shouldAttemptRefresh
|
|
126
|
+
) {
|
|
127
|
+
try {
|
|
128
|
+
const refreshed = await refreshAccessToken({
|
|
129
|
+
serverUrl: base,
|
|
130
|
+
refreshToken: storedCreds.refresh_token,
|
|
131
|
+
axiosInstance,
|
|
132
|
+
cwd,
|
|
133
|
+
});
|
|
134
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
135
|
+
const newAccess = refreshed.access_token;
|
|
136
|
+
const newRefresh = refreshed.refresh_token || storedCreds.refresh_token;
|
|
137
|
+
setCredentials(base, {
|
|
138
|
+
...storedCreds,
|
|
139
|
+
access_token: newAccess,
|
|
140
|
+
refresh_token: newRefresh,
|
|
141
|
+
expires_in: refreshed.expires_in || storedCreds.expires_in,
|
|
142
|
+
expires_at: refreshed.expires_in ? nowSec + Number(refreshed.expires_in) : null,
|
|
143
|
+
token_type: refreshed.token_type || storedCreds.token_type || 'Bearer',
|
|
144
|
+
});
|
|
145
|
+
res = await doRequest(newAccess);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code === 'INVALID_APPLICATION') {
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
const authErr = new Error('Session expired. Run `spaps login` again.');
|
|
151
|
+
authErr.code = 'SESSION_EXPIRED';
|
|
152
|
+
authErr.cause = err;
|
|
153
|
+
throw authErr;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...res,
|
|
159
|
+
raw: res.data,
|
|
160
|
+
data: unwrapApiData(res.data),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
buildRequestHeaders,
|
|
166
|
+
resolveServerUrl,
|
|
167
|
+
refreshAccessToken,
|
|
168
|
+
authFetch,
|
|
169
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// File-backed credential storage for the SPAPS CLI.
|
|
2
|
+
//
|
|
3
|
+
// Stores tokens per-server at ~/.config/spaps/credentials.json with 0600 perms.
|
|
4
|
+
// Uses a separate filename from the Python client's ~/.config/spaps/tokens.json
|
|
5
|
+
// to avoid schema collisions; cross-client unification can come later.
|
|
6
|
+
//
|
|
7
|
+
// TODO(keyring): fall back to OS keyring (Keychain / Secret Service / Credential
|
|
8
|
+
// Manager) when available, then to this file store.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
function defaultCredentialsPath() {
|
|
17
|
+
if (process.env.SPAPS_CREDENTIALS_PATH) {
|
|
18
|
+
return process.env.SPAPS_CREDENTIALS_PATH;
|
|
19
|
+
}
|
|
20
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
21
|
+
return path.join(base, 'spaps', 'credentials.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeServerUrl(url) {
|
|
25
|
+
if (!url) return '';
|
|
26
|
+
return String(url).trim().replace(/\/+$/, '').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emptyStore() {
|
|
30
|
+
return { version: SCHEMA_VERSION, servers: {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createStore(filePath) {
|
|
34
|
+
const target = filePath || defaultCredentialsPath();
|
|
35
|
+
|
|
36
|
+
function loadAll() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(target, 'utf8');
|
|
39
|
+
const data = JSON.parse(raw);
|
|
40
|
+
if (!data || typeof data !== 'object') return emptyStore();
|
|
41
|
+
if (!data.servers || typeof data.servers !== 'object') data.servers = {};
|
|
42
|
+
if (!data.version) data.version = SCHEMA_VERSION;
|
|
43
|
+
return data;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') return emptyStore();
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveAll(data) {
|
|
51
|
+
const dir = path.dirname(target);
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
55
|
+
fs.renameSync(tmp, target);
|
|
56
|
+
try {
|
|
57
|
+
fs.chmodSync(target, 0o600);
|
|
58
|
+
} catch {
|
|
59
|
+
// Best effort — Windows ignores chmod, and that's OK.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getCredentials(serverUrl) {
|
|
64
|
+
const data = loadAll();
|
|
65
|
+
return data.servers[normalizeServerUrl(serverUrl)] || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setCredentials(serverUrl, creds) {
|
|
69
|
+
const data = loadAll();
|
|
70
|
+
data.servers[normalizeServerUrl(serverUrl)] = {
|
|
71
|
+
...creds,
|
|
72
|
+
saved_at: Math.floor(Date.now() / 1000),
|
|
73
|
+
};
|
|
74
|
+
saveAll(data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clearCredentials(serverUrl) {
|
|
78
|
+
const data = loadAll();
|
|
79
|
+
const key = normalizeServerUrl(serverUrl);
|
|
80
|
+
if (data.servers[key]) {
|
|
81
|
+
delete data.servers[key];
|
|
82
|
+
saveAll(data);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
path: target,
|
|
90
|
+
getCredentials,
|
|
91
|
+
setCredentials,
|
|
92
|
+
clearCredentials,
|
|
93
|
+
_loadAll: loadAll,
|
|
94
|
+
_saveAll: saveAll,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Default singleton used by handlers in normal CLI operation.
|
|
99
|
+
const defaultStore = createStore();
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
SCHEMA_VERSION,
|
|
103
|
+
createStore,
|
|
104
|
+
defaultCredentialsPath,
|
|
105
|
+
normalizeServerUrl,
|
|
106
|
+
get CREDENTIALS_PATH() { return defaultStore.path; },
|
|
107
|
+
getCredentials: (url) => defaultStore.getCredentials(url),
|
|
108
|
+
setCredentials: (url, creds) => defaultStore.setCredentials(url, creds),
|
|
109
|
+
clearCredentials: (url) => defaultStore.clearCredentials(url),
|
|
110
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// RFC 8628 device authorization grant client for the SPAPS CLI.
|
|
2
|
+
//
|
|
3
|
+
// Wraps the server endpoints under /api/cli/device/{authorize,verify,token}:
|
|
4
|
+
// - startDeviceAuthorization() -> POST /api/cli/device/authorize
|
|
5
|
+
// - pollForToken() -> POST /api/cli/device/token (polling)
|
|
6
|
+
//
|
|
7
|
+
// The `verify` endpoint is JWT-gated and driven by the user's browser session,
|
|
8
|
+
// not this client.
|
|
9
|
+
|
|
10
|
+
const axios = require('axios');
|
|
11
|
+
|
|
12
|
+
const { buildApiUrl, extractApiError, unwrapApiData } = require('./http');
|
|
13
|
+
|
|
14
|
+
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
15
|
+
const DEFAULT_CLIENT_ID = null;
|
|
16
|
+
|
|
17
|
+
class DeviceFlowError extends Error {
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message || code);
|
|
20
|
+
this.name = 'DeviceFlowError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function startDeviceAuthorization({
|
|
26
|
+
serverUrl,
|
|
27
|
+
clientId = DEFAULT_CLIENT_ID,
|
|
28
|
+
axiosInstance = axios,
|
|
29
|
+
}) {
|
|
30
|
+
if (!serverUrl || !clientId) {
|
|
31
|
+
throw new DeviceFlowError('config_error', 'serverUrl and clientId are required');
|
|
32
|
+
}
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await axiosInstance.post(
|
|
36
|
+
buildApiUrl(serverUrl, '/cli/device/authorize'),
|
|
37
|
+
{ client_id: clientId },
|
|
38
|
+
{
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
validateStatus: () => true,
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new DeviceFlowError(
|
|
45
|
+
'network_error',
|
|
46
|
+
`Failed to reach SPAPS at ${serverUrl}: ${err.message || err}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (res.status >= 400) {
|
|
51
|
+
const body = res.data || {};
|
|
52
|
+
const apiError = extractApiError(body, res.status);
|
|
53
|
+
throw new DeviceFlowError(
|
|
54
|
+
apiError.code || 'authorize_failed',
|
|
55
|
+
apiError.message || `HTTP ${res.status}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return unwrapApiData(res.data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultSleep(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function pollForToken({
|
|
66
|
+
serverUrl,
|
|
67
|
+
deviceCode,
|
|
68
|
+
clientId = DEFAULT_CLIENT_ID,
|
|
69
|
+
interval = 5,
|
|
70
|
+
expiresIn = 900,
|
|
71
|
+
axiosInstance = axios,
|
|
72
|
+
onTick = null,
|
|
73
|
+
clock = Date.now,
|
|
74
|
+
sleepFn = defaultSleep,
|
|
75
|
+
}) {
|
|
76
|
+
if (!serverUrl || !deviceCode || !clientId) {
|
|
77
|
+
throw new DeviceFlowError(
|
|
78
|
+
'config_error',
|
|
79
|
+
'serverUrl, deviceCode, and clientId are required'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const startTime = clock();
|
|
84
|
+
const expiresAtMs = startTime + Number(expiresIn) * 1000;
|
|
85
|
+
// Use Number.isFinite so an explicit 0 is respected (tests use interval=0
|
|
86
|
+
// to skip real sleeps; `0 || 5` would clobber that to 5).
|
|
87
|
+
const parsedInterval = Number(interval);
|
|
88
|
+
let currentInterval = Math.max(
|
|
89
|
+
0,
|
|
90
|
+
Number.isFinite(parsedInterval) ? parsedInterval : 5
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Loop until the server returns success, an unrecoverable error, or the
|
|
94
|
+
// device code TTL is exhausted.
|
|
95
|
+
while (clock() < expiresAtMs) {
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await axiosInstance.post(
|
|
99
|
+
buildApiUrl(serverUrl, '/cli/device/token'),
|
|
100
|
+
{
|
|
101
|
+
grant_type: GRANT_TYPE,
|
|
102
|
+
device_code: deviceCode,
|
|
103
|
+
client_id: clientId,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
validateStatus: () => true,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new DeviceFlowError(
|
|
112
|
+
'network_error',
|
|
113
|
+
`Failed to reach SPAPS during poll: ${err.message || err}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const responseData = unwrapApiData(res.data);
|
|
118
|
+
|
|
119
|
+
if (res.status >= 200 && res.status < 300 && responseData && responseData.access_token) {
|
|
120
|
+
return responseData;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const apiError = extractApiError(res.data || {}, res.status);
|
|
124
|
+
const errorCode = apiError.code || `http_${res.status}`;
|
|
125
|
+
const errorDesc = apiError.message || '';
|
|
126
|
+
|
|
127
|
+
if (errorCode === 'authorization_pending') {
|
|
128
|
+
if (onTick) onTick({ status: 'pending', interval: currentInterval });
|
|
129
|
+
await sleepFn(currentInterval * 1000);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (errorCode === 'slow_down') {
|
|
133
|
+
currentInterval += 5;
|
|
134
|
+
if (onTick) onTick({ status: 'slow_down', interval: currentInterval });
|
|
135
|
+
await sleepFn(currentInterval * 1000);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (errorCode === 'access_denied') {
|
|
139
|
+
throw new DeviceFlowError('access_denied', errorDesc || 'User denied the authorization request');
|
|
140
|
+
}
|
|
141
|
+
if (errorCode === 'expired_token') {
|
|
142
|
+
throw new DeviceFlowError('expired_token', errorDesc || 'Device code expired before authorization');
|
|
143
|
+
}
|
|
144
|
+
throw new DeviceFlowError(
|
|
145
|
+
errorCode,
|
|
146
|
+
errorDesc || `Device token exchange failed (HTTP ${res.status})`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new DeviceFlowError('expired_token', 'Timed out waiting for user authorization');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
DEFAULT_CLIENT_ID,
|
|
155
|
+
GRANT_TYPE,
|
|
156
|
+
DeviceFlowError,
|
|
157
|
+
startDeviceAuthorization,
|
|
158
|
+
pollForToken,
|
|
159
|
+
};
|