@sequenceholdings/artifact-studio 0.6.7 → 0.7.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 +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 +34 -0
- package/dist/gateway/control-config.d.ts +21 -0
- package/dist/gateway/control-config.js +18 -0
- package/dist/gateway/control.d.ts +12 -0
- package/dist/gateway/control.js +21 -4
- package/dist/gateway/hosted-proxy.d.ts +3 -0
- package/dist/gateway/hosted-proxy.js +12 -2
- package/dist/gateway/server.d.ts +1 -1
- package/dist/gateway/server.js +20 -20
- package/dist/gateway/session.d.ts +8 -8
- package/dist/gateway/session.js +4 -9
- 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/runtime-contracts.d.ts +8 -0
- package/dist/runtime-contracts.js +10 -0
- package/dist/sdk-orm.d.ts +3 -3
- package/dist/source-resolver.js +7 -16
- package/dist/templates/nextjs/README.md +7 -0
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/trusted-install.d.ts +9 -2
- package/dist/trusted-install.js +9 -2
- package/package.json +3 -3
- package/templates/nextjs/README.md +7 -0
- package/templates/react-vite/package.json +1 -1
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,34 @@
|
|
|
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
|
+
/** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
|
|
25
|
+
* exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
|
|
26
|
+
export function bootstrapPage({ response, parentOrigin }) {
|
|
27
|
+
const nonce = randomBytes(24).toString('base64');
|
|
28
|
+
response.writeHead(200, {
|
|
29
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
|
30
|
+
'x-content-type-options': 'nosniff',
|
|
31
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
|
|
32
|
+
});
|
|
33
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>Connecting application</title><p id="status">Connecting application…</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>`);
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -115,15 +115,27 @@ export declare class IngressControlError extends GatewayControlError {
|
|
|
115
115
|
readonly retryAfterSeconds?: number | undefined;
|
|
116
116
|
constructor(status: number, code: IngressControlCode | null, retryAfterSeconds?: number | undefined);
|
|
117
117
|
}
|
|
118
|
+
/** Calls Atlas with the gateway service account's identity token instead of the shared control token. */
|
|
119
|
+
export interface GatewayControlIdentity {
|
|
120
|
+
audience: string;
|
|
121
|
+
token: (input: {
|
|
122
|
+
audience: string;
|
|
123
|
+
signal: AbortSignal;
|
|
124
|
+
}) => Promise<string>;
|
|
125
|
+
}
|
|
118
126
|
export declare class ArtifactGatewayControl {
|
|
119
127
|
private readonly options;
|
|
120
128
|
private readonly origin;
|
|
129
|
+
/** `token` stays required with an identity: Atlas still presents it when it calls the gateway (tokenMatches). */
|
|
121
130
|
constructor(options: {
|
|
122
131
|
url: string;
|
|
123
132
|
token: string;
|
|
133
|
+
identity?: GatewayControlIdentity;
|
|
124
134
|
fetch?: typeof fetch;
|
|
125
135
|
});
|
|
126
136
|
private call;
|
|
137
|
+
/** An identity token that cannot be minted is an unavailable control plane, never a call without credentials. */
|
|
138
|
+
private credential;
|
|
127
139
|
private send;
|
|
128
140
|
/** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
|
|
129
141
|
ingressSnapshot(): Promise<IngressSnapshot>;
|
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 = {
|
|
@@ -94,6 +95,7 @@ async function ingressRefusal(response) {
|
|
|
94
95
|
export class ArtifactGatewayControl {
|
|
95
96
|
options;
|
|
96
97
|
origin;
|
|
98
|
+
/** `token` stays required with an identity: Atlas still presents it when it calls the gateway (tokenMatches). */
|
|
97
99
|
constructor(options) {
|
|
98
100
|
this.options = options;
|
|
99
101
|
this.origin = normalizePublicOrigin(options.url);
|
|
@@ -103,11 +105,26 @@ export class ArtifactGatewayControl {
|
|
|
103
105
|
call(path, body, timeoutMs = 10_000) {
|
|
104
106
|
return this.send({ path, body, timeoutMs, maxBytes: 128 * 1024 });
|
|
105
107
|
}
|
|
108
|
+
/** An identity token that cannot be minted is an unavailable control plane, never a call without credentials. */
|
|
109
|
+
async credential(signal) {
|
|
110
|
+
const identity = this.options.identity;
|
|
111
|
+
if (!identity)
|
|
112
|
+
return this.options.token;
|
|
113
|
+
// The token source may ignore `signal`; the call's own deadline still bounds the wait.
|
|
114
|
+
try {
|
|
115
|
+
return await untilAborted(identity.token({ audience: identity.audience, signal }), signal);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new GatewayControlError(503);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
106
121
|
async send({ path, body, timeoutMs, maxBytes, ingress = false }) {
|
|
107
|
-
const
|
|
122
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
123
|
+
const credential = await this.credential(signal);
|
|
124
|
+
const response = await (this.options.fetch ?? fetch)(`${this.origin}${ARTIFACT_NEXT_CONTROL_PATH}/${path}`, {
|
|
108
125
|
method: body === undefined ? 'GET' : 'POST',
|
|
109
|
-
headers: { authorization: `Bearer ${
|
|
110
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal
|
|
126
|
+
headers: { authorization: `Bearer ${credential}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
127
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal, redirect: 'error',
|
|
111
128
|
});
|
|
112
129
|
if (!response.ok)
|
|
113
130
|
throw ingress ? await ingressRefusal(response) : new GatewayControlError(response.status);
|
|
@@ -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)
|
package/dist/gateway/server.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface ArtifactGatewayOptions {
|
|
|
11
11
|
parentOrigin: string;
|
|
12
12
|
gatewayOrigin: string;
|
|
13
13
|
sessionKey: string;
|
|
14
|
-
/** Defaults to `Strict
|
|
14
|
+
/** Defaults to `Strict`; `None` when app origins are cross-site to Atlas (see gatewaySessionSameSite). */
|
|
15
15
|
sessionCookieSameSite?: GatewaySessionSameSite;
|
|
16
16
|
contextPrivateKey: string;
|
|
17
17
|
contextKeyId: string;
|
package/dist/gateway/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPrivateKey,
|
|
1
|
+
import { createPrivateKey, randomUUID } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ArtifactTunnelBroker } from '../tunnel/index.js';
|
|
@@ -6,12 +6,13 @@ import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js'
|
|
|
6
6
|
import { GatewayControlError } from './control.js';
|
|
7
7
|
import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
|
|
8
8
|
import { proxyHostedArtifact } from './hosted-proxy.js';
|
|
9
|
+
import { bootstrapPage } from './bootstrap-page.js';
|
|
9
10
|
import { routeSequenceRequest } from './lattice-invoke.js';
|
|
10
11
|
import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
|
|
11
12
|
import { ArtifactAdmissionError } from './admission.js';
|
|
12
13
|
import { gatewayClientAddress } from './control-admission.js';
|
|
13
14
|
import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
|
|
14
|
-
import { GatewayUsageReporter } from './usage.js';
|
|
15
|
+
import { gatewayElapsedMs, GatewayUsageReporter, snapshotGatewayTimings } from './usage.js';
|
|
15
16
|
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
16
17
|
import { createDevelopmentIngressProxy } from './ingress-development.js';
|
|
17
18
|
import { createIngressHandler, handleClientErrors } from './ingress-handler.js';
|
|
@@ -73,17 +74,6 @@ async function boundedJson(request) {
|
|
|
73
74
|
}
|
|
74
75
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
75
76
|
}
|
|
76
|
-
/** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
|
|
77
|
-
* exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
|
|
78
|
-
function bootstrapPage({ response, parentOrigin }) {
|
|
79
|
-
const nonce = randomBytes(24).toString('base64');
|
|
80
|
-
response.writeHead(200, {
|
|
81
|
-
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
|
82
|
-
'x-content-type-options': 'nosniff',
|
|
83
|
-
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
|
|
84
|
-
});
|
|
85
|
-
response.end(`<!doctype html><meta charset="utf-8"><title>Connecting application</title><p id="status">Connecting application…</p><script nonce="${nonce}">(() => {const ticket=new URLSearchParams(location.hash.slice(1)).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('/');}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
|
|
86
|
-
}
|
|
87
77
|
/** Probes reach the container by its own address; an application host keeps its own /health route. */
|
|
88
78
|
function isReadinessRequest({ request, gatewayOrigin, runtimeDomain }) {
|
|
89
79
|
if (request.method !== 'GET')
|
|
@@ -132,15 +122,18 @@ export function createArtifactGateway(options) {
|
|
|
132
122
|
const { ticket } = z.object({ ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict().parse(await boundedJson(request));
|
|
133
123
|
// Anyone can post a ticket: rate limit and admit before the exchange reaches Atlas.
|
|
134
124
|
const release = await admitControlCall('session', request);
|
|
135
|
-
let session;
|
|
125
|
+
let session, target;
|
|
126
|
+
const asked = authorizations.reserve();
|
|
136
127
|
try {
|
|
137
|
-
({ session } = await options.control.bootstrap(ticket));
|
|
128
|
+
({ session, target } = await options.control.bootstrap(ticket));
|
|
138
129
|
}
|
|
139
130
|
finally {
|
|
140
131
|
release();
|
|
141
132
|
}
|
|
142
133
|
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
143
134
|
throw new GatewayControlError(403);
|
|
135
|
+
// Atlas just authorized this session: the document load that follows reuses it, not a second call.
|
|
136
|
+
authorizations.seed({ key: gatewayAuthorizationKey(session), value: target, asked, notAfter: value => value.expiresAt });
|
|
144
137
|
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie'), sameSite: options.sessionCookieSameSite }));
|
|
145
138
|
json(response, 200, 'connected');
|
|
146
139
|
}
|
|
@@ -186,9 +179,11 @@ export function createArtifactGateway(options) {
|
|
|
186
179
|
// same session and target for up to the cache TTL (never past the target's own expiry). Mutations
|
|
187
180
|
// always ask Atlas, so revocation, suspension and a changed activation stop them immediately.
|
|
188
181
|
const lookup = { key: gatewayAuthorizationKey(session), load: () => options.control.authorize(session), notAfter: (value) => value.expiresAt };
|
|
189
|
-
const
|
|
182
|
+
const decision = safeMethod
|
|
183
|
+
? authorizations.getObserved(lookup) : { promise: authorizations.refresh(lookup), source: 'refresh' };
|
|
184
|
+
const target = await decision.promise;
|
|
190
185
|
const requestId = randomUUID();
|
|
191
|
-
const result = { session, target, requestId, requestContext: signGatewayContext({
|
|
186
|
+
const result = { session, target, requestId, authorizationCache: decision.source, requestContext: signGatewayContext({
|
|
192
187
|
session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
|
|
193
188
|
capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
|
|
194
189
|
}) };
|
|
@@ -308,7 +303,9 @@ export function createArtifactGateway(options) {
|
|
|
308
303
|
request.url = url.pathname + url.search;
|
|
309
304
|
}
|
|
310
305
|
}
|
|
311
|
-
const
|
|
306
|
+
const authorizationStarted = performance.now();
|
|
307
|
+
const { session, target, requestContext, requestId, authorizationCache } = await authorize(request);
|
|
308
|
+
const gatewayTimings = { authorizationMs: gatewayElapsedMs(authorizationStarted), authorizationCache };
|
|
312
309
|
let reported = false;
|
|
313
310
|
const report = () => {
|
|
314
311
|
if (reported)
|
|
@@ -321,6 +318,7 @@ export function createArtifactGateway(options) {
|
|
|
321
318
|
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
|
|
322
319
|
outcome: !response.writableFinished ? 'cancelled' : response.statusCode >= 400 ? 'error' : 'success',
|
|
323
320
|
httpStatus: response.statusCode,
|
|
321
|
+
gatewayTimings: snapshotGatewayTimings(gatewayTimings),
|
|
324
322
|
});
|
|
325
323
|
};
|
|
326
324
|
response.once('finish', report);
|
|
@@ -342,12 +340,14 @@ export function createArtifactGateway(options) {
|
|
|
342
340
|
}
|
|
343
341
|
if (!options.admission)
|
|
344
342
|
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
345
|
-
const
|
|
343
|
+
const admissionStarted = performance.now();
|
|
344
|
+
const admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources })
|
|
345
|
+
.finally(() => { gatewayTimings.admissionMs = gatewayElapsedMs(admissionStarted); });
|
|
346
346
|
admitted.bind(response, () => response.destroy());
|
|
347
347
|
if (response.destroyed)
|
|
348
348
|
return;
|
|
349
349
|
const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
|
|
350
|
-
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs } });
|
|
350
|
+
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs }, gatewayTimings });
|
|
351
351
|
})().catch(error => {
|
|
352
352
|
options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
|
|
353
353
|
: error instanceof ArtifactAdmissionError ? 'admission_rejected' : 'request_failed' });
|
|
@@ -38,16 +38,16 @@ export declare function renewGatewaySession({ session, authorizedExpiresAt, now
|
|
|
38
38
|
now?: number;
|
|
39
39
|
}): GatewaySession | null;
|
|
40
40
|
/**
|
|
41
|
-
* `Strict`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* Partitioned
|
|
41
|
+
* `Strict` when every app origin sits under Atlas's own HTTPS host, so the iframe is same-site with
|
|
42
|
+
* its parent. Anywhere else — app origins on seqholdingsusercontent.com, or the local stack's
|
|
43
|
+
* http://localhost Atlas — a browser drops a `Strict` cookie inside the Atlas iframe, so the cookie
|
|
44
|
+
* is `SameSite=None; Partitioned`: the browser keys it to Atlas's top-level site and never sends it
|
|
45
|
+
* under any other. Cross-site writes are refused on `Origin` either way, not on SameSite.
|
|
45
46
|
*/
|
|
46
47
|
export type GatewaySessionSameSite = 'Strict' | 'None';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
appEnv: string;
|
|
48
|
+
export declare function gatewaySessionSameSite({ parentOrigin, runtimeDomain }: {
|
|
49
|
+
parentOrigin: string;
|
|
50
|
+
runtimeDomain: string;
|
|
51
51
|
}): GatewaySessionSameSite;
|
|
52
52
|
export declare function gatewaySessionCookies({ session, key, cookie, renewal, now, sameSite }: {
|
|
53
53
|
session: GatewaySession;
|
package/dist/gateway/session.js
CHANGED
|
@@ -39,15 +39,10 @@ export function renewGatewaySession({ session, authorizedExpiresAt, now = Date.n
|
|
|
39
39
|
const expiresAt = Math.min(authorizedExpiresAt, session.maxExpiresAt ?? session.expiresAt);
|
|
40
40
|
return expiresAt > session.expiresAt ? { ...session, expiresAt } : null;
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (value !== 'none')
|
|
47
|
-
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE must be "strict" or "none"');
|
|
48
|
-
if (appEnv !== 'development')
|
|
49
|
-
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE=none is only for a local development gateway');
|
|
50
|
-
return 'None';
|
|
42
|
+
export function gatewaySessionSameSite({ parentOrigin, runtimeDomain }) {
|
|
43
|
+
const parent = new URL(parentOrigin);
|
|
44
|
+
const underParent = runtimeDomain === parent.hostname || runtimeDomain.endsWith(`.${parent.hostname}`);
|
|
45
|
+
return parent.protocol === 'https:' && underParent ? 'Strict' : 'None';
|
|
51
46
|
}
|
|
52
47
|
function cookieAttributes(sameSite) {
|
|
53
48
|
return `Path=/; HttpOnly; Secure; ${sameSite === 'None' ? 'SameSite=None; Partitioned' : 'SameSite=Strict'}`;
|
package/dist/gateway/usage.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/** Closed, non-sensitive phase observations. An absent phase did not finish before reporting. */
|
|
2
|
+
export interface GatewayTimings {
|
|
3
|
+
authorizationMs?: number;
|
|
4
|
+
authorizationCache?: 'hit' | 'shared' | 'load' | 'refresh';
|
|
5
|
+
admissionMs?: number;
|
|
6
|
+
identityTokenMs?: number;
|
|
7
|
+
/** Transport and app work until response headers, not pure app execution time. */
|
|
8
|
+
upstreamHeadersMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function gatewayElapsedMs(started: number): number;
|
|
11
|
+
/** Detach the once-only usage event from work that can settle after cancellation. */
|
|
12
|
+
export declare function snapshotGatewayTimings(timings: GatewayTimings): Readonly<GatewayTimings>;
|
|
1
13
|
export interface GatewayUsageEvent {
|
|
2
14
|
eventId: string;
|
|
3
15
|
requestId: string;
|
|
@@ -22,6 +34,7 @@ export interface GatewayUsageEvent {
|
|
|
22
34
|
};
|
|
23
35
|
outcome: 'success' | 'error' | 'cancelled';
|
|
24
36
|
httpStatus: number;
|
|
37
|
+
gatewayTimings?: Readonly<GatewayTimings>;
|
|
25
38
|
}
|
|
26
39
|
export type GatewayUsageFailure = {
|
|
27
40
|
kind: 'usage_queue_full' | 'usage_delivery_failed' | 'usage_delivery_rejected' | 'usage_events_rejected' | 'usage_lost_on_shutdown';
|
package/dist/gateway/usage.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
const MAX_GATEWAY_TIMING_MS = 600_000;
|
|
2
|
+
export function gatewayElapsedMs(started) {
|
|
3
|
+
return Math.min(MAX_GATEWAY_TIMING_MS, Math.max(0, Math.round(performance.now() - started)));
|
|
4
|
+
}
|
|
5
|
+
/** Detach the once-only usage event from work that can settle after cancellation. */
|
|
6
|
+
export function snapshotGatewayTimings(timings) {
|
|
7
|
+
const snapshot = {};
|
|
8
|
+
const durationKeys = ['authorizationMs', 'admissionMs', 'identityTokenMs', 'upstreamHeadersMs'];
|
|
9
|
+
for (const key of durationKeys) {
|
|
10
|
+
const value = timings[key];
|
|
11
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_GATEWAY_TIMING_MS)
|
|
12
|
+
snapshot[key] = value;
|
|
13
|
+
}
|
|
14
|
+
const source = timings.authorizationCache;
|
|
15
|
+
if (source === 'hit' || source === 'shared' || source === 'load' || source === 'refresh')
|
|
16
|
+
snapshot.authorizationCache = source;
|
|
17
|
+
return Object.freeze(snapshot);
|
|
18
|
+
}
|
|
1
19
|
export const GATEWAY_USAGE_LIMITS = Object.freeze({ capacity: 10_000, maxBatch: 100, flushIntervalMs: 1_000, concurrency: 4 });
|
|
2
20
|
const ATTEMPTS = 3;
|
|
3
21
|
/**
|
package/dist/gateway-main.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
4
|
import { ArtifactGatewayControl } from './gateway/control.js';
|
|
5
|
+
import { resolveGatewayControlConfig } from './gateway/control-config.js';
|
|
6
|
+
import { hostedIdentityToken } from './gateway/hosted-proxy.js';
|
|
5
7
|
import { createArtifactGateway } from './gateway/server.js';
|
|
6
8
|
import { gatewaySessionSameSite } from './gateway/session.js';
|
|
7
9
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
@@ -58,9 +60,9 @@ async function main() {
|
|
|
58
60
|
let stopping = false;
|
|
59
61
|
subscriber.on('ready', () => { if (!stopping)
|
|
60
62
|
void relay.start().catch(() => undefined); });
|
|
61
|
-
const
|
|
63
|
+
const controlConfig = resolveGatewayControlConfig({ env: process.env, identityToken: hostedIdentityToken });
|
|
62
64
|
const controlToken = required('ARTIFACT_NEXT_CONTROL_TOKEN');
|
|
63
|
-
const control = new ArtifactGatewayControl({ url:
|
|
65
|
+
const control = new ArtifactGatewayControl({ url: controlConfig.url, token: controlToken, identity: controlConfig.identity });
|
|
64
66
|
const contextPrivateKey = required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY');
|
|
65
67
|
const contextKeyId = required('ARTIFACT_NEXT_CONTEXT_KEY_ID');
|
|
66
68
|
const admission = new ArtifactRequestAdmission({ redis: command, namespace });
|
|
@@ -73,13 +75,14 @@ async function main() {
|
|
|
73
75
|
else
|
|
74
76
|
refuse(failure.kind); },
|
|
75
77
|
});
|
|
78
|
+
const runtimeDomain = required('ARTIFACT_NEXT_RUNTIME_DOMAIN');
|
|
76
79
|
const gateway = createArtifactGateway({
|
|
77
80
|
control,
|
|
78
|
-
runtimeDomain
|
|
79
|
-
parentOrigin:
|
|
81
|
+
runtimeDomain,
|
|
82
|
+
parentOrigin: controlConfig.parentOrigin,
|
|
80
83
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
81
84
|
sessionKey,
|
|
82
|
-
sessionCookieSameSite: gatewaySessionSameSite({
|
|
85
|
+
sessionCookieSameSite: gatewaySessionSameSite({ parentOrigin: controlConfig.parentOrigin, runtimeDomain }),
|
|
83
86
|
contextPrivateKey,
|
|
84
87
|
contextKeyId,
|
|
85
88
|
nodeId, routing: ownership, relay,
|
package/dist/git-clone.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
|
|
2
|
-
export declare function resolveGitPatFromEnv(): string | undefined;
|
|
3
1
|
/** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
|
|
4
2
|
export declare function looksLikeCommitSha(ref: string): boolean;
|
|
5
3
|
/**
|
|
@@ -20,7 +18,7 @@ export type RunGitClone = (args: {
|
|
|
20
18
|
* default branch in full, then check the SHA out.
|
|
21
19
|
*/
|
|
22
20
|
depth?: number;
|
|
23
|
-
/**
|
|
21
|
+
/** Login access token used as Basic-auth password (username `git`). */
|
|
24
22
|
pat: string;
|
|
25
23
|
/** Hard ceiling before the git child is killed (default 5m). */
|
|
26
24
|
timeoutMs?: number;
|
package/dist/git-clone.js
CHANGED
|
@@ -5,20 +5,15 @@
|
|
|
5
5
|
* re-exports it from `src/repos/git-clone.ts` so the `repos` tool and the
|
|
6
6
|
* artifact `--repo` deploy path share one clone path.
|
|
7
7
|
*
|
|
8
|
-
* Credentials are supplied via an ephemeral GIT_ASKPASS script so the
|
|
8
|
+
* Credentials are supplied via an ephemeral GIT_ASKPASS script so the token
|
|
9
9
|
* never lands in `remote.origin.url` or the git argv. Callers pass a clean
|
|
10
|
-
* HTTPS clone URL plus the
|
|
10
|
+
* HTTPS clone URL plus the login token separately.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createScrubbedChildEnv } from './child-environment.js';
|
|
17
|
-
/** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
|
|
18
|
-
export function resolveGitPatFromEnv() {
|
|
19
|
-
const value = process.env.ATLAS_GIT_PAT?.trim();
|
|
20
|
-
return value || undefined;
|
|
21
|
-
}
|
|
22
17
|
/** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
|
|
23
18
|
export function looksLikeCommitSha(ref) {
|
|
24
19
|
return /^[0-9a-f]{7,40}$/i.test(ref);
|
|
@@ -151,9 +146,12 @@ export async function withGitAskpass({ pat, fn, }) {
|
|
|
151
146
|
GIT_TERMINAL_PROMPT: '0',
|
|
152
147
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
153
148
|
SEQ_STUDIO_GIT_ASKPASS_PASSWORD: pat,
|
|
154
|
-
GIT_CONFIG_COUNT: '
|
|
149
|
+
GIT_CONFIG_COUNT: '2',
|
|
155
150
|
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
156
151
|
GIT_CONFIG_VALUE_0: '',
|
|
152
|
+
// Never redirect a platform credential, even to another path on this host.
|
|
153
|
+
GIT_CONFIG_KEY_1: 'http.followRedirects',
|
|
154
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
157
155
|
},
|
|
158
156
|
}));
|
|
159
157
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export { resolveArtifactNextImages } from './next-image-config.js';
|
|
3
|
+
/** Atlas routes the gateway calls; every one authenticates the gateway itself. */
|
|
4
|
+
export declare const ARTIFACT_NEXT_CONTROL_PATH = "/api/artifact-studio/next/control";
|
|
5
|
+
/**
|
|
6
|
+
* The identity-token audience for gateway control calls: Atlas's public origin plus the control
|
|
7
|
+
* path, whichever address the call is routed to. Path-scoped, so a token minted for another
|
|
8
|
+
* Atlas audience is never accepted here.
|
|
9
|
+
*/
|
|
10
|
+
export declare function artifactNextControlAudience(atlasOrigin: string): string;
|
|
3
11
|
/** Shared by local collection, repository collection and the bounded source-upload API. */
|
|
4
12
|
export declare const ARTIFACT_NEXT_SOURCE_LIMITS: Readonly<{
|
|
5
13
|
maxFiles: 2000;
|
|
@@ -3,6 +3,16 @@ import { ARTIFACT_NEXT_RUNTIME, artifactNextSecretNameSchema } from './manifest-
|
|
|
3
3
|
import { MAX_STUDIO_PATH_LENGTH } from './paths.js';
|
|
4
4
|
import { isLoopbackHostname } from './loopback.js';
|
|
5
5
|
export { resolveArtifactNextImages } from './next-image-config.js';
|
|
6
|
+
/** Atlas routes the gateway calls; every one authenticates the gateway itself. */
|
|
7
|
+
export const ARTIFACT_NEXT_CONTROL_PATH = '/api/artifact-studio/next/control';
|
|
8
|
+
/**
|
|
9
|
+
* The identity-token audience for gateway control calls: Atlas's public origin plus the control
|
|
10
|
+
* path, whichever address the call is routed to. Path-scoped, so a token minted for another
|
|
11
|
+
* Atlas audience is never accepted here.
|
|
12
|
+
*/
|
|
13
|
+
export function artifactNextControlAudience(atlasOrigin) {
|
|
14
|
+
return `${new URL(atlasOrigin).origin}${ARTIFACT_NEXT_CONTROL_PATH}`;
|
|
15
|
+
}
|
|
6
16
|
/** Shared by local collection, repository collection and the bounded source-upload API. */
|
|
7
17
|
export const ARTIFACT_NEXT_SOURCE_LIMITS = Object.freeze({
|
|
8
18
|
maxFiles: 2_000,
|
package/dist/sdk-orm.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type SqlOperationExecutor } from '@sequenceholdings/orm/client';
|
|
2
2
|
import { type ArtifactOrmJsonScalars } from './orm-json-scalars.js';
|
|
3
3
|
import { type SequenceApi } from './sdk.js';
|
|
4
4
|
export interface ArtifactOrmExecutorConfig {
|
|
5
5
|
namespace: string;
|
|
6
6
|
/** The generated operations.manifest.json, validated before any host call. */
|
|
7
|
-
manifest
|
|
7
|
+
manifest?: unknown;
|
|
8
8
|
/** Optional action-scoped host client (for example, withTraceAction's client). */
|
|
9
9
|
sdk?: {
|
|
10
10
|
api: Pick<SequenceApi, 'fetch'>;
|
|
@@ -21,4 +21,4 @@ export interface ArtifactOrmExecutorConfig {
|
|
|
21
21
|
* Declare the namespace path in capabilities.api.write (queries also use POST).
|
|
22
22
|
* Namespace grants, capabilities, row policies, and write checks still apply.
|
|
23
23
|
*/
|
|
24
|
-
export declare function createArtifactOrmExecutor({ namespace, manifest, sdk, jsonScalars }: ArtifactOrmExecutorConfig):
|
|
24
|
+
export declare function createArtifactOrmExecutor({ namespace, manifest, sdk, jsonScalars }: ArtifactOrmExecutorConfig): SqlOperationExecutor;
|
package/dist/source-resolver.js
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { createScrubbedChildEnv } from './child-environment.js';
|
|
7
7
|
import { materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
|
|
8
|
-
import { gitServiceCloneUrl,
|
|
8
|
+
import { gitServiceCloneUrl, runGitClone } from './git-clone.js';
|
|
9
9
|
/** True for sources that must be fetched from a remote (git-service / git-url). */
|
|
10
10
|
export function isRemoteSpec(spec) {
|
|
11
11
|
return spec.kind !== 'local';
|
|
@@ -136,21 +136,12 @@ export async function resolveArtifactSource(spec, opts = {}) {
|
|
|
136
136
|
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, ${count} file(s), M2M)`);
|
|
137
137
|
return await materialized({ gitCommit: sha, gitBranch: ref, gitDirty: false });
|
|
138
138
|
}
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (pat) {
|
|
146
|
-
const cloneUrl = gitServiceCloneUrl(opts.baseUrl, repo.id);
|
|
147
|
-
await runGitClone({ cloneUrl, destDir: dest, pat, ref: sha });
|
|
148
|
-
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, git clone)`);
|
|
149
|
-
return await materialized({ gitCommit: sha, gitBranch: ref, gitDirty: false });
|
|
150
|
-
}
|
|
151
|
-
throw new Error('Deploying from --repo requires a git PAT. Set ATLAS_GIT_PAT to a token with the ' +
|
|
152
|
-
'repo:read scope (create one in Atlas → Settings → Tokens, or `seq-studio auth pat create`). ' +
|
|
153
|
-
'The git-service clone uses smart-HTTP, which authenticates by PAT — the same token you clone the repo with.');
|
|
139
|
+
// Use the same selected human identity for metadata and Git transport.
|
|
140
|
+
// An ambient PAT must not silently change the principal on the clone.
|
|
141
|
+
const cloneUrl = gitServiceCloneUrl(opts.baseUrl, repo.id);
|
|
142
|
+
await runGitClone({ cloneUrl, destDir: dest, pat: opts.token, ref: sha });
|
|
143
|
+
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, git clone)`);
|
|
144
|
+
return await materialized({ gitCommit: sha, gitBranch: ref, gitDirty: false });
|
|
154
145
|
}
|
|
155
146
|
const { sha, branch } = cloneGitUrl(spec.url, spec.ref, dest);
|
|
156
147
|
console.log(`[seq-studio] source: ${redactGitUrl(spec.url)}${spec.ref ? `@${spec.ref}` : ''} (${sha.slice(0, 10)})`);
|
|
@@ -179,6 +179,13 @@ own test credentials, mocks or an existing governed function. Use a hosted
|
|
|
179
179
|
preview for application code that needs a managed secret. Never put secrets in
|
|
180
180
|
`build.public_env`, `NEXT_PUBLIC_*`, client components or committed files.
|
|
181
181
|
|
|
182
|
+
Cookies your application sets itself should use a `__Host-` name with `Secure`,
|
|
183
|
+
`HttpOnly`, `Path=/` and no `Domain`. Applications on one deployment share a
|
|
184
|
+
registrable domain, and only a `__Host-` cookie is guaranteed to come from your
|
|
185
|
+
own origin rather than a neighbour's. When the application origin is on a
|
|
186
|
+
different site from its Atlas frame, a cookie it needs there must also be
|
|
187
|
+
`SameSite=None; Partitioned`, or the browser drops it.
|
|
188
|
+
|
|
182
189
|
The local process is ordinary code running as your operating-system user.
|
|
183
190
|
Atlas governs access to platform resources; it does not sandbox your laptop's
|
|
184
191
|
filesystem or all outbound network connections.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@sequenceholdings/artifact-studio": "^0.
|
|
12
|
+
"@sequenceholdings/artifact-studio": "^0.7.0",
|
|
13
13
|
"@sequenceholdings/atlas-ui": "^3.1.1",
|
|
14
14
|
"@tailwindcss/postcss": "^4.2.2",
|
|
15
15
|
"@tailwindcss/vite": "^4.2.2",
|
|
@@ -7,7 +7,14 @@ export declare const PNPM_WORKSPACE_FILE_RE: RegExp;
|
|
|
7
7
|
/** Platform peers the CLI aliases at build time — must not be prod-installed remotely. */
|
|
8
8
|
export declare const REMOTE_INSTALL_PLATFORM_PACKAGES: Set<string>;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* `pnpm install`
|
|
10
|
+
* Trust gate for remote static React/Vite artifact installs (called by
|
|
11
|
+
* `prepare-build.ts` before `pnpm install`); also reused for remote process repos.
|
|
12
|
+
* It rejects install-control files and non-registry dependency specs.
|
|
13
|
+
*
|
|
14
|
+
* Hosted Next.js builds use `prepareHostedBuildSource` in Atlas
|
|
15
|
+
* `next-hosted/source-policy.ts` and a controlled install workspace from
|
|
16
|
+
* `hostedInstallWorkspace` in `next-hosted/release-age.ts` instead. Do not apply
|
|
17
|
+
* this static gate to a Next.js template: its workspace file and approved
|
|
18
|
+
* platform dependencies follow the hosted policy.
|
|
12
19
|
*/
|
|
13
20
|
export declare function assertTrustedArtifactInstallTree(dir: string): void;
|
package/dist/trusted-install.js
CHANGED
|
@@ -225,8 +225,15 @@ function assertChainguardLockfile(dir) {
|
|
|
225
225
|
assertParsedPackageResolutions(lockfileText);
|
|
226
226
|
}
|
|
227
227
|
/**
|
|
228
|
-
*
|
|
229
|
-
* `pnpm install`
|
|
228
|
+
* Trust gate for remote static React/Vite artifact installs (called by
|
|
229
|
+
* `prepare-build.ts` before `pnpm install`); also reused for remote process repos.
|
|
230
|
+
* It rejects install-control files and non-registry dependency specs.
|
|
231
|
+
*
|
|
232
|
+
* Hosted Next.js builds use `prepareHostedBuildSource` in Atlas
|
|
233
|
+
* `next-hosted/source-policy.ts` and a controlled install workspace from
|
|
234
|
+
* `hostedInstallWorkspace` in `next-hosted/release-age.ts` instead. Do not apply
|
|
235
|
+
* this static gate to a Next.js template: its workspace file and approved
|
|
236
|
+
* platform dependencies follow the hosted policy.
|
|
230
237
|
*/
|
|
231
238
|
export function assertTrustedArtifactInstallTree(dir) {
|
|
232
239
|
for (const rel of listRelativeFiles(dir)) {
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.7.0",
|
|
13
13
|
"description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
@@ -166,9 +166,9 @@
|
|
|
166
166
|
"google-auth-library": "^10.5.0",
|
|
167
167
|
"server-only": "0.0.1",
|
|
168
168
|
"parse5": "7.3.0",
|
|
169
|
+
"@sequenceholdings/lattice-form-renderer": "0.2.0",
|
|
169
170
|
"@sequenceholdings/storage": "0.1.1",
|
|
170
|
-
"@sequenceholdings/orm": "2.
|
|
171
|
-
"@sequenceholdings/lattice-form-renderer": "0.2.0"
|
|
171
|
+
"@sequenceholdings/orm": "2.6.0"
|
|
172
172
|
},
|
|
173
173
|
"devDependencies": {
|
|
174
174
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -179,6 +179,13 @@ own test credentials, mocks or an existing governed function. Use a hosted
|
|
|
179
179
|
preview for application code that needs a managed secret. Never put secrets in
|
|
180
180
|
`build.public_env`, `NEXT_PUBLIC_*`, client components or committed files.
|
|
181
181
|
|
|
182
|
+
Cookies your application sets itself should use a `__Host-` name with `Secure`,
|
|
183
|
+
`HttpOnly`, `Path=/` and no `Domain`. Applications on one deployment share a
|
|
184
|
+
registrable domain, and only a `__Host-` cookie is guaranteed to come from your
|
|
185
|
+
own origin rather than a neighbour's. When the application origin is on a
|
|
186
|
+
different site from its Atlas frame, a cookie it needs there must also be
|
|
187
|
+
`SameSite=None; Partitioned`, or the browser drops it.
|
|
188
|
+
|
|
182
189
|
The local process is ordinary code running as your operating-system user.
|
|
183
190
|
Atlas governs access to platform resources; it does not sandbox your laptop's
|
|
184
191
|
filesystem or all outbound network connections.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@sequenceholdings/artifact-studio": "^0.
|
|
12
|
+
"@sequenceholdings/artifact-studio": "^0.7.0",
|
|
13
13
|
"@sequenceholdings/atlas-ui": "^3.1.1",
|
|
14
14
|
"@tailwindcss/postcss": "^4.2.2",
|
|
15
15
|
"@tailwindcss/vite": "^4.2.2",
|