@astrale-os/cli 1.0.0-beta.30 → 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.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/dist/astrale.js +178 -57
  3. package/dist/public/connect-core.js +42 -13
  4. package/dist/public/keys/index.js +14 -0
  5. package/dist/public/paths/index.js +14 -0
  6. package/dist/types/connection/auth.d.ts +1 -0
  7. package/dist/types/connection/credential.d.ts +1 -1
  8. package/dist/types/connection/lifetime.d.ts +6 -0
  9. package/dist/types/lib/credential-lifetime.d.ts +3 -0
  10. package/dist/types/lib/idp-session.d.ts +2 -0
  11. package/dist/types/lib/idp.d.ts +1 -1
  12. package/dist/types/state/exchange-credentials.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/commands/__tests__/read-commands.test.ts +2 -2
  15. package/src/commands/query.ts +5 -5
  16. package/src/commands/update.ts +3 -2
  17. package/src/connection/.spec/architecture.md +5 -2
  18. package/src/connection/.spec/flows/session.ts +2 -1
  19. package/src/connection/.spec/laws/connection.ts +9 -1
  20. package/src/connection/.spec/layout.ts +1 -0
  21. package/src/connection/__tests__/credential.test.ts +115 -0
  22. package/src/connection/__tests__/exchange.test.ts +94 -9
  23. package/src/connection/auth.ts +20 -4
  24. package/src/connection/credential.ts +29 -8
  25. package/src/connection/exchange.ts +65 -7
  26. package/src/connection/lifetime.ts +25 -0
  27. package/src/graph/.spec/api.d.ts +1 -1
  28. package/src/graph/.spec/laws/documents.ts +7 -7
  29. package/src/graph/__tests__/query.test.ts +12 -5
  30. package/src/graph/query.ts +9 -11
  31. package/src/lib/__tests__/idp-session.driver.ts +10 -3
  32. package/src/lib/__tests__/idp-session.test.ts +25 -0
  33. package/src/lib/__tests__/skills.test.ts +27 -2
  34. package/src/lib/credential-lifetime.ts +24 -0
  35. package/src/lib/idp-session.ts +26 -4
  36. package/src/lib/idp.ts +24 -4
  37. package/src/lib/skills/sync.ts +72 -8
  38. package/src/program/__tests__/program.test.ts +39 -2
  39. package/src/state/__tests__/exchange-credentials.test.ts +112 -5
  40. package/src/state/exchange-credentials.ts +26 -8
  41. package/studio/server/cli-consumers.test.ts +1 -1
  42. package/studio/server/cli.test.ts +2 -2
  43. package/studio/server/views/target.ts +1 -1
