@crouter/sdk 0.3.377
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 +170 -0
- package/dist/client.d.ts +153 -0
- package/dist/client.js +491 -0
- package/dist/error-codes.d.ts +7 -0
- package/dist/error-codes.js +35 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +76 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +13 -0
- package/dist/keygen-cli.d.ts +2 -0
- package/dist/keygen-cli.js +6 -0
- package/dist/keygen-command.d.ts +6 -0
- package/dist/keygen-command.js +157 -0
- package/dist/oauth/index.d.ts +147 -0
- package/dist/oauth/index.js +377 -0
- package/dist/oauth/keygen.d.ts +18 -0
- package/dist/oauth/keygen.js +46 -0
- package/dist/resources/activity.d.ts +22 -0
- package/dist/resources/activity.js +43 -0
- package/dist/resources/attachments.d.ts +15 -0
- package/dist/resources/attachments.js +13 -0
- package/dist/resources/bash.d.ts +9 -0
- package/dist/resources/bash.js +15 -0
- package/dist/resources/canvas/history.d.ts +11 -0
- package/dist/resources/canvas/history.js +20 -0
- package/dist/resources/canvas.d.ts +19 -0
- package/dist/resources/canvas.js +42 -0
- package/dist/resources/crons.d.ts +15 -0
- package/dist/resources/crons.js +33 -0
- package/dist/resources/custom-objects.d.ts +20 -0
- package/dist/resources/custom-objects.js +84 -0
- package/dist/resources/files.d.ts +34 -0
- package/dist/resources/files.js +34 -0
- package/dist/resources/forward.d.ts +7 -0
- package/dist/resources/forward.js +64 -0
- package/dist/resources/human/inbox.d.ts +14 -0
- package/dist/resources/human/inbox.js +30 -0
- package/dist/resources/human/requests.d.ts +13 -0
- package/dist/resources/human/requests.js +26 -0
- package/dist/resources/human.d.ts +8 -0
- package/dist/resources/human.js +10 -0
- package/dist/resources/identifiers.d.ts +8 -0
- package/dist/resources/identifiers.js +33 -0
- package/dist/resources/memory.d.ts +69 -0
- package/dist/resources/memory.js +30 -0
- package/dist/resources/models/config.d.ts +8 -0
- package/dist/resources/models/config.js +9 -0
- package/dist/resources/models/credentials.d.ts +10 -0
- package/dist/resources/models/credentials.js +17 -0
- package/dist/resources/models.d.ts +8 -0
- package/dist/resources/models.js +10 -0
- package/dist/resources/node-stream.d.ts +34 -0
- package/dist/resources/node-stream.js +176 -0
- package/dist/resources/nodes/jobs.d.ts +9 -0
- package/dist/resources/nodes/jobs.js +14 -0
- package/dist/resources/nodes/result.d.ts +8 -0
- package/dist/resources/nodes/result.js +11 -0
- package/dist/resources/nodes/worktree.d.ts +9 -0
- package/dist/resources/nodes/worktree.js +14 -0
- package/dist/resources/nodes.d.ts +23 -0
- package/dist/resources/nodes.js +47 -0
- package/dist/resources/providers.d.ts +51 -0
- package/dist/resources/providers.js +15 -0
- package/dist/resources/questions.d.ts +49 -0
- package/dist/resources/questions.js +21 -0
- package/dist/resources/request.d.ts +3 -0
- package/dist/resources/request.js +11 -0
- package/dist/resources/run-reply.d.ts +79 -0
- package/dist/resources/run-reply.js +84 -0
- package/dist/resources/run-stream.d.ts +40 -0
- package/dist/resources/run-stream.js +206 -0
- package/dist/resources/runs.d.ts +184 -0
- package/dist/resources/runs.js +197 -0
- package/dist/resources/shares.d.ts +40 -0
- package/dist/resources/shares.js +19 -0
- package/dist/resources/uploads.d.ts +27 -0
- package/dist/resources/uploads.js +11 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -0
- package/dist/stores/postgres.d.ts +29 -0
- package/dist/stores/postgres.js +86 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, importJWK, importPKCS8, SignJWT } from 'jose';
|
|
2
|
+
import { JwksCache, parseScope, verifyIdToken } from '@crouter/identity';
|
|
3
|
+
import { APIError, CrouterError, mapError } from '../errors.js';
|
|
4
|
+
import { Crouter } from '../client.js';
|
|
5
|
+
/** Refresh refusals the app must answer by reconnecting; each latches the connected client (SDK-AUTH-17). */
|
|
6
|
+
const reconnectCodes = new Set(['grant_revoked', 'grant_removed', 'grant_suspended', 'refresh_token_expired', 'refresh_token_reused']);
|
|
7
|
+
const refusals = {
|
|
8
|
+
grant_removed: 'removed', grant_suspended: 'paused',
|
|
9
|
+
refresh_token_expired: 'reconnect', refresh_token_reused: 'reconnect', grant_revoked: 'reconnect',
|
|
10
|
+
};
|
|
11
|
+
/** What a refused refresh means (`RefreshRefusal`), or `null` when `error` is not the directory refusing one.
|
|
12
|
+
* Takes any error a connected client or `refresh` threw; only `APIError` with `origin: 'directory'` counts. */
|
|
13
|
+
export function refreshRefusal(error) {
|
|
14
|
+
if (!(error instanceof APIError) || error.origin !== 'directory')
|
|
15
|
+
return null;
|
|
16
|
+
return Object.hasOwn(refusals, error.code) ? refusals[error.code] : null;
|
|
17
|
+
}
|
|
18
|
+
export class MemoryConnectionStore {
|
|
19
|
+
connections = new Map();
|
|
20
|
+
locks = new Map();
|
|
21
|
+
savedAt = new Map();
|
|
22
|
+
async load(userId) { return this.connections.get(userId) ?? null; }
|
|
23
|
+
async save(connection) {
|
|
24
|
+
this.connections.set(connection.userId, connection);
|
|
25
|
+
this.savedAt.set(connection.userId, Date.now());
|
|
26
|
+
}
|
|
27
|
+
async listStale(before) {
|
|
28
|
+
return [...this.connections.values()].filter((connection) => (this.savedAt.get(connection.userId) ?? Infinity) < before.getTime());
|
|
29
|
+
}
|
|
30
|
+
async lock(userId, fn) {
|
|
31
|
+
const prior = this.locks.get(userId);
|
|
32
|
+
let release;
|
|
33
|
+
const done = new Promise((resolve) => { release = resolve; });
|
|
34
|
+
this.locks.set(userId, done);
|
|
35
|
+
await prior;
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
release();
|
|
41
|
+
if (this.locks.get(userId) === done)
|
|
42
|
+
this.locks.delete(userId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** The `profile` claims a verified ID token carries, each only when present: use it on `Connection.idToken` after `verifyIdToken` for a sign-in that requested runtime scopes (`exchangeCode`), as `exchangeIdentity` does for a sign-in-only one. */
|
|
47
|
+
export function profileFromClaims(claims) {
|
|
48
|
+
const text = (value) => typeof value === 'string' && value !== '' ? value : undefined;
|
|
49
|
+
const [name, givenName, familyName, picture] = [text(claims.name), text(claims.given_name), text(claims.family_name), httpsUrl(claims.picture)];
|
|
50
|
+
return { ...(name ? { name } : {}), ...(givenName ? { givenName } : {}), ...(familyName ? { familyName } : {}), ...(picture ? { picture } : {}) };
|
|
51
|
+
}
|
|
52
|
+
/** `value` when it is a string parsing as an `https:` URL, else undefined: a `picture` claim is rendered as an image source, so no other scheme gets through. */
|
|
53
|
+
function httpsUrl(value) {
|
|
54
|
+
if (typeof value !== 'string')
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(value).protocol === 'https:' ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const assertionType = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
64
|
+
export class OAuth2Client {
|
|
65
|
+
options;
|
|
66
|
+
issuer;
|
|
67
|
+
fetcher;
|
|
68
|
+
jwks;
|
|
69
|
+
discoveryPromise;
|
|
70
|
+
inFlight = new Map();
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
if (!options.issuer || !options.issuer.trim())
|
|
74
|
+
throw new CrouterError('OAuth2Client requires an issuer');
|
|
75
|
+
this.issuer = options.issuer;
|
|
76
|
+
this.fetcher = options.fetch ?? fetch;
|
|
77
|
+
}
|
|
78
|
+
discovery() {
|
|
79
|
+
this.discoveryPromise ??= (async () => {
|
|
80
|
+
const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`);
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw new CrouterError(`Directory discovery failed: ${response.status}`);
|
|
83
|
+
const value = await response.json();
|
|
84
|
+
if (!value.authorization_endpoint || !value.token_endpoint || !value.jwks_uri)
|
|
85
|
+
throw new CrouterError('Directory discovery has missing endpoints');
|
|
86
|
+
return value;
|
|
87
|
+
})();
|
|
88
|
+
return this.discoveryPromise;
|
|
89
|
+
}
|
|
90
|
+
/** Start a sign-in. When `scopes` includes `openid` the result carries the `nonce` to pass to the exchange; with literal scopes its type is `string`.
|
|
91
|
+
* `prompt: 'consent'` makes the directory show its consent screen even when the person already approved these scopes. */
|
|
92
|
+
async authorizeUrl({ scopes, state, codeVerifier, prompt }) {
|
|
93
|
+
for (const scope of scopes) {
|
|
94
|
+
if (scope === 'openid' || scope === 'email' || scope === 'profile')
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
parseScope(scope);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new APIError(400, 'invalid_scope', `Unsupported scope: ${scope}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const verifier = codeVerifier ?? randomBase64url(32);
|
|
104
|
+
const nonce = scopes.includes('openid') ? randomBase64url(32) : undefined;
|
|
105
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
106
|
+
const challenge = base64url(new Uint8Array(hash));
|
|
107
|
+
const url = new URL((await this.discovery()).authorization_endpoint);
|
|
108
|
+
url.search = new URLSearchParams({
|
|
109
|
+
client_id: this.options.clientId, response_type: 'code', redirect_uri: this.options.redirectUri,
|
|
110
|
+
scope: scopes.join(' '), state, code_challenge: challenge, code_challenge_method: 'S256',
|
|
111
|
+
...(nonce ? { nonce } : {}), ...(prompt === undefined ? {} : { prompt }),
|
|
112
|
+
}).toString();
|
|
113
|
+
return { url: url.href, codeVerifier: verifier, state, ...(nonce ? { nonce } : {}) };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Finish a sign-in that asked for runtime scopes and return the person's `Connection`. A sign-in-only
|
|
117
|
+
* request (`openid`, `openid email`, `openid profile`, or `openid email profile`) gets no connection from the directory; finish it with
|
|
118
|
+
* `exchangeIdentity`, which this method names when it receives such a response.
|
|
119
|
+
*/
|
|
120
|
+
async exchangeCode({ code, codeVerifier, state, expectedState, nonce }) {
|
|
121
|
+
assertState(state, expectedState);
|
|
122
|
+
const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
|
|
123
|
+
if (response.refresh_token === undefined && response.runtimeUrl === undefined && typeof response.id_token === 'string') {
|
|
124
|
+
throw new CrouterError('The directory answered a sign-in-only request (openid, optionally with email and profile) with an identity and no connection; finish a sign-in-only request with exchangeIdentity(), not exchangeCode()', 'oauth_exchange_mismatch');
|
|
125
|
+
}
|
|
126
|
+
const connection = this.connectionFrom(response);
|
|
127
|
+
if (connection.idToken) {
|
|
128
|
+
const claims = await this.verifyIdToken(connection.idToken, { nonce });
|
|
129
|
+
if (typeof claims.nonce !== 'string' || claims.nonce !== nonce)
|
|
130
|
+
throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
|
|
131
|
+
}
|
|
132
|
+
return connection;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Finish a sign-in-only request (`authorizeUrl` with scopes `openid` or `openid email`) and return the
|
|
136
|
+
* person's verified identity. There is no connection, refresh token, or runtime access on this path.
|
|
137
|
+
* `nonce` is the value `authorizeUrl` returned and is required. A response without an ID token, or with
|
|
138
|
+
* one that fails verification, throws `id_token_invalid`; a response carrying a runtime connection (the
|
|
139
|
+
* request asked for runtime scopes) throws `oauth_exchange_mismatch` and must be finished with `exchangeCode`.
|
|
140
|
+
*/
|
|
141
|
+
async exchangeIdentity({ code, codeVerifier, state, expectedState, nonce }) {
|
|
142
|
+
assertState(state, expectedState);
|
|
143
|
+
if (typeof nonce !== 'string' || !nonce)
|
|
144
|
+
throw new CrouterError('exchangeIdentity requires the nonce authorizeUrl returned', 'oauth_nonce_missing');
|
|
145
|
+
const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
|
|
146
|
+
if (response.refresh_token !== undefined || response.runtimeUrl !== undefined) {
|
|
147
|
+
throw new CrouterError('The directory answered with a runtime connection, so the request asked for runtime scopes; finish it with exchangeCode(), or request only openid or openid email for a sign-in-only visitor', 'oauth_exchange_mismatch');
|
|
148
|
+
}
|
|
149
|
+
if (typeof response.id_token !== 'string' || !response.id_token)
|
|
150
|
+
throw new CrouterError('Directory returned no ID token for a sign-in-only request', 'id_token_invalid');
|
|
151
|
+
const claims = await this.verifyIdToken(response.id_token, { nonce });
|
|
152
|
+
return {
|
|
153
|
+
sub: claims.sub,
|
|
154
|
+
...(typeof claims['email'] === 'string' ? { email: claims['email'] } : {}),
|
|
155
|
+
...(typeof claims['email_verified'] === 'boolean' ? { emailVerified: claims['email_verified'] } : {}),
|
|
156
|
+
...profileFromClaims(claims),
|
|
157
|
+
claims,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Refresh a store's idle connections once. The app owns scheduling; failures remain visible to its worker,
|
|
161
|
+
* each with `reason` set when the directory refused that connection's refresh (see `RefreshRefusal`). */
|
|
162
|
+
async refreshIdleConnections(store, { olderThanMs }) {
|
|
163
|
+
if (!store.listStale)
|
|
164
|
+
throw new CrouterError('ConnectionStore.listStale(before) is required to refresh idle connections');
|
|
165
|
+
if (!Number.isFinite(olderThanMs) || olderThanMs <= 0)
|
|
166
|
+
throw new CrouterError('olderThanMs must be a positive number');
|
|
167
|
+
const stale = await store.listStale(new Date(Date.now() - olderThanMs));
|
|
168
|
+
let refreshed = 0;
|
|
169
|
+
const failures = [];
|
|
170
|
+
for (const connection of stale) {
|
|
171
|
+
try {
|
|
172
|
+
// refresh() holds the cross-process store lock and reloads before rotating.
|
|
173
|
+
const current = await this.refresh(connection, store);
|
|
174
|
+
if (current.refreshToken !== connection.refreshToken)
|
|
175
|
+
refreshed++;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
failures.push({ userId: connection.userId, error, reason: refreshRefusal(error) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { refreshed, failures };
|
|
182
|
+
}
|
|
183
|
+
async refresh(connection, store) {
|
|
184
|
+
const existing = this.inFlight.get(connection.userId);
|
|
185
|
+
if (existing)
|
|
186
|
+
return existing;
|
|
187
|
+
const rotate = async () => {
|
|
188
|
+
const stored = await store?.load(connection.userId);
|
|
189
|
+
if (stored && stored.refreshToken !== connection.refreshToken)
|
|
190
|
+
return stored;
|
|
191
|
+
const updated = this.connectionFrom(await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: connection.refreshToken }), connection);
|
|
192
|
+
await store?.save(updated);
|
|
193
|
+
return updated;
|
|
194
|
+
};
|
|
195
|
+
const pending = store?.lock ? store.lock(connection.userId, rotate) : rotate();
|
|
196
|
+
this.inFlight.set(connection.userId, pending);
|
|
197
|
+
try {
|
|
198
|
+
return await pending;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
this.inFlight.delete(connection.userId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
client(connection, store) {
|
|
205
|
+
let current = connection;
|
|
206
|
+
let revoked;
|
|
207
|
+
const refresh = async (force) => {
|
|
208
|
+
if (revoked)
|
|
209
|
+
throw revoked;
|
|
210
|
+
if (!force && current.accessToken && current.accessTokenExpiresAt - Date.now() > 30_000)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
current = await this.refresh(current, store);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error instanceof APIError && reconnectCodes.has(error.code))
|
|
217
|
+
revoked = error;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
return new Crouter({
|
|
222
|
+
baseURL: connection.runtimeUrl,
|
|
223
|
+
tokenSource: async () => { await refresh(false); return current.accessToken; },
|
|
224
|
+
onUnauthorized: async () => { await refresh(true); },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** Verify an ID token: ES256 signature against the discovered JWKS, `iss`, `aud` = client id, `exp`, and `nonce` when given. A token that fails any check throws `id_token_invalid`; a JWKS that can't be fetched or is malformed rethrows that error. */
|
|
228
|
+
async verifyIdToken(idToken, { nonce } = {}) {
|
|
229
|
+
const discovery = await this.discovery();
|
|
230
|
+
this.jwks ??= new JwksCache(this.issuer, this.fetcher, discovery.jwks_uri);
|
|
231
|
+
const invalid = (reason) => new CrouterError(`ID token failed verification: ${reason}`, 'id_token_invalid');
|
|
232
|
+
try {
|
|
233
|
+
decodeProtectedHeader(idToken);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw invalid('it is not a signed JWT');
|
|
237
|
+
}
|
|
238
|
+
let claims;
|
|
239
|
+
try {
|
|
240
|
+
claims = await verifyIdToken({ token: idToken, jwks: this.jwks, issuer: this.issuer, audience: this.options.clientId });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
// A token that fails verification is id_token_invalid; a JWKS that cannot be fetched or is malformed is the
|
|
244
|
+
// directory's fault, not the token's, and is rethrown as is. jose's token errors (bad signature, wrong iss/aud,
|
|
245
|
+
// expired, no matching key) carry an ERR_J* code; ERR_JWKS_INVALID and ERR_JWKS_TIMEOUT describe the key set.
|
|
246
|
+
// The identity package's own checks throw "Invalid ID token …". Duck-typed, since jose may be a separate copy.
|
|
247
|
+
const joseCode = error?.code;
|
|
248
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
249
|
+
const keySetFault = joseCode === 'ERR_JWKS_INVALID' || joseCode === 'ERR_JWKS_TIMEOUT';
|
|
250
|
+
if ((typeof joseCode === 'string' && joseCode.startsWith('ERR_J') && !keySetFault) || /^Invalid ID token/.test(message))
|
|
251
|
+
throw invalid(message);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
if (nonce !== undefined && claims.nonce !== nonce)
|
|
255
|
+
throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
|
|
256
|
+
return claims;
|
|
257
|
+
}
|
|
258
|
+
async revoke(connection) {
|
|
259
|
+
const metadata = await this.discovery();
|
|
260
|
+
if (!metadata.revocation_endpoint)
|
|
261
|
+
return false;
|
|
262
|
+
await this.post(metadata.revocation_endpoint, { token: connection.refreshToken, token_type_hint: 'refresh_token' }, true);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
async tokenResponse(params) {
|
|
266
|
+
const metadata = await this.discovery();
|
|
267
|
+
if (!metadata.token_endpoint_auth_methods_supported?.includes('private_key_jwt'))
|
|
268
|
+
throw new CrouterError('Directory does not support private_key_jwt');
|
|
269
|
+
return await this.post(metadata.token_endpoint, params);
|
|
270
|
+
}
|
|
271
|
+
connectionFrom(response, previous) {
|
|
272
|
+
if (typeof response.access_token !== 'string' || typeof response.refresh_token !== 'string')
|
|
273
|
+
throw new CrouterError('Directory returned an invalid token response');
|
|
274
|
+
const claims = decodeJwt(response.access_token);
|
|
275
|
+
const runtimeUrl = typeof response.runtimeUrl === 'string' && response.runtimeUrl ? response.runtimeUrl : previous?.runtimeUrl;
|
|
276
|
+
if (typeof claims.sub !== 'string' || typeof claims.grant !== 'string' || typeof claims.exp !== 'number' || !runtimeUrl) {
|
|
277
|
+
throw new CrouterError('Directory returned an incomplete connection');
|
|
278
|
+
}
|
|
279
|
+
const idToken = typeof response.id_token === 'string' ? response.id_token : previous?.idToken;
|
|
280
|
+
return {
|
|
281
|
+
userId: claims.sub, runtimeUrl, refreshToken: response.refresh_token,
|
|
282
|
+
grantId: claims.grant,
|
|
283
|
+
accessToken: response.access_token, accessTokenExpiresAt: claims.exp * 1_000,
|
|
284
|
+
...(idToken ? { idToken } : {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async post(endpoint, values, revocation = false) {
|
|
288
|
+
const key = this.options.privateKey;
|
|
289
|
+
if (typeof key !== 'string' && key.alg !== undefined && key.alg !== 'ES256' && key.alg !== 'RS256')
|
|
290
|
+
throw new CrouterError('privateKey must use ES256 or RS256');
|
|
291
|
+
let alg;
|
|
292
|
+
let imported;
|
|
293
|
+
if (typeof key === 'string') {
|
|
294
|
+
try {
|
|
295
|
+
imported = await importPKCS8(key, 'ES256');
|
|
296
|
+
alg = 'ES256';
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
imported = await importPKCS8(key, 'RS256');
|
|
300
|
+
alg = 'RS256';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
alg = key.alg === 'RS256' || key.kty === 'RSA' ? 'RS256' : 'ES256';
|
|
305
|
+
imported = await importJWK(key, alg);
|
|
306
|
+
}
|
|
307
|
+
const kid = this.options.keyId ?? (typeof key === 'string' ? undefined : key.kid);
|
|
308
|
+
for (let attempt = 0;; attempt++) {
|
|
309
|
+
const now = Math.floor(Date.now() / 1000);
|
|
310
|
+
const assertion = await new SignJWT({})
|
|
311
|
+
.setProtectedHeader({ alg, typ: 'JWT', ...(kid ? { kid } : {}) })
|
|
312
|
+
.setIssuer(this.options.clientId).setSubject(this.options.clientId).setAudience(endpoint)
|
|
313
|
+
.setIssuedAt(now).setExpirationTime(now + 60).setJti(crypto.randomUUID()).sign(imported);
|
|
314
|
+
const response = await this.fetcher(endpoint, {
|
|
315
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
316
|
+
body: new URLSearchParams({ ...values, client_id: this.options.clientId, client_assertion_type: assertionType, client_assertion: assertion }),
|
|
317
|
+
});
|
|
318
|
+
if (revocation && response.ok)
|
|
319
|
+
return undefined;
|
|
320
|
+
const payload = await response.json();
|
|
321
|
+
if (response.ok)
|
|
322
|
+
return payload;
|
|
323
|
+
const refusal = payload;
|
|
324
|
+
// invalid_grant carries the directory's reason as its error_description; a reason it does not name is grant_revoked.
|
|
325
|
+
const description = refusal.error_description ?? '';
|
|
326
|
+
const code = refusal.error === 'invalid_grant'
|
|
327
|
+
? description === 'runtime_provisioning' || reconnectCodes.has(description) ? description : 'grant_revoked'
|
|
328
|
+
: refusal.error === 'invalid_client' ? 'unauthorized' : 'invalid_request';
|
|
329
|
+
const reconnect = reconnectCodes.has(code);
|
|
330
|
+
if (code === 'runtime_provisioning' && attempt < 2) {
|
|
331
|
+
const retryAfter = refusal.retry_after_s ?? Number(response.headers.get('retry-after'));
|
|
332
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt * (0.5 + Math.random());
|
|
333
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const message = refusal.error_description ?? refusal.error ?? 'Directory token request failed';
|
|
337
|
+
const keyHint = code === 'unauthorized' ? await describeSigningKey(key, kid) : undefined;
|
|
338
|
+
// mapError gives the refusal its status class (401 → AuthenticationError) as errors.md documents.
|
|
339
|
+
throw mapError(new APIError(code === 'runtime_provisioning' ? 503 : reconnect || code === 'unauthorized' ? 401 : response.status, code, keyHint ? `${message}: ${keyHint.hint}` : message, code === 'invalid_request' ? { oauth_error: refusal.error }
|
|
340
|
+
: keyHint ? { oauth_error: 'invalid_client', kid: keyHint.kid, ...(keyHint.thumbprint ? { key_thumbprint: keyHint.thumbprint } : {}) } : undefined, response.headers, { origin: 'directory', type: code === 'runtime_provisioning' ? 'server_error' : code === 'invalid_request' ? 'invalid_request_error' : 'authentication_error',
|
|
341
|
+
retryable: code === 'runtime_provisioning', user_action: reconnect ? 'reconnect' : undefined }));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Name the key an `invalid_client` refusal rejected, so a developer who switched key files before
|
|
347
|
+
* uploading the public JWK sees which key the SDK signed with and where it must be registered.
|
|
348
|
+
*/
|
|
349
|
+
async function describeSigningKey(key, kid) {
|
|
350
|
+
const question = "is this key's public JWK registered on the app's Sign-in tab in the console?";
|
|
351
|
+
if (kid)
|
|
352
|
+
return { kid, hint: `the directory rejected the client assertion signed with key id (kid) "${kid}"; ${question}` };
|
|
353
|
+
// An assertion without a kid: the console lists such a key by its RFC 7638 thumbprint.
|
|
354
|
+
let thumbprint;
|
|
355
|
+
try {
|
|
356
|
+
thumbprint = typeof key === 'string'
|
|
357
|
+
? await calculateJwkThumbprint(await exportJWK(await importPKCS8(key, 'ES256', { extractable: true }).catch(() => importPKCS8(key, 'RS256', { extractable: true }))), 'sha256')
|
|
358
|
+
: await calculateJwkThumbprint(key, 'sha256');
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
thumbprint = undefined;
|
|
362
|
+
}
|
|
363
|
+
const named = thumbprint ? `signed without a kid by the key whose thumbprint is "${thumbprint}"` : 'signed without a kid';
|
|
364
|
+
return { kid: null, ...(thumbprint ? { thumbprint } : {}), hint: `the directory rejected the client assertion ${named}; ${question}` };
|
|
365
|
+
}
|
|
366
|
+
// expectedState is the state saved by the app at authorizeUrl(), not a value from the callback.
|
|
367
|
+
function assertState(state, expectedState) {
|
|
368
|
+
if (!state || !expectedState || state !== expectedState)
|
|
369
|
+
throw new CrouterError('OAuth state is missing or does not match', 'oauth_state_mismatch');
|
|
370
|
+
}
|
|
371
|
+
function randomBase64url(length) { return base64url(crypto.getRandomValues(new Uint8Array(length))); }
|
|
372
|
+
function base64url(bytes) {
|
|
373
|
+
let binary = '';
|
|
374
|
+
for (const byte of bytes)
|
|
375
|
+
binary += String.fromCharCode(byte);
|
|
376
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
377
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type JWK } from 'jose';
|
|
2
|
+
/** Make an app signing key and the public JWK to register in the directory. */
|
|
3
|
+
export declare function generateKeyPair(): Promise<{
|
|
4
|
+
privateJwk: JWK;
|
|
5
|
+
publicJwk: JWK;
|
|
6
|
+
kid: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** Check that `value` (a JWK object or its JSON text) is a private signing key with a `kid`, and return it as
|
|
9
|
+
* `OAuth2Options.privateKey` takes it. `source` names where it came from in the error, e.g. a file path. */
|
|
10
|
+
export declare function parsePrivateJwk(value: string | JWK, source?: string): JWK & {
|
|
11
|
+
d: string;
|
|
12
|
+
kid: string;
|
|
13
|
+
};
|
|
14
|
+
/** Read a private JWK file, such as the one `crouter-sdk keygen` writes, and check it as `parsePrivateJwk` does. Node only. */
|
|
15
|
+
export declare function privateKeyFromFile(path: string): Promise<JWK & {
|
|
16
|
+
d: string;
|
|
17
|
+
kid: string;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { calculateJwkThumbprint, exportJWK, generateKeyPair as generateSigningKeyPair } from 'jose';
|
|
2
|
+
import { CrouterError } from '../errors.js';
|
|
3
|
+
/** Make an app signing key and the public JWK to register in the directory. */
|
|
4
|
+
export async function generateKeyPair() {
|
|
5
|
+
const keys = await generateSigningKeyPair('ES256', { extractable: true });
|
|
6
|
+
const publicKey = await exportJWK(keys.publicKey);
|
|
7
|
+
const kid = await calculateJwkThumbprint(publicKey, 'sha256');
|
|
8
|
+
const properties = { kid, alg: 'ES256', use: 'sig' };
|
|
9
|
+
return {
|
|
10
|
+
privateJwk: { ...await exportJWK(keys.privateKey), ...properties },
|
|
11
|
+
publicJwk: { ...publicKey, ...properties },
|
|
12
|
+
kid,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const keygenHint = 'make one with: pnpm exec crouter-sdk keygen';
|
|
16
|
+
/** Check that `value` (a JWK object or its JSON text) is a private signing key with a `kid`, and return it as
|
|
17
|
+
* `OAuth2Options.privateKey` takes it. `source` names where it came from in the error, e.g. a file path. */
|
|
18
|
+
export function parsePrivateJwk(value, source = 'The key') {
|
|
19
|
+
let key = value;
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
try {
|
|
22
|
+
key = JSON.parse(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new CrouterError(`${source} is not JSON; it must hold a private JWK with a kid (${keygenHint}).`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!key || typeof key !== 'object' || Array.isArray(key) || typeof key.d !== 'string' || !key.d
|
|
29
|
+
|| typeof key.kid !== 'string' || !key.kid) {
|
|
30
|
+
throw new CrouterError(`${source} must hold a private JWK with a kid (${keygenHint}).`);
|
|
31
|
+
}
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
/** Read a private JWK file, such as the one `crouter-sdk keygen` writes, and check it as `parsePrivateJwk` does. Node only. */
|
|
35
|
+
export async function privateKeyFromFile(path) {
|
|
36
|
+
const load = new Function('specifier', 'return import(specifier)');
|
|
37
|
+
const { readFile } = await load('node:fs/promises');
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = await readFile(path, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new CrouterError(`${path} could not be read (${error instanceof Error ? error.message : String(error)}); it must hold a private JWK with a kid (${keygenHint}).`);
|
|
44
|
+
}
|
|
45
|
+
return parsePrivateJwk(text, path);
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NodeStreamEvent } from './node-stream.js';
|
|
2
|
+
/** One tool call rendered as an activity step. */
|
|
3
|
+
export interface ActivityStep {
|
|
4
|
+
/** The tool call's id, stable across its status changes. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Tool name, e.g. `bash` or `read`. */
|
|
7
|
+
tool: string;
|
|
8
|
+
/** The daemon's one-line summary of the call. */
|
|
9
|
+
summary: string;
|
|
10
|
+
/** What `describe` returned for this call, for display. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Where the call stands. */
|
|
13
|
+
status: 'running' | 'done' | 'failed';
|
|
14
|
+
}
|
|
15
|
+
/** Turn a streamed tool call into a label, or return null to omit it. */
|
|
16
|
+
export type DescribeTool = (tool: string, summary: string) => string | null;
|
|
17
|
+
/** Plain labels for the tools every node can use. */
|
|
18
|
+
export declare const describeToolDefault: DescribeTool;
|
|
19
|
+
/** Yield an immutable activity snapshot after every streamed tool-call change. */
|
|
20
|
+
export declare function followActivity(stream: AsyncIterable<NodeStreamEvent>, options?: {
|
|
21
|
+
describe?: DescribeTool;
|
|
22
|
+
}): AsyncGenerator<ActivityStep[]>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Plain labels for the tools every node can use. */
|
|
2
|
+
export const describeToolDefault = (tool) => {
|
|
3
|
+
switch (tool) {
|
|
4
|
+
case 'bash': return 'running bash';
|
|
5
|
+
case 'read': return 'reading';
|
|
6
|
+
case 'write': return 'writing';
|
|
7
|
+
case 'edit': return 'editing';
|
|
8
|
+
default: return tool;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** Yield an immutable activity snapshot after every streamed tool-call change. */
|
|
12
|
+
export async function* followActivity(stream, options = {}) {
|
|
13
|
+
const describe = options.describe ?? describeToolDefault;
|
|
14
|
+
const steps = [];
|
|
15
|
+
const byId = new Map();
|
|
16
|
+
for await (const event of stream) {
|
|
17
|
+
if (event.type === 'node.tool_call.started') {
|
|
18
|
+
const label = describe(event.tool, event.summary);
|
|
19
|
+
if (label === null || byId.has(event.tool_call_id))
|
|
20
|
+
continue;
|
|
21
|
+
const step = {
|
|
22
|
+
id: event.tool_call_id,
|
|
23
|
+
tool: event.tool,
|
|
24
|
+
summary: event.summary,
|
|
25
|
+
label,
|
|
26
|
+
status: 'running',
|
|
27
|
+
};
|
|
28
|
+
byId.set(step.id, step);
|
|
29
|
+
steps.push(step);
|
|
30
|
+
yield snapshot(steps);
|
|
31
|
+
}
|
|
32
|
+
else if (event.type === 'node.tool_call.completed') {
|
|
33
|
+
const step = byId.get(event.tool_call_id);
|
|
34
|
+
if (step === undefined || step.status !== 'running')
|
|
35
|
+
continue;
|
|
36
|
+
step.status = event.status === 'ok' ? 'done' : 'failed';
|
|
37
|
+
yield snapshot(steps);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function snapshot(steps) {
|
|
42
|
+
return steps.map((step) => ({ ...step }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RequestOptions } from '../types.js';
|
|
2
|
+
import type { Request } from './request.js';
|
|
3
|
+
/** A short-lived signed link to an uploaded file's stored bytes. */
|
|
4
|
+
export interface AttachmentLink {
|
|
5
|
+
url: string;
|
|
6
|
+
expires_at: string;
|
|
7
|
+
}
|
|
8
|
+
/** Links to files a run received as attachments (app listener only). */
|
|
9
|
+
export declare class Attachments {
|
|
10
|
+
private readonly request;
|
|
11
|
+
constructor(request: Request);
|
|
12
|
+
/** A signed download link for the upload fetched to `path`, an absolute path under a run's
|
|
13
|
+
* `attachments/` folder. A path with no recorded upload, or in a run the caller cannot read, is `not_found`. */
|
|
14
|
+
link(path: string, options?: RequestOptions): Promise<AttachmentLink>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { withQuery } from './request.js';
|
|
2
|
+
/** Links to files a run received as attachments (app listener only). */
|
|
3
|
+
export class Attachments {
|
|
4
|
+
request;
|
|
5
|
+
constructor(request) {
|
|
6
|
+
this.request = request;
|
|
7
|
+
}
|
|
8
|
+
/** A signed download link for the upload fetched to `path`, an absolute path under a run's
|
|
9
|
+
* `attachments/` folder. A path with no recorded upload, or in a run the caller cannot read, is `not_found`. */
|
|
10
|
+
link(path, options) {
|
|
11
|
+
return this.request('GET', withQuery('/v1/attachments/link', { path }), undefined, options);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BashRunDTO, type BashRunParams } from '@crouter/api';
|
|
2
|
+
import type { RequestOptions } from '../types.js';
|
|
3
|
+
import type { Request } from './request.js';
|
|
4
|
+
export declare class Bash {
|
|
5
|
+
private readonly request;
|
|
6
|
+
private readonly defaultTimeout;
|
|
7
|
+
constructor(request: Request, defaultTimeout: number);
|
|
8
|
+
run(params: BashRunParams, options?: RequestOptions): Promise<BashRunDTO>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { routes } from '@crouter/api';
|
|
2
|
+
const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
3
|
+
const TIMEOUT_ALLOWANCE_MS = 15_000;
|
|
4
|
+
export class Bash {
|
|
5
|
+
request;
|
|
6
|
+
defaultTimeout;
|
|
7
|
+
constructor(request, defaultTimeout) {
|
|
8
|
+
this.request = request;
|
|
9
|
+
this.defaultTimeout = defaultTimeout;
|
|
10
|
+
}
|
|
11
|
+
run(params, options) {
|
|
12
|
+
const requiredTimeout = (params.timeout_s ?? DEFAULT_TIMEOUT_SECONDS) * 1000 + TIMEOUT_ALLOWANCE_MS;
|
|
13
|
+
return this.request('POST', routes.bash(), params, { ...options, timeout: Math.max(options?.timeout ?? this.defaultTimeout, requiredTimeout) });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type HistoryGrepQuery, type HistoryGrepResultDTO, type HistoryReadQuery, type HistoryReadResultDTO, type HistorySearchQuery, type HistorySearchResultDTO, type HistoryStatsQuery, type HistoryStatsResultDTO } from '@crouter/api';
|
|
2
|
+
import type { RequestOptions } from '../../types.js';
|
|
3
|
+
import type { Request } from '../request.js';
|
|
4
|
+
export declare class CanvasHistory {
|
|
5
|
+
private readonly request;
|
|
6
|
+
constructor(request: Request);
|
|
7
|
+
search(body: HistorySearchQuery, options?: RequestOptions): Promise<HistorySearchResultDTO>;
|
|
8
|
+
grep(body: HistoryGrepQuery, options?: RequestOptions): Promise<HistoryGrepResultDTO>;
|
|
9
|
+
read(query: HistoryReadQuery, options?: RequestOptions): Promise<HistoryReadResultDTO>;
|
|
10
|
+
stats(body: HistoryStatsQuery, options?: RequestOptions): Promise<HistoryStatsResultDTO>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { routes, } from '@crouter/api';
|
|
2
|
+
import { withQuery } from '../request.js';
|
|
3
|
+
export class CanvasHistory {
|
|
4
|
+
request;
|
|
5
|
+
constructor(request) {
|
|
6
|
+
this.request = request;
|
|
7
|
+
}
|
|
8
|
+
search(body, options) {
|
|
9
|
+
return this.request('POST', routes.canvasHistorySearch(), body, options);
|
|
10
|
+
}
|
|
11
|
+
grep(body, options) {
|
|
12
|
+
return this.request('POST', routes.canvasHistoryGrep(), body, options);
|
|
13
|
+
}
|
|
14
|
+
read(query, options) {
|
|
15
|
+
return this.request('GET', withQuery(routes.canvasHistoryRead(), query), undefined, options);
|
|
16
|
+
}
|
|
17
|
+
stats(body, options) {
|
|
18
|
+
return this.request('POST', routes.canvasHistoryStats(), body, options);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AttentionCountsDTO, type AttentionCountsRequest, type AttentionDTO, type DashboardDTO, type DashboardQuery, type PruneRequest, type PruneResultDTO, type RosterDTO, type SnapshotDTO, type WatchRequest, type WatchResultDTO, type UnwatchQuery, type UnwatchResultDTO } from '@crouter/api';
|
|
2
|
+
import type { RequestOptions } from '../types.js';
|
|
3
|
+
import { CanvasHistory } from './canvas/history.js';
|
|
4
|
+
import type { Request } from './request.js';
|
|
5
|
+
export declare class Canvas {
|
|
6
|
+
private readonly request;
|
|
7
|
+
readonly history: CanvasHistory;
|
|
8
|
+
constructor(request: Request);
|
|
9
|
+
attention(options?: RequestOptions): Promise<AttentionDTO>;
|
|
10
|
+
attentionCounts(body: AttentionCountsRequest, options?: RequestOptions): Promise<AttentionCountsDTO>;
|
|
11
|
+
snapshot(options?: RequestOptions): Promise<SnapshotDTO>;
|
|
12
|
+
roster(options?: RequestOptions): Promise<RosterDTO>;
|
|
13
|
+
dashboard(query?: DashboardQuery, options?: RequestOptions): Promise<DashboardDTO>;
|
|
14
|
+
/** Watch an object; `watcher` makes a custom object the receiver instead of the caller's node. */
|
|
15
|
+
watch(ref: string, body?: WatchRequest, options?: RequestOptions): Promise<WatchResultDTO>;
|
|
16
|
+
/** Remove a watch. `watcher` identifies the custom object whose watch is removed. */
|
|
17
|
+
unwatch(ref: string, query?: UnwatchQuery, options?: RequestOptions): Promise<UnwatchResultDTO>;
|
|
18
|
+
prune(body: PruneRequest, options?: RequestOptions): Promise<PruneResultDTO>;
|
|
19
|
+
}
|