@astrale-os/cli 1.0.0-beta.31 → 1.0.0-beta.32

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/dist/astrale.js CHANGED
@@ -2578,7 +2578,7 @@ var package_default;
2578
2578
  var init_package = __esm(() => {
2579
2579
  package_default = {
2580
2580
  name: "@astrale-os/cli",
2581
- version: "1.0.0-beta.31",
2581
+ version: "1.0.0-beta.32",
2582
2582
  description: "Astrale CLI — connect to existing Astrale kernels",
2583
2583
  keywords: [
2584
2584
  "astrale",
@@ -34087,6 +34087,20 @@ var init_auth4 = __esm(() => {
34087
34087
  init_auth();
34088
34088
  });
34089
34089
 
34090
+ // src/lib/credential-lifetime.ts
34091
+ function remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds = Math.ceil(Date.now() / 1000)) {
34092
+ if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
34093
+ throw new TypeError("Credential expiration and current time must be safe epoch seconds.");
34094
+ }
34095
+ return expiresAtEpochSeconds - nowEpochSeconds - 1;
34096
+ }
34097
+ function credentialLifetimeCovers(expiresAtEpochSeconds, minimumRemainingSeconds, nowEpochSeconds) {
34098
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
34099
+ throw new TypeError("Credential minimum lifetime must be a positive safe integer.");
34100
+ }
34101
+ return remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >= minimumRemainingSeconds;
34102
+ }
34103
+
34090
34104
  // src/state/paths.ts
34091
34105
  import { homedir } from "node:os";
34092
34106
  import { join } from "node:path";
@@ -34141,15 +34155,19 @@ class ExchangeCredentialCache {
34141
34155
  constructor(path = EXCHANGE_CREDENTIALS_PATH) {
34142
34156
  this.path = path;
34143
34157
  }
34144
- getOrRefresh(key, refresh, now = () => Math.floor(Date.now() / 1000)) {
34158
+ getOrRefresh(key, minimumRemainingSeconds, refresh, now = () => Math.floor(Date.now() / 1000)) {
34159
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
34160
+ throw new TypeError("Exchange credential minimum lifetime must be a positive safe integer.");
34161
+ }
34145
34162
  const encoded = encodeKey(key);
34146
- const current = this.refreshing.get(encoded);
34163
+ const pendingKey = `${encoded}\x00${minimumRemainingSeconds}`;
34164
+ const current = this.refreshing.get(pendingKey);
34147
34165
  if (current !== undefined)
34148
34166
  return current;
34149
- const pending = this.getOrRefreshOnce(key, encoded, refresh, now).finally(() => {
34150
- this.refreshing.delete(encoded);
34167
+ const pending = this.getOrRefreshOnce(key, encoded, minimumRemainingSeconds, refresh, now).finally(() => {
34168
+ this.refreshing.delete(pendingKey);
34151
34169
  });
34152
- this.refreshing.set(encoded, pending);
34170
+ this.refreshing.set(pendingKey, pending);
34153
34171
  return pending;
34154
34172
  }
34155
34173
  async deleteKernel(kernelIssuer) {
@@ -34167,20 +34185,20 @@ class ExchangeCredentialCache {
34167
34185
  delete store.entries[encoded];
34168
34186
  });
34169
34187
  }
34170
- async getOrRefreshOnce(key, encoded, refresh, now) {
34188
+ async getOrRefreshOnce(key, encoded, minimumRemainingSeconds, refresh, now) {
34171
34189
  return withFileLock(`${this.path}.lock`, async () => {
34172
34190
  await ensurePrivateDirectory(this.path);
34173
34191
  const store = await readStore(this.path);
34174
34192
  const changed = scrub(store, now());
34175
34193
  const cached3 = store.entries[encoded];
34176
- if (cached3 !== undefined && validEntry(key, cached3, now())) {
34194
+ if (cached3 !== undefined && validEntry(key, cached3, now(), minimumRemainingSeconds)) {
34177
34195
  if (changed)
34178
34196
  await writeStore(this.path, store);
34179
34197
  return cached3.credential;
34180
34198
  }
34181
34199
  const next = await refresh();
34182
- if (!validEntry(key, next, now(), 1)) {
34183
- throw new Error("Token exchange returned a credential inconsistent with its cache key.");
34200
+ if (!validEntry(key, next, now(), minimumRemainingSeconds)) {
34201
+ throw new Error("Token exchange returned a credential inconsistent with its cache key or required lifetime.");
34184
34202
  }
34185
34203
  store.entries[encoded] = Object.freeze({ ...next });
34186
34204
  await writeStore(this.path, store);
@@ -34239,7 +34257,7 @@ function validEntry(key, entry, now, minimumRemaining = MINIMUM_REMAINING_SECOND
34239
34257
  return false;
34240
34258
  }
34241
34259
  exports_grant.accept({ expr: Reflect.get(issued, "expr") });
34242
- return inspected.iss === key.domainIssuer && inspected.aud === key.kernelIssuer && inspected.claims.exp === entry.expiresAt && !Object.hasOwn(inspected.claims, "delegation") && proof.iss === key.kernelIssuer && proof.sub === entry.user && proof.aud === key.kernelIssuer;
34260
+ return inspected.iss === key.domainIssuer && inspected.aud === key.kernelIssuer && inspected.claims.exp === entry.expiresAt && !Object.hasOwn(inspected.claims, "delegation") && proof.iss === key.kernelIssuer && proof.sub === entry.user && proof.aud === key.kernelIssuer && typeof proof.claims.exp === "number" && Number.isSafeInteger(proof.claims.exp) && credentialLifetimeCovers(proof.claims.exp, minimumRemaining, now);
34243
34261
  } catch {
34244
34262
  return false;
34245
34263
  }
@@ -38190,18 +38208,25 @@ function isSessionExpired(session, skewMs = 60000) {
38190
38208
  return false;
38191
38209
  return new Date(expiresAt).getTime() <= Date.now() + skewMs;
38192
38210
  }
38193
- function accessTokenForAudience(session, audience) {
38211
+ function accessTokenForAudience(session, audience, minimumRemainingMs = 60000) {
38194
38212
  if (audience === undefined) {
38195
- return isSessionExpired(session) ? undefined : session.access_token;
38213
+ return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined;
38196
38214
  }
38197
38215
  const entry = session.tokens?.[audience];
38198
- if (entry && !isSessionExpired(entry))
38216
+ if (entry && tokenHasMinimumLifetime(entry, minimumRemainingMs))
38199
38217
  return entry.access_token;
38200
- if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
38218
+ if (tokenHasMinimumLifetime(session, minimumRemainingMs) && tokenAudienceMatches(session.access_token, audience)) {
38201
38219
  return session.access_token;
38202
38220
  }
38203
38221
  return;
38204
38222
  }
38223
+ function tokenHasMinimumLifetime(value2, minimumRemainingMs) {
38224
+ const expiration = decodeTokenClaims(value2.access_token)?.exp;
38225
+ if (typeof expiration === "number" && Number.isSafeInteger(expiration)) {
38226
+ return credentialLifetimeCovers(expiration, Math.ceil(minimumRemainingMs / 1000));
38227
+ }
38228
+ return !isSessionExpired(value2, minimumRemainingMs);
38229
+ }
38205
38230
  function withCachedToken(tokens, accessToken, expiresAt) {
38206
38231
  const next = {};
38207
38232
  for (const [aud2, entry] of Object.entries(tokens ?? {})) {
@@ -80780,10 +80805,11 @@ function idpSessionLockPath(identityName) {
80780
80805
  return `${idpSessionPath(identityName)}.lock`;
80781
80806
  }
80782
80807
  async function ensureFreshSession(identityName, opts = {}) {
80808
+ const minimumRemainingMs = minimumLifetimeMs(opts.minimumRemainingSeconds);
80783
80809
  const session3 = await readIdpSession(identityName);
80784
80810
  if (!session3)
80785
80811
  throw new IdpSessionMissingError(identityName);
80786
- if (accessTokenForAudience(session3, opts.audience))
80812
+ if (accessTokenForAudience(session3, opts.audience, minimumRemainingMs))
80787
80813
  return session3;
80788
80814
  if (!session3.refresh_token)
80789
80815
  throw new IdpSessionNoRefreshTokenError(identityName);
@@ -80791,7 +80817,7 @@ async function ensureFreshSession(identityName, opts = {}) {
80791
80817
  const current = await readIdpSession(identityName);
80792
80818
  if (!current)
80793
80819
  throw new IdpSessionMissingError(identityName);
80794
- if (accessTokenForAudience(current, opts.audience))
80820
+ if (accessTokenForAudience(current, opts.audience, minimumRemainingMs))
80795
80821
  return current;
80796
80822
  const bookmarkOrg = opts.organizationId ?? (opts.audience ? await orgIdForAudience(opts.audience) : undefined);
80797
80823
  const organizationId = bookmarkOrg ?? (opts.audience ? await (opts.resolveOrganizationId ?? fetchOrgHint)(opts.audience) : undefined);
@@ -80810,23 +80836,30 @@ async function ensureFreshSession(identityName, opts = {}) {
80810
80836
  });
80811
80837
  }
80812
80838
  }
80813
- const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, e);
80839
+ const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, minimumRemainingMs, e);
80814
80840
  if (rescued)
80815
80841
  return rescued;
80816
80842
  throw e;
80817
80843
  }
80818
80844
  });
80819
80845
  }
80820
- async function rescueAfterInvalidGrant(identityName, seen, audience, error52) {
80846
+ async function rescueAfterInvalidGrant(identityName, seen, audience, minimumRemainingMs, error52) {
80821
80847
  if (!(error52 instanceof OAuthTokenError) || error52.code !== "invalid_grant")
80822
80848
  return;
80823
80849
  const latest = await readIdpSession(identityName).catch(() => null);
80824
80850
  if (!latest || latest.updatedAt === seen.updatedAt)
80825
80851
  return;
80826
- if (!accessTokenForAudience(latest, audience))
80852
+ if (!accessTokenForAudience(latest, audience, minimumRemainingMs))
80827
80853
  return;
80828
80854
  return latest;
80829
80855
  }
80856
+ function minimumLifetimeMs(input) {
80857
+ const seconds = input ?? 60;
80858
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > Math.floor(Number.MAX_SAFE_INTEGER / 1000)) {
80859
+ throw new TypeError("IdP session minimum lifetime must be a positive safe integer.");
80860
+ }
80861
+ return seconds * 1000;
80862
+ }
80830
80863
  var IdpSessionMissingError, IdpSessionNoRefreshTokenError;
80831
80864
  var init_idp_session = __esm(() => {
80832
80865
  init_state();
@@ -80871,14 +80904,14 @@ async function resolveCredential2(opts, config2, audience = config2.issuer, regi
80871
80904
  resolvedIdentity = identity4;
80872
80905
  resolvedName = identityName;
80873
80906
  if ((identity4.source ?? "key") === "idp")
80874
- return await resolveIdpAccessToken(identityName, identity4, audience);
80907
+ return await resolveIdpAccessToken(identityName, identity4, audience, opts.minimumRemainingSeconds);
80875
80908
  return await signAs(identity4.subject, KEYS_DIR, resolveKeyIdentityAuthOptions(identity4, config2, audience, registrationKey));
80876
80909
  }
80877
80910
  const identity3 = await getDefault();
80878
80911
  resolvedIdentity = identity3;
80879
80912
  resolvedName = identity3.name;
80880
80913
  if ((identity3.source ?? "key") === "idp") {
80881
- return await resolveIdpAccessToken(identity3.name, identity3, audience);
80914
+ return await resolveIdpAccessToken(identity3.name, identity3, audience, opts.minimumRemainingSeconds);
80882
80915
  }
80883
80916
  return await signAs(identity3.subject, KEYS_DIR, resolveKeyIdentityAuthOptions(identity3, config2, audience, registrationKey));
80884
80917
  } catch (e) {
@@ -80920,10 +80953,10 @@ function resolveKeyIdentityAuthOptions(identity3, config2, audience = config2.is
80920
80953
  function systemIdentityIssuer(identity3, audience, config2) {
80921
80954
  return identity3.subject === "system" ? audience : config2.issuer;
80922
80955
  }
80923
- async function resolveIdpAccessToken(identityName, identity3, audience) {
80956
+ async function resolveIdpAccessToken(identityName, identity3, audience, minimumRemainingSeconds) {
80924
80957
  let resolved;
80925
80958
  try {
80926
- resolved = await ensureFreshSession(identityName, { audience });
80959
+ resolved = await ensureFreshSession(identityName, { audience, minimumRemainingSeconds });
80927
80960
  } catch (e) {
80928
80961
  if (e instanceof IdpSessionMissingError) {
80929
80962
  throw new Error(`No cached IdP session for "${identityName}". Run: astrale auth login --idp ${identity3.idp ?? "<idp>"}`);
@@ -80978,9 +81011,26 @@ var init_auth7 = __esm(() => {
80978
81011
  };
80979
81012
  });
80980
81013
 
81014
+ // src/connection/lifetime.ts
81015
+ function invocationCredentialTtlSeconds(timeoutMs) {
81016
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
81017
+ throw new TypeError("Invocation credential timeout must be a positive safe integer.");
81018
+ }
81019
+ return Math.max(MINIMUM_CREDENTIAL_TTL_SECONDS, Math.ceil(timeoutMs / 1000) + INVOCATION_RECEIPT_MARGIN_SECONDS);
81020
+ }
81021
+ function exchangeCredentialTtlSeconds(timeoutMs) {
81022
+ return invocationCredentialTtlSeconds(timeoutMs) + TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS;
81023
+ }
81024
+ function cachedCredentialTtlSeconds(timeoutMs) {
81025
+ return invocationCredentialTtlSeconds(timeoutMs) + CACHE_HANDOFF_MARGIN_SECONDS;
81026
+ }
81027
+ var MINIMUM_CREDENTIAL_TTL_SECONDS = 60, INVOCATION_RECEIPT_MARGIN_SECONDS = 5, CACHE_HANDOFF_MARGIN_SECONDS = 5, TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS = 15;
81028
+
80981
81029
  // src/connection/exchange.ts
80982
81030
  function createExchangeCredentialResolver(target2, source2, fetch2, timeoutMs, cache2 = new ExchangeCredentialCache) {
80983
81031
  requireExchangeTransport(target2);
81032
+ const cacheTtlSeconds = cachedCredentialTtlSeconds(timeoutMs);
81033
+ const exchangeTtlSeconds = exchangeCredentialTtlSeconds(timeoutMs);
80984
81034
  return Object.freeze({
80985
81035
  async resolve(kernelIssuer, signal) {
80986
81036
  requireLive3(signal);
@@ -80992,8 +81042,8 @@ function createExchangeCredentialResolver(target2, source2, fetch2, timeoutMs, c
80992
81042
  domainIssuer: target2.domainIssuer,
80993
81043
  sourceIssuer: sourceIdentity.issuer,
80994
81044
  sourceSubject: sourceIdentity.subject
80995
- }), async () => {
80996
- const delegationTtlSeconds = delegationLifetime(sourceToken);
81045
+ }), cacheTtlSeconds, async () => {
81046
+ const delegationTtlSeconds = delegationLifetime(sourceToken, exchangeTtlSeconds);
80997
81047
  const client = new Client({ url: `${kernelIssuer}/invoke`, fetch: fetch2, timeoutMs });
80998
81048
  try {
80999
81049
  const authenticated3 = client.as(sourceToken);
@@ -81022,7 +81072,7 @@ function createExchangeCredentialResolver(target2, source2, fetch2, timeoutMs, c
81022
81072
  if (envelope === undefined)
81023
81073
  throw new Error("Token delegation returned no credential.");
81024
81074
  return {
81025
- ...await exchange(target2.domainIssuer, kernelIssuer, envelope, fetch2, signal),
81075
+ ...await exchange(target2.domainIssuer, kernelIssuer, envelope, cacheTtlSeconds, fetch2, signal),
81026
81076
  user: user.id,
81027
81077
  sourceIssuer: sourceIdentity.issuer,
81028
81078
  sourceSubject: sourceIdentity.subject
@@ -81051,18 +81101,21 @@ function unknownFunctionOutcome(cause) {
81051
81101
  return false;
81052
81102
  return error52.reason.code === "FUNCTION_OUTCOME_UNKNOWN";
81053
81103
  }
81054
- function delegationLifetime(sourceToken) {
81104
+ function delegationLifetime(sourceToken, requiredTtlSeconds) {
81055
81105
  const expiresAt = exports_credential.inspect(sourceToken).claims.exp;
81056
81106
  if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt)) {
81057
81107
  throw new AstraleError("TOKEN_EXCHANGE_SOURCE_INVALID", "The source identity credential has no valid expiration.");
81058
81108
  }
81059
- const remaining = expiresAt - Math.ceil(Date.now() / 1000) - 1;
81109
+ const remaining = remainingCredentialLifetimeSeconds(expiresAt);
81060
81110
  if (!Number.isSafeInteger(remaining) || remaining < 1) {
81061
81111
  throw new AstraleError("TOKEN_EXCHANGE_SOURCE_EXPIRED", "The source identity credential has no lifetime available for token exchange.");
81062
81112
  }
81063
- return Math.min(EXCHANGE_TTL_SECONDS, remaining);
81113
+ if (remaining < requiredTtlSeconds) {
81114
+ throw new AstraleError("TOKEN_EXCHANGE_SOURCE_LIFETIME_INSUFFICIENT", "The source credential cannot cover the requested command timeout.", `Refresh the identity session or use a shorter --timeout; ${remaining} seconds remain but ${requiredTtlSeconds} are required.`);
81115
+ }
81116
+ return requiredTtlSeconds;
81064
81117
  }
81065
- async function exchange(domainIssuer, kernelIssuer, envelope, fetch2, signal) {
81118
+ async function exchange(domainIssuer, kernelIssuer, envelope, requiredTtlSeconds, fetch2, signal) {
81066
81119
  const configurationUrl = new URL(exports_exchange.paths(domainIssuer).configuration, domainIssuer).toString();
81067
81120
  const configurationResponse = await fetchExchange(fetch2, configurationUrl, {
81068
81121
  method: "GET",
@@ -81119,8 +81172,31 @@ async function exchange(domainIssuer, kernelIssuer, envelope, fetch2, signal) {
81119
81172
  if (inspected.iss !== domainIssuer || inspected.aud !== kernelIssuer || inspected.claims.exp !== exchanged.expiresAt) {
81120
81173
  throw new AstraleError("TOKEN_EXCHANGE_PROTOCOL_ERROR", "Token exchange returned a credential inconsistent with the requested Domain and Kernel.");
81121
81174
  }
81175
+ const remaining = effectiveExchangeLifetime(inspected, exchanged.expiresAt);
81176
+ if (remaining < requiredTtlSeconds) {
81177
+ throw new AstraleError("TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT", "The Domain exchange credential cannot cover the requested command timeout.", `The Domain issuer returned ${Math.max(0, remaining)} seconds but ${requiredTtlSeconds} are required. Use a shorter --timeout or update the Domain execution service.`);
81178
+ }
81122
81179
  return Object.freeze({ credential: exchanged.token, expiresAt: exchanged.expiresAt });
81123
81180
  }
81181
+ function effectiveExchangeLifetime(inspected, outerExpiresAt) {
81182
+ let proofExpiresAt;
81183
+ try {
81184
+ const carried = exports_grant.acceptUnresolved(inspected.claims.grant).expr;
81185
+ if (carried.kind !== "identity" || !("credential" in carried) || typeof carried.credential !== "string") {
81186
+ throw new TypeError("Domain credential does not carry an identity proof.");
81187
+ }
81188
+ const value3 = exports_credential.inspect(carried.credential).claims.exp;
81189
+ if (typeof value3 !== "number" || !Number.isSafeInteger(value3)) {
81190
+ throw new TypeError("Domain credential carries an identity proof without an expiration.");
81191
+ }
81192
+ proofExpiresAt = value3;
81193
+ } catch (cause) {
81194
+ if (!(cause instanceof TypeError))
81195
+ throw cause;
81196
+ throw new AstraleError("TOKEN_EXCHANGE_PROTOCOL_ERROR", "Token exchange returned an invalid carried identity proof.", cause.message);
81197
+ }
81198
+ return remainingCredentialLifetimeSeconds(Math.min(outerExpiresAt, proofExpiresAt));
81199
+ }
81124
81200
  async function fetchExchange(fetch2, input, init, message2) {
81125
81201
  try {
81126
81202
  return await fetch2(input, init);
@@ -81191,14 +81267,13 @@ function requireLive3(signal) {
81191
81267
  return;
81192
81268
  throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
81193
81269
  }
81194
- var EXCHANGE_TTL_SECONDS, MAXIMUM_RESPONSE_BYTES;
81270
+ var MAXIMUM_RESPONSE_BYTES;
81195
81271
  var init_exchange4 = __esm(() => {
81196
81272
  init_auth4();
81197
81273
  init_auth4();
81198
81274
  init_client3();
81199
81275
  init_errors2();
81200
81276
  init_exchange_credentials();
81201
- EXCHANGE_TTL_SECONDS = 5 * 60;
81202
81277
  MAXIMUM_RESPONSE_BYTES = 256 * 1024;
81203
81278
  });
81204
81279
 
@@ -81388,28 +81463,36 @@ var init_target2 = __esm(() => {
81388
81463
  });
81389
81464
 
81390
81465
  // src/connection/credential.ts
81391
- function createConnectionCredential(expectedSourceIssuer, source2) {
81466
+ function createConnectionCredential(expectedSourceIssuer, source2, ttlSeconds = DELEGATION_TTL_SECONDS) {
81467
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1) {
81468
+ throw new TypeError("Connection credential ttlSeconds must be a positive safe integer.");
81469
+ }
81392
81470
  const resolveSource = source2.resolve.bind(source2);
81393
81471
  return Object.freeze({
81394
- ttlSeconds: DELEGATION_TTL_SECONDS,
81472
+ ttlSeconds,
81395
81473
  async resolve(_call, signal) {
81396
81474
  const resolved = await resolveSource(expectedSourceIssuer, signal);
81397
- const ttlSeconds = sourceBoundDelegationTtl(resolved);
81475
+ const delegatedTtlSeconds = sourceBoundDelegationTtl(resolved, ttlSeconds);
81398
81476
  return Object.freeze({
81399
81477
  credential: resolved,
81400
- ...ttlSeconds === undefined ? {} : { delegate: { ttlSeconds } }
81478
+ ...delegatedTtlSeconds === undefined ? {} : { delegate: { ttlSeconds: delegatedTtlSeconds } }
81401
81479
  });
81402
81480
  }
81403
81481
  });
81404
81482
  }
81405
- function sourceBoundDelegationTtl(input) {
81483
+ function sourceBoundDelegationTtl(input, requestedTtlSeconds) {
81406
81484
  try {
81407
81485
  const expiresAt = exports_credential.inspect(input).claims.exp;
81408
81486
  if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt))
81409
81487
  return;
81410
- const remaining = expiresAt - Math.ceil(Date.now() / 1000) - 1;
81411
- return Math.max(1, Math.min(DELEGATION_TTL_SECONDS, remaining));
81412
- } catch {
81488
+ const remaining = remainingCredentialLifetimeSeconds(expiresAt);
81489
+ if (remaining < requestedTtlSeconds) {
81490
+ throw new AstraleError("CREDENTIAL_LIFETIME_INSUFFICIENT", "The selected credential cannot cover the requested command timeout.", `Use a fresh identity session or a shorter --timeout; ${Math.max(0, remaining)} seconds remain but ${requestedTtlSeconds} are required.`);
81491
+ }
81492
+ return Math.max(1, Math.min(requestedTtlSeconds, remaining));
81493
+ } catch (cause) {
81494
+ if (cause instanceof AstraleError)
81495
+ throw cause;
81413
81496
  return;
81414
81497
  }
81415
81498
  }
@@ -81420,7 +81503,8 @@ function createCliCredential(target2, options, config2, fetch2 = globalThis.fetc
81420
81503
  const authOptions = Object.freeze({
81421
81504
  ...options.as === undefined ? {} : { as: options.as },
81422
81505
  ...options.creds === undefined ? {} : { creds: options.creds },
81423
- ...target2.defaultIdentity === undefined ? {} : { defaultIdentity: target2.defaultIdentity }
81506
+ ...target2.defaultIdentity === undefined ? {} : { defaultIdentity: target2.defaultIdentity },
81507
+ minimumRemainingSeconds: target2.domainIssuer === undefined ? invocationCredentialTtlSeconds(timeoutMs) : exchangeCredentialTtlSeconds(timeoutMs)
81424
81508
  });
81425
81509
  const source2 = {
81426
81510
  async resolve(audience, signal) {
@@ -81431,7 +81515,8 @@ function createCliCredential(target2, options, config2, fetch2 = globalThis.fetc
81431
81515
  }
81432
81516
  };
81433
81517
  const effective = target2.domainIssuer === undefined || options.creds !== undefined ? source2 : createExchangeCredentialResolver({ ...target2, domainIssuer: target2.domainIssuer }, source2, fetch2, timeoutMs);
81434
- return createConnectionCredential(target2.kernelIssuer, effective);
81518
+ const ttlSeconds = invocationCredentialTtlSeconds(timeoutMs);
81519
+ return createConnectionCredential(target2.kernelIssuer, effective, ttlSeconds);
81435
81520
  }
81436
81521
  function validateCredentialSelection(options) {
81437
81522
  if (options.as !== undefined && options.creds !== undefined) {
@@ -22743,6 +22743,20 @@ function date4(params) {
22743
22743
 
22744
22744
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
22745
22745
  config(en_default());
22746
+ // src/lib/credential-lifetime.ts
22747
+ function remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds = Math.ceil(Date.now() / 1000)) {
22748
+ if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
22749
+ throw new TypeError("Credential expiration and current time must be safe epoch seconds.");
22750
+ }
22751
+ return expiresAtEpochSeconds - nowEpochSeconds - 1;
22752
+ }
22753
+ function credentialLifetimeCovers(expiresAtEpochSeconds, minimumRemainingSeconds, nowEpochSeconds) {
22754
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
22755
+ throw new TypeError("Credential minimum lifetime must be a positive safe integer.");
22756
+ }
22757
+ return remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >= minimumRemainingSeconds;
22758
+ }
22759
+
22746
22760
  // src/state/paths.ts
22747
22761
  import { homedir } from "node:os";
22748
22762
  import { join } from "node:path";
@@ -24130,18 +24144,25 @@ function isSessionExpired(session, skewMs = 60000) {
24130
24144
  return false;
24131
24145
  return new Date(expiresAt).getTime() <= Date.now() + skewMs;
24132
24146
  }
24133
- function accessTokenForAudience(session, audience) {
24147
+ function accessTokenForAudience(session, audience, minimumRemainingMs = 60000) {
24134
24148
  if (audience === undefined) {
24135
- return isSessionExpired(session) ? undefined : session.access_token;
24149
+ return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined;
24136
24150
  }
24137
24151
  const entry = session.tokens?.[audience];
24138
- if (entry && !isSessionExpired(entry))
24152
+ if (entry && tokenHasMinimumLifetime(entry, minimumRemainingMs))
24139
24153
  return entry.access_token;
24140
- if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
24154
+ if (tokenHasMinimumLifetime(session, minimumRemainingMs) && tokenAudienceMatches(session.access_token, audience)) {
24141
24155
  return session.access_token;
24142
24156
  }
24143
24157
  return;
24144
24158
  }
24159
+ function tokenHasMinimumLifetime(value2, minimumRemainingMs) {
24160
+ const expiration = decodeTokenClaims(value2.access_token)?.exp;
24161
+ if (typeof expiration === "number" && Number.isSafeInteger(expiration)) {
24162
+ return credentialLifetimeCovers(expiration, Math.ceil(minimumRemainingMs / 1000));
24163
+ }
24164
+ return !isSessionExpired(value2, minimumRemainingMs);
24165
+ }
24145
24166
  function withCachedToken(tokens, accessToken, expiresAt) {
24146
24167
  const next = {};
24147
24168
  for (const [aud2, entry] of Object.entries(tokens ?? {})) {
@@ -25047,10 +25068,11 @@ function idpSessionLockPath(identityName) {
25047
25068
  return `${idpSessionPath(identityName)}.lock`;
25048
25069
  }
25049
25070
  async function ensureFreshSession(identityName, opts = {}) {
25071
+ const minimumRemainingMs = minimumLifetimeMs(opts.minimumRemainingSeconds);
25050
25072
  const session = await readIdpSession(identityName);
25051
25073
  if (!session)
25052
25074
  throw new IdpSessionMissingError(identityName);
25053
- if (accessTokenForAudience(session, opts.audience))
25075
+ if (accessTokenForAudience(session, opts.audience, minimumRemainingMs))
25054
25076
  return session;
25055
25077
  if (!session.refresh_token)
25056
25078
  throw new IdpSessionNoRefreshTokenError(identityName);
@@ -25058,7 +25080,7 @@ async function ensureFreshSession(identityName, opts = {}) {
25058
25080
  const current = await readIdpSession(identityName);
25059
25081
  if (!current)
25060
25082
  throw new IdpSessionMissingError(identityName);
25061
- if (accessTokenForAudience(current, opts.audience))
25083
+ if (accessTokenForAudience(current, opts.audience, minimumRemainingMs))
25062
25084
  return current;
25063
25085
  const bookmarkOrg = opts.organizationId ?? (opts.audience ? await orgIdForAudience(opts.audience) : undefined);
25064
25086
  const organizationId = bookmarkOrg ?? (opts.audience ? await (opts.resolveOrganizationId ?? fetchOrgHint)(opts.audience) : undefined);
@@ -25077,23 +25099,30 @@ async function ensureFreshSession(identityName, opts = {}) {
25077
25099
  });
25078
25100
  }
25079
25101
  }
25080
- const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, e);
25102
+ const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, minimumRemainingMs, e);
25081
25103
  if (rescued)
25082
25104
  return rescued;
25083
25105
  throw e;
25084
25106
  }
25085
25107
  });
25086
25108
  }
25087
- async function rescueAfterInvalidGrant(identityName, seen, audience, error51) {
25109
+ async function rescueAfterInvalidGrant(identityName, seen, audience, minimumRemainingMs, error51) {
25088
25110
  if (!(error51 instanceof OAuthTokenError) || error51.code !== "invalid_grant")
25089
25111
  return;
25090
25112
  const latest = await readIdpSession(identityName).catch(() => null);
25091
25113
  if (!latest || latest.updatedAt === seen.updatedAt)
25092
25114
  return;
25093
- if (!accessTokenForAudience(latest, audience))
25115
+ if (!accessTokenForAudience(latest, audience, minimumRemainingMs))
25094
25116
  return;
25095
25117
  return latest;
25096
25118
  }
25119
+ function minimumLifetimeMs(input) {
25120
+ const seconds = input ?? 60;
25121
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > Math.floor(Number.MAX_SAFE_INTEGER / 1000)) {
25122
+ throw new TypeError("IdP session minimum lifetime must be a positive safe integer.");
25123
+ }
25124
+ return seconds * 1000;
25125
+ }
25097
25126
 
25098
25127
  // src/connection/auth.ts
25099
25128
  async function resolveCredential(opts, config2, audience = config2.issuer, registrationKey) {
@@ -25108,14 +25137,14 @@ async function resolveCredential(opts, config2, audience = config2.issuer, regis
25108
25137
  resolvedIdentity = identity3;
25109
25138
  resolvedName = identityName;
25110
25139
  if ((identity3.source ?? "key") === "idp")
25111
- return await resolveIdpAccessToken(identityName, identity3, audience);
25140
+ return await resolveIdpAccessToken(identityName, identity3, audience, opts.minimumRemainingSeconds);
25112
25141
  return await signAs(identity3.subject, KEYS_DIR, resolveKeyIdentityAuthOptions(identity3, config2, audience, registrationKey));
25113
25142
  }
25114
25143
  const identity2 = await getDefault();
25115
25144
  resolvedIdentity = identity2;
25116
25145
  resolvedName = identity2.name;
25117
25146
  if ((identity2.source ?? "key") === "idp") {
25118
- return await resolveIdpAccessToken(identity2.name, identity2, audience);
25147
+ return await resolveIdpAccessToken(identity2.name, identity2, audience, opts.minimumRemainingSeconds);
25119
25148
  }
25120
25149
  return await signAs(identity2.subject, KEYS_DIR, resolveKeyIdentityAuthOptions(identity2, config2, audience, registrationKey));
25121
25150
  } catch (e) {
@@ -25157,10 +25186,10 @@ function resolveKeyIdentityAuthOptions(identity2, config2, audience = config2.is
25157
25186
  function systemIdentityIssuer(identity2, audience, config2) {
25158
25187
  return identity2.subject === "system" ? audience : config2.issuer;
25159
25188
  }
25160
- async function resolveIdpAccessToken(identityName, identity2, audience) {
25189
+ async function resolveIdpAccessToken(identityName, identity2, audience, minimumRemainingSeconds) {
25161
25190
  let resolved;
25162
25191
  try {
25163
- resolved = await ensureFreshSession(identityName, { audience });
25192
+ resolved = await ensureFreshSession(identityName, { audience, minimumRemainingSeconds });
25164
25193
  } catch (e) {
25165
25194
  if (e instanceof IdpSessionMissingError) {
25166
25195
  throw new Error(`No cached IdP session for "${identityName}". Run: astrale auth login --idp ${identity2.idp ?? "<idp>"}`);
@@ -15613,6 +15613,20 @@ function date4(params) {
15613
15613
 
15614
15614
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
15615
15615
  config(en_default());
15616
+ // src/lib/credential-lifetime.ts
15617
+ function remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds = Math.ceil(Date.now() / 1000)) {
15618
+ if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
15619
+ throw new TypeError("Credential expiration and current time must be safe epoch seconds.");
15620
+ }
15621
+ return expiresAtEpochSeconds - nowEpochSeconds - 1;
15622
+ }
15623
+ function credentialLifetimeCovers(expiresAtEpochSeconds, minimumRemainingSeconds, nowEpochSeconds) {
15624
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
15625
+ throw new TypeError("Credential minimum lifetime must be a positive safe integer.");
15626
+ }
15627
+ return remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >= minimumRemainingSeconds;
15628
+ }
15629
+
15616
15630
  // src/state/paths.ts
15617
15631
  import { homedir } from "node:os";
15618
15632
  import { join } from "node:path";
@@ -14446,6 +14446,20 @@ function date4(params) {
14446
14446
 
14447
14447
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
14448
14448
  config(en_default());
14449
+ // src/lib/credential-lifetime.ts
14450
+ function remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds = Math.ceil(Date.now() / 1000)) {
14451
+ if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
14452
+ throw new TypeError("Credential expiration and current time must be safe epoch seconds.");
14453
+ }
14454
+ return expiresAtEpochSeconds - nowEpochSeconds - 1;
14455
+ }
14456
+ function credentialLifetimeCovers(expiresAtEpochSeconds, minimumRemainingSeconds, nowEpochSeconds) {
14457
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
14458
+ throw new TypeError("Credential minimum lifetime must be a positive safe integer.");
14459
+ }
14460
+ return remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >= minimumRemainingSeconds;
14461
+ }
14462
+
14449
14463
  // src/state/paths.ts
14450
14464
  import { homedir } from "node:os";
14451
14465
  import { join } from "node:path";
@@ -26,6 +26,7 @@ export declare function resolveCredential(opts: {
26
26
  as?: string;
27
27
  creds?: string;
28
28
  defaultIdentity?: string;
29
+ minimumRemainingSeconds?: number;
29
30
  }, config: AstraleConfig, audience?: string, registrationKey?: string): Promise<string>;
30
31
  export declare function resolveKeyIdentityAuthOptions(identity: Identity, config: AstraleConfig, audience?: string, registrationKey?: string): KeyIdentityAuthOptions;
31
32
  export declare function classifyNoRefreshTokenError(requestedAudience: string, sourceAudience: string | undefined, error: IdpSessionNoRefreshTokenError): IdpSessionNoRefreshTokenError | IdpAudienceMismatchError;
@@ -7,7 +7,7 @@ export interface SourceCredentialResolver {
7
7
  resolve(audience: IssuerId, signal: AbortSignal): Promise<string>;
8
8
  }
9
9
  /** Resolve source authority; an omitted Session delegation preserves exact current authority. */
10
- export declare function createConnectionCredential(expectedSourceIssuer: IssuerId, source: SourceCredentialResolver): SessionAuth;
10
+ export declare function createConnectionCredential(expectedSourceIssuer: IssuerId, source: SourceCredentialResolver, ttlSeconds?: number): SessionAuth;
11
11
  /** Bind CLI identity state and Core Auth delegation to one Session auth capability. */
12
12
  export declare function createCliCredential(target: ConnectionTarget, options: ConnectionOptions, config: AstraleConfig, fetch?: Fetch, timeoutMs?: number): SessionAuth | undefined;
13
13
  /** Reject contradictory explicit credential selections before identity or network access. */
@@ -0,0 +1,6 @@
1
+ /** Cover the complete command deadline while preserving the existing short-command floor. */
2
+ export declare function invocationCredentialTtlSeconds(timeoutMs: number): number;
3
+ /** Leave the final carrier lifetime intact after source delegation and issuer exchange settle. */
4
+ export declare function exchangeCredentialTtlSeconds(timeoutMs: number): number;
5
+ /** Refresh a cached token before the final Session carrier can cross a second boundary. */
6
+ export declare function cachedCredentialTtlSeconds(timeoutMs: number): number;
@@ -0,0 +1,3 @@
1
+ /** Whole seconds safely available after second-boundary rounding and carrier handoff. */
2
+ export declare function remainingCredentialLifetimeSeconds(expiresAtEpochSeconds: number, nowEpochSeconds?: number): number;
3
+ export declare function credentialLifetimeCovers(expiresAtEpochSeconds: number, minimumRemainingSeconds: number, nowEpochSeconds?: number): boolean;
@@ -12,6 +12,8 @@ export declare class IdpSessionNoRefreshTokenError extends Error {
12
12
  export type EnsureFreshSessionOptions = {
13
13
  audience?: string;
14
14
  organizationId?: string;
15
+ /** Minimum source-token lifetime required before the caller starts its operation. */
16
+ minimumRemainingSeconds?: number;
15
17
  /**
16
18
  * Org-hint resolver, consulted only when a refresh actually happens.
17
19
  * Defaults to `fetchOrgHint`; injectable for tests.
@@ -196,7 +196,7 @@ export declare function isSessionExpired(session: Pick<IdpSession, 'expires_at'>
196
196
  * top-level token's own `aud` claim. Without an `audience`, freshness of the
197
197
  * top-level token is the only requirement.
198
198
  */
199
- export declare function accessTokenForAudience(session: IdpSession, audience?: string): string | undefined;
199
+ export declare function accessTokenForAudience(session: IdpSession, audience?: string, minimumRemainingMs?: number): string | undefined;
200
200
  /**
201
201
  * Fold a freshly minted access token into the per-audience map under every
202
202
  * `aud` it carries, dropping entries that have already expired.
@@ -21,7 +21,7 @@ export declare class ExchangeCredentialCache {
21
21
  private readonly path;
22
22
  private readonly refreshing;
23
23
  constructor(path?: string);
24
- getOrRefresh(key: exchange.Key, refresh: () => Promise<exchange.Entry>, now?: () => number): Promise<string>;
24
+ getOrRefresh(key: exchange.Key, minimumRemainingSeconds: number, refresh: () => Promise<exchange.Entry>, now?: () => number): Promise<string>;
25
25
  deleteKernel(kernelIssuer: string): Promise<void>;
26
26
  clear(): Promise<void>;
27
27
  private getOrRefreshOnce;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.31",
3
+ "version": "1.0.0-beta.32",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",