@sequenceholdings/artifact-studio 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/VERSION-PIN.md +12 -1
  2. package/dist/api.d.ts +11 -1
  3. package/dist/api.js +26 -9
  4. package/dist/build.d.ts +2 -2
  5. package/dist/build.js +4 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +134 -13
  8. package/dist/dev-link.d.ts +1 -1
  9. package/dist/dev-link.js +2 -1
  10. package/dist/gateway/admission.d.ts +56 -0
  11. package/dist/gateway/admission.js +134 -0
  12. package/dist/gateway/authorization-cache.d.ts +52 -0
  13. package/dist/gateway/authorization-cache.js +111 -0
  14. package/dist/gateway/control-admission.d.ts +48 -0
  15. package/dist/gateway/control-admission.js +127 -0
  16. package/dist/gateway/control.d.ts +94 -0
  17. package/dist/gateway/control.js +116 -0
  18. package/dist/gateway/hosted-proxy.d.ts +36 -0
  19. package/dist/gateway/hosted-proxy.js +344 -0
  20. package/dist/gateway/release-admission.d.ts +23 -0
  21. package/dist/gateway/release-admission.js +259 -0
  22. package/dist/gateway/server.d.ts +38 -0
  23. package/dist/gateway/server.js +382 -0
  24. package/dist/gateway/session.d.ts +72 -0
  25. package/dist/gateway/session.js +144 -0
  26. package/dist/gateway/usage.d.ts +70 -0
  27. package/dist/gateway/usage.js +116 -0
  28. package/dist/gateway-main.d.ts +1 -0
  29. package/dist/gateway-main.js +114 -0
  30. package/dist/git-clone.d.ts +23 -1
  31. package/dist/git-clone.js +32 -15
  32. package/dist/manifest-next.d.ts +217 -0
  33. package/dist/manifest-next.js +132 -0
  34. package/dist/manifest-static.d.ts +76 -0
  35. package/dist/manifest-static.js +124 -0
  36. package/dist/manifest.d.ts +118 -13
  37. package/dist/manifest.js +12 -119
  38. package/dist/next/bridge.d.ts +47 -0
  39. package/dist/next/bridge.js +236 -0
  40. package/dist/next/browser-logs.d.ts +7 -0
  41. package/dist/next/browser-logs.js +51 -0
  42. package/dist/next/browser.d.ts +6 -0
  43. package/dist/next/browser.js +57 -0
  44. package/dist/next/protocol.d.ts +73 -0
  45. package/dist/next/protocol.js +56 -0
  46. package/dist/next/release.d.ts +15 -0
  47. package/dist/next/release.js +46 -0
  48. package/dist/next/requests.d.ts +15 -0
  49. package/dist/next/requests.js +59 -0
  50. package/dist/next/routes.d.ts +4 -0
  51. package/dist/next/routes.js +21 -0
  52. package/dist/next/streams.d.ts +16 -0
  53. package/dist/next/streams.js +132 -0
  54. package/dist/next/theme.d.ts +10 -0
  55. package/dist/next/theme.js +45 -0
  56. package/dist/next/transport.d.ts +15 -0
  57. package/dist/next/transport.js +13 -0
  58. package/dist/next-build.d.ts +29 -0
  59. package/dist/next-build.js +129 -0
  60. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  61. package/dist/next-cache/adapter-runtime.js +73 -0
  62. package/dist/next-cache/component-handler.d.ts +3 -0
  63. package/dist/next-cache/component-handler.js +10 -0
  64. package/dist/next-cache/components.d.ts +20 -0
  65. package/dist/next-cache/components.js +113 -0
  66. package/dist/next-cache/config.d.ts +9 -0
  67. package/dist/next-cache/config.js +105 -0
  68. package/dist/next-cache/incremental-handler.d.ts +18 -0
  69. package/dist/next-cache/incremental-handler.js +17 -0
  70. package/dist/next-cache/incremental.d.ts +27 -0
  71. package/dist/next-cache/incremental.js +99 -0
  72. package/dist/next-cache/index.d.ts +18 -0
  73. package/dist/next-cache/index.js +16 -0
  74. package/dist/next-cache/memory-store.d.ts +27 -0
  75. package/dist/next-cache/memory-store.js +118 -0
  76. package/dist/next-cache/redis-scripts.d.ts +3 -0
  77. package/dist/next-cache/redis-scripts.js +85 -0
  78. package/dist/next-cache/redis-store.d.ts +18 -0
  79. package/dist/next-cache/redis-store.js +56 -0
  80. package/dist/next-cache/runtime.d.ts +29 -0
  81. package/dist/next-cache/runtime.js +63 -0
  82. package/dist/next-cache/serialization.d.ts +39 -0
  83. package/dist/next-cache/serialization.js +333 -0
  84. package/dist/next-cache/store.d.ts +54 -0
  85. package/dist/next-cache/store.js +45 -0
  86. package/dist/next-cache/valkey-transport.d.ts +52 -0
  87. package/dist/next-cache/valkey-transport.js +189 -0
  88. package/dist/next-dev-control.d.ts +48 -0
  89. package/dist/next-dev-control.js +63 -0
  90. package/dist/next-dev-process.d.ts +17 -0
  91. package/dist/next-dev-process.js +110 -0
  92. package/dist/next-dev.d.ts +12 -0
  93. package/dist/next-dev.js +267 -0
  94. package/dist/next-image-config.d.ts +9 -0
  95. package/dist/next-image-config.js +101 -0
  96. package/dist/next-project.d.ts +22 -0
  97. package/dist/next-project.js +64 -0
  98. package/dist/next-public-fonts-local.d.ts +7 -0
  99. package/dist/next-public-fonts-local.js +64 -0
  100. package/dist/next-public-fonts.d.ts +13 -0
  101. package/dist/next-public-fonts.js +49 -0
  102. package/dist/next-source-hash.d.ts +14 -0
  103. package/dist/next-source-hash.js +18 -0
  104. package/dist/next-source.d.ts +35 -0
  105. package/dist/next-source.js +155 -0
  106. package/dist/next.d.ts +9 -0
  107. package/dist/next.js +5 -0
  108. package/dist/project.d.ts +3 -0
  109. package/dist/project.js +9 -1
  110. package/dist/runtime-contracts.d.ts +229 -0
  111. package/dist/runtime-contracts.js +224 -0
  112. package/dist/sdk.d.ts +2 -2
  113. package/dist/server/broker-contract.d.ts +68 -0
  114. package/dist/server/broker-contract.js +33 -0
  115. package/dist/server/client.d.ts +81 -0
  116. package/dist/server/client.js +159 -0
  117. package/dist/server/logger.d.ts +26 -0
  118. package/dist/server/logger.js +24 -0
  119. package/dist/server/release-forms.d.ts +13 -0
  120. package/dist/server/release-forms.js +124 -0
  121. package/dist/server/request-context.d.ts +75 -0
  122. package/dist/server/request-context.js +107 -0
  123. package/dist/server/runtime-config.d.ts +54 -0
  124. package/dist/server/runtime-config.js +64 -0
  125. package/dist/server.d.ts +8 -0
  126. package/dist/server.js +4 -0
  127. package/dist/templates/nextjs/.artifactignore +14 -0
  128. package/dist/templates/nextjs/README.md +157 -0
  129. package/dist/templates/nextjs/app/about/page.tsx +26 -0
  130. package/dist/templates/nextjs/app/actions.ts +18 -0
  131. package/dist/templates/nextjs/app/api/health/route.ts +6 -0
  132. package/dist/templates/nextjs/app/components/counter.tsx +19 -0
  133. package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
  134. package/dist/templates/nextjs/app/globals.css +4 -0
  135. package/dist/templates/nextjs/app/layout.tsx +27 -0
  136. package/dist/templates/nextjs/app/page.tsx +51 -0
  137. package/dist/templates/nextjs/app/providers.tsx +107 -0
  138. package/dist/templates/nextjs/artifact.bundle.yml +55 -0
  139. package/dist/templates/nextjs/gitignore +9 -0
  140. package/dist/templates/nextjs/next.config.mjs +27 -0
  141. package/dist/templates/nextjs/package.json +32 -0
  142. package/dist/templates/nextjs/pnpm-workspace.yaml +22 -0
  143. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  144. package/dist/templates/nextjs/tsconfig.json +20 -0
  145. package/dist/templates/react-vite/package.json +1 -1
  146. package/dist/trusted-install.js +62 -2
  147. package/dist/tunnel/broker.d.ts +75 -0
  148. package/dist/tunnel/broker.js +463 -0
  149. package/dist/tunnel/client.d.ts +22 -0
  150. package/dist/tunnel/client.js +177 -0
  151. package/dist/tunnel/index.d.ts +6 -0
  152. package/dist/tunnel/index.js +4 -0
  153. package/dist/tunnel/io.d.ts +33 -0
  154. package/dist/tunnel/io.js +108 -0
  155. package/dist/tunnel/peer.d.ts +25 -0
  156. package/dist/tunnel/peer.js +147 -0
  157. package/dist/tunnel/protocol.d.ts +78 -0
  158. package/dist/tunnel/protocol.js +210 -0
  159. package/dist/tunnel/redis-auth.d.ts +37 -0
  160. package/dist/tunnel/redis-auth.js +92 -0
  161. package/dist/tunnel/redis-ownership.d.ts +38 -0
  162. package/dist/tunnel/redis-ownership.js +100 -0
  163. package/dist/tunnel/redis-socket.d.ts +13 -0
  164. package/dist/tunnel/redis-socket.js +48 -0
  165. package/dist/tunnel/redis-types.d.ts +75 -0
  166. package/dist/tunnel/redis-types.js +9 -0
  167. package/dist/tunnel/redis.d.ts +56 -0
  168. package/dist/tunnel/redis.js +404 -0
  169. package/dist/tunnel/transport.d.ts +17 -0
  170. package/dist/tunnel/transport.js +1 -0
  171. package/package.json +67 -4
  172. package/templates/nextjs/.artifactignore +14 -0
  173. package/templates/nextjs/README.md +157 -0
  174. package/templates/nextjs/app/about/page.tsx +26 -0
  175. package/templates/nextjs/app/actions.ts +18 -0
  176. package/templates/nextjs/app/api/health/route.ts +6 -0
  177. package/templates/nextjs/app/components/counter.tsx +19 -0
  178. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  179. package/templates/nextjs/app/globals.css +4 -0
  180. package/templates/nextjs/app/layout.tsx +27 -0
  181. package/templates/nextjs/app/page.tsx +51 -0
  182. package/templates/nextjs/app/providers.tsx +107 -0
  183. package/templates/nextjs/artifact.bundle.yml +55 -0
  184. package/templates/nextjs/gitignore +9 -0
  185. package/templates/nextjs/next.config.mjs +27 -0
  186. package/templates/nextjs/package.json +32 -0
  187. package/templates/nextjs/pnpm-workspace.yaml +22 -0
  188. package/templates/nextjs/postcss.config.mjs +5 -0
  189. package/templates/nextjs/tsconfig.json +20 -0
  190. package/templates/react-vite/package.json +1 -1
