spaps 0.9.2 → 0.9.3
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 +18 -1
- package/assets/local-runtime/scripts/fetch-prod-db.sh +28 -3
- package/bin/spaps.js +4 -0
- package/package.json +1 -1
- package/src/auth/client.js +213 -23
- package/src/auth/credentials.js +150 -16
- package/src/auth/handlers.js +77 -28
- package/src/cli-dispatcher.js +3 -1
- package/src/error-handler.js +2 -2
- package/src/handlers.js +27 -4
- package/src/local-server.js +45 -8
package/README.md
CHANGED
|
@@ -276,7 +276,11 @@ The CLI no longer assumes a built-in `spaps-cli` application slug. Resolve the c
|
|
|
276
276
|
- repo-local `.spaps/app.json`
|
|
277
277
|
- `/health/local-mode` test application metadata
|
|
278
278
|
|
|
279
|
-
|
|
279
|
+
Public-client device-flow sessions store `client_id` and refresh keylessly with
|
|
280
|
+
`{ refresh_token, client_id }` (no `X-API-Key`). Bearer-only endpoints such as
|
|
281
|
+
`whoami`, `logout`, and `sessions` work without a repo-local key for those
|
|
282
|
+
sessions. Confidential credentials (no `client_id`) still need an app key for
|
|
283
|
+
refresh; the CLI resolves it from:
|
|
280
284
|
|
|
281
285
|
- `SPAPS_API_KEY`
|
|
282
286
|
- `NEXT_PUBLIC_SPAPS_API_KEY`
|
|
@@ -284,6 +288,19 @@ Authenticated follow-up calls such as `whoami`, `logout`, and token refresh stil
|
|
|
284
288
|
- repo-local `.env.local`
|
|
285
289
|
- repo-local `.env`
|
|
286
290
|
|
|
291
|
+
### Machine and CI auth
|
|
292
|
+
|
|
293
|
+
Automation should not default to operator device-flow sessions:
|
|
294
|
+
|
|
295
|
+
| Pattern | When to use |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `SPAPS_ACCESS_TOKEN` | Short-lived CI access. Highest precedence; skips `credentials.json` and refresh. |
|
|
298
|
+
| Confidential secret key (`spaps_sec_...`) + refresh token (no `client_id`) | Refreshable machine/workload automation; refresh sends `X-API-Key`. |
|
|
299
|
+
|
|
300
|
+
Avoid as machine-auth defaults: `spaps login` public-client refresh, publishable
|
|
301
|
+
keys alone, or public offline access. Public-client refresh is for human/operator
|
|
302
|
+
CLI sessions; pre-DPoP refresh-token theft is not cryptographically prevented.
|
|
303
|
+
|
|
287
304
|
Examples:
|
|
288
305
|
|
|
289
306
|
```bash
|
|
@@ -27,6 +27,27 @@ err() {
|
|
|
27
27
|
printf '\n%s %s\n' "❌" "$1" >&2
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
file_mtime_epoch() {
|
|
31
|
+
local path="$1"
|
|
32
|
+
local mtime
|
|
33
|
+
|
|
34
|
+
# Probe GNU coreutils first (`stat -c %Y`), then BSD/macOS (`stat -f %m`).
|
|
35
|
+
# The two flag dialects are mutually incompatible: `stat -f` on GNU means
|
|
36
|
+
# "file system status" and prints multi-line garbage, so we must validate
|
|
37
|
+
# that we captured a bare epoch integer before trusting it.
|
|
38
|
+
if mtime=$(stat -c %Y "${path}" 2>/dev/null) && [[ "${mtime}" =~ ^[0-9]+$ ]]; then
|
|
39
|
+
printf '%s\n' "${mtime}"
|
|
40
|
+
return 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if mtime=$(stat -f %m "${path}" 2>/dev/null) && [[ "${mtime}" =~ ^[0-9]+$ ]]; then
|
|
44
|
+
printf '%s\n' "${mtime}"
|
|
45
|
+
return 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
resolve_prod_host() {
|
|
31
52
|
local candidate
|
|
32
53
|
local candidates=("${PRIMARY_PROD_HOST}")
|
|
@@ -51,9 +72,13 @@ if [[ -f "${PROD_DUMP}" ]]; then
|
|
|
51
72
|
info "SPAPS_PROD_DB_FRESH=1 set, fetching fresh dump from production..."
|
|
52
73
|
;;
|
|
53
74
|
*)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
if DUMP_MTIME="$(file_mtime_epoch "${PROD_DUMP}")"; then
|
|
76
|
+
DUMP_AGE=$(( $(date +%s) - DUMP_MTIME ))
|
|
77
|
+
DUMP_AGE_HOURS=$(( DUMP_AGE / 3600 ))
|
|
78
|
+
info "Using cached prod dump (${DUMP_AGE_HOURS}h old): ${PROD_DUMP}"
|
|
79
|
+
else
|
|
80
|
+
info "Using cached prod dump: ${PROD_DUMP}"
|
|
81
|
+
fi
|
|
57
82
|
info "Run with SPAPS_PROD_DB_FRESH=1 to fetch fresh data."
|
|
58
83
|
exit 0
|
|
59
84
|
;;
|
package/bin/spaps.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spaps",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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/auth/client.js
CHANGED
|
@@ -12,10 +12,17 @@ const { DEFAULT_PORT } = require('../config');
|
|
|
12
12
|
const {
|
|
13
13
|
getCredentials,
|
|
14
14
|
setCredentials,
|
|
15
|
+
withCredentialsLockAsync,
|
|
15
16
|
} = require('./credentials');
|
|
16
17
|
const { resolveAuthApiKey } = require('./api-key');
|
|
17
18
|
const { buildApiUrl, extractApiError, unwrapApiData } = require('./http');
|
|
18
19
|
|
|
20
|
+
const AUTH_BEARER_ONLY_PREFIXES = [
|
|
21
|
+
'/auth/user',
|
|
22
|
+
'/auth/logout',
|
|
23
|
+
'/auth/sessions',
|
|
24
|
+
];
|
|
25
|
+
|
|
19
26
|
function resolveServerUrl(options = {}) {
|
|
20
27
|
if (options.serverUrl) return String(options.serverUrl).replace(/\/+$/, '');
|
|
21
28
|
if (process.env.SPAPS_API_URL) return String(process.env.SPAPS_API_URL).replace(/\/+$/, '');
|
|
@@ -23,7 +30,46 @@ function resolveServerUrl(options = {}) {
|
|
|
23
30
|
return `http://localhost:${port}`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
function
|
|
33
|
+
function normalizeApiPath(pathOrUrl) {
|
|
34
|
+
if (/^https?:\/\//.test(pathOrUrl)) {
|
|
35
|
+
try {
|
|
36
|
+
const pathname = new URL(pathOrUrl).pathname;
|
|
37
|
+
return pathname.startsWith('/api/')
|
|
38
|
+
? pathname.slice('/api'.length)
|
|
39
|
+
: pathname;
|
|
40
|
+
} catch {
|
|
41
|
+
return pathOrUrl;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (pathOrUrl.startsWith('/api/')) {
|
|
45
|
+
return pathOrUrl.slice('/api'.length);
|
|
46
|
+
}
|
|
47
|
+
return pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isAuthBearerOnlyPath(pathOrUrl) {
|
|
51
|
+
const normalized = normalizeApiPath(pathOrUrl).replace(/\/+$/, '') || '/';
|
|
52
|
+
return AUTH_BEARER_ONLY_PREFIXES.some(
|
|
53
|
+
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPublicClientCredentials(creds) {
|
|
58
|
+
return Boolean(creds && creds.client_id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shouldSkipApiKey({ storedCreds, pathOrUrl }) {
|
|
62
|
+
if (!isPublicClientCredentials(storedCreds)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return isAuthBearerOnlyPath(pathOrUrl);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildRequestHeaders(headers = {}, cwd = process.cwd(), options = {}) {
|
|
69
|
+
if (options.skipApiKey) {
|
|
70
|
+
return headers;
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
const resolved = resolveAuthApiKey({ cwd });
|
|
28
74
|
if (
|
|
29
75
|
!resolved.apiKey ||
|
|
@@ -39,34 +85,172 @@ function buildRequestHeaders(headers = {}, cwd = process.cwd()) {
|
|
|
39
85
|
};
|
|
40
86
|
}
|
|
41
87
|
|
|
88
|
+
function normalizeErrorToken(value) {
|
|
89
|
+
return String(value || '').trim().toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function enrichRefreshError(err, payload, status) {
|
|
93
|
+
const apiError = extractApiError(payload || {}, status);
|
|
94
|
+
const envelopeError =
|
|
95
|
+
payload &&
|
|
96
|
+
typeof payload === 'object' &&
|
|
97
|
+
payload.success === false &&
|
|
98
|
+
payload.error &&
|
|
99
|
+
typeof payload.error === 'object'
|
|
100
|
+
? payload.error
|
|
101
|
+
: null;
|
|
102
|
+
const details = envelopeError?.details || null;
|
|
103
|
+
const stableCode =
|
|
104
|
+
(details && details.stable_code) ||
|
|
105
|
+
envelopeError?.stable_code ||
|
|
106
|
+
null;
|
|
107
|
+
|
|
108
|
+
const out = err instanceof Error ? err : new Error(apiError.message || String(err));
|
|
109
|
+
out.code = apiError.code || out.code || 'REFRESH_FAILED';
|
|
110
|
+
out.status = status;
|
|
111
|
+
out.stableCode = stableCode;
|
|
112
|
+
out.details = details;
|
|
113
|
+
out.message = apiError.message || out.message;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isInteractionRequiredRefreshError(err) {
|
|
118
|
+
const code = normalizeErrorToken(err && err.code);
|
|
119
|
+
const stable = normalizeErrorToken(err && err.stableCode);
|
|
120
|
+
if (code === 'interaction_required' || stable === 'interaction_required') {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (err && err.details && err.details.interaction_required === true) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (code === 'refresh_token_expired' || code === 'refresh_idle_ttl') {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isRotationRaceRefreshError(err) {
|
|
133
|
+
const code = normalizeErrorToken(err && err.code);
|
|
134
|
+
const stable = normalizeErrorToken(err && err.stableCode);
|
|
135
|
+
return (
|
|
136
|
+
code === 'refresh_token_replay' ||
|
|
137
|
+
code === 'already_rotated' ||
|
|
138
|
+
code === 'rotation_in_progress' ||
|
|
139
|
+
stable === 'already_rotated' ||
|
|
140
|
+
stable === 'rotation_in_progress'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tokenLooksFresh(creds, skewSec = 30) {
|
|
145
|
+
if (!creds || !creds.access_token) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
149
|
+
if (!creds.expires_at) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return creds.expires_at - skewSec >= nowSec;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mergeRefreshedCredentials(existing, refreshed, nowSec) {
|
|
156
|
+
return {
|
|
157
|
+
...existing,
|
|
158
|
+
access_token: refreshed.access_token,
|
|
159
|
+
refresh_token: refreshed.refresh_token || existing.refresh_token,
|
|
160
|
+
expires_in: refreshed.expires_in || existing.expires_in,
|
|
161
|
+
expires_at: refreshed.expires_in
|
|
162
|
+
? nowSec + Number(refreshed.expires_in)
|
|
163
|
+
: existing.expires_at,
|
|
164
|
+
token_type: refreshed.token_type || existing.token_type || 'Bearer',
|
|
165
|
+
client_id: existing.client_id || null,
|
|
166
|
+
session_id: refreshed.session_id || existing.session_id || null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
42
170
|
async function refreshAccessToken({
|
|
43
171
|
serverUrl,
|
|
44
172
|
refreshToken,
|
|
173
|
+
clientId = null,
|
|
45
174
|
axiosInstance = axios,
|
|
46
175
|
headers = {},
|
|
47
176
|
cwd = process.cwd(),
|
|
48
177
|
}) {
|
|
178
|
+
const body = { refresh_token: refreshToken };
|
|
179
|
+
if (clientId) {
|
|
180
|
+
body.client_id = clientId;
|
|
181
|
+
}
|
|
182
|
+
|
|
49
183
|
const res = await axiosInstance.post(
|
|
50
184
|
buildApiUrl(serverUrl, '/auth/refresh'),
|
|
51
|
-
|
|
185
|
+
body,
|
|
52
186
|
{
|
|
53
187
|
headers: buildRequestHeaders(
|
|
54
188
|
{ 'Content-Type': 'application/json', ...headers },
|
|
55
|
-
cwd
|
|
189
|
+
cwd,
|
|
190
|
+
{ skipApiKey: Boolean(clientId) }
|
|
56
191
|
),
|
|
57
192
|
validateStatus: () => true,
|
|
58
193
|
}
|
|
59
194
|
);
|
|
60
195
|
if (res.status >= 400) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
throw
|
|
196
|
+
const baseErr = new Error(
|
|
197
|
+
extractApiError(res.data || {}, res.status).message ||
|
|
198
|
+
`refresh failed (HTTP ${res.status})`
|
|
199
|
+
);
|
|
200
|
+
throw enrichRefreshError(baseErr, res.data || {}, res.status);
|
|
66
201
|
}
|
|
67
202
|
return unwrapApiData(res.data);
|
|
68
203
|
}
|
|
69
204
|
|
|
205
|
+
async function performCredentialRefresh({
|
|
206
|
+
serverUrl,
|
|
207
|
+
cwd = process.cwd(),
|
|
208
|
+
axiosInstance = axios,
|
|
209
|
+
allowRotationRetry = true,
|
|
210
|
+
}) {
|
|
211
|
+
return withCredentialsLockAsync(async () => {
|
|
212
|
+
const creds = getCredentials(serverUrl);
|
|
213
|
+
if (!creds || !creds.refresh_token) {
|
|
214
|
+
const err = new Error('Not authenticated. Run `spaps login` first.');
|
|
215
|
+
err.code = 'NOT_AUTHENTICATED';
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (tokenLooksFresh(creds)) {
|
|
220
|
+
return creds;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const beforeAccessToken = creds.access_token;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const refreshed = await refreshAccessToken({
|
|
227
|
+
serverUrl,
|
|
228
|
+
refreshToken: creds.refresh_token,
|
|
229
|
+
clientId: creds.client_id || null,
|
|
230
|
+
axiosInstance,
|
|
231
|
+
cwd,
|
|
232
|
+
});
|
|
233
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
234
|
+
const updated = mergeRefreshedCredentials(creds, refreshed, nowSec);
|
|
235
|
+
setCredentials(serverUrl, updated);
|
|
236
|
+
return updated;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (allowRotationRetry && isRotationRaceRefreshError(err)) {
|
|
239
|
+
const reread = getCredentials(serverUrl);
|
|
240
|
+
if (
|
|
241
|
+
reread &&
|
|
242
|
+
reread.access_token &&
|
|
243
|
+
reread.access_token !== beforeAccessToken &&
|
|
244
|
+
tokenLooksFresh(reread)
|
|
245
|
+
) {
|
|
246
|
+
return reread;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
70
254
|
async function authFetch(
|
|
71
255
|
pathOrUrl,
|
|
72
256
|
{
|
|
@@ -96,6 +280,8 @@ async function authFetch(
|
|
|
96
280
|
accessToken = storedCreds.access_token;
|
|
97
281
|
}
|
|
98
282
|
|
|
283
|
+
const skipApiKey = shouldSkipApiKey({ storedCreds, pathOrUrl });
|
|
284
|
+
|
|
99
285
|
const doRequest = (tokenToUse) =>
|
|
100
286
|
axiosInstance({
|
|
101
287
|
url,
|
|
@@ -105,7 +291,7 @@ async function authFetch(
|
|
|
105
291
|
...headers,
|
|
106
292
|
Authorization: `Bearer ${tokenToUse}`,
|
|
107
293
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
108
|
-
}, cwd),
|
|
294
|
+
}, cwd, { skipApiKey }),
|
|
109
295
|
validateStatus: () => true,
|
|
110
296
|
});
|
|
111
297
|
|
|
@@ -125,28 +311,24 @@ async function authFetch(
|
|
|
125
311
|
shouldAttemptRefresh
|
|
126
312
|
) {
|
|
127
313
|
try {
|
|
128
|
-
const
|
|
314
|
+
const updated = await performCredentialRefresh({
|
|
129
315
|
serverUrl: base,
|
|
130
|
-
refreshToken: storedCreds.refresh_token,
|
|
131
316
|
axiosInstance,
|
|
132
317
|
cwd,
|
|
133
318
|
});
|
|
134
|
-
|
|
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);
|
|
319
|
+
res = await doRequest(updated.access_token);
|
|
146
320
|
} catch (err) {
|
|
147
321
|
if (err.code === 'INVALID_APPLICATION') {
|
|
148
322
|
throw err;
|
|
149
323
|
}
|
|
324
|
+
if (isInteractionRequiredRefreshError(err)) {
|
|
325
|
+
const authErr = new Error(
|
|
326
|
+
'Device login required. Run `spaps login` again.'
|
|
327
|
+
);
|
|
328
|
+
authErr.code = 'INTERACTION_REQUIRED';
|
|
329
|
+
authErr.cause = err;
|
|
330
|
+
throw authErr;
|
|
331
|
+
}
|
|
150
332
|
const authErr = new Error('Session expired. Run `spaps login` again.');
|
|
151
333
|
authErr.code = 'SESSION_EXPIRED';
|
|
152
334
|
authErr.cause = err;
|
|
@@ -162,8 +344,16 @@ async function authFetch(
|
|
|
162
344
|
}
|
|
163
345
|
|
|
164
346
|
module.exports = {
|
|
347
|
+
AUTH_BEARER_ONLY_PREFIXES,
|
|
165
348
|
buildRequestHeaders,
|
|
349
|
+
isAuthBearerOnlyPath,
|
|
350
|
+
isInteractionRequiredRefreshError,
|
|
351
|
+
isPublicClientCredentials,
|
|
352
|
+
isRotationRaceRefreshError,
|
|
353
|
+
mergeRefreshedCredentials,
|
|
354
|
+
performCredentialRefresh,
|
|
166
355
|
resolveServerUrl,
|
|
167
356
|
refreshAccessToken,
|
|
168
357
|
authFetch,
|
|
358
|
+
tokenLooksFresh,
|
|
169
359
|
};
|
package/src/auth/credentials.js
CHANGED
|
@@ -12,6 +12,8 @@ const os = require('node:os');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
|
|
14
14
|
const SCHEMA_VERSION = 1;
|
|
15
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
|
|
16
|
+
const DEFAULT_STALE_LOCK_MS = 30_000;
|
|
15
17
|
|
|
16
18
|
function defaultCredentialsPath() {
|
|
17
19
|
if (process.env.SPAPS_CREDENTIALS_PATH) {
|
|
@@ -30,8 +32,123 @@ function emptyStore() {
|
|
|
30
32
|
return { version: SCHEMA_VERSION, servers: {} };
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function lockPathFor(target) {
|
|
36
|
+
return `${target}.lock`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isLockStale(lockPath, maxAgeMs = DEFAULT_STALE_LOCK_MS) {
|
|
40
|
+
try {
|
|
41
|
+
const stat = fs.statSync(lockPath);
|
|
42
|
+
return Date.now() - stat.mtimeMs > maxAgeMs;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function spinWait(ms) {
|
|
49
|
+
const deadline = Date.now() + ms;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
// Busy-wait briefly; credential ops are short-lived.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function acquireLockSync(
|
|
56
|
+
target,
|
|
57
|
+
timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
|
58
|
+
staleLockMs = DEFAULT_STALE_LOCK_MS
|
|
59
|
+
) {
|
|
60
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
61
|
+
const lockPath = lockPathFor(target);
|
|
62
|
+
const deadline = Date.now() + timeoutMs;
|
|
63
|
+
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
try {
|
|
66
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
67
|
+
fs.writeSync(
|
|
68
|
+
fd,
|
|
69
|
+
JSON.stringify({ pid: process.pid, acquired_at: Date.now() })
|
|
70
|
+
);
|
|
71
|
+
fs.closeSync(fd);
|
|
72
|
+
return lockPath;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code !== 'EEXIST') {
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
if (isLockStale(lockPath, staleLockMs)) {
|
|
78
|
+
try {
|
|
79
|
+
fs.unlinkSync(lockPath);
|
|
80
|
+
} catch {
|
|
81
|
+
// Another process may have released the lock; retry.
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
spinWait(50);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lockErr = new Error('Timed out acquiring credentials lock');
|
|
90
|
+
lockErr.code = 'CREDENTIALS_LOCK_TIMEOUT';
|
|
91
|
+
throw lockErr;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function releaseLockSync(lockPath) {
|
|
95
|
+
try {
|
|
96
|
+
fs.unlinkSync(lockPath);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.code !== 'ENOENT') {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
33
104
|
function createStore(filePath) {
|
|
34
105
|
const target = filePath || defaultCredentialsPath();
|
|
106
|
+
let lockDepth = 0;
|
|
107
|
+
let activeLockPath = null;
|
|
108
|
+
|
|
109
|
+
function withCredentialsLock(fn, options = {}) {
|
|
110
|
+
if (lockDepth > 0) {
|
|
111
|
+
return fn();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
activeLockPath = acquireLockSync(
|
|
115
|
+
target,
|
|
116
|
+
options.timeoutMs,
|
|
117
|
+
options.staleLockMs
|
|
118
|
+
);
|
|
119
|
+
lockDepth += 1;
|
|
120
|
+
try {
|
|
121
|
+
return fn();
|
|
122
|
+
} finally {
|
|
123
|
+
lockDepth -= 1;
|
|
124
|
+
if (lockDepth === 0) {
|
|
125
|
+
releaseLockSync(activeLockPath);
|
|
126
|
+
activeLockPath = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function withCredentialsLockAsync(fn, options = {}) {
|
|
132
|
+
if (lockDepth > 0) {
|
|
133
|
+
return await fn();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
activeLockPath = acquireLockSync(
|
|
137
|
+
target,
|
|
138
|
+
options.timeoutMs,
|
|
139
|
+
options.staleLockMs
|
|
140
|
+
);
|
|
141
|
+
lockDepth += 1;
|
|
142
|
+
try {
|
|
143
|
+
return await fn();
|
|
144
|
+
} finally {
|
|
145
|
+
lockDepth -= 1;
|
|
146
|
+
if (lockDepth === 0) {
|
|
147
|
+
releaseLockSync(activeLockPath);
|
|
148
|
+
activeLockPath = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
35
152
|
|
|
36
153
|
function loadAll() {
|
|
37
154
|
try {
|
|
@@ -61,28 +178,34 @@ function createStore(filePath) {
|
|
|
61
178
|
}
|
|
62
179
|
|
|
63
180
|
function getCredentials(serverUrl) {
|
|
64
|
-
|
|
65
|
-
|
|
181
|
+
return withCredentialsLock(() => {
|
|
182
|
+
const data = loadAll();
|
|
183
|
+
return data.servers[normalizeServerUrl(serverUrl)] || null;
|
|
184
|
+
});
|
|
66
185
|
}
|
|
67
186
|
|
|
68
187
|
function setCredentials(serverUrl, creds) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
188
|
+
return withCredentialsLock(() => {
|
|
189
|
+
const data = loadAll();
|
|
190
|
+
data.servers[normalizeServerUrl(serverUrl)] = {
|
|
191
|
+
...creds,
|
|
192
|
+
saved_at: Math.floor(Date.now() / 1000),
|
|
193
|
+
};
|
|
194
|
+
saveAll(data);
|
|
195
|
+
});
|
|
75
196
|
}
|
|
76
197
|
|
|
77
198
|
function clearCredentials(serverUrl) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
199
|
+
return withCredentialsLock(() => {
|
|
200
|
+
const data = loadAll();
|
|
201
|
+
const key = normalizeServerUrl(serverUrl);
|
|
202
|
+
if (data.servers[key]) {
|
|
203
|
+
delete data.servers[key];
|
|
204
|
+
saveAll(data);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
});
|
|
86
209
|
}
|
|
87
210
|
|
|
88
211
|
return {
|
|
@@ -90,8 +213,13 @@ function createStore(filePath) {
|
|
|
90
213
|
getCredentials,
|
|
91
214
|
setCredentials,
|
|
92
215
|
clearCredentials,
|
|
216
|
+
withCredentialsLock,
|
|
217
|
+
withCredentialsLockAsync,
|
|
93
218
|
_loadAll: loadAll,
|
|
94
219
|
_saveAll: saveAll,
|
|
220
|
+
_acquireLockSync: () => acquireLockSync(target),
|
|
221
|
+
_releaseLockSync: (lockPath) => releaseLockSync(lockPath),
|
|
222
|
+
_lockPathFor: () => lockPathFor(target),
|
|
95
223
|
};
|
|
96
224
|
}
|
|
97
225
|
|
|
@@ -100,11 +228,17 @@ const defaultStore = createStore();
|
|
|
100
228
|
|
|
101
229
|
module.exports = {
|
|
102
230
|
SCHEMA_VERSION,
|
|
231
|
+
DEFAULT_LOCK_TIMEOUT_MS,
|
|
232
|
+
DEFAULT_STALE_LOCK_MS,
|
|
103
233
|
createStore,
|
|
104
234
|
defaultCredentialsPath,
|
|
105
235
|
normalizeServerUrl,
|
|
236
|
+
lockPathFor,
|
|
106
237
|
get CREDENTIALS_PATH() { return defaultStore.path; },
|
|
107
238
|
getCredentials: (url) => defaultStore.getCredentials(url),
|
|
108
239
|
setCredentials: (url, creds) => defaultStore.setCredentials(url, creds),
|
|
109
240
|
clearCredentials: (url) => defaultStore.clearCredentials(url),
|
|
241
|
+
withCredentialsLock: (fn, options) => defaultStore.withCredentialsLock(fn, options),
|
|
242
|
+
withCredentialsLockAsync: (fn, options) =>
|
|
243
|
+
defaultStore.withCredentialsLockAsync(fn, options),
|
|
110
244
|
};
|
package/src/auth/handlers.js
CHANGED
|
@@ -19,7 +19,12 @@ const {
|
|
|
19
19
|
CREDENTIALS_PATH,
|
|
20
20
|
} = require('./credentials');
|
|
21
21
|
const { isHeadless, tryOpenBrowser } = require('./env');
|
|
22
|
-
const {
|
|
22
|
+
const {
|
|
23
|
+
authFetch,
|
|
24
|
+
resolveServerUrl,
|
|
25
|
+
isInteractionRequiredRefreshError,
|
|
26
|
+
performCredentialRefresh,
|
|
27
|
+
} = require('./client');
|
|
23
28
|
const { resolveLoginClientId } = require('./client-id');
|
|
24
29
|
const {
|
|
25
30
|
fetchAuthMethods,
|
|
@@ -169,6 +174,7 @@ async function loginHandler({ options }) {
|
|
|
169
174
|
expires_at: expiresAt,
|
|
170
175
|
user_id: tokenPayload.user_id || null,
|
|
171
176
|
client_id: clientId,
|
|
177
|
+
session_id: tokenPayload.session_id || null,
|
|
172
178
|
});
|
|
173
179
|
|
|
174
180
|
if (isJson) {
|
|
@@ -289,9 +295,17 @@ async function whoamiHandler({ options }) {
|
|
|
289
295
|
console.error(chalk.gray(' Run: npx spaps login'));
|
|
290
296
|
} else if (err.code === 'SESSION_EXPIRED') {
|
|
291
297
|
console.error(chalk.gray(' Run: npx spaps login (your session expired)'));
|
|
298
|
+
} else if (err.code === 'INTERACTION_REQUIRED') {
|
|
299
|
+
console.error(chalk.gray(' Run: npx spaps login (device login required)'));
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
|
-
process.exit(
|
|
302
|
+
process.exit(
|
|
303
|
+
err.code === 'NOT_AUTHENTICATED' ||
|
|
304
|
+
err.code === 'SESSION_EXPIRED' ||
|
|
305
|
+
err.code === 'INTERACTION_REQUIRED'
|
|
306
|
+
? 2
|
|
307
|
+
: 1
|
|
308
|
+
);
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
if (res.status >= 400) {
|
|
@@ -392,31 +406,45 @@ async function tokenHandler({ options }) {
|
|
|
392
406
|
process.exit(2);
|
|
393
407
|
}
|
|
394
408
|
|
|
409
|
+
const printTokenRefreshError = ({ code, message, expiresAt, cause }) => {
|
|
410
|
+
const loginCommand = `spaps login --server-url ${serverUrl}`;
|
|
411
|
+
if (isJson) {
|
|
412
|
+
console.log(
|
|
413
|
+
JSON.stringify(
|
|
414
|
+
{
|
|
415
|
+
success: false,
|
|
416
|
+
command: 'token',
|
|
417
|
+
server_url: serverUrl,
|
|
418
|
+
expires_at: expiresAt || null,
|
|
419
|
+
login_command: loginCommand,
|
|
420
|
+
error: {
|
|
421
|
+
code,
|
|
422
|
+
message,
|
|
423
|
+
...(cause && cause.code ? { cause_code: cause.code } : {}),
|
|
424
|
+
...(cause && cause.stableCode ? { cause_stable_code: cause.stableCode } : {}),
|
|
425
|
+
...(cause && cause.status ? { cause_status: cause.status } : {}),
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
null,
|
|
429
|
+
2
|
|
430
|
+
)
|
|
431
|
+
);
|
|
432
|
+
} else {
|
|
433
|
+
console.error(chalk.red(`❌ ${message}`));
|
|
434
|
+
console.error(chalk.gray(` Run: ${loginCommand}`));
|
|
435
|
+
}
|
|
436
|
+
process.exit(2);
|
|
437
|
+
};
|
|
438
|
+
|
|
395
439
|
// If the token is within 30s of expiry, try a silent refresh so the caller
|
|
396
|
-
// gets a fresh token.
|
|
397
|
-
//
|
|
440
|
+
// gets a fresh token. Never print an expired/stale token after refresh fails:
|
|
441
|
+
// downstream consumers otherwise surface confusing 401s.
|
|
398
442
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
creds.expires_at - 30 < nowSec &&
|
|
402
|
-
creds.refresh_token
|
|
403
|
-
) {
|
|
443
|
+
const tokenNeedsRefresh = Boolean(creds.expires_at && creds.expires_at - 30 < nowSec);
|
|
444
|
+
if (tokenNeedsRefresh && creds.refresh_token) {
|
|
404
445
|
try {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
refreshToken: creds.refresh_token,
|
|
408
|
-
});
|
|
409
|
-
const newExpiresAt = refreshed.expires_in
|
|
410
|
-
? nowSec + Number(refreshed.expires_in)
|
|
411
|
-
: null;
|
|
412
|
-
setCredentials(serverUrl, {
|
|
413
|
-
...creds,
|
|
414
|
-
access_token: refreshed.access_token,
|
|
415
|
-
refresh_token: refreshed.refresh_token || creds.refresh_token,
|
|
416
|
-
expires_in: refreshed.expires_in || creds.expires_in,
|
|
417
|
-
expires_at: newExpiresAt,
|
|
418
|
-
token_type: refreshed.token_type || creds.token_type || 'Bearer',
|
|
419
|
-
});
|
|
446
|
+
const updated = await performCredentialRefresh({ serverUrl });
|
|
447
|
+
const newExpiresAt = updated.expires_at || null;
|
|
420
448
|
if (isJson) {
|
|
421
449
|
console.log(
|
|
422
450
|
JSON.stringify(
|
|
@@ -424,7 +452,7 @@ async function tokenHandler({ options }) {
|
|
|
424
452
|
success: true,
|
|
425
453
|
command: 'token',
|
|
426
454
|
source: 'refreshed',
|
|
427
|
-
access_token:
|
|
455
|
+
access_token: updated.access_token,
|
|
428
456
|
expires_at: newExpiresAt,
|
|
429
457
|
},
|
|
430
458
|
null,
|
|
@@ -432,14 +460,35 @@ async function tokenHandler({ options }) {
|
|
|
432
460
|
)
|
|
433
461
|
);
|
|
434
462
|
} else {
|
|
435
|
-
process.stdout.write(
|
|
463
|
+
process.stdout.write(updated.access_token + '\n');
|
|
436
464
|
}
|
|
437
465
|
return;
|
|
438
|
-
} catch {
|
|
439
|
-
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (isInteractionRequiredRefreshError(err)) {
|
|
468
|
+
printTokenRefreshError({
|
|
469
|
+
code: 'INTERACTION_REQUIRED',
|
|
470
|
+
expiresAt: creds.expires_at,
|
|
471
|
+
message: `Stored SPAPS session requires device login. Re-authenticate to ${serverUrl}.`,
|
|
472
|
+
cause: err,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
printTokenRefreshError({
|
|
476
|
+
code: 'TOKEN_REFRESH_FAILED',
|
|
477
|
+
expiresAt: creds.expires_at,
|
|
478
|
+
message: `Stored SPAPS access token is expired or expiring, and refresh failed. Re-authenticate to ${serverUrl}.`,
|
|
479
|
+
cause: err,
|
|
480
|
+
});
|
|
440
481
|
}
|
|
441
482
|
}
|
|
442
483
|
|
|
484
|
+
if (tokenNeedsRefresh) {
|
|
485
|
+
printTokenRefreshError({
|
|
486
|
+
code: 'TOKEN_EXPIRED',
|
|
487
|
+
expiresAt: creds.expires_at,
|
|
488
|
+
message: `Stored SPAPS access token is expired or expiring, and no refresh token is available. Re-authenticate to ${serverUrl}.`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
443
492
|
if (isJson) {
|
|
444
493
|
console.log(
|
|
445
494
|
JSON.stringify(
|
package/src/cli-dispatcher.js
CHANGED
|
@@ -516,7 +516,8 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
|
|
|
516
516
|
.option('--user-id <id>', 'User id for log attribution')
|
|
517
517
|
.option('--owner-id <id>', 'Owner id for log attribution')
|
|
518
518
|
.option('--subject-override <text>', 'Subject override')
|
|
519
|
-
.option('--body-override <text>', 'Body override')
|
|
519
|
+
.option('--body-override <text>', 'Body override')
|
|
520
|
+
.option('--idempotency-key <key>', 'Idempotency key for retry-safe sends'),
|
|
520
521
|
(opts) => ({
|
|
521
522
|
templateKey: opts.templateKey || null,
|
|
522
523
|
to: opts.to || null,
|
|
@@ -525,6 +526,7 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
|
|
|
525
526
|
ownerId: opts.ownerId || null,
|
|
526
527
|
subjectOverride: opts.subjectOverride || null,
|
|
527
528
|
bodyOverride: opts.bodyOverride || null,
|
|
529
|
+
idempotencyKey: opts.idempotencyKey || null,
|
|
528
530
|
})
|
|
529
531
|
);
|
|
530
532
|
|
package/src/error-handler.js
CHANGED
|
@@ -257,7 +257,7 @@ function formatError(error, context = {}) {
|
|
|
257
257
|
// Additional help
|
|
258
258
|
output.push(chalk.gray('\n📚 For more help:'));
|
|
259
259
|
output.push(chalk.gray(' • Documentation: https://sweetpotato.dev/docs'));
|
|
260
|
-
output.push(chalk.gray(' • GitHub Issues: https://github.com/
|
|
260
|
+
output.push(chalk.gray(' • GitHub Issues: https://github.com/build000r/sweet-potato/issues'));
|
|
261
261
|
output.push(chalk.gray(' • Discord: https://discord.gg/sweetpotato\n'));
|
|
262
262
|
|
|
263
263
|
return output.join('\n');
|
|
@@ -285,7 +285,7 @@ function formatJsonError(error, context = {}) {
|
|
|
285
285
|
},
|
|
286
286
|
help: {
|
|
287
287
|
docs: 'https://sweetpotato.dev/docs',
|
|
288
|
-
issues: 'https://github.com/
|
|
288
|
+
issues: 'https://github.com/build000r/sweet-potato/issues',
|
|
289
289
|
discord: 'https://discord.gg/sweetpotato'
|
|
290
290
|
}
|
|
291
291
|
};
|
package/src/handlers.js
CHANGED
|
@@ -27,6 +27,28 @@ const loadProjectScaffolder = lazy(() => require('./project-scaffolder'));
|
|
|
27
27
|
const loadAuthCommandHandlers = lazy(() => require('./auth/handlers'));
|
|
28
28
|
const loadDomainCli = lazy(() => require('./domain-cli'));
|
|
29
29
|
|
|
30
|
+
function openExternalUrl(url, { spawnImpl = null, platform = process.platform } = {}) {
|
|
31
|
+
const spawn = spawnImpl || require('child_process').spawn;
|
|
32
|
+
let command;
|
|
33
|
+
let args;
|
|
34
|
+
|
|
35
|
+
if (platform === 'darwin') {
|
|
36
|
+
command = 'open';
|
|
37
|
+
args = [url];
|
|
38
|
+
} else if (platform === 'win32') {
|
|
39
|
+
command = 'rundll32';
|
|
40
|
+
args = ['url.dll,FileProtocolHandler', url];
|
|
41
|
+
} else {
|
|
42
|
+
command = 'xdg-open';
|
|
43
|
+
args = [url];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
47
|
+
child.on?.('error', () => {});
|
|
48
|
+
child.unref?.();
|
|
49
|
+
return { command, args };
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
function createHandlers(version, logo) {
|
|
31
53
|
function invalidArgument(message) {
|
|
32
54
|
const error = new Error(message);
|
|
@@ -129,10 +151,8 @@ function createHandlers(version, logo) {
|
|
|
129
151
|
await server.start();
|
|
130
152
|
|
|
131
153
|
if (options.open && !isJson) {
|
|
132
|
-
const { exec } = require('child_process');
|
|
133
154
|
const url = `http://localhost:${options.port}/docs`;
|
|
134
|
-
|
|
135
|
-
exec(`${start} ${url}`);
|
|
155
|
+
openExternalUrl(url);
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
// Set up shutdown handler
|
|
@@ -170,12 +190,14 @@ function createHandlers(version, logo) {
|
|
|
170
190
|
const status = await getServerStatus(options.port);
|
|
171
191
|
if (options.json) {
|
|
172
192
|
console.log(JSON.stringify(status));
|
|
193
|
+
if (!status.running) process.exitCode = 1;
|
|
173
194
|
} else {
|
|
174
195
|
if (!status.running) {
|
|
175
196
|
console.log(chalk.red('\n❌ SPAPS server is not running'));
|
|
176
197
|
console.log('Start it with:');
|
|
177
198
|
console.log(chalk.cyan(` npx spaps local --port ${options.port}`));
|
|
178
199
|
console.log();
|
|
200
|
+
process.exitCode = 1;
|
|
179
201
|
} else {
|
|
180
202
|
console.log(chalk.green('\n✅ SPAPS server is running!\n'));
|
|
181
203
|
console.log(' URL:', chalk.cyan(status.url));
|
|
@@ -938,6 +960,7 @@ async function emailHandler({ options }) {
|
|
|
938
960
|
assignIfDefined(body, 'owner_id', options.ownerId);
|
|
939
961
|
assignIfDefined(body, 'subject_override', options.subjectOverride);
|
|
940
962
|
assignIfDefined(body, 'body_override', options.bodyOverride);
|
|
963
|
+
assignIfDefined(body, 'idempotency_key', options.idempotencyKey);
|
|
941
964
|
|
|
942
965
|
const result = await callEndpoint({ options, method: 'POST', path: '/api/email/send', body });
|
|
943
966
|
emit({ intent: 'email.send', result, isJson });
|
|
@@ -1372,4 +1395,4 @@ async function capabilityHandler({ name, options }) {
|
|
|
1372
1395
|
}
|
|
1373
1396
|
}
|
|
1374
1397
|
|
|
1375
|
-
module.exports = { createHandlers };
|
|
1398
|
+
module.exports = { createHandlers, openExternalUrl };
|
package/src/local-server.js
CHANGED
|
@@ -559,6 +559,12 @@ class LocalServer {
|
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
printConnectionInfo({ restorePlan, restoreApplied }) {
|
|
562
|
+
// Local mode authenticates by header-selected persona, not by
|
|
563
|
+
// email/password. Picking a persona is what actually works against the
|
|
564
|
+
// running server (see middleware/local_mode.py + spaps_deps.py), so we
|
|
565
|
+
// surface the X-Test-User / ?_user contract rather than fake credentials.
|
|
566
|
+
const localModeEnabled =
|
|
567
|
+
String(this.composeEnv.SPAPS_LOCAL_MODE ?? 'true').trim().toLowerCase() === 'true';
|
|
562
568
|
const connectionInfo = {
|
|
563
569
|
SPAPS_API_URL: this.apiUrl,
|
|
564
570
|
SPAPS_API_KEY: 'spaps_local_development_key',
|
|
@@ -567,10 +573,26 @@ class LocalServer {
|
|
|
567
573
|
data_source: this.dataSource,
|
|
568
574
|
restore_applied: Boolean(restoreApplied),
|
|
569
575
|
restore_source: restorePlan ? restorePlan.source : null,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
576
|
+
test_personas: {
|
|
577
|
+
local_mode_enabled: localModeEnabled,
|
|
578
|
+
select_via: 'X-Test-User: <persona> header (or ?_user=<persona> query param)',
|
|
579
|
+
default_persona: 'user',
|
|
580
|
+
note: 'Local-mode personas are header-selected; there are no password logins.',
|
|
581
|
+
personas: [
|
|
582
|
+
{ persona: 'user', header: 'X-Test-User: user', email: 'user@localhost', tier: 'free' },
|
|
583
|
+
{
|
|
584
|
+
persona: 'admin',
|
|
585
|
+
header: 'X-Test-User: admin',
|
|
586
|
+
email: 'buildooor@gmail.com',
|
|
587
|
+
tier: 'enterprise',
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
persona: 'premium',
|
|
591
|
+
header: 'X-Test-User: premium',
|
|
592
|
+
email: 'premium@localhost',
|
|
593
|
+
tier: 'premium',
|
|
594
|
+
},
|
|
595
|
+
],
|
|
574
596
|
},
|
|
575
597
|
};
|
|
576
598
|
|
|
@@ -614,10 +636,25 @@ class LocalServer {
|
|
|
614
636
|
console.log(` ${chalk.bold('Application ID:')} ${connectionInfo.SPAPS_APPLICATION_ID}`);
|
|
615
637
|
console.log(` ${chalk.bold('Self-service:')} ${connectionInfo.SELF_SERVICE_PASSWORD}`);
|
|
616
638
|
console.log();
|
|
617
|
-
|
|
618
|
-
console.log(
|
|
619
|
-
|
|
620
|
-
|
|
639
|
+
const { test_personas: testPersonas } = connectionInfo;
|
|
640
|
+
console.log(chalk.cyan('👥 Test Personas (local mode):'));
|
|
641
|
+
if (testPersonas.local_mode_enabled) {
|
|
642
|
+
console.log(chalk.dim(' Select one with the X-Test-User header (or ?_user= query param).'));
|
|
643
|
+
for (const persona of testPersonas.personas) {
|
|
644
|
+
const label = `${persona.persona}:`.padEnd(9);
|
|
645
|
+
console.log(
|
|
646
|
+
` ${chalk.bold(label)} X-Test-User: ${chalk.bold(persona.persona)} (${persona.email}, tier=${persona.tier})`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
console.log(
|
|
650
|
+
chalk.dim(` Default persona when no header is sent: ${testPersonas.default_persona}.`)
|
|
651
|
+
);
|
|
652
|
+
console.log(chalk.dim(` Example: curl -H "X-Test-User: admin" ${this.apiUrl}/api/auth/user`));
|
|
653
|
+
} else {
|
|
654
|
+
console.log(
|
|
655
|
+
chalk.dim(' Local-mode persona bypass is disabled (SPAPS_LOCAL_MODE != true); use real auth flows.')
|
|
656
|
+
);
|
|
657
|
+
}
|
|
621
658
|
console.log();
|
|
622
659
|
|
|
623
660
|
if (this.detach) {
|