@@ -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.30",
3
+ "version": "1.0.0-beta.32",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -195,7 +195,7 @@ describe('query command', () => {
195
195
 
196
196
  await queryCommand([], {
197
197
  json: true,
198
- definition: '/:issues.astrale.ai:class.Issue',
198
+ class: '/:issues.astrale.ai:class.Issue',
199
199
  limit: '20',
200
200
  })
201
201
 
@@ -239,7 +239,7 @@ describe('query command', () => {
239
239
 
240
240
  await queryCommand([], {
241
241
  json: true,
242
- definition: '/:issues.astrale.ai:class.Issue',
242
+ class: '/:issues.astrale.ai:class.Issue',
243
243
  limit: '1',
244
244
  })
245
245
 
@@ -14,7 +14,7 @@ import { isMachine, output } from '../lib/output'
14
14
  type QueryOpts = KernelCommandOpts & {
15
15
  ast?: string
16
16
  file?: string
17
- definition?: string
17
+ class?: string
18
18
  edge?: string
19
19
  direction?: QueryDirection
20
20
  limit?: string
@@ -28,7 +28,7 @@ export async function queryCommand(sources: string[], opts: QueryOpts): Promise<
28
28
  input = {
29
29
  sources,
30
30
  ...(ast === undefined ? {} : { ast }),
31
- definition: opts.definition,
31
+ class: opts.class,
32
32
  edge: opts.edge,
33
33
  direction: opts.direction,
34
34
  limit: opts.limit,
@@ -103,7 +103,7 @@ export default {
103
103
  description: 'Run one canonical Query V6 graph read',
104
104
  afterHelpText: `
105
105
  Behavior:
106
- Positional Paths and --definition author a finite Query V6 read. --edge adds
106
+ Positional Paths and --class author a finite Query V6 read. --edge adds
107
107
  one exact Edge-Class expansion; --direction defaults to outgoing. --ast and
108
108
  --file accept a complete canonical astrale.graph.query/v6 document, including
109
109
  Property ordering and Node or Edge reference/value projections. --cursor resumes
@@ -115,7 +115,7 @@ Behavior:
115
115
 
116
116
  Examples:
117
117
  $ astrale query /:notes.example.dev:class.Note --limit 50
118
- $ astrale query --definition /:notes.example.dev:class.Note --limit 50
118
+ $ astrale query --class /:notes.example.dev:class.Note --limit 50
119
119
  $ astrale query @note --edge /:notes.example.dev:class.references --direction outgoing --limit 25
120
120
  $ astrale query --file query.v6.json --cursor "$CURSOR"
121
121
  `,
@@ -124,7 +124,7 @@ Examples:
124
124
  { flags: '--ast <json>', description: 'Canonical Query V6 JSON document' },
125
125
  { flags: '-f, --file <path>', description: 'Read a canonical Query V6 document from a file' },
126
126
  {
127
- flags: '--definition <path>',
127
+ flags: '--class <path>',
128
128
  description: 'Select Nodes satisfying one exact Class',
129
129
  },
130
130
  { flags: '--edge <class>', description: 'Expand one exact Edge Class' },
@@ -112,7 +112,8 @@ async function refreshSdkDeps(check: boolean, assumeYes = false): Promise<boolea
112
112
  * unified and NON-THROWING: an explicit package-managed result uses npm release
113
113
  * identity, while script-install failures remain script failures in `error`
114
114
  * instead of being recategorized. The SDK axis is already best-effort. Skills
115
- * report meaningful health/freshness states without exposing installer metadata.
115
+ * report meaningful health/freshness states. A current cohort also exposes its exact
116
+ * source revision, skill trees, and entrypoints without leaking installer-local paths.
116
117
  */
117
118
  export type StaleReport = {
118
119
  stale: boolean
@@ -237,7 +238,7 @@ Behavior:
237
238
  script installs only — if Astrale was installed by another package manager this
238
239
  command refuses so that manager stays in charge; downloads are checksum-verified
239
240
  before the binary is replaced. (2) The Astrale agent skills: installs every
240
- top-level skill published from astrale-os/cli main, updates healthy older
241
+ top-level skill published from one resolved astrale-os/cli main commit, updates healthy older
241
242
  installs, repairs inconsistent installs, and verifies the result before
242
243
  reporting success. (3) SDK deps: inside a pnpm domain
243
244
  project, proposes any @astrale-os/* dependency with a newer release and, on
@@ -31,8 +31,11 @@ current Grant for routing. Connection itself persists no credential or route; th
31
31
  owner persists exchanged source credentials and the separate Kernel Client route artifact. A valid exchanged credential is selected by the
32
32
  authenticated source issuer and subject before any live `whoami`; cache misses alone resolve the
33
33
  registered Kernel User and perform delegation plus Domain exchange.
34
- Exchange authority is bounded to five minutes and never outlives the current source credential,
35
- matching the CLI route-age ceiling without forcing an unrelated two-minute refresh cycle.
34
+ Exchange and destination-carrier authority cover the selected command timeout plus one bounded
35
+ receipt margin, never outlive the current source credential, and retain the existing one-minute
36
+ floor for short commands. A cached or freshly exchanged credential that cannot cover that lifetime
37
+ is refreshed or rejected before destination dispatch, so a long durable mutation does not first
38
+ discover expired callback authority after its provider effect commits.
36
39
 
37
40
  Every ClientSession receives the CLI-owned `state/session-routes` representation capability. Kernel
38
41
  Client still owns route keying, admission, expiry, and one safe stale/miss recovery; Connection does
@@ -24,8 +24,9 @@ declare const resolveSourceCredential: (
24
24
  ) => Promise<string>
25
25
  /** Bind source authority without learning or minting destination credentials. */
26
26
  function createConnectionAuth(target: ConnectionTarget, options: ConnectionOptions): SessionAuth {
27
+ const ttlSeconds = Math.max(60, Math.ceil(resolveTimeoutMs(options.timeout) / 1_000) + 5)
27
28
  return {
28
- ttlSeconds: 3_600,
29
+ ttlSeconds,
29
30
  async resolve(_call: Call, signal: AbortSignal) {
30
31
  return {
31
32
  credential: await resolveSourceCredential(target, options, target.issuer, signal),
@@ -135,12 +135,20 @@ export const CLI_CONNECTION_TERMINAL_CLOSE = defineLaw({
135
135
  export const CLI_CONNECTION_TIMEOUT = defineLaw({
136
136
  id: 'CLI-CONNECTION-TIMEOUT',
137
137
  statement:
138
- 'The CLI accepts only a positive integer timeout before constructing a Client Session and applies it to both source-Auth and Session operations.',
138
+ 'The CLI accepts only a positive integer timeout before constructing a Client Session, applies it to source-Auth and Session operations, and requires exchanged and destination-carrier authority to cover that timeout plus the bounded receipt margin before destination dispatch.',
139
139
  tests: [
140
140
  {
141
141
  file: '__tests__/session.test.ts',
142
142
  id: 'TEST-CLI-CONNECTION-REJECTS-INVALID-TIMEOUT-BEFORE-OPEN',
143
143
  },
144
+ {
145
+ file: '__tests__/credential.test.ts',
146
+ id: 'TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT',
147
+ },
148
+ {
149
+ file: '__tests__/exchange.test.ts',
150
+ id: 'TEST-CLI-EXCHANGE-REJECTS-INSUFFICIENT-LIFETIME',
151
+ },
144
152
  ],
145
153
  })
146
154
 
@@ -11,6 +11,7 @@ export default defineLayout({
11
11
  'failure/',
12
12
  'exchange.ts',
13
13
  'index.ts',
14
+ 'lifetime.ts',
14
15
  'reasons.ts',
15
16
  'self.ts',
16
17
  'session.ts',
@@ -3,9 +3,13 @@ import type { SessionAuth } from '@astrale-os/sdk/client/session'
3
3
  import { issuer, type IssuerId } from '@astrale-os/sdk/auth'
4
4
  import { Path } from '@astrale-os/sdk/graph/path'
5
5
  import { describe, expect, test } from 'bun:test'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
6
9
 
7
10
  import type { AstraleConfig } from '../../lib/config'
8
11
 
12
+ import { persistKeypair, signAs } from '../../keys/index'
9
13
  import { bindCredentialIdentity } from '../auth'
10
14
  import { createCliCredential, createConnectionCredential } from '../credential'
11
15
 
@@ -77,6 +81,117 @@ describe('connection credential', () => {
77
81
  expect(resolved.delegate?.ttlSeconds).toBeLessThan(120)
78
82
  })
79
83
 
84
+ /** @evidence TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT */
85
+ test('covers a long command deadline with one destination carrier', async () => {
86
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 300
87
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
88
+
89
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
90
+ credential: token(expiresAt),
91
+ delegate: { ttlSeconds: 185 },
92
+ })
93
+ expect(auth.ttlSeconds).toBe(185)
94
+ })
95
+
96
+ test('real local-key credentials cover the supported long-operation carrier', async () => {
97
+ const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-key-'))
98
+ try {
99
+ await persistKeypair('alice', { keysDir: directory })
100
+ const source = await signAs('alice', directory, {
101
+ issuer: SOURCE,
102
+ audience: SOURCE,
103
+ })
104
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 185)
105
+
106
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
107
+ credential: source,
108
+ delegate: { ttlSeconds: 185 },
109
+ })
110
+ } finally {
111
+ await rm(directory, { recursive: true, force: true })
112
+ }
113
+ })
114
+
115
+ test('real five-minute local-key credentials reject a 600-second carrier before dispatch', async () => {
116
+ const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-ceiling-key-'))
117
+ try {
118
+ await persistKeypair('alice', { keysDir: directory })
119
+ const source = await signAs('alice', directory, {
120
+ issuer: SOURCE,
121
+ audience: SOURCE,
122
+ })
123
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 605)
124
+
125
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
126
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
127
+ })
128
+ } finally {
129
+ await rm(directory, { recursive: true, force: true })
130
+ }
131
+ })
132
+
133
+ test('derives every destination carrier lifetime from the selected CLI timeout', () => {
134
+ for (const target of [
135
+ { url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
136
+ {
137
+ url: `${SOURCE}/invoke`,
138
+ kernelIssuer: SOURCE,
139
+ domainIssuer: issuer.accept('https://admin.example'),
140
+ },
141
+ ]) {
142
+ const auth = createCliCredential(target, {}, config, globalThis.fetch, 180_000)
143
+ expect(auth?.ttlSeconds).toBe(185)
144
+ }
145
+ })
146
+
147
+ test('rejects a long command before dispatch when its source bearer is too short', async () => {
148
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 120
149
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
150
+
151
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
152
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
153
+ })
154
+ })
155
+
156
+ test('rejects an inspectable short or expired bearer before a default command dispatch', async () => {
157
+ for (const expiresAt of [
158
+ Math.ceil(Date.now() / 1_000) + 30,
159
+ Math.ceil(Date.now() / 1_000) - 30,
160
+ ]) {
161
+ const auth = createConnectionCredential(SOURCE, {
162
+ resolve: async () => token(expiresAt),
163
+ })
164
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
165
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
166
+ })
167
+ }
168
+ })
169
+
170
+ test('rejects a too-short explicit Domain bearer before exchange or destination I/O', async () => {
171
+ let fetches = 0
172
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 120
173
+ const auth = createCliCredential(
174
+ {
175
+ url: `${SOURCE}/invoke`,
176
+ kernelIssuer: SOURCE,
177
+ domainIssuer: issuer.accept('https://admin.example'),
178
+ },
179
+ { creds: token(expiresAt) },
180
+ config,
181
+ async () => {
182
+ fetches += 1
183
+ throw new Error('network must remain untouched')
184
+ },
185
+ 180_000,
186
+ )
187
+ if (auth === undefined) throw new Error('expected authenticated credential')
188
+
189
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
190
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
191
+ })
192
+ expect(fetches).toBe(0)
193
+ })
194
+
80
195
  /** @evidence TEST-CLI-CONNECTION-USES-RAW-SOURCE-CREDENTIAL */
81
196
  test('binds explicit CLI credentials to source-Kernel auth only', async () => {
82
197
  const auth = createCliCredential(