@sequenceholdings/artifact-studio 0.6.6 → 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.
Files changed (42) hide show
  1. package/README.md +96 -4
  2. package/VERSION-PIN.md +22 -0
  3. package/dist/cli.js +4 -6
  4. package/dist/gateway/abort.d.ts +6 -0
  5. package/dist/gateway/abort.js +16 -0
  6. package/dist/gateway/authorization-cache.d.ts +24 -0
  7. package/dist/gateway/authorization-cache.js +21 -3
  8. package/dist/gateway/bootstrap-page.d.ts +16 -0
  9. package/dist/gateway/bootstrap-page.js +34 -0
  10. package/dist/gateway/control-config.d.ts +21 -0
  11. package/dist/gateway/control-config.js +18 -0
  12. package/dist/gateway/control.d.ts +12 -0
  13. package/dist/gateway/control.js +21 -4
  14. package/dist/gateway/hosted-proxy.d.ts +3 -0
  15. package/dist/gateway/hosted-proxy.js +12 -2
  16. package/dist/gateway/server.d.ts +1 -1
  17. package/dist/gateway/server.js +20 -20
  18. package/dist/gateway/session.d.ts +8 -8
  19. package/dist/gateway/session.js +4 -9
  20. package/dist/gateway/usage.d.ts +13 -0
  21. package/dist/gateway/usage.js +18 -0
  22. package/dist/gateway-main.js +8 -5
  23. package/dist/git-clone.d.ts +1 -3
  24. package/dist/git-clone.js +6 -8
  25. package/dist/manifest-next-browser-data.d.ts +17 -0
  26. package/dist/manifest-next-browser-data.js +46 -0
  27. package/dist/manifest-next.d.ts +7 -4
  28. package/dist/manifest-next.js +3 -0
  29. package/dist/manifest.d.ts +6 -4
  30. package/dist/runtime-contracts.d.ts +8 -0
  31. package/dist/runtime-contracts.js +10 -0
  32. package/dist/sdk-orm.d.ts +3 -3
  33. package/dist/server/internal/verify-any.d.ts +14 -0
  34. package/dist/server/internal/verify-any.js +27 -1
  35. package/dist/source-resolver.js +7 -16
  36. package/dist/templates/nextjs/README.md +7 -0
  37. package/dist/templates/react-vite/package.json +1 -1
  38. package/dist/trusted-install.d.ts +9 -2
  39. package/dist/trusted-install.js +9 -2
  40. package/package.json +3 -3
  41. package/templates/nextjs/README.md +7 -0
  42. 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:
@@ -17,7 +39,7 @@ const orm = createArtifactOrmExecutor({ namespace: 'team_directory', manifest })
17
39
  const result = await orm.execute('Profiles', { limit: 100, offset: 0 })
18
40
  ```
19
41
 
20
- Declare the exact namespace path in `artifact.bundle.yml`:
42
+ For a static (schema-v1) artifact, declare the exact namespace path in `artifact.bundle.yml`:
21
43
 
22
44
  ```yaml
23
45
  capabilities:
@@ -25,7 +47,7 @@ capabilities:
25
47
  write: ['/api/data/v2/team_directory']
26
48
  ```
27
49
 
28
- Persisted queries and mutations both use POST, so both require this `write`
50
+ In that runtime, persisted queries and mutations both use POST, so both require this `write`
29
51
  declaration. This declaration permits the artifact to make the request; each
30
52
  user still needs namespace runtime access and the operation's role/capability.
31
53
  ORM row policies and write checks enforce that caller's access. The helper uses
@@ -36,10 +58,80 @@ contract for external clients.
36
58
  The adapter validates the namespace and manifest, rejects unknown operations,
37
59
  and returns the operation's `data`. Errors retain `OrmOperationError.status`,
38
60
  `code`, and `errors`; missing host context or malformed success responses fail
39
- instead of reporting saved data. The adapter never retries a call automatically. For
40
- several operations in one traced UI action, pass `withTraceAction`'s client as
61
+ instead of reporting saved data. The adapter never retries a call automatically. On the
62
+ static runtime, for several operations in one traced UI action, pass `withTraceAction`'s client as
41
63
  the optional `sdk` argument.
42
64
 
65
+ ### Next.js browser ORM admission (internal prerelease)
66
+
67
+ The following schema-v2 manifest controls are planned for Artifact Studio 0.6.7
68
+ and Studio CLI 0.4.7. They require the matching platform deployment as well as
69
+ the SDK/CLI release; workspace source and version numbers alone do not establish
70
+ published availability. The Next.js runtime remains an internal, opt-in Preview
71
+ surface.
72
+
73
+ For a Next.js artifact, declare browser table reach and optionally restrict the
74
+ persisted operations and artifact capabilities required to use that reach:
75
+
76
+ ```yaml
77
+ capabilities:
78
+ roles:
79
+ capabilities:
80
+ directory-reader:
81
+ description: Read the team directory in this application
82
+ data:
83
+ read: [team_directory/profiles]
84
+ write: []
85
+ operations: [team_directory/ops/Profiles]
86
+ required_capabilities:
87
+ team_directory: [directory-reader]
88
+ ```
89
+
90
+ `operations` accepts at most 256 exact `namespace/ops/Operation` references;
91
+ wildcards are not accepted. Omitting it preserves existing table-based
92
+ admission. An explicit `operations: []` denies every browser ORM operation.
93
+ Listing an operation does not replace its `data.read` or `data.write` reach:
94
+ queries still need every table they read, and mutations still need write reach.
95
+ The Next.js host routes ORM requests through this data contract; no additional
96
+ API path declaration is needed. The POST transport does not grant mutation authority.
97
+
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
102
+ `DatasetPreview_*` hashes are excluded even if a registered operation has the
103
+ same name. Operation admission retains the existing active and previous
104
+ operation-set fallback; the allowlist pins names, not one content hash. Give a
105
+ security-tightened replacement query a new operation name and remove the old
106
+ name from the allowlist, otherwise a retained older hash with that name can
107
+ remain admissible.
108
+
109
+ `required_capabilities` maps a source namespace to a nonempty list of artifact
110
+ capability pins. Every listed pin is required, and each must be declared in
111
+ `capabilities.uses` or, for a local bare key, defined under
112
+ `capabilities.roles.capabilities`. Pins use the existing bare `<key>`,
113
+ `org/<key>`, or `app/<application-id>/<key>` grammar. Each namespace in either control must
114
+ also be reached by browser `data.read` or `data.write`. Preview bindings retain
115
+ the source namespace keys in these declarations.
116
+
117
+ The platform checks the persisted manifest against the authenticated user's
118
+ current artifact capability claims before ORM delegation. Caller-provided
119
+ claims and UI checks cannot satisfy the requirement. Namespace runtime grants,
120
+ operation capability checks, and row/write policies still apply independently;
121
+ these declarations grant no access. They constrain only Next.js browser ORM
122
+ requests, not server SDK calls or external ingress, which keep their own
123
+ declarations and authorization. They also do not transitively restrict ORM
124
+ work inside Managed Functions or general API calls; those surfaces enforce
125
+ their own permission contracts. The `createArtifactOrmExecutor` API is
126
+ unchanged.
127
+
128
+ For request correlation, the original `seq.api.fetch` response envelope includes
129
+ an `x-request-id` header after browser authorization succeeds, including later
130
+ denials. The platform generates this ID and records it in the browser admission
131
+ audit; it never uses an iframe-supplied ID. Authentication failures have no
132
+ browser admission ID. The ORM executor still returns the operation data, so
133
+ capture the fetch envelope when support tooling needs this correlation value.
134
+
43
135
  ### Exact numbers
44
136
 
45
137
  By default `numeric` and `bigint` values come back as JSON numbers, which
package/VERSION-PIN.md CHANGED
@@ -112,6 +112,28 @@ entry points, which remain classified **internal**. They add no package export
112
112
  subpath or supported direct HTTP surface. Publication and a reviewed public
113
113
  contract decision are separate from local build verification.
114
114
 
115
+ ## Planned browser ORM admission release
116
+
117
+ Artifact Studio 0.6.7 and Studio CLI 0.4.7 are the planned package pair for
118
+ schema-v2 Next.js browser `capabilities.data.operations` and
119
+ `capabilities.data.required_capabilities`. Atlas UI stays pinned at 3.4.1.
120
+ Publish the SDK and downstream CLI through the normal chain release, and
121
+ deploy the matching platform support before using these controls. This note
122
+ records the intended release; it is not evidence of npm publication or
123
+ environment rollout. The Next.js manifest/runtime surfaces remain internal.
124
+
125
+ Older schema-v2 parsers reject the new keys. Existing manifests that omit them
126
+ keep table-based browser admission. An empty `operations` list instead denies
127
+ all browser ORM operations; a nonempty list names exact persisted operations.
128
+ Namespace-scoped `required_capabilities` adds an all-of artifact capability
129
+ check to the existing namespace grants and row/write policies. These controls
130
+ do not change server SDK or external-ingress admission or transitively restrict
131
+ Managed Functions and general API calls. The executor API is unchanged.
132
+ Operation names retain active/previous persisted-operation fallback, so a
133
+ security-tightened query needs a fresh name to exclude retained older hashes;
134
+ autogenerated preview hashes never qualify. See the SDK README for the full
135
+ declaration contract and source-namespace preview behavior.
136
+
115
137
  ## What this means
116
138
 
117
139
  `shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
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 and require a repo:read git
62
- PAT in ATLAS_GIT_PAT — create one with \`seq-studio auth pat create --scopes
63
- repo:read\` or in Atlas → Settings → Tokens. Headless M2M builds use JSON
64
- materialize and accept only platform-managed --repo sources. (--env +
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>;
@@ -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 response = await (this.options.fetch ?? fetch)(`${this.origin}/api/artifact-studio/next/control/${path}`, {
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 ${this.options.token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
110
- ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
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 token = await provider({ audience: audience.origin, signal: this.controller.signal });
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
- this.outgoing = transport(destination, { method: request.method, path: request.url, headers, maxHeaderSize: 32 * 1024 }, (incoming) => this.receive({ incoming, upstream, publicOrigin }));
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)
@@ -11,7 +11,7 @@ export interface ArtifactGatewayOptions {
11
11
  parentOrigin: string;
12
12
  gatewayOrigin: string;
13
13
  sessionKey: string;
14
- /** Defaults to `Strict`. `None` only for a local development gateway (see GatewaySessionSameSite). */
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;
@@ -1,4 +1,4 @@
1
- import { createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
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 target = safeMethod ? await authorizations.get(lookup) : await authorizations.refresh(lookup);
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 { session, target, requestContext, requestId } = await authorize(request);
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 admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources });
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' });