@sequenceholdings/artifact-studio 0.6.7 → 0.7.1
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 +26 -1
- package/dist/cli.js +4 -6
- package/dist/gateway/abort.d.ts +6 -0
- package/dist/gateway/abort.js +16 -0
- package/dist/gateway/authorization-cache.d.ts +24 -0
- package/dist/gateway/authorization-cache.js +21 -3
- package/dist/gateway/bootstrap-page.d.ts +16 -0
- package/dist/gateway/bootstrap-page.js +42 -0
- package/dist/gateway/control-config.d.ts +21 -0
- package/dist/gateway/control-config.js +18 -0
- package/dist/gateway/control.d.ts +61 -0
- package/dist/gateway/control.js +86 -7
- package/dist/gateway/hosted-proxy.d.ts +3 -0
- package/dist/gateway/hosted-proxy.js +12 -2
- package/dist/gateway/release-admission.d.ts +5 -0
- package/dist/gateway/release-admission.js +19 -0
- package/dist/gateway/server.d.ts +4 -1
- package/dist/gateway/server.js +35 -42
- package/dist/gateway/session.d.ts +9 -8
- package/dist/gateway/session.js +9 -11
- package/dist/gateway/static-files.d.ts +66 -0
- package/dist/gateway/static-files.js +112 -0
- package/dist/gateway/usage.d.ts +13 -0
- package/dist/gateway/usage.js +18 -0
- package/dist/gateway-main.js +8 -5
- package/dist/git-clone.d.ts +1 -3
- package/dist/git-clone.js +6 -8
- package/dist/manifest-next.d.ts +4 -0
- package/dist/manifest-static.d.ts +8 -0
- package/dist/manifest-static.js +5 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/runtime-contracts.d.ts +8 -0
- package/dist/runtime-contracts.js +10 -0
- package/dist/sdk-orm.d.ts +3 -3
- package/dist/source-boundary.d.ts +6 -0
- package/dist/source-boundary.js +7 -2
- package/dist/source-resolver.js +7 -16
- package/dist/templates/nextjs/README.md +7 -0
- package/dist/templates/react-vite/package.json +5 -2
- package/dist/templates/react-vite/tsconfig.json +17 -0
- package/dist/trusted-install.d.ts +9 -2
- package/dist/trusted-install.js +9 -2
- package/package.json +4 -4
- package/templates/nextjs/README.md +7 -0
- package/templates/react-vite/package.json +5 -2
- package/templates/react-vite/tsconfig.json +17 -0
package/README.md
CHANGED
|
@@ -5,6 +5,28 @@ requests use the current user's normal permissions.
|
|
|
5
5
|
|
|
6
6
|
## Governed ORM operations
|
|
7
7
|
|
|
8
|
+
### Default SQL (planned SDK 0.7.0)
|
|
9
|
+
|
|
10
|
+
With the matching Atlas runtime, no operation manifest or registration is needed
|
|
11
|
+
for read-only runtime SQL:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const orm = createArtifactOrmExecutor({ namespace: 'lending' })
|
|
15
|
+
const result = await orm.sql({
|
|
16
|
+
sql: `SELECT l.id, c.name FROM lending.loans l
|
|
17
|
+
JOIN party.customers c ON c.id = l."customerId"`,
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Keep the namespace bridge path declaration below. Caller permissions, row
|
|
22
|
+
policies, foreign export gates and optional ontology consumer contracts still
|
|
23
|
+
apply. Results contain `columns`, array-valued `rows`, and `truncated`; exact
|
|
24
|
+
numeric/bigint values are strings. Queries are capped at 1,000 rows / 8 MiB.
|
|
25
|
+
This prerelease source requires package publication and the matching server
|
|
26
|
+
rollout. The generated-manifest path below remains backward-compatible.
|
|
27
|
+
|
|
28
|
+
### Registered operations
|
|
29
|
+
|
|
8
30
|
Use the published caller-aware adapter inside an Artifact Studio Preview or a
|
|
9
31
|
deployed artifact. Generate and apply the namespace first, then import its
|
|
10
32
|
`operations.manifest.json` into your application:
|
|
@@ -73,7 +95,10 @@ queries still need every table they read, and mutations still need write reach.
|
|
|
73
95
|
The Next.js host routes ORM requests through this data contract; no additional
|
|
74
96
|
API path declaration is needed. The POST transport does not grant mutation authority.
|
|
75
97
|
|
|
76
|
-
The allowlist admits registered persisted operations
|
|
98
|
+
The allowlist admits registered persisted operations and the server-owned
|
|
99
|
+
`<namespace>/ops/BuiltinSqlRead` identity when explicitly listed. Default SQL
|
|
100
|
+
still needs all of its table read reach and caller policies; listing it does
|
|
101
|
+
not grant arbitrary database access. Autogenerated
|
|
77
102
|
`DatasetPreview_*` hashes are excluded even if a registered operation has the
|
|
78
103
|
same name. Operation admission retains the existing active and previous
|
|
79
104
|
operation-set fallback; the allowlist pins names, not one content hash. Give a
|
package/dist/cli.js
CHANGED
|
@@ -58,12 +58,10 @@ const USAGE = `usage:
|
|
|
58
58
|
trees; feature branches and dirty source trees remain preview-only for
|
|
59
59
|
deployed environments (\`artifact dev\` / preview channel).
|
|
60
60
|
|
|
61
|
-
Interactive --repo builds clone over smart-HTTP
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
seq-studio login are still needed to resolve the repo and upload the
|
|
66
|
-
deployment.)
|
|
61
|
+
Interactive --repo builds clone over smart-HTTP using your seq-studio login
|
|
62
|
+
and repository read access. No PAT is needed. Headless M2M builds use JSON
|
|
63
|
+
materialize and accept only platform-managed --repo sources. Use --env to
|
|
64
|
+
select the same environment for source and deployment.
|
|
67
65
|
|
|
68
66
|
Next.js: init --template nextjs, then pnpm install with Node.js 24. dev starts
|
|
69
67
|
a local Next server and an owner-only Atlas preview tunnel (no cloud credentials).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles with `work`, or rejects with the signal's reason as soon as it aborts. For work
|
|
3
|
+
* that cannot itself be cancelled (google-auth-library token refreshes): the caller stops
|
|
4
|
+
* waiting at its deadline, and the abandoned work still settles, with its rejection handled.
|
|
5
|
+
*/
|
|
6
|
+
export declare function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles with `work`, or rejects with the signal's reason as soon as it aborts. For work
|
|
3
|
+
* that cannot itself be cancelled (google-auth-library token refreshes): the caller stops
|
|
4
|
+
* waiting at its deadline, and the abandoned work still settles, with its rejection handled.
|
|
5
|
+
*/
|
|
6
|
+
export function untilAborted(work, signal) {
|
|
7
|
+
if (signal.aborted) {
|
|
8
|
+
work.catch(() => undefined);
|
|
9
|
+
return Promise.reject(signal.reason);
|
|
10
|
+
}
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const onAbort = () => reject(signal.reason);
|
|
13
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
14
|
+
work.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -10,6 +10,10 @@ type Lookup<T> = {
|
|
|
10
10
|
load: () => Promise<T>;
|
|
11
11
|
notAfter: (value: T) => number;
|
|
12
12
|
};
|
|
13
|
+
export type SeedReservation = {
|
|
14
|
+
readonly sequence: number;
|
|
15
|
+
readonly askedAt: number;
|
|
16
|
+
};
|
|
13
17
|
/**
|
|
14
18
|
* Per-process, positive-only decision cache with single-flight loading: the burst of chunk
|
|
15
19
|
* requests behind one page load costs one control-plane call per gateway replica per window.
|
|
@@ -31,11 +35,31 @@ export declare class PositiveDecisionCache<T> {
|
|
|
31
35
|
});
|
|
32
36
|
/** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
|
|
33
37
|
get(lookup: Lookup<T>): Promise<T>;
|
|
38
|
+
/** The decision source is observed without another lookup or a wrapper around its promise. */
|
|
39
|
+
getObserved(lookup: Lookup<T>): {
|
|
40
|
+
promise: Promise<T>;
|
|
41
|
+
source: 'hit' | 'shared' | 'load';
|
|
42
|
+
};
|
|
34
43
|
/**
|
|
35
44
|
* Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
|
|
36
45
|
* cached entry; a refusal evicts it and fences older lookups, so the next read asks again too.
|
|
37
46
|
*/
|
|
38
47
|
refresh(lookup: Lookup<T>): Promise<T>;
|
|
48
|
+
/**
|
|
49
|
+
* Places a question asked through another call, such as a ticket exchange, in this cache's order.
|
|
50
|
+
* Reserve before asking, then {@link seed} the answer.
|
|
51
|
+
*/
|
|
52
|
+
reserve(): SeedReservation;
|
|
53
|
+
/**
|
|
54
|
+
* Stores an allow returned for a {@link reserve}d question. It ages from when that question was
|
|
55
|
+
* asked, and an answer asked later for the same key, a refusal included, is never replaced by it.
|
|
56
|
+
*/
|
|
57
|
+
seed({ key, value, asked, notAfter }: {
|
|
58
|
+
key: string;
|
|
59
|
+
value: T;
|
|
60
|
+
asked: SeedReservation;
|
|
61
|
+
notAfter: (value: T) => number;
|
|
62
|
+
}): void;
|
|
39
63
|
clear(): void;
|
|
40
64
|
private ask;
|
|
41
65
|
private record;
|
|
@@ -31,13 +31,17 @@ export class PositiveDecisionCache {
|
|
|
31
31
|
}
|
|
32
32
|
/** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
|
|
33
33
|
get(lookup) {
|
|
34
|
+
return this.getObserved(lookup).promise;
|
|
35
|
+
}
|
|
36
|
+
/** The decision source is observed without another lookup or a wrapper around its promise. */
|
|
37
|
+
getObserved(lookup) {
|
|
34
38
|
const cached = this.entries.get(lookup.key);
|
|
35
39
|
if (cached && !cached.refused && cached.expiresAt > this.now())
|
|
36
|
-
return Promise.resolve(cached.value);
|
|
40
|
+
return { promise: Promise.resolve(cached.value), source: 'hit' };
|
|
37
41
|
const inFlight = this.pending.get(lookup.key);
|
|
38
42
|
if (inFlight)
|
|
39
|
-
return inFlight.promise;
|
|
40
|
-
return this.ask({ lookup, share: true });
|
|
43
|
+
return { promise: inFlight.promise, source: 'shared' };
|
|
44
|
+
return { promise: this.ask({ lookup, share: true }), source: 'load' };
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
|
|
@@ -46,6 +50,20 @@ export class PositiveDecisionCache {
|
|
|
46
50
|
refresh(lookup) {
|
|
47
51
|
return this.ask({ lookup, share: false });
|
|
48
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Places a question asked through another call, such as a ticket exchange, in this cache's order.
|
|
55
|
+
* Reserve before asking, then {@link seed} the answer.
|
|
56
|
+
*/
|
|
57
|
+
reserve() {
|
|
58
|
+
return { sequence: ++this.sequence, askedAt: this.now() };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stores an allow returned for a {@link reserve}d question. It ages from when that question was
|
|
62
|
+
* asked, and an answer asked later for the same key, a refusal included, is never replaced by it.
|
|
63
|
+
*/
|
|
64
|
+
seed({ key, value, asked, notAfter }) {
|
|
65
|
+
this.record({ key, entry: { sequence: asked.sequence, refused: false, value, expiresAt: Math.min(asked.askedAt + this.ttlMs, notAfter(value)) } });
|
|
66
|
+
}
|
|
49
67
|
clear() {
|
|
50
68
|
this.entries.clear();
|
|
51
69
|
this.pending.clear();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Where the frame lands once the ticket is exchanged: the host's deep link when it is a path on
|
|
4
|
+
* this origin, else the application root. Landing on the route directly spares the application a
|
|
5
|
+
* root render (and any root redirect) followed by a client navigation once the bridge connects.
|
|
6
|
+
* Returns the validated absolute URL, never a path the browser would parse again: a path that
|
|
7
|
+
* normalizes to `//host` would otherwise read as protocol-relative and leave the application.
|
|
8
|
+
* The page embeds this function's source, so it must stay self-contained.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bootstrapLandingUrl(route: string | null, origin: string): string;
|
|
11
|
+
/** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
|
|
12
|
+
* exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
|
|
13
|
+
export declare function bootstrapPage({ response, parentOrigin }: {
|
|
14
|
+
response: ServerResponse;
|
|
15
|
+
parentOrigin: string;
|
|
16
|
+
}): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Where the frame lands once the ticket is exchanged: the host's deep link when it is a path on
|
|
4
|
+
* this origin, else the application root. Landing on the route directly spares the application a
|
|
5
|
+
* root render (and any root redirect) followed by a client navigation once the bridge connects.
|
|
6
|
+
* Returns the validated absolute URL, never a path the browser would parse again: a path that
|
|
7
|
+
* normalizes to `//host` would otherwise read as protocol-relative and leave the application.
|
|
8
|
+
* The page embeds this function's source, so it must stay self-contained.
|
|
9
|
+
*/
|
|
10
|
+
export function bootstrapLandingUrl(route, origin) {
|
|
11
|
+
const root = origin + '/';
|
|
12
|
+
if (typeof route !== 'string' || route.length > 2048 || !route.startsWith('/') || route.startsWith('//') || route.includes('\\'))
|
|
13
|
+
return root;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(route, origin);
|
|
16
|
+
if (url.origin !== origin || url.pathname.startsWith('//') || url.pathname.startsWith('/_sequence'))
|
|
17
|
+
return root;
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Transparent while it connects, so the frame keeps the host's own background through the exchange
|
|
26
|
+
* and the redirect (a white page reading "Connecting" flashed between Atlas and the app). Only a
|
|
27
|
+
* failure is shown, on its own light card so it reads over either host theme.
|
|
28
|
+
*/
|
|
29
|
+
const BOOTSTRAP_STYLE = 'html,body{margin:0;height:100%;background:transparent}'
|
|
30
|
+
+ 'body{display:flex;align-items:center;justify-content:center;font:14px/1.4 system-ui,sans-serif}'
|
|
31
|
+
+ '#status:empty{display:none}#status{margin:16px;padding:12px 16px;border-radius:8px;background:#fff;color:#27272a;box-shadow:0 1px 3px rgba(0,0,0,.2)}';
|
|
32
|
+
/** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
|
|
33
|
+
* exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
|
|
34
|
+
export function bootstrapPage({ response, parentOrigin }) {
|
|
35
|
+
const nonce = randomBytes(24).toString('base64');
|
|
36
|
+
response.writeHead(200, {
|
|
37
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
|
38
|
+
'x-content-type-options': 'nosniff',
|
|
39
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
|
|
40
|
+
});
|
|
41
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>Opening application</title><style nonce="${nonce}">${BOOTSTRAP_STYLE}</style><p id="status" role="status"></p><script nonce="${nonce}">(() => {const landing=${bootstrapLandingUrl.toString()};const fragment=new URLSearchParams(location.hash.slice(1));const ticket=fragment.get('ticket');history.replaceState(null,'',location.pathname);if(!ticket||window.top===window){document.getElementById('status').textContent='Open this application from Atlas.';return;}fetch('/_sequence/session',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})}).then(r=>{if(!r.ok)throw new Error();location.replace(landing(fragment.get('route'),location.origin));}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GatewayControlIdentity } from './control.js';
|
|
2
|
+
export interface GatewayControlConfig {
|
|
3
|
+
/** Where the gateway calls Atlas: its public origin, or its private Cloud Run address. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** The Atlas origin allowed to frame applications. */
|
|
6
|
+
parentOrigin: string;
|
|
7
|
+
/** Absent only for a local gateway: the shared control token authenticates its calls. */
|
|
8
|
+
identity?: GatewayControlIdentity;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A deployed gateway presents its service account's identity token, the only gateway
|
|
12
|
+
* credential a deployed Atlas accepts. A local gateway has no metadata server to mint one,
|
|
13
|
+
* so it presents the shared control token, which only a local Atlas accepts. Cloud Run sets
|
|
14
|
+
* K_SERVICE, so a mislabelled deployment never falls back to the shared token. The token's
|
|
15
|
+
* audience is the parent origin's control path: the public origin Atlas also derives it
|
|
16
|
+
* from, whichever address the call is routed to.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveGatewayControlConfig({ env, identityToken }: {
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
identityToken: GatewayControlIdentity['token'];
|
|
21
|
+
}): GatewayControlConfig;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { artifactNextControlAudience } from '../runtime-contracts.js';
|
|
2
|
+
/**
|
|
3
|
+
* A deployed gateway presents its service account's identity token, the only gateway
|
|
4
|
+
* credential a deployed Atlas accepts. A local gateway has no metadata server to mint one,
|
|
5
|
+
* so it presents the shared control token, which only a local Atlas accepts. Cloud Run sets
|
|
6
|
+
* K_SERVICE, so a mislabelled deployment never falls back to the shared token. The token's
|
|
7
|
+
* audience is the parent origin's control path: the public origin Atlas also derives it
|
|
8
|
+
* from, whichever address the call is routed to.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveGatewayControlConfig({ env, identityToken }) {
|
|
11
|
+
const url = env.ARTIFACT_NEXT_CONTROL_PLANE_URL?.trim();
|
|
12
|
+
if (!url)
|
|
13
|
+
throw new Error('Missing required gateway configuration: ARTIFACT_NEXT_CONTROL_PLANE_URL');
|
|
14
|
+
const parentOrigin = env.ARTIFACT_NEXT_PARENT_ORIGIN?.trim() || new URL(url).origin;
|
|
15
|
+
if (env.APP_ENV === 'development' && !env.K_SERVICE)
|
|
16
|
+
return { url, parentOrigin };
|
|
17
|
+
return { url, parentOrigin, identity: { audience: artifactNextControlAudience(parentOrigin), token: identityToken } };
|
|
18
|
+
}
|
|
@@ -67,6 +67,36 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
67
67
|
}, z.core.$strip>;
|
|
68
68
|
}, z.core.$strip>;
|
|
69
69
|
}, z.core.$strip>>;
|
|
70
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
71
|
+
kind: z.ZodLiteral<"static">;
|
|
72
|
+
deploymentId: z.ZodString;
|
|
73
|
+
contentHash: z.ZodString;
|
|
74
|
+
active: z.ZodBoolean;
|
|
75
|
+
published: z.ZodOptional<z.ZodBoolean>;
|
|
76
|
+
projectId: z.ZodString;
|
|
77
|
+
ownerId: z.ZodString;
|
|
78
|
+
environment: z.ZodString;
|
|
79
|
+
publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
80
|
+
expiresAt: z.ZodNumber;
|
|
81
|
+
maxExpiresAt: z.ZodOptional<z.ZodNumber>;
|
|
82
|
+
capabilities: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
83
|
+
serviceAccountId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
84
|
+
lattice: z.ZodOptional<z.ZodObject<{
|
|
85
|
+
runId: z.ZodNullable<z.ZodString>;
|
|
86
|
+
nodeId: z.ZodNullable<z.ZodString>;
|
|
87
|
+
stepId: z.ZodNullable<z.ZodString>;
|
|
88
|
+
processId: z.ZodNullable<z.ZodString>;
|
|
89
|
+
}, z.core.$strict>>;
|
|
90
|
+
taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
91
|
+
manifest: z.ZodOptional<z.ZodObject<{
|
|
92
|
+
server: z.ZodObject<{
|
|
93
|
+
resources: z.ZodObject<{
|
|
94
|
+
concurrency: z.ZodNumber;
|
|
95
|
+
max_instances: z.ZodNumber;
|
|
96
|
+
timeout_seconds: z.ZodNumber;
|
|
97
|
+
}, z.core.$strip>;
|
|
98
|
+
}, z.core.$strip>;
|
|
99
|
+
}, z.core.$strip>>;
|
|
70
100
|
}, z.core.$strip>], "kind">;
|
|
71
101
|
export type GatewayTarget = z.infer<typeof gatewayTargetSchema>;
|
|
72
102
|
declare const latticeInvokeGrantSchema: z.ZodObject<{
|
|
@@ -91,6 +121,15 @@ declare const latticeInvokeGrantSchema: z.ZodObject<{
|
|
|
91
121
|
}, z.core.$strict>;
|
|
92
122
|
}, z.core.$strict>;
|
|
93
123
|
export type LatticeInvokeGrant = z.infer<typeof latticeInvokeGrantSchema>;
|
|
124
|
+
/** Paths a static deployment serves: its page, entry bundle, source map and lazy chunks. */
|
|
125
|
+
export declare const STATIC_PAGE = "index.html";
|
|
126
|
+
/** The static bundle ceiling (30 MiB) plus headroom; a larger file is a platform fault, never served. */
|
|
127
|
+
export declare const STATIC_FILE_MAX_BYTES: number;
|
|
128
|
+
export interface StaticArtifactFile {
|
|
129
|
+
body: Buffer;
|
|
130
|
+
contentType: string;
|
|
131
|
+
etag?: string;
|
|
132
|
+
}
|
|
94
133
|
export declare class GatewayControlError extends Error {
|
|
95
134
|
readonly status: number;
|
|
96
135
|
constructor(status: number);
|
|
@@ -115,15 +154,27 @@ export declare class IngressControlError extends GatewayControlError {
|
|
|
115
154
|
readonly retryAfterSeconds?: number | undefined;
|
|
116
155
|
constructor(status: number, code: IngressControlCode | null, retryAfterSeconds?: number | undefined);
|
|
117
156
|
}
|
|
157
|
+
/** Calls Atlas with the gateway service account's identity token instead of the shared control token. */
|
|
158
|
+
export interface GatewayControlIdentity {
|
|
159
|
+
audience: string;
|
|
160
|
+
token: (input: {
|
|
161
|
+
audience: string;
|
|
162
|
+
signal: AbortSignal;
|
|
163
|
+
}) => Promise<string>;
|
|
164
|
+
}
|
|
118
165
|
export declare class ArtifactGatewayControl {
|
|
119
166
|
private readonly options;
|
|
120
167
|
private readonly origin;
|
|
168
|
+
/** `token` stays required with an identity: Atlas still presents it when it calls the gateway (tokenMatches). */
|
|
121
169
|
constructor(options: {
|
|
122
170
|
url: string;
|
|
123
171
|
token: string;
|
|
172
|
+
identity?: GatewayControlIdentity;
|
|
124
173
|
fetch?: typeof fetch;
|
|
125
174
|
});
|
|
126
175
|
private call;
|
|
176
|
+
/** An identity token that cannot be minted is an unavailable control plane, never a call without credentials. */
|
|
177
|
+
private credential;
|
|
127
178
|
private send;
|
|
128
179
|
/** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
|
|
129
180
|
ingressSnapshot(): Promise<IngressSnapshot>;
|
|
@@ -144,6 +195,16 @@ export declare class ArtifactGatewayControl {
|
|
|
144
195
|
target: GatewayTarget;
|
|
145
196
|
}>;
|
|
146
197
|
authorize(session: GatewaySession): Promise<GatewayTarget>;
|
|
198
|
+
/**
|
|
199
|
+
* One immutable file of a static deployment (the page, entry bundle, source map or a chunk), raw
|
|
200
|
+
* bytes up to the static bundle ceiling. The gateway authorized the browser's session first; this
|
|
201
|
+
* call carries only the gateway's own credential.
|
|
202
|
+
*/
|
|
203
|
+
staticFile({ deploymentId, path, parentOrigin }: {
|
|
204
|
+
deploymentId: string;
|
|
205
|
+
path: string;
|
|
206
|
+
parentOrigin: string;
|
|
207
|
+
}): Promise<StaticArtifactFile>;
|
|
147
208
|
/** SHA-256 both sides so a wrong bearer still takes constant time. */
|
|
148
209
|
tokenMatches(presented: string): boolean;
|
|
149
210
|
latticeInvoke(body: {
|
package/dist/gateway/control.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
4
4
|
import { artifactRequestContextSchema } from '../server/request-context.js';
|
|
5
|
-
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
5
|
+
import { ARTIFACT_NEXT_CONTROL_PATH, artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
6
|
+
import { untilAborted } from './abort.js';
|
|
6
7
|
import { INGRESS_SNAPSHOT_MAX_BYTES, ingressExchangeResponseSchema, ingressGrantResponseSchema, ingressSnapshotSchema, } from '../server/ingress-control-contract.js';
|
|
7
8
|
const id = z.string().min(1).max(255);
|
|
8
9
|
const base = {
|
|
@@ -19,6 +20,8 @@ const base = {
|
|
|
19
20
|
export const gatewayTargetSchema = z.discriminatedUnion('kind', [
|
|
20
21
|
z.object({ ...base, kind: z.literal('development'), sessionId: id, epoch: z.number().int().positive() }),
|
|
21
22
|
z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(), active: z.boolean(), published: z.boolean().optional() }),
|
|
23
|
+
// A static (React/Vite) deployment: the gateway serves its immutable files itself, fetched from Atlas.
|
|
24
|
+
z.object({ ...base, kind: z.literal('static'), deploymentId: id, contentHash: z.string().min(1).max(128), active: z.boolean(), published: z.boolean().optional() }),
|
|
22
25
|
]);
|
|
23
26
|
const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
|
|
24
27
|
const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
|
|
@@ -40,6 +43,39 @@ const latticeInvokeGrantSchema = z.object({
|
|
|
40
43
|
timeout_seconds: z.number().int().min(1).max(300),
|
|
41
44
|
}).strict(),
|
|
42
45
|
}).strict();
|
|
46
|
+
/** Paths a static deployment serves: its page, entry bundle, source map and lazy chunks. */
|
|
47
|
+
export const STATIC_PAGE = 'index.html';
|
|
48
|
+
const STATIC_FILE_PATH = /^(?:index\.html|bundle\.js|sourcemap\.json|chunks\/[A-Za-z0-9._-]{1,200})$/;
|
|
49
|
+
/** The static bundle ceiling (30 MiB) plus headroom; a larger file is a platform fault, never served. */
|
|
50
|
+
export const STATIC_FILE_MAX_BYTES = 32 * 1024 * 1024;
|
|
51
|
+
const STATIC_CONTENT_TYPES = new Set(['text/html', 'application/javascript', 'text/javascript', 'application/json', 'text/css']);
|
|
52
|
+
/** Reads at most `maxBytes` of a response body, refusing (502) anything larger. */
|
|
53
|
+
async function readBounded(response, maxBytes) {
|
|
54
|
+
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
55
|
+
await response.body?.cancel().catch(() => undefined);
|
|
56
|
+
throw new GatewayControlError(502);
|
|
57
|
+
}
|
|
58
|
+
const reader = response.body?.getReader();
|
|
59
|
+
if (!reader)
|
|
60
|
+
throw new GatewayControlError(502);
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let length = 0;
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done)
|
|
67
|
+
break;
|
|
68
|
+
length += value.length;
|
|
69
|
+
if (length > maxBytes)
|
|
70
|
+
throw new GatewayControlError(502);
|
|
71
|
+
chunks.push(value);
|
|
72
|
+
}
|
|
73
|
+
return Buffer.concat(chunks);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await reader.cancel().catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
43
79
|
export class GatewayControlError extends Error {
|
|
44
80
|
status;
|
|
45
81
|
constructor(status) {
|
|
@@ -94,6 +130,7 @@ async function ingressRefusal(response) {
|
|
|
94
130
|
export class ArtifactGatewayControl {
|
|
95
131
|
options;
|
|
96
132
|
origin;
|
|
133
|
+
/** `token` stays required with an identity: Atlas still presents it when it calls the gateway (tokenMatches). */
|
|
97
134
|
constructor(options) {
|
|
98
135
|
this.options = options;
|
|
99
136
|
this.origin = normalizePublicOrigin(options.url);
|
|
@@ -103,11 +140,26 @@ export class ArtifactGatewayControl {
|
|
|
103
140
|
call(path, body, timeoutMs = 10_000) {
|
|
104
141
|
return this.send({ path, body, timeoutMs, maxBytes: 128 * 1024 });
|
|
105
142
|
}
|
|
143
|
+
/** An identity token that cannot be minted is an unavailable control plane, never a call without credentials. */
|
|
144
|
+
async credential(signal) {
|
|
145
|
+
const identity = this.options.identity;
|
|
146
|
+
if (!identity)
|
|
147
|
+
return this.options.token;
|
|
148
|
+
// The token source may ignore `signal`; the call's own deadline still bounds the wait.
|
|
149
|
+
try {
|
|
150
|
+
return await untilAborted(identity.token({ audience: identity.audience, signal }), signal);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new GatewayControlError(503);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
106
156
|
async send({ path, body, timeoutMs, maxBytes, ingress = false }) {
|
|
107
|
-
const
|
|
157
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
158
|
+
const credential = await this.credential(signal);
|
|
159
|
+
const response = await (this.options.fetch ?? fetch)(`${this.origin}${ARTIFACT_NEXT_CONTROL_PATH}/${path}`, {
|
|
108
160
|
method: body === undefined ? 'GET' : 'POST',
|
|
109
|
-
headers: { authorization: `Bearer ${
|
|
110
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal
|
|
161
|
+
headers: { authorization: `Bearer ${credential}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
162
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal, redirect: 'error',
|
|
111
163
|
});
|
|
112
164
|
if (!response.ok)
|
|
113
165
|
throw ingress ? await ingressRefusal(response) : new GatewayControlError(response.status);
|
|
@@ -169,11 +221,11 @@ export class ArtifactGatewayControl {
|
|
|
169
221
|
version: 1, kind: target.kind, projectId: target.projectId, ownerId: target.ownerId,
|
|
170
222
|
environment: target.environment, publicOrigin: target.publicOrigin,
|
|
171
223
|
sessionId: target.kind === 'development' ? target.sessionId : null,
|
|
172
|
-
deploymentId: target.kind === '
|
|
224
|
+
deploymentId: target.kind === 'development' ? null : target.deploymentId,
|
|
173
225
|
frameworkBuildId: target.kind === 'hosted' ? target.frameworkBuildId : null,
|
|
174
226
|
expiresAt: Math.min(target.expiresAt, target.maxExpiresAt ?? target.expiresAt, Date.now() + 8 * 60 * 60_000),
|
|
175
227
|
maxExpiresAt: Math.min(target.maxExpiresAt ?? (target.kind === 'development' ? target.expiresAt : Date.now() + 8 * 60 * 60_000), Date.now() + 8 * 60 * 60_000),
|
|
176
|
-
published: target.kind
|
|
228
|
+
published: target.kind !== 'development' && (target.published ?? target.active), bridge,
|
|
177
229
|
...(target.lattice ? { lattice: target.lattice } : {}),
|
|
178
230
|
...(target.taskRoutes ? { taskRoutes: target.taskRoutes } : {}),
|
|
179
231
|
};
|
|
@@ -189,10 +241,37 @@ export class ArtifactGatewayControl {
|
|
|
189
241
|
const target = gatewayTargetSchema.parse(value);
|
|
190
242
|
if (target.kind !== session.kind || target.projectId !== session.projectId || target.ownerId !== session.ownerId ||
|
|
191
243
|
target.environment !== session.environment || target.publicOrigin !== session.publicOrigin || target.expiresAt <= Date.now() ||
|
|
192
|
-
(target.kind === 'development' ? target.sessionId !== session.sessionId
|
|
244
|
+
(target.kind === 'development' ? target.sessionId !== session.sessionId
|
|
245
|
+
: target.deploymentId !== session.deploymentId || (target.kind === 'hosted' && target.frameworkBuildId !== session.frameworkBuildId)))
|
|
193
246
|
throw new GatewayControlError(403);
|
|
194
247
|
return target;
|
|
195
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* One immutable file of a static deployment (the page, entry bundle, source map or a chunk), raw
|
|
251
|
+
* bytes up to the static bundle ceiling. The gateway authorized the browser's session first; this
|
|
252
|
+
* call carries only the gateway's own credential.
|
|
253
|
+
*/
|
|
254
|
+
async staticFile({ deploymentId, path, parentOrigin }) {
|
|
255
|
+
if (!STATIC_FILE_PATH.test(path) || !/^[A-Za-z0-9_-]{1,128}$/.test(deploymentId))
|
|
256
|
+
throw new GatewayControlError(404);
|
|
257
|
+
const signal = AbortSignal.timeout(15_000);
|
|
258
|
+
const credential = await this.credential(signal);
|
|
259
|
+
const query = path === STATIC_PAGE ? `?parentOrigin=${encodeURIComponent(parentOrigin)}` : '';
|
|
260
|
+
const response = await (this.options.fetch ?? fetch)(`${this.origin}${ARTIFACT_NEXT_CONTROL_PATH}/static/${deploymentId}/${path}${query}`, {
|
|
261
|
+
headers: { authorization: `Bearer ${credential}` }, signal, redirect: 'error',
|
|
262
|
+
});
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
await response.body?.cancel().catch(() => undefined);
|
|
265
|
+
throw new GatewayControlError(response.status);
|
|
266
|
+
}
|
|
267
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
268
|
+
if (!STATIC_CONTENT_TYPES.has(contentType.split(';', 1)[0].trim().toLowerCase())) {
|
|
269
|
+
await response.body?.cancel().catch(() => undefined);
|
|
270
|
+
throw new GatewayControlError(502);
|
|
271
|
+
}
|
|
272
|
+
const body = await readBounded(response, STATIC_FILE_MAX_BYTES);
|
|
273
|
+
return { body, contentType, etag: response.headers.get('etag') ?? undefined };
|
|
274
|
+
}
|
|
196
275
|
/** SHA-256 both sides so a wrong bearer still takes constant time. */
|
|
197
276
|
tokenMatches(presented) {
|
|
198
277
|
if (presented.length === 0 || presented.length > 1024)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import { type HostedArtifactIngressMode } from './ingress-response.js';
|
|
3
3
|
import { type HostedRuntimeTarget } from './runtime-transport.js';
|
|
4
|
+
import { type GatewayTimings } from './usage.js';
|
|
4
5
|
export { cloudRunOrigin, resolveHostedTarget, type HostedRuntimeTarget } from './runtime-transport.js';
|
|
5
6
|
export declare const HOSTED_ARTIFACT_PROXY_LIMITS: Readonly<{
|
|
6
7
|
maxRequestBytes: number;
|
|
@@ -35,6 +36,8 @@ export interface HostedArtifactProxyOptions {
|
|
|
35
36
|
};
|
|
36
37
|
/** Present only for an external ingress request. */
|
|
37
38
|
ingress?: HostedArtifactIngressMode;
|
|
39
|
+
/** Request-local observations; the caller snapshots them when recording usage. */
|
|
40
|
+
gatewayTimings?: GatewayTimings;
|
|
38
41
|
}
|
|
39
42
|
export declare function hostedIdentityToken(input: {
|
|
40
43
|
audience: string;
|
|
@@ -8,6 +8,7 @@ import { enforceBrowserCachePolicy, mergeFramePolicy, rewriteLocation } from './
|
|
|
8
8
|
import { createReleaseFormTransform } from '../server/release-forms.js';
|
|
9
9
|
import { declaredContentLength, INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
|
|
10
10
|
import { resolveHostedTarget } from './runtime-transport.js';
|
|
11
|
+
import { gatewayElapsedMs } from './usage.js';
|
|
11
12
|
export { cloudRunOrigin, resolveHostedTarget } from './runtime-transport.js';
|
|
12
13
|
export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxRequestBytes: 32 * 1024 * 1024, maxResponseBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
|
|
13
14
|
const googleAuth = new GoogleAuth();
|
|
@@ -129,7 +130,11 @@ class HostedExchange {
|
|
|
129
130
|
stripCloudHeaders(headers);
|
|
130
131
|
if (audience) {
|
|
131
132
|
const provider = this.options.identityTokenProvider ?? defaultIdentityTokenProvider;
|
|
132
|
-
const
|
|
133
|
+
const identityStarted = performance.now();
|
|
134
|
+
const token = await provider({ audience: audience.origin, signal: this.controller.signal }).finally(() => {
|
|
135
|
+
if (!this.stopped && this.options.gatewayTimings)
|
|
136
|
+
this.options.gatewayTimings.identityTokenMs = gatewayElapsedMs(identityStarted);
|
|
137
|
+
});
|
|
133
138
|
if (this.stopped)
|
|
134
139
|
return;
|
|
135
140
|
if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
|
|
@@ -145,7 +150,12 @@ class HostedExchange {
|
|
|
145
150
|
headers['x-sequence-artifact-context'] = requestContext;
|
|
146
151
|
const destination = this.resolveDestination(upstream);
|
|
147
152
|
const transport = destination.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
148
|
-
|
|
153
|
+
const upstreamStarted = performance.now();
|
|
154
|
+
this.outgoing = transport(destination, { method: request.method, path: request.url, headers, maxHeaderSize: 32 * 1024 }, (incoming) => {
|
|
155
|
+
if (!this.stopped && this.options.gatewayTimings)
|
|
156
|
+
this.options.gatewayTimings.upstreamHeadersMs = gatewayElapsedMs(upstreamStarted);
|
|
157
|
+
this.receive({ incoming, upstream, publicOrigin });
|
|
158
|
+
});
|
|
149
159
|
this.outgoing.on('error', () => this.fail(this.unavailableStatus));
|
|
150
160
|
this.outgoing.once('upgrade', (_incoming, socket) => { socket.destroy(); this.fail(502); });
|
|
151
161
|
this.outgoing.on('drain', () => { if (!this.stopped)
|
|
@@ -20,4 +20,9 @@ export declare function admitHostedMutation({ request, expectedDeploymentId, exp
|
|
|
20
20
|
}): Promise<{
|
|
21
21
|
body?: AsyncIterable<Uint8Array>;
|
|
22
22
|
}>;
|
|
23
|
+
/** The release a request names: the deployment header, or the Next build id (header or `dpl` query), never conflicting. */
|
|
24
|
+
export declare function releaseHints(request: IncomingMessage): {
|
|
25
|
+
deploymentId?: string;
|
|
26
|
+
frameworkBuildId?: string;
|
|
27
|
+
};
|
|
23
28
|
export {};
|
|
@@ -257,3 +257,22 @@ export async function admitHostedMutation({ request, expectedDeploymentId, expec
|
|
|
257
257
|
}
|
|
258
258
|
return { body: continuation() };
|
|
259
259
|
}
|
|
260
|
+
/** The release a request names: the deployment header, or the Next build id (header or `dpl` query), never conflicting. */
|
|
261
|
+
export function releaseHints(request) {
|
|
262
|
+
function uniqueHeader(name) {
|
|
263
|
+
const value = request.headers[name];
|
|
264
|
+
if (value === undefined)
|
|
265
|
+
return undefined;
|
|
266
|
+
const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
|
|
267
|
+
if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
|
|
268
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
const deploymentId = uniqueHeader('x-sequence-artifact-release');
|
|
272
|
+
const frameworkHeader = uniqueHeader('x-deployment-id');
|
|
273
|
+
const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
|
|
274
|
+
if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
|
|
275
|
+
(frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
|
|
276
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
277
|
+
return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
|
|
278
|
+
}
|
package/dist/gateway/server.d.ts
CHANGED
|
@@ -5,13 +5,14 @@ import { type GatewaySessionSameSite } from './session.js';
|
|
|
5
5
|
import { type ArtifactRequestAdmission } from './admission.js';
|
|
6
6
|
import { type GatewayControlAdmission } from './control-admission.js';
|
|
7
7
|
import { type ArtifactGatewayIngressOptions } from './ingress.js';
|
|
8
|
+
import { StaticArtifactFiles } from './static-files.js';
|
|
8
9
|
export interface ArtifactGatewayOptions {
|
|
9
10
|
control: ArtifactGatewayControl;
|
|
10
11
|
runtimeDomain: string;
|
|
11
12
|
parentOrigin: string;
|
|
12
13
|
gatewayOrigin: string;
|
|
13
14
|
sessionKey: string;
|
|
14
|
-
/** Defaults to `Strict
|
|
15
|
+
/** Defaults to `Strict`; `None` when app origins are cross-site to Atlas (see gatewaySessionSameSite). */
|
|
15
16
|
sessionCookieSameSite?: GatewaySessionSameSite;
|
|
16
17
|
contextPrivateKey: string;
|
|
17
18
|
contextKeyId: string;
|
|
@@ -28,6 +29,8 @@ export interface ArtifactGatewayOptions {
|
|
|
28
29
|
controlAdmission?: Pick<GatewayControlAdmission, 'admit'>;
|
|
29
30
|
/** External ingress front door (design §(e)). Absent: every `ingress-*` host answers 404. */
|
|
30
31
|
ingress?: ArtifactGatewayIngressOptions;
|
|
32
|
+
/** Static deployments' files (default: fetched from Atlas, cached by deployment within the default byte budget). */
|
|
33
|
+
staticFiles?: Pick<StaticArtifactFiles, 'get'>;
|
|
31
34
|
/** Reuse of a positive Atlas authorization (default GATEWAY_AUTHORIZATION_TTL_MS; 0 disables). */
|
|
32
35
|
authorizationCacheTtlMs?: number;
|
|
33
36
|
onError?: (event: {
|