@isomorph.ai/app-sdk 1.21.0 → 1.22.0
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 +6 -0
- package/dist/aws.d.ts +13 -0
- package/dist/aws.js +34 -0
- package/dist/gateway-session.d.ts +22 -0
- package/dist/gateway-session.js +65 -0
- package/dist/index.d.ts +2 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +32 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -453,3 +453,9 @@ await isomorph.actions.invoke('research', input, { onProgress: (step) => setStep
|
|
|
453
453
|
Without `onProgress` the call is unchanged and `ctx.progress` does nothing. At
|
|
454
454
|
most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
|
|
455
455
|
The action still has the 300 s action budget. (Unreleased)
|
|
456
|
+
|
|
457
|
+
## Running on Lambda
|
|
458
|
+
|
|
459
|
+
On a serverless cell Harbour runs an app's server code as Lambda functions; nothing in the app changes. `ISOMORPH_GATEWAY_IDENTITY=sts-session` replaces `HARBOUR_WORKLOAD_TOKEN_FILE`: the runtime's bearer for the gateway is a one-hour session it gets by posting a presigned STS `GetCallerIdentity`, signed with the function's credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`), to `<gateway>/_harbour/internal/session`. A cluster runtime leaves it unset.
|
|
460
|
+
|
|
461
|
+
(1.22.0; data plane ADR 0065)
|
package/dist/aws.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AwsEnvironment {
|
|
2
|
+
accessKeyId: string;
|
|
3
|
+
secretAccessKey: string;
|
|
4
|
+
sessionToken?: string;
|
|
5
|
+
region: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function awsEnvironment(env?: NodeJS.ProcessEnv): AwsEnvironment;
|
|
8
|
+
/**
|
|
9
|
+
* A presigned GET of STS GetCallerIdentity on the region's dual-stack
|
|
10
|
+
* endpoint. Host is the only signed header: the gateway forwards the query
|
|
11
|
+
* alone (ADR 0068), so a signature over any other header could never verify.
|
|
12
|
+
*/
|
|
13
|
+
export declare function presignCallerIdentity(aws: AwsEnvironment, signingDate?: Date): Promise<string>;
|
package/dist/aws.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The AWS calls a serverless runtime makes with its function role, signed
|
|
2
|
+
// here rather than through an AWS SDK client (ADR 0065). Credentials and the
|
|
3
|
+
// region come from the Lambda environment on every call.
|
|
4
|
+
import { Sha256 } from '@aws-crypto/sha256-js';
|
|
5
|
+
import { HttpRequest } from '@smithy/protocol-http';
|
|
6
|
+
import { SignatureV4 } from '@smithy/signature-v4';
|
|
7
|
+
export function awsEnvironment(env = process.env) {
|
|
8
|
+
const accessKeyId = env.AWS_ACCESS_KEY_ID;
|
|
9
|
+
const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
|
|
10
|
+
const region = env.AWS_REGION || env.AWS_DEFAULT_REGION;
|
|
11
|
+
if (!accessKeyId || !secretAccessKey)
|
|
12
|
+
throw new Error('the function has no AWS credentials in its environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)');
|
|
13
|
+
if (!region)
|
|
14
|
+
throw new Error('the function has no AWS region in its environment (AWS_REGION)');
|
|
15
|
+
return { accessKeyId, secretAccessKey, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}), region };
|
|
16
|
+
}
|
|
17
|
+
function signer(aws, service) {
|
|
18
|
+
return new SignatureV4({ service, region: aws.region, sha256: Sha256, credentials: { accessKeyId: aws.accessKeyId, secretAccessKey: aws.secretAccessKey, ...(aws.sessionToken ? { sessionToken: aws.sessionToken } : {}) } });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A presigned GET of STS GetCallerIdentity on the region's dual-stack
|
|
22
|
+
* endpoint. Host is the only signed header: the gateway forwards the query
|
|
23
|
+
* alone (ADR 0068), so a signature over any other header could never verify.
|
|
24
|
+
*/
|
|
25
|
+
export async function presignCallerIdentity(aws, signingDate = new Date()) {
|
|
26
|
+
const hostname = `sts.${aws.region}.api.aws`;
|
|
27
|
+
const request = new HttpRequest({ method: 'GET', protocol: 'https:', hostname, path: '/', query: { Action: 'GetCallerIdentity', Version: '2011-06-15' }, headers: { host: hostname } });
|
|
28
|
+
const signed = await signer(aws, 'sts').presign(request, { expiresIn: 300, signingDate });
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
for (const [key, value] of Object.entries(signed.query ?? {}))
|
|
31
|
+
if (typeof value === 'string')
|
|
32
|
+
query.set(key, value);
|
|
33
|
+
return `https://${hostname}/?${query.toString()}`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const SESSION_PATH = "/_harbour/internal/session";
|
|
2
|
+
export interface GatewaySessionOptions {
|
|
3
|
+
gatewayUrl: string;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
/** The pauses between attempts the gateway or STS did not answer; the tests shorten them. */
|
|
7
|
+
retryDelaysMs?: number[];
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}
|
|
10
|
+
export declare class GatewaySession {
|
|
11
|
+
private readonly options;
|
|
12
|
+
private current;
|
|
13
|
+
private pending;
|
|
14
|
+
constructor(options: GatewaySessionOptions);
|
|
15
|
+
/**
|
|
16
|
+
* The cached token while it stays valid for at least `minValidityMs`, else a
|
|
17
|
+
* new one. Concurrent callers share one exchange.
|
|
18
|
+
*/
|
|
19
|
+
token(minValidityMs: number): Promise<string>;
|
|
20
|
+
private now;
|
|
21
|
+
private exchange;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The runtime's gateway identity on a serverless cell, where there is no
|
|
2
|
+
// projected ServiceAccount token to read (ISOMORPH_GATEWAY_IDENTITY=sts-session,
|
|
3
|
+
// ADR 0065). The runtime proves its function role with a presigned STS
|
|
4
|
+
// GetCallerIdentity and the gateway answers with a one-hour bearer for the app
|
|
5
|
+
// whose declaration names that role (the gateway's ADR 0068).
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
import { awsEnvironment, presignCallerIdentity } from './aws.js';
|
|
8
|
+
export const SESSION_PATH = '/_harbour/internal/session';
|
|
9
|
+
// The gateway answers 503 when STS did not, with no Retry-After; an ALB in
|
|
10
|
+
// front answers 502 or 504 while the gateway task restarts.
|
|
11
|
+
const RETRY_STATUSES = new Set([502, 503, 504]);
|
|
12
|
+
const RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
|
|
13
|
+
export class GatewaySession {
|
|
14
|
+
options;
|
|
15
|
+
current;
|
|
16
|
+
pending;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The cached token while it stays valid for at least `minValidityMs`, else a
|
|
22
|
+
* new one. Concurrent callers share one exchange.
|
|
23
|
+
*/
|
|
24
|
+
async token(minValidityMs) {
|
|
25
|
+
if (this.current && this.current.expiresAt - this.now() >= minValidityMs)
|
|
26
|
+
return this.current.token;
|
|
27
|
+
this.pending ??= this.exchange().finally(() => { this.pending = undefined; });
|
|
28
|
+
return this.pending;
|
|
29
|
+
}
|
|
30
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
31
|
+
async exchange() {
|
|
32
|
+
const delays = this.options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
33
|
+
let refusedOnce = false;
|
|
34
|
+
for (let attempt = 0;; attempt += 1) {
|
|
35
|
+
// Presigned per attempt: the gateway refuses one signed more than five minutes ago.
|
|
36
|
+
const presignedUrl = await presignCallerIdentity(awsEnvironment(this.options.env));
|
|
37
|
+
let response;
|
|
38
|
+
let failure;
|
|
39
|
+
try {
|
|
40
|
+
response = await (this.options.fetch ?? fetch)(`${this.options.gatewayUrl}${SESSION_PATH}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ presignedUrl }), signal: AbortSignal.timeout(10_000) });
|
|
41
|
+
failure = `the gateway answered ${response.status}`;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
failure = `the gateway did not answer: ${error instanceof Error ? error.message : String(error)}`;
|
|
45
|
+
}
|
|
46
|
+
if (response?.ok) {
|
|
47
|
+
const body = await response.json();
|
|
48
|
+
const expiresAt = typeof body.data?.expiresAt === 'string' ? Date.parse(body.data.expiresAt) : NaN;
|
|
49
|
+
if (typeof body.data?.token !== 'string' || !body.data.token || Number.isNaN(expiresAt))
|
|
50
|
+
throw new Error('the gateway session answer has no token and expiry');
|
|
51
|
+
this.current = { token: body.data.token, expiresAt };
|
|
52
|
+
return this.current.token;
|
|
53
|
+
}
|
|
54
|
+
// A refusal gets one more exchange with a new presign; the second is final.
|
|
55
|
+
if (response?.status === 401 && !refusedOnce) {
|
|
56
|
+
refusedOnce = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const retryable = response === undefined || RETRY_STATUSES.has(response.status);
|
|
60
|
+
if (!retryable || attempt >= delays.length)
|
|
61
|
+
throw new Error(`the gateway session was not issued: ${failure}`);
|
|
62
|
+
await sleep(delays[attempt]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1126,6 +1126,8 @@ export interface SlackUserLookupResult {
|
|
|
1126
1126
|
displayName: string;
|
|
1127
1127
|
}
|
|
1128
1128
|
export interface WarehouseViewReadResult {
|
|
1129
|
+
/** True when these are development sample rows, not warehouse data. Use this to label charts and exports. */
|
|
1130
|
+
sample?: boolean;
|
|
1129
1131
|
rows: Array<Record<string, unknown>>;
|
|
1130
1132
|
/** True when more rows matched than `limit`: the rows are incomplete, so never total or average them in code. */
|
|
1131
1133
|
truncated: boolean;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface RuntimeOptions {
|
|
|
85
85
|
queuePollMs?: number;
|
|
86
86
|
/** Set when the runtime itself runs the App Gateway for its trusted actions (the Lambda option). */
|
|
87
87
|
localGateway?: Omit<LocalGatewayOptions, 'log'>;
|
|
88
|
+
/** 'sts-session' when the workload's bearer is a gateway session for the function role (ISOMORPH_GATEWAY_IDENTITY). */
|
|
89
|
+
gatewayIdentity?: 'sts-session';
|
|
88
90
|
}
|
|
89
91
|
export declare class Runtime {
|
|
90
92
|
private readonly options;
|
|
@@ -97,6 +99,7 @@ export declare class Runtime {
|
|
|
97
99
|
private queueLive;
|
|
98
100
|
private queueWake;
|
|
99
101
|
private readonly runs;
|
|
102
|
+
private session;
|
|
100
103
|
private readonly log;
|
|
101
104
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
102
105
|
constructor(options: RuntimeOptions);
|
package/dist/runtime.js
CHANGED
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
// runs without waiting on the data path. Set by
|
|
50
50
|
// Provision; the table exists otherwise
|
|
51
51
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
52
|
+
// ISOMORPH_GATEWAY_IDENTITY=sts-session
|
|
53
|
+
// the workload's bearer is a gateway session the
|
|
54
|
+
// runtime obtains with its function role (a
|
|
55
|
+
// serverless cell, ADR 0065), not a token file
|
|
52
56
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
53
57
|
import { createServer } from 'node:http';
|
|
54
58
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
@@ -77,6 +81,11 @@ const MAX_BODY_BYTES = 1 << 20;
|
|
|
77
81
|
const LEASE_MS = 5 * 60_000;
|
|
78
82
|
const QUEUE_POLL_MS = 2_000;
|
|
79
83
|
const TOKEN_REFRESH_MS = 60_000;
|
|
84
|
+
// A gateway session lasts an hour. An action runs at most 300 s, so a request
|
|
85
|
+
// takes a new one in the session's last 10 minutes; a scheduled run may take
|
|
86
|
+
// up to 900 s on Lambda and takes one in the last 15.
|
|
87
|
+
const SESSION_MIN_VALIDITY_MS = 10 * 60_000;
|
|
88
|
+
const JOB_SESSION_MIN_VALIDITY_MS = 15 * 60_000;
|
|
80
89
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
81
90
|
// A CronJob pod may start while the app's database is still waking: --run-job
|
|
82
91
|
// waits this long for the gateway's data path, probing every retry interval.
|
|
@@ -248,6 +257,9 @@ export class Runtime {
|
|
|
248
257
|
queueLive = false;
|
|
249
258
|
queueWake = false;
|
|
250
259
|
runs;
|
|
260
|
+
// Built on first use: the AWS signing modules load only on a serverless
|
|
261
|
+
// cell, so a cluster runtime never imports them.
|
|
262
|
+
session;
|
|
251
263
|
log;
|
|
252
264
|
server = createServer((request, response) => { void this.serve(request, response); });
|
|
253
265
|
constructor(options) {
|
|
@@ -257,7 +269,12 @@ export class Runtime {
|
|
|
257
269
|
}
|
|
258
270
|
async start() {
|
|
259
271
|
this.inventory = await discover(this.options.root);
|
|
260
|
-
|
|
272
|
+
// A Web Function must listen within 10 s of starting, so the session is
|
|
273
|
+
// only begun here; the first request that needs it waits for it.
|
|
274
|
+
if (this.options.gatewayIdentity)
|
|
275
|
+
void this.refreshToken().catch((error) => this.log(`gateway session: ${error instanceof Error ? error.message : String(error)}`));
|
|
276
|
+
else
|
|
277
|
+
await this.refreshToken();
|
|
261
278
|
if (this.options.localGateway && this.inventory.trusted.length > 0) {
|
|
262
279
|
this.localGateway = new LocalGateway({ ...this.options.localGateway, log: this.log });
|
|
263
280
|
await this.localGateway.start();
|
|
@@ -328,7 +345,7 @@ export class Runtime {
|
|
|
328
345
|
*/
|
|
329
346
|
async runJobOnce(name, now = new Date()) {
|
|
330
347
|
this.inventory = await discover(this.options.root);
|
|
331
|
-
await this.refreshToken();
|
|
348
|
+
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
332
349
|
if (!this.inventory.jobs.some((job) => job.name === name)) {
|
|
333
350
|
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
334
351
|
return false;
|
|
@@ -401,7 +418,12 @@ export class Runtime {
|
|
|
401
418
|
})().catch(error => { this.aiBinding = undefined; throw error; });
|
|
402
419
|
await this.aiBinding;
|
|
403
420
|
}
|
|
404
|
-
async refreshToken() {
|
|
421
|
+
async refreshToken(minValidityMs = SESSION_MIN_VALIDITY_MS) {
|
|
422
|
+
if (this.options.gatewayIdentity) {
|
|
423
|
+
this.session ??= import('./gateway-session.js').then(({ GatewaySession }) => new GatewaySession({ gatewayUrl: this.options.gatewayUrl }));
|
|
424
|
+
process.env.ISOMORPH_WORKLOAD_TOKEN = await (await this.session).token(minValidityMs);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
405
427
|
if (!this.options.tokenFile)
|
|
406
428
|
return;
|
|
407
429
|
const token = (await readFile(this.options.tokenFile, 'utf8')).trim();
|
|
@@ -553,6 +575,12 @@ export class Runtime {
|
|
|
553
575
|
return refuse(response, 400, 'VALIDATION_FAILED', 'the request body is not JSON', requestId);
|
|
554
576
|
}
|
|
555
577
|
}
|
|
578
|
+
// Only the runtime's own calls, a trusted action's and the AI binding
|
|
579
|
+
// send the bearer; an action acting as its caller does not wait for it.
|
|
580
|
+
// Without a session such a call fails with AUTH_REQUIRED, which the builder reads.
|
|
581
|
+
const sendsBearer = name.startsWith(RESERVED_PREFIX) ? name !== PING_ACTION : this.inventory.trusted.includes(name) || Boolean(process.env.ISOMORPH_AI_VERSION_ID);
|
|
582
|
+
if (this.options.gatewayIdentity && sendsBearer)
|
|
583
|
+
await this.refreshToken().catch((error) => this.log(`gateway session: ${error instanceof Error ? error.message : String(error)}`));
|
|
556
584
|
try {
|
|
557
585
|
if (name.startsWith(RESERVED_PREFIX))
|
|
558
586
|
return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
|
|
@@ -663,6 +691,7 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
663
691
|
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
|
|
664
692
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
665
693
|
jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
|
|
694
|
+
...(env.ISOMORPH_GATEWAY_IDENTITY === 'sts-session' ? { gatewayIdentity: 'sts-session' } : {}),
|
|
666
695
|
...(env.ISOMORPH_LOCAL_GATEWAY_BINARY && env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE
|
|
667
696
|
? { localGateway: { binary: env.ISOMORPH_LOCAL_GATEWAY_BINARY, configFile: env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE, url: (env.ISOMORPH_LOCAL_GATEWAY_URL || '').replace(/\/$/, '') } }
|
|
668
697
|
: {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/app-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
+
"@aws-crypto/sha256-js": "5.2.0",
|
|
42
|
+
"@smithy/protocol-http": "5.6.2",
|
|
43
|
+
"@smithy/signature-v4": "5.7.4",
|
|
41
44
|
"acorn": "8.18.0"
|
|
42
45
|
}
|
|
43
46
|
}
|