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 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
- Authenticated follow-up calls such as `whoami`, `logout`, and token refresh still need a normal SPAPS app key when the server is not in local mode. The CLI resolves that key from:
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
- DUMP_AGE=$(( $(date +%s) - $(stat -f %m "${PROD_DUMP}" 2>/dev/null || stat -c %Y "${PROD_DUMP}" 2>/dev/null) ))
55
- DUMP_AGE_HOURS=$(( DUMP_AGE / 3600 ))
56
- info "Using cached prod dump (${DUMP_AGE_HOURS}h old): ${PROD_DUMP}"
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
@@ -38,6 +38,10 @@ function shouldRouteToHome(args) {
38
38
  continue;
39
39
  }
40
40
 
41
+ if (arg.startsWith('--port=') || arg.startsWith('--server-url=')) {
42
+ continue;
43
+ }
44
+
41
45
  if (flagsWithValues.has(arg)) {
42
46
  index += 1;
43
47
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps",
3
- "version": "0.9.2",
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": {
@@ -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 buildRequestHeaders(headers = {}, cwd = process.cwd()) {
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
- { refresh_token: refreshToken },
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 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;
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 refreshed = await refreshAccessToken({
314
+ const updated = await performCredentialRefresh({
129
315
  serverUrl: base,
130
- refreshToken: storedCreds.refresh_token,
131
316
  axiosInstance,
132
317
  cwd,
133
318
  });
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);
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
  };
@@ -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
- const data = loadAll();
65
- return data.servers[normalizeServerUrl(serverUrl)] || null;
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
- const data = loadAll();
70
- data.servers[normalizeServerUrl(serverUrl)] = {
71
- ...creds,
72
- saved_at: Math.floor(Date.now() / 1000),
73
- };
74
- saveAll(data);
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
- 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;
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
  };
@@ -19,7 +19,12 @@ const {
19
19
  CREDENTIALS_PATH,
20
20
  } = require('./credentials');
21
21
  const { isHeadless, tryOpenBrowser } = require('./env');
22
- const { authFetch, resolveServerUrl, refreshAccessToken } = require('./client');
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(err.code === 'NOT_AUTHENTICATED' || err.code === 'SESSION_EXPIRED' ? 2 : 1);
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. If refresh fails, fall through and print whatever we
397
- // have the consumer will see a 401 and re-login.
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
- if (
400
- creds.expires_at &&
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 refreshed = await refreshAccessToken({
406
- serverUrl,
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: refreshed.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(refreshed.access_token + '\n');
463
+ process.stdout.write(updated.access_token + '\n');
436
464
  }
437
465
  return;
438
- } catch {
439
- // fall through — print the stale token
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(
@@ -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
 
@@ -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/yourusername/sweet-potato/issues'));
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/yourusername/sweet-potato/issues',
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
- const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
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 };
@@ -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
- test_users: {
571
- admin: { email: 'admin@spaps.dev', password: 'Admin1234x' },
572
- user: { email: 'user@spaps.dev', password: 'User1234x' },
573
- premium: { email: 'premium@spaps.dev', password: 'Premium1234x' },
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
- console.log(chalk.cyan('👥 Test Users:'));
618
- console.log(` ${chalk.bold('Admin:')} ${connectionInfo.test_users.admin.email} / ${connectionInfo.test_users.admin.password}`);
619
- console.log(` ${chalk.bold('User:')} ${connectionInfo.test_users.user.email} / ${connectionInfo.test_users.user.password}`);
620
- console.log(` ${chalk.bold('Premium:')} ${connectionInfo.test_users.premium.email} / ${connectionInfo.test_users.premium.password}`);
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) {