@@ -0,0 +1,382 @@
1
+ import { createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { z } from 'zod';
4
+ import { ArtifactTunnelBroker } from '../tunnel/index.js';
5
+ import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js';
6
+ import { GatewayControlError } from './control.js';
7
+ import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
8
+ import { proxyHostedArtifact } from './hosted-proxy.js';
9
+ import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
10
+ import { ArtifactAdmissionError } from './admission.js';
11
+ import { gatewayClientAddress } from './control-admission.js';
12
+ import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
13
+ import { GatewayUsageReporter } from './usage.js';
14
+ import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
15
+ function json(response, status, error) {
16
+ if (response.headersSent) {
17
+ response.destroy();
18
+ return;
19
+ }
20
+ if (status >= 400)
21
+ response.setHeader('connection', 'close');
22
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
23
+ response.end(JSON.stringify({ error }));
24
+ }
25
+ function rejectUpgrade(socket, status = 403) {
26
+ socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
27
+ }
28
+ function header(request, key) {
29
+ const value = request.headers[key];
30
+ return typeof value === 'string' ? value : undefined;
31
+ }
32
+ /**
33
+ * Browsers mark every service worker script fetch, for registration and for update checks. A worker
34
+ * outlives the release that installed it on the stable application origin, so it could keep serving
35
+ * a rolled-back release's code and read the bootstrap ticket. No application origin may install one.
36
+ */
37
+ function isServiceWorkerScript(request) {
38
+ return request.headers['service-worker'] !== undefined || request.headers['sec-fetch-dest'] === 'serviceworker';
39
+ }
40
+ function releaseHints(request) {
41
+ function uniqueHeader(name) {
42
+ const value = request.headers[name];
43
+ if (value === undefined)
44
+ return undefined;
45
+ const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
46
+ if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
47
+ throw new ReleaseAdmissionError('conflicting-release');
48
+ return value;
49
+ }
50
+ const deploymentId = uniqueHeader('x-sequence-artifact-release');
51
+ const frameworkHeader = uniqueHeader('x-deployment-id');
52
+ const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
53
+ if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
54
+ (frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
55
+ throw new ReleaseAdmissionError('conflicting-release');
56
+ return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
57
+ }
58
+ async function boundedJson(request) {
59
+ if (header(request, 'content-type')?.split(';', 1)[0] !== 'application/json')
60
+ throw new GatewayControlError(400);
61
+ const chunks = [];
62
+ let length = 0;
63
+ for await (const chunk of request) {
64
+ const bytes = Buffer.from(chunk);
65
+ length += bytes.length;
66
+ if (length > 2048)
67
+ throw new GatewayControlError(413);
68
+ chunks.push(bytes);
69
+ }
70
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
71
+ }
72
+ /** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
73
+ * exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
74
+ function bootstrapPage({ response, parentOrigin }) {
75
+ const nonce = randomBytes(24).toString('base64');
76
+ response.writeHead(200, {
77
+ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
78
+ 'x-content-type-options': 'nosniff',
79
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
80
+ });
81
+ 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>`);
82
+ }
83
+ /** Probes reach the container by its own address; an application host keeps its own /health route. */
84
+ function isReadinessRequest({ request, gatewayOrigin, runtimeDomain }) {
85
+ if (request.method !== 'GET')
86
+ return false;
87
+ if (request.url === '/_sequence/health')
88
+ return true;
89
+ if (request.url !== '/health')
90
+ return false;
91
+ let hostname;
92
+ try {
93
+ hostname = new URL(`http://${header(request, 'host') ?? ''}`).hostname;
94
+ }
95
+ catch {
96
+ return true;
97
+ }
98
+ return hostname === new URL(gatewayOrigin).hostname || !hostname.endsWith(`.${runtimeDomain}`);
99
+ }
100
+ export function createArtifactGateway(options) {
101
+ const parentOrigin = normalizePublicOrigin(options.parentOrigin);
102
+ const gatewayOrigin = normalizePublicOrigin(options.gatewayOrigin);
103
+ if (!/^[a-z0-9.-]+$/.test(options.runtimeDomain) || options.runtimeDomain.includes('..'))
104
+ throw new Error('Invalid Artifact runtime domain');
105
+ // Validate signing and cookie keys before accepting traffic.
106
+ if (Buffer.byteLength(options.sessionKey) < 32)
107
+ throw new Error('Artifact gateway session key is too short');
108
+ if (createPrivateKey(options.contextPrivateKey).asymmetricKeyType !== 'ed25519')
109
+ throw new Error('Artifact context key must be Ed25519');
110
+ const activeRequests = new WeakMap();
111
+ const leases = new Map();
112
+ // Positive decisions only, for a few seconds: revocation reaches admitted browsers within the TTL.
113
+ const authorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
114
+ const tunnelAuthorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
115
+ const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
116
+ let draining = false;
117
+ /** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
118
+ async function admitControlCall(endpoint, request) {
119
+ if (!options.controlAdmission)
120
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
121
+ return options.controlAdmission.admit({ endpoint, client: gatewayClientAddress(request) });
122
+ }
123
+ /** One-use ticket exchange for a host-only session cookie. */
124
+ async function exchangeTicket({ request, response, origin }) {
125
+ if (header(request, 'origin') !== origin)
126
+ throw new GatewayControlError(403);
127
+ const { ticket } = z.object({ ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict().parse(await boundedJson(request));
128
+ // Anyone can post a ticket: rate limit and admit before the exchange reaches Atlas.
129
+ const release = await admitControlCall('session', request);
130
+ let session;
131
+ try {
132
+ ({ session } = await options.control.bootstrap(ticket));
133
+ }
134
+ finally {
135
+ release();
136
+ }
137
+ if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
138
+ throw new GatewayControlError(403);
139
+ response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie') }));
140
+ json(response, 200, 'connected');
141
+ }
142
+ /** Tunnel credential validation, shared by connection admission and every lease check. */
143
+ async function tunnelLease(credential) {
144
+ const lease = await tunnelAuthorizations.get({
145
+ key: tunnelAuthorizationKey(credential), load: () => options.control.tunnel(credential), notAfter: value => value.expiresAt,
146
+ });
147
+ // Brokers update lease expiry in place; never hand them the cached object.
148
+ return { ...lease };
149
+ }
150
+ function requestOrigin(request) {
151
+ const host = header(request, 'host');
152
+ if (!host || /[\s/@\\?#]/.test(host))
153
+ throw new GatewayControlError(403);
154
+ const origin = `${new URL(gatewayOrigin).protocol}//${host}`;
155
+ const url = new URL(origin);
156
+ // One DNS label per application/session prevents suffix tricks and parent-domain cookies.
157
+ const suffix = `.${options.runtimeDomain}`;
158
+ if (url.hostname !== new URL(gatewayOrigin).hostname &&
159
+ (!url.hostname.endsWith(suffix) || !/^[a-z0-9-]+$/.test(url.hostname.slice(0, -suffix.length))))
160
+ throw new GatewayControlError(403);
161
+ if (url.port !== new URL(gatewayOrigin).port)
162
+ throw new GatewayControlError(403);
163
+ return origin;
164
+ }
165
+ async function authorize(request) {
166
+ const cached = activeRequests.get(request);
167
+ if (cached)
168
+ return cached;
169
+ const origin = requestOrigin(request);
170
+ assertSafeRequest(request.method ?? 'GET', request.url ?? '/');
171
+ const hints = releaseHints(request);
172
+ // Retained releases must belong to the valid main session's actor and scope.
173
+ const session = decodeGatewaySession({ cookie: header(request, 'cookie'), key: options.sessionKey, origin, ...hints });
174
+ if (!session || session.bridge.parentOrigin !== parentOrigin)
175
+ throw new GatewayControlError(403);
176
+ const method = request.method ?? 'GET';
177
+ const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method);
178
+ if ((!safeMethod || header(request, 'upgrade')) && header(request, 'origin') !== origin)
179
+ throw new GatewayControlError(403);
180
+ // Every request is authorized, static chunks included. Reads reuse this replica's answer for the
181
+ // same session and target for up to the cache TTL (never past the target's own expiry). Mutations
182
+ // always ask Atlas, so revocation, suspension and a changed activation stop them immediately.
183
+ const lookup = { key: gatewayAuthorizationKey(session), load: () => options.control.authorize(session), notAfter: (value) => value.expiresAt };
184
+ const target = safeMethod ? await authorizations.get(lookup) : await authorizations.refresh(lookup);
185
+ const requestId = randomUUID();
186
+ const result = { session, target, requestId, requestContext: signGatewayContext({
187
+ session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
188
+ capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
189
+ }) };
190
+ activeRequests.set(request, result);
191
+ return result;
192
+ }
193
+ const broker = new ArtifactTunnelBroker({
194
+ nodeId: options.nodeId, routing: options.routing, relay: options.relay, resolveLease: options.resolveLease,
195
+ authorizeTunnel: async (request) => {
196
+ if (draining || requestOrigin(request) !== gatewayOrigin || header(request, 'origin'))
197
+ return null;
198
+ const authorization = header(request, 'authorization'), sessionId = header(request, 'x-artifact-session-id');
199
+ const epoch = Number(header(request, 'x-artifact-tunnel-epoch'));
200
+ if (!authorization?.startsWith('Bearer ') || !sessionId || !Number.isSafeInteger(epoch) || epoch < 1)
201
+ return null;
202
+ const token = authorization.slice(7);
203
+ // Unauthenticated until Atlas validates the credential: admit before calling it.
204
+ const release = await admitControlCall('tunnel', request);
205
+ let lease;
206
+ try {
207
+ lease = await tunnelLease({ token, sessionId, epoch });
208
+ }
209
+ finally {
210
+ release();
211
+ }
212
+ leases.set(sessionId, { token, lease });
213
+ return lease;
214
+ },
215
+ authorizeRequest: async (request, sessionId) => {
216
+ const authorized = await authorize(request);
217
+ if (authorized.target.kind !== 'development' || authorized.target.sessionId !== sessionId)
218
+ return null;
219
+ return { ownerId: authorized.session.ownerId, publicOrigin: authorized.session.publicOrigin, requestContext: authorized.requestContext };
220
+ },
221
+ validateLease: async (lease) => {
222
+ const credential = leases.get(lease.sessionId);
223
+ // Remote browser nodes use the control-plane session check above; only the owner holds the tunnel credential.
224
+ if (!credential) {
225
+ const owner = await options.routing.lookup(lease.sessionId);
226
+ return Boolean(options.relay && owner && owner.nodeId !== options.nodeId && owner.epoch === lease.epoch);
227
+ }
228
+ if (credential.lease.epoch !== lease.epoch)
229
+ return false;
230
+ try {
231
+ // Per-request and periodic checks share the cached decision; revocation closes the tunnel
232
+ // at the first check after the cache window.
233
+ const current = await tunnelLease({ token: credential.token, sessionId: lease.sessionId, epoch: lease.epoch });
234
+ lease.expiresAt = current.expiresAt;
235
+ credential.lease = current;
236
+ const owner = await options.routing.lookup(lease.sessionId);
237
+ // Before the claim, an authorized newer epoch must reach routing.claim, which atomically fences
238
+ // the older owner, including a record a dead node left behind. Only a current owner is checked and renewed.
239
+ if (!owner || owner.epoch < lease.epoch)
240
+ return true;
241
+ if (owner.nodeId !== options.nodeId || owner.epoch !== lease.epoch)
242
+ return false;
243
+ if (options.renewLease && !await options.renewLease(lease))
244
+ return false;
245
+ return true;
246
+ }
247
+ catch {
248
+ if (leases.get(lease.sessionId) === credential)
249
+ leases.delete(lease.sessionId);
250
+ return false;
251
+ }
252
+ },
253
+ onTunnelClosed: lease => { if (leases.get(lease.sessionId)?.lease.epoch === lease.epoch)
254
+ leases.delete(lease.sessionId); },
255
+ });
256
+ const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
257
+ const startedAt = new Date().toISOString(), started = performance.now();
258
+ void (async () => {
259
+ if (isReadinessRequest({ request, gatewayOrigin, runtimeDomain: options.runtimeDomain })) {
260
+ const ready = !draining && (options.ready?.() ?? true);
261
+ json(response, ready ? 200 : 503, ready ? 'ready' : 'unavailable');
262
+ return;
263
+ }
264
+ if (draining) {
265
+ response.setHeader('connection', 'close');
266
+ json(response, 503, 'gateway_draining');
267
+ return;
268
+ }
269
+ // Refused before authorization, for development and hosted origins alike: nothing may register.
270
+ if (isServiceWorkerScript(request)) {
271
+ json(response, 403, 'service_workers_unsupported');
272
+ return;
273
+ }
274
+ const origin = requestOrigin(request);
275
+ if (request.url === '/_sequence/bootstrap' && request.method === 'GET') {
276
+ // Atlas only embeds this page (see frame-ancestors). Refusing top-level loads keeps a crafted
277
+ // link from exchanging someone else's ticket in the victim's browser (login CSRF).
278
+ const destination = header(request, 'sec-fetch-dest');
279
+ if (destination !== undefined && destination !== 'iframe')
280
+ throw new GatewayControlError(403);
281
+ bootstrapPage({ response, parentOrigin });
282
+ return;
283
+ }
284
+ if (request.url === '/_sequence/session' && request.method === 'POST') {
285
+ await exchangeTicket({ request, response, origin });
286
+ return;
287
+ }
288
+ if (request.url?.startsWith('/_sequence/')) {
289
+ json(response, 404, 'not_found');
290
+ return;
291
+ }
292
+ if (['GET', 'HEAD'].includes(request.method ?? 'GET')) {
293
+ const url = new URL(request.url ?? '/', origin);
294
+ if (url.searchParams.has('__sequence_release')) {
295
+ url.searchParams.delete('__sequence_release');
296
+ request.url = url.pathname + url.search;
297
+ }
298
+ }
299
+ const { session, target, requestContext, requestId } = await authorize(request);
300
+ let reported = false;
301
+ const report = () => {
302
+ if (reported)
303
+ return;
304
+ reported = true;
305
+ usage.record({
306
+ eventId: `gateway:${requestId}`, requestId, actorId: session.ownerId, projectId: target.projectId, environment: target.environment,
307
+ ...(target.kind === 'development' ? { devSessionId: target.sessionId } : { deploymentId: target.deploymentId }),
308
+ startedAt, endedAt: new Date().toISOString(),
309
+ measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
310
+ outcome: !response.writableFinished ? 'cancelled' : response.statusCode >= 400 ? 'error' : 'success',
311
+ httpStatus: response.statusCode,
312
+ });
313
+ };
314
+ response.once('finish', report);
315
+ response.once('close', report);
316
+ if (response.destroyed) {
317
+ report();
318
+ return;
319
+ }
320
+ const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
321
+ if (renewed)
322
+ response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true }));
323
+ // Authorization precedes every response, including Next static assets and cached pages.
324
+ response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
325
+ response.setHeader('referrer-policy', 'same-origin');
326
+ response.setHeader('x-content-type-options', 'nosniff');
327
+ if (target.kind === 'development') {
328
+ await broker.handleHttp(request, response, target.sessionId);
329
+ return;
330
+ }
331
+ if (!options.admission)
332
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
333
+ const admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources });
334
+ admitted.bind(response, () => response.destroy());
335
+ if (response.destroyed)
336
+ return;
337
+ const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
338
+ await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs } });
339
+ })().catch(error => {
340
+ options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
341
+ : error instanceof ArtifactAdmissionError ? 'admission_rejected' : 'request_failed' });
342
+ const status = error instanceof ReleaseAdmissionError || error instanceof GatewayControlError || error instanceof ArtifactAdmissionError ? error.status : 400;
343
+ if (error instanceof ArtifactAdmissionError && error.retryAfterSeconds && !response.headersSent)
344
+ response.setHeader('retry-after', String(error.retryAfterSeconds));
345
+ json(response, status === 401 || status === 404 ? 403 : status, error instanceof ReleaseAdmissionError ? 'update_required' : 'artifact_request_failed');
346
+ });
347
+ });
348
+ server.on('upgrade', (request, socket, head) => {
349
+ void (async () => {
350
+ if (draining) {
351
+ rejectUpgrade(socket, 503);
352
+ return;
353
+ }
354
+ if (request.url === '/_sequence/tunnel') {
355
+ await broker.handleTunnelUpgrade(request, socket, head);
356
+ return;
357
+ }
358
+ const authorized = await authorize(request);
359
+ if (authorized.target.kind !== 'development') {
360
+ rejectUpgrade(socket);
361
+ return;
362
+ }
363
+ await broker.handleWebSocketUpgrade(request, socket, head, authorized.target.sessionId);
364
+ })().catch(() => rejectUpgrade(socket));
365
+ });
366
+ return {
367
+ server,
368
+ /** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
369
+ get pendingUsage() { return usage.pending; },
370
+ async drain() {
371
+ draining = true;
372
+ server.closeIdleConnections();
373
+ broker.close();
374
+ leases.clear();
375
+ authorizations.clear();
376
+ tunnelAuthorizations.clear();
377
+ // The usage timer keeps flushing while in-flight requests finish.
378
+ await new Promise(resolve => server.close(() => resolve()));
379
+ await usage.close();
380
+ },
381
+ };
382
+ }
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ export declare const GATEWAY_SESSION_COOKIE = "__Host-atlas-artifact-session";
3
+ export declare function gatewayReleaseCookieName(deploymentId: string): string;
4
+ declare const sessionSchema: z.ZodObject<{
5
+ version: z.ZodLiteral<1>;
6
+ kind: z.ZodEnum<{
7
+ development: "development";
8
+ hosted: "hosted";
9
+ }>;
10
+ sessionId: z.ZodNullable<z.ZodString>;
11
+ deploymentId: z.ZodNullable<z.ZodString>;
12
+ frameworkBuildId: z.ZodNullable<z.ZodString>;
13
+ projectId: z.ZodString;
14
+ ownerId: z.ZodString;
15
+ environment: z.ZodString;
16
+ publicOrigin: z.ZodString;
17
+ expiresAt: z.ZodNumber;
18
+ maxExpiresAt: z.ZodOptional<z.ZodNumber>;
19
+ published: z.ZodBoolean;
20
+ bridge: z.ZodObject<{
21
+ parentOrigin: z.ZodString;
22
+ channelId: z.ZodString;
23
+ nonce: z.ZodString;
24
+ }, z.core.$strict>;
25
+ lattice: z.ZodOptional<z.ZodObject<{
26
+ runId: z.ZodNullable<z.ZodString>;
27
+ nodeId: z.ZodNullable<z.ZodString>;
28
+ stepId: z.ZodNullable<z.ZodString>;
29
+ processId: z.ZodNullable<z.ZodString>;
30
+ }, z.core.$strict>>;
31
+ taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
32
+ }, z.core.$strict>;
33
+ export type GatewaySession = z.infer<typeof sessionSchema>;
34
+ /** Active use renews the idle window, but never extends the original admission's eight-hour bound. */
35
+ export declare function renewGatewaySession({ session, authorizedExpiresAt, now }: {
36
+ session: GatewaySession;
37
+ authorizedExpiresAt: number;
38
+ now?: number;
39
+ }): GatewaySession | null;
40
+ export declare function gatewaySessionCookies({ session, key, cookie, renewal, now }: {
41
+ session: GatewaySession;
42
+ key: string;
43
+ cookie?: string;
44
+ renewal?: boolean;
45
+ now?: number;
46
+ }): string[];
47
+ export declare function encodeGatewaySession({ session, key }: {
48
+ session: GatewaySession;
49
+ key: string;
50
+ }): string;
51
+ /** Retained releases preserve tabs, never a different login or runtime scope. */
52
+ export declare function decodeGatewaySession(input: {
53
+ cookie: string | undefined;
54
+ key: string;
55
+ origin: string;
56
+ now?: number;
57
+ deploymentId?: string;
58
+ frameworkBuildId?: string;
59
+ }): GatewaySession | null;
60
+ /** A fresh jti on every signature: a context names one originating request and its method. */
61
+ export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId }: {
62
+ session: GatewaySession;
63
+ method: string;
64
+ path: string;
65
+ privateKey: string;
66
+ keyId: string;
67
+ now?: number;
68
+ capabilities?: string[];
69
+ serviceAccountId?: string | null;
70
+ requestId?: string;
71
+ }): string;
72
+ export {};
@@ -0,0 +1,144 @@
1
+ import { createHmac, createPrivateKey, randomUUID, sign, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { artifactRequestContextSchema } from '../server/request-context.js';
4
+ import { normalizePublicOrigin } from '../tunnel/protocol.js';
5
+ import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
6
+ export const GATEWAY_SESSION_COOKIE = '__Host-atlas-artifact-session';
7
+ export function gatewayReleaseCookieName(deploymentId) {
8
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(deploymentId))
9
+ throw new Error('Invalid deployment identifier');
10
+ return `${GATEWAY_SESSION_COOKIE}-${deploymentId}`;
11
+ }
12
+ const origin = z.string().refine(value => { try {
13
+ return normalizePublicOrigin(value) === value;
14
+ }
15
+ catch {
16
+ return false;
17
+ } });
18
+ const id = z.string().min(1).max(255);
19
+ const sessionSchema = z.object({
20
+ version: z.literal(1), kind: z.enum(['development', 'hosted']),
21
+ sessionId: id.nullable(), deploymentId: id.nullable(),
22
+ frameworkBuildId: artifactNextFrameworkBuildIdSchema.nullable(), projectId: id, ownerId: id,
23
+ environment: id, publicOrigin: origin, expiresAt: z.number().int().positive(), maxExpiresAt: z.number().int().positive().optional(), published: z.boolean(),
24
+ bridge: artifactRequestContextSchema.shape.bridge,
25
+ lattice: artifactRequestContextSchema.shape.lattice.optional(),
26
+ taskRoutes: artifactRequestContextSchema.shape.taskRoutes,
27
+ }).strict().refine(value => value.kind === 'development'
28
+ ? value.sessionId !== null && value.deploymentId === null && value.frameworkBuildId === null && !value.published
29
+ : value.sessionId === null && value.deploymentId !== null && value.frameworkBuildId !== null);
30
+ function sameSessionScope(left, right) {
31
+ return left.ownerId === right.ownerId && left.projectId === right.projectId &&
32
+ left.environment === right.environment && left.publicOrigin === right.publicOrigin &&
33
+ left.kind === right.kind && left.published === right.published;
34
+ }
35
+ /** Active use renews the idle window, but never extends the original admission's eight-hour bound. */
36
+ export function renewGatewaySession({ session, authorizedExpiresAt, now = Date.now() }) {
37
+ if (session.expiresAt <= now || session.expiresAt - now > 15 * 60_000)
38
+ return null;
39
+ const expiresAt = Math.min(authorizedExpiresAt, session.maxExpiresAt ?? session.expiresAt);
40
+ return expiresAt > session.expiresAt ? { ...session, expiresAt } : null;
41
+ }
42
+ export function gatewaySessionCookies({ session, key, cookie, renewal = false, now = Date.now() }) {
43
+ const maxAge = Math.max(0, Math.floor((session.expiresAt - now) / 1000));
44
+ const value = `${encodeGatewaySession({ session, key })}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`;
45
+ const current = renewal ? decodeGatewaySession({ cookie, key, origin: session.publicOrigin, now }) : session;
46
+ if (!current || !sameSessionScope(current, session))
47
+ return [];
48
+ const sameTarget = current.deploymentId === session.deploymentId && current.sessionId === session.sessionId;
49
+ const cookies = sameTarget ? [`${GATEWAY_SESSION_COOKIE}=${value}`] : [];
50
+ if (session.deploymentId)
51
+ cookies.push(`${gatewayReleaseCookieName(session.deploymentId)}=${value}`);
52
+ if (!renewal) {
53
+ const selected = session.deploymentId ? gatewayReleaseCookieName(session.deploymentId) : '';
54
+ const retained = [...new Set((cookie ?? '').split(';').map(item => item.trim().split('=', 1)[0])
55
+ .filter(name => name.startsWith(`${GATEWAY_SESSION_COOKIE}-`) && name !== selected && /^[a-zA-Z0-9_-]+$/.test(name)))];
56
+ const sameScope = [], revoked = [];
57
+ for (const name of retained) {
58
+ const release = decodeSignedCookie({ cookie, key, origin: session.publicOrigin, now,
59
+ deploymentId: name.slice(GATEWAY_SESSION_COOKIE.length + 1) });
60
+ if (release && sameSessionScope(release, session))
61
+ sameScope.push(name);
62
+ else
63
+ revoked.push(name);
64
+ }
65
+ for (const name of [...revoked, ...sameScope.slice(0, Math.max(0, sameScope.length - 5))]) {
66
+ cookies.push(`${name}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`);
67
+ }
68
+ }
69
+ return cookies;
70
+ }
71
+ function mac(value, key) {
72
+ if (Buffer.byteLength(key) < 32)
73
+ throw new Error('Gateway session key requires at least 32 bytes');
74
+ return createHmac('sha256', key).update(value).digest();
75
+ }
76
+ export function encodeGatewaySession({ session, key }) {
77
+ const payload = Buffer.from(JSON.stringify(sessionSchema.parse(session))).toString('base64url');
78
+ return `${payload}.${mac(payload, key).toString('base64url')}`;
79
+ }
80
+ function decodeSignedCookie({ cookie, key, origin: expectedOrigin, now = Date.now(), deploymentId }) {
81
+ try {
82
+ if (!cookie || cookie.length > 32768)
83
+ return null;
84
+ const name = deploymentId ? gatewayReleaseCookieName(deploymentId) : GATEWAY_SESSION_COOKIE;
85
+ const values = cookie.split(';').map(value => value.trim()).filter(value => value.startsWith(`${name}=`));
86
+ if (values.length !== 1)
87
+ return null;
88
+ const [payload, signature, extra] = values[0].slice(name.length + 1).split('.');
89
+ if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature))
90
+ return null;
91
+ const expected = mac(payload, key), actual = Buffer.from(signature, 'base64url');
92
+ if (actual.length !== expected.length || !timingSafeEqual(expected, actual))
93
+ return null;
94
+ const session = sessionSchema.parse(JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')));
95
+ return session.expiresAt > now && session.publicOrigin === expectedOrigin &&
96
+ (!deploymentId || session.deploymentId === deploymentId) ? session : null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /** Retained releases preserve tabs, never a different login or runtime scope. */
103
+ export function decodeGatewaySession(input) {
104
+ const current = decodeSignedCookie({ ...input, deploymentId: undefined });
105
+ if (!current)
106
+ return null;
107
+ const matchesBuild = (candidate) => !input.frameworkBuildId || candidate.frameworkBuildId === input.frameworkBuildId;
108
+ if (input.deploymentId) {
109
+ const selected = current.deploymentId === input.deploymentId ? current : decodeSignedCookie(input);
110
+ return selected && sameSessionScope(current, selected) && matchesBuild(selected) ? selected : null;
111
+ }
112
+ // Next's build id identifies immutable assets, not an activation. A rollback
113
+ // can reuse that build: prefer the current activation when it matches.
114
+ if (matchesBuild(current))
115
+ return current;
116
+ const names = [...new Set((input.cookie ?? '').split(';').map(item => item.trim().split('=', 1)[0])
117
+ .filter(name => name.startsWith(`${GATEWAY_SESSION_COOKIE}-`) && /^[a-zA-Z0-9_-]+$/.test(name)))];
118
+ const matches = names.map(name => decodeSignedCookie({ ...input,
119
+ deploymentId: name.slice(GATEWAY_SESSION_COOKIE.length + 1),
120
+ })).filter((candidate) => candidate !== null && sameSessionScope(current, candidate) && matchesBuild(candidate));
121
+ // Several retained activations may share identical output. Prefer the latest
122
+ // admitted session deterministically; every candidate is reauthorized below.
123
+ return matches.sort((left, right) => right.expiresAt - left.expiresAt || (left.deploymentId ?? '').localeCompare(right.deploymentId ?? ''))[0] ?? null;
124
+ }
125
+ /** A fresh jti on every signature: a context names one originating request and its method. */
126
+ export function signGatewayContext({ session, method, path, privateKey, keyId, now = Math.floor(Date.now() / 1000), capabilities = [], serviceAccountId = null, requestId = randomUUID() }) {
127
+ const context = artifactRequestContextSchema.parse({
128
+ v: 1, iss: 'atlas-artifact-gateway', aud: `artifact:${session.projectId}`,
129
+ sub: session.ownerId, projectId: session.projectId, environment: session.environment,
130
+ mode: session.kind === 'development' ? 'development' : session.published ? 'published' : 'preview',
131
+ deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId, jti: randomUUID(),
132
+ method, path, iat: now, exp: now + 60, capabilities, serviceAccountId,
133
+ bridge: session.bridge, theme: 'system',
134
+ lattice: session.lattice ?? { runId: null, nodeId: null, stepId: null, processId: null },
135
+ ...(session.taskRoutes ? { taskRoutes: session.taskRoutes } : {}),
136
+ });
137
+ const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: keyId })).toString('base64url');
138
+ const payload = Buffer.from(JSON.stringify(context)).toString('base64url');
139
+ const key = createPrivateKey(privateKey);
140
+ if (key.asymmetricKeyType !== 'ed25519')
141
+ throw new Error('Gateway context key must be Ed25519');
142
+ const signed = `${header}.${payload}`;
143
+ return `${signed}.${sign(null, Buffer.from(signed), key).toString('base64url')}`;
144
+ }