@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.
- package/README.md +5 -1
- package/dist/astrale.js +178 -57
- package/dist/public/connect-core.js +42 -13
- package/dist/public/keys/index.js +14 -0
- package/dist/public/paths/index.js +14 -0
- package/dist/types/connection/auth.d.ts +1 -0
- package/dist/types/connection/credential.d.ts +1 -1
- package/dist/types/connection/lifetime.d.ts +6 -0
- package/dist/types/lib/credential-lifetime.d.ts +3 -0
- package/dist/types/lib/idp-session.d.ts +2 -0
- package/dist/types/lib/idp.d.ts +1 -1
- package/dist/types/state/exchange-credentials.d.ts +1 -1
- package/package.json +1 -1
- package/src/commands/__tests__/read-commands.test.ts +2 -2
- package/src/commands/query.ts +5 -5
- package/src/commands/update.ts +3 -2
- package/src/connection/.spec/architecture.md +5 -2
- package/src/connection/.spec/flows/session.ts +2 -1
- package/src/connection/.spec/laws/connection.ts +9 -1
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +115 -0
- package/src/connection/__tests__/exchange.test.ts +94 -9
- package/src/connection/auth.ts +20 -4
- package/src/connection/credential.ts +29 -8
- package/src/connection/exchange.ts +65 -7
- package/src/connection/lifetime.ts +25 -0
- package/src/graph/.spec/api.d.ts +1 -1
- package/src/graph/.spec/laws/documents.ts +7 -7
- package/src/graph/__tests__/query.test.ts +12 -5
- package/src/graph/query.ts +9 -11
- package/src/lib/__tests__/idp-session.driver.ts +10 -3
- package/src/lib/__tests__/idp-session.test.ts +25 -0
- package/src/lib/__tests__/skills.test.ts +27 -2
- package/src/lib/credential-lifetime.ts +24 -0
- package/src/lib/idp-session.ts +26 -4
- package/src/lib/idp.ts +24 -4
- package/src/lib/skills/sync.ts +72 -8
- package/src/program/__tests__/program.test.ts +39 -2
- package/src/state/__tests__/exchange-credentials.test.ts +112 -5
- package/src/state/exchange-credentials.ts +26 -8
- package/studio/server/cli-consumers.test.ts +1 -1
- package/studio/server/cli.test.ts +2 -2
- 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
|
|
24149
|
+
return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined;
|
|
24136
24150
|
}
|
|
24137
24151
|
const entry = session.tokens?.[audience];
|
|
24138
|
-
if (entry &&
|
|
24152
|
+
if (entry && tokenHasMinimumLifetime(entry, minimumRemainingMs))
|
|
24139
24153
|
return entry.access_token;
|
|
24140
|
-
if (
|
|
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.
|
package/dist/types/lib/idp.d.ts
CHANGED
|
@@ -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
|
@@ -195,7 +195,7 @@ describe('query command', () => {
|
|
|
195
195
|
|
|
196
196
|
await queryCommand([], {
|
|
197
197
|
json: true,
|
|
198
|
-
|
|
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
|
-
|
|
242
|
+
class: '/:issues.astrale.ai:class.Issue',
|
|
243
243
|
limit: '1',
|
|
244
244
|
})
|
|
245
245
|
|
package/src/commands/query.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { isMachine, output } from '../lib/output'
|
|
|
14
14
|
type QueryOpts = KernelCommandOpts & {
|
|
15
15
|
ast?: string
|
|
16
16
|
file?: string
|
|
17
|
-
|
|
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
|
-
|
|
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 --
|
|
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 --
|
|
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: '--
|
|
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' },
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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(
|