@sequenceholdings/artifact-studio 0.3.0 → 0.4.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 +37 -0
- package/VERSION-PIN.md +4 -0
- package/dist/build.js +1 -0
- package/dist/cli.js +34 -4
- package/dist/dev-link.js +1 -1
- package/dist/gateway/admission.d.ts +2 -1
- package/dist/gateway/admission.js +3 -1
- package/dist/gateway/authorization-cache.d.ts +52 -0
- package/dist/gateway/authorization-cache.js +111 -0
- package/dist/gateway/control-admission.d.ts +48 -0
- package/dist/gateway/control-admission.js +127 -0
- package/dist/gateway/control.d.ts +4 -1
- package/dist/gateway/control.js +5 -2
- package/dist/gateway/hosted-proxy.js +4 -7
- package/dist/gateway/server.d.ts +12 -0
- package/dist/gateway/server.js +102 -18
- package/dist/gateway/session.d.ts +15 -1
- package/dist/gateway/session.js +19 -4
- package/dist/gateway/usage.d.ts +45 -12
- package/dist/gateway/usage.js +96 -26
- package/dist/gateway-main.js +37 -5
- package/dist/loopback.d.ts +8 -0
- package/dist/loopback.js +13 -0
- package/dist/manifest-custom-roles.d.ts +11 -1
- package/dist/manifest-custom-roles.js +13 -5
- package/dist/manifest-next.js +13 -1
- package/dist/next/bridge.js +8 -1
- package/dist/next/protocol.js +2 -1
- package/dist/next-cache/config.js +31 -4
- package/dist/next-dev.js +2 -1
- package/dist/next-image-config.js +1 -1
- package/dist/next-project.js +2 -1
- package/dist/project.js +3 -3
- package/dist/role-definitions-notice.d.ts +7 -0
- package/dist/role-definitions-notice.js +19 -0
- package/dist/runtime-contracts.js +2 -1
- package/dist/sandbox-lint.js +1 -1
- package/dist/sdk-orm.d.ts +18 -0
- package/dist/sdk-orm.js +21 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1 -0
- package/dist/server/client.d.ts +16 -2
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +6 -1
- package/dist/server/runtime-config.d.ts +1 -0
- package/dist/server/runtime-config.js +2 -1
- package/dist/server.d.ts +1 -1
- package/dist/templates/nextjs/README.md +6 -0
- package/dist/templates/nextjs/gitignore +9 -0
- package/dist/templates/nextjs/pnpm-workspace.yaml +14 -0
- package/dist/templates/react-vite/AGENTS.md +1 -1
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/trusted-install.js +62 -2
- package/dist/tunnel/broker.js +34 -22
- package/dist/tunnel/client.js +10 -3
- package/dist/tunnel/protocol.d.ts +9 -0
- package/dist/tunnel/protocol.js +33 -18
- package/dist/tunnel/redis-auth.d.ts +37 -0
- package/dist/tunnel/redis-auth.js +92 -0
- package/dist/tunnel/redis-types.d.ts +5 -0
- package/dist/tunnel/redis.d.ts +5 -2
- package/dist/tunnel/redis.js +19 -6
- package/package.json +2 -2
- package/templates/nextjs/README.md +6 -0
- package/templates/nextjs/gitignore +9 -0
- package/templates/nextjs/pnpm-workspace.yaml +14 -0
- package/templates/react-vite/AGENTS.md +1 -1
- package/templates/react-vite/package.json +1 -1
package/dist/gateway/server.js
CHANGED
|
@@ -8,6 +8,8 @@ import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signG
|
|
|
8
8
|
import { proxyHostedArtifact } from './hosted-proxy.js';
|
|
9
9
|
import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
|
|
10
10
|
import { ArtifactAdmissionError } from './admission.js';
|
|
11
|
+
import { gatewayClientAddress } from './control-admission.js';
|
|
12
|
+
import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
|
|
11
13
|
import { GatewayUsageReporter } from './usage.js';
|
|
12
14
|
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
13
15
|
function json(response, status, error) {
|
|
@@ -27,6 +29,14 @@ function header(request, key) {
|
|
|
27
29
|
const value = request.headers[key];
|
|
28
30
|
return typeof value === 'string' ? value : undefined;
|
|
29
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
|
+
}
|
|
30
40
|
function releaseHints(request) {
|
|
31
41
|
function uniqueHeader(name) {
|
|
32
42
|
const value = request.headers[name];
|
|
@@ -70,6 +80,23 @@ function bootstrapPage({ response, parentOrigin }) {
|
|
|
70
80
|
});
|
|
71
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>`);
|
|
72
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
|
+
}
|
|
73
100
|
export function createArtifactGateway(options) {
|
|
74
101
|
const parentOrigin = normalizePublicOrigin(options.parentOrigin);
|
|
75
102
|
const gatewayOrigin = normalizePublicOrigin(options.gatewayOrigin);
|
|
@@ -82,8 +109,44 @@ export function createArtifactGateway(options) {
|
|
|
82
109
|
throw new Error('Artifact context key must be Ed25519');
|
|
83
110
|
const activeRequests = new WeakMap();
|
|
84
111
|
const leases = new Map();
|
|
85
|
-
|
|
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) });
|
|
86
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'), sameSite: options.sessionCookieSameSite }));
|
|
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
|
+
}
|
|
87
150
|
function requestOrigin(request) {
|
|
88
151
|
const host = header(request, 'host');
|
|
89
152
|
if (!host || /[\s/@\\?#]/.test(host))
|
|
@@ -111,9 +174,14 @@ export function createArtifactGateway(options) {
|
|
|
111
174
|
if (!session || session.bridge.parentOrigin !== parentOrigin)
|
|
112
175
|
throw new GatewayControlError(403);
|
|
113
176
|
const method = request.method ?? 'GET';
|
|
114
|
-
|
|
177
|
+
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method);
|
|
178
|
+
if ((!safeMethod || header(request, 'upgrade')) && header(request, 'origin') !== origin)
|
|
115
179
|
throw new GatewayControlError(403);
|
|
116
|
-
|
|
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);
|
|
117
185
|
const requestId = randomUUID();
|
|
118
186
|
const result = { session, target, requestId, requestContext: signGatewayContext({
|
|
119
187
|
session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
|
|
@@ -132,7 +200,15 @@ export function createArtifactGateway(options) {
|
|
|
132
200
|
if (!authorization?.startsWith('Bearer ') || !sessionId || !Number.isSafeInteger(epoch) || epoch < 1)
|
|
133
201
|
return null;
|
|
134
202
|
const token = authorization.slice(7);
|
|
135
|
-
|
|
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
|
+
}
|
|
136
212
|
leases.set(sessionId, { token, lease });
|
|
137
213
|
return lease;
|
|
138
214
|
},
|
|
@@ -152,7 +228,9 @@ export function createArtifactGateway(options) {
|
|
|
152
228
|
if (credential.lease.epoch !== lease.epoch)
|
|
153
229
|
return false;
|
|
154
230
|
try {
|
|
155
|
-
|
|
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 });
|
|
156
234
|
lease.expiresAt = current.expiresAt;
|
|
157
235
|
credential.lease = current;
|
|
158
236
|
const owner = await options.routing.lookup(lease.sessionId);
|
|
@@ -178,7 +256,7 @@ export function createArtifactGateway(options) {
|
|
|
178
256
|
const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
|
|
179
257
|
const startedAt = new Date().toISOString(), started = performance.now();
|
|
180
258
|
void (async () => {
|
|
181
|
-
if ((
|
|
259
|
+
if (isReadinessRequest({ request, gatewayOrigin, runtimeDomain: options.runtimeDomain })) {
|
|
182
260
|
const ready = !draining && (options.ready?.() ?? true);
|
|
183
261
|
json(response, ready ? 200 : 503, ready ? 'ready' : 'unavailable');
|
|
184
262
|
return;
|
|
@@ -188,6 +266,11 @@ export function createArtifactGateway(options) {
|
|
|
188
266
|
json(response, 503, 'gateway_draining');
|
|
189
267
|
return;
|
|
190
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
|
+
}
|
|
191
274
|
const origin = requestOrigin(request);
|
|
192
275
|
if (request.url === '/_sequence/bootstrap' && request.method === 'GET') {
|
|
193
276
|
// Atlas only embeds this page (see frame-ancestors). Refusing top-level loads keeps a crafted
|
|
@@ -199,14 +282,7 @@ export function createArtifactGateway(options) {
|
|
|
199
282
|
return;
|
|
200
283
|
}
|
|
201
284
|
if (request.url === '/_sequence/session' && request.method === 'POST') {
|
|
202
|
-
|
|
203
|
-
throw new GatewayControlError(403);
|
|
204
|
-
const { ticket } = z.object({ ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict().parse(await boundedJson(request));
|
|
205
|
-
const { session } = await options.control.bootstrap(ticket);
|
|
206
|
-
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
207
|
-
throw new GatewayControlError(403);
|
|
208
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie') }));
|
|
209
|
-
json(response, 200, 'connected');
|
|
285
|
+
await exchangeTicket({ request, response, origin });
|
|
210
286
|
return;
|
|
211
287
|
}
|
|
212
288
|
if (request.url?.startsWith('/_sequence/')) {
|
|
@@ -227,7 +303,7 @@ export function createArtifactGateway(options) {
|
|
|
227
303
|
return;
|
|
228
304
|
reported = true;
|
|
229
305
|
usage.record({
|
|
230
|
-
eventId: `gateway:${requestId}`, requestId, projectId: target.projectId, environment: target.environment,
|
|
306
|
+
eventId: `gateway:${requestId}`, requestId, actorId: session.ownerId, projectId: target.projectId, environment: target.environment,
|
|
231
307
|
...(target.kind === 'development' ? { devSessionId: target.sessionId } : { deploymentId: target.deploymentId }),
|
|
232
308
|
startedAt, endedAt: new Date().toISOString(),
|
|
233
309
|
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
|
|
@@ -243,7 +319,7 @@ export function createArtifactGateway(options) {
|
|
|
243
319
|
}
|
|
244
320
|
const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
|
|
245
321
|
if (renewed)
|
|
246
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true }));
|
|
322
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true, sameSite: options.sessionCookieSameSite }));
|
|
247
323
|
// Authorization precedes every response, including Next static assets and cached pages.
|
|
248
324
|
response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
|
|
249
325
|
response.setHeader('referrer-policy', 'same-origin');
|
|
@@ -261,8 +337,11 @@ export function createArtifactGateway(options) {
|
|
|
261
337
|
const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
|
|
262
338
|
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs } });
|
|
263
339
|
})().catch(error => {
|
|
264
|
-
options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
|
|
340
|
+
options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
|
|
341
|
+
: error instanceof ArtifactAdmissionError ? 'admission_rejected' : 'request_failed' });
|
|
265
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));
|
|
266
345
|
json(response, status === 401 || status === 404 ? 403 : status, error instanceof ReleaseAdmissionError ? 'update_required' : 'artifact_request_failed');
|
|
267
346
|
});
|
|
268
347
|
});
|
|
@@ -286,13 +365,18 @@ export function createArtifactGateway(options) {
|
|
|
286
365
|
});
|
|
287
366
|
return {
|
|
288
367
|
server,
|
|
368
|
+
/** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
|
|
369
|
+
get pendingUsage() { return usage.pending; },
|
|
289
370
|
async drain() {
|
|
290
371
|
draining = true;
|
|
291
372
|
server.closeIdleConnections();
|
|
292
373
|
broker.close();
|
|
293
374
|
leases.clear();
|
|
375
|
+
authorizations.clear();
|
|
376
|
+
tunnelAuthorizations.clear();
|
|
377
|
+
// The usage timer keeps flushing while in-flight requests finish.
|
|
294
378
|
await new Promise(resolve => server.close(() => resolve()));
|
|
295
|
-
await usage.
|
|
379
|
+
await usage.close();
|
|
296
380
|
},
|
|
297
381
|
};
|
|
298
382
|
}
|
|
@@ -37,12 +37,25 @@ export declare function renewGatewaySession({ session, authorizedExpiresAt, now
|
|
|
37
37
|
authorizedExpiresAt: number;
|
|
38
38
|
now?: number;
|
|
39
39
|
}): GatewaySession | null;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `Strict` everywhere Atlas and the runtime domain are one site, as every deployed environment is.
|
|
42
|
+
* `None` is for the local stack only: Atlas on http://localhost is cross-site to
|
|
43
|
+
* https://*.apps.localhost, so browsers drop a `Strict` cookie inside the preview iframe.
|
|
44
|
+
* Partitioned keeps the relaxed cookie to Atlas's top-level site.
|
|
45
|
+
*/
|
|
46
|
+
export type GatewaySessionSameSite = 'Strict' | 'None';
|
|
47
|
+
/** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
|
|
48
|
+
export declare function gatewaySessionSameSite({ value, appEnv }: {
|
|
49
|
+
value: string | undefined;
|
|
50
|
+
appEnv: string;
|
|
51
|
+
}): GatewaySessionSameSite;
|
|
52
|
+
export declare function gatewaySessionCookies({ session, key, cookie, renewal, now, sameSite }: {
|
|
41
53
|
session: GatewaySession;
|
|
42
54
|
key: string;
|
|
43
55
|
cookie?: string;
|
|
44
56
|
renewal?: boolean;
|
|
45
57
|
now?: number;
|
|
58
|
+
sameSite?: GatewaySessionSameSite;
|
|
46
59
|
}): string[];
|
|
47
60
|
export declare function encodeGatewaySession({ session, key }: {
|
|
48
61
|
session: GatewaySession;
|
|
@@ -57,6 +70,7 @@ export declare function decodeGatewaySession(input: {
|
|
|
57
70
|
deploymentId?: string;
|
|
58
71
|
frameworkBuildId?: string;
|
|
59
72
|
}): GatewaySession | null;
|
|
73
|
+
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
60
74
|
export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId }: {
|
|
61
75
|
session: GatewaySession;
|
|
62
76
|
method: string;
|
package/dist/gateway/session.js
CHANGED
|
@@ -39,9 +39,23 @@ 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
|
-
|
|
42
|
+
/** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
|
|
43
|
+
export function gatewaySessionSameSite({ value, appEnv }) {
|
|
44
|
+
if (value === undefined || value === '' || value === 'strict')
|
|
45
|
+
return 'Strict';
|
|
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';
|
|
51
|
+
}
|
|
52
|
+
function cookieAttributes(sameSite) {
|
|
53
|
+
return `Path=/; HttpOnly; Secure; ${sameSite === 'None' ? 'SameSite=None; Partitioned' : 'SameSite=Strict'}`;
|
|
54
|
+
}
|
|
55
|
+
export function gatewaySessionCookies({ session, key, cookie, renewal = false, now = Date.now(), sameSite = 'Strict' }) {
|
|
43
56
|
const maxAge = Math.max(0, Math.floor((session.expiresAt - now) / 1000));
|
|
44
|
-
const
|
|
57
|
+
const attributes = cookieAttributes(sameSite);
|
|
58
|
+
const value = `${encodeGatewaySession({ session, key })}; ${attributes}; Max-Age=${maxAge}`;
|
|
45
59
|
const current = renewal ? decodeGatewaySession({ cookie, key, origin: session.publicOrigin, now }) : session;
|
|
46
60
|
if (!current || !sameSessionScope(current, session))
|
|
47
61
|
return [];
|
|
@@ -63,7 +77,7 @@ export function gatewaySessionCookies({ session, key, cookie, renewal = false, n
|
|
|
63
77
|
revoked.push(name);
|
|
64
78
|
}
|
|
65
79
|
for (const name of [...revoked, ...sameScope.slice(0, Math.max(0, sameScope.length - 5))]) {
|
|
66
|
-
cookies.push(`${name}=;
|
|
80
|
+
cookies.push(`${name}=; ${attributes}; Max-Age=0`);
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
83
|
return cookies;
|
|
@@ -122,12 +136,13 @@ export function decodeGatewaySession(input) {
|
|
|
122
136
|
// admitted session deterministically; every candidate is reauthorized below.
|
|
123
137
|
return matches.sort((left, right) => right.expiresAt - left.expiresAt || (left.deploymentId ?? '').localeCompare(right.deploymentId ?? ''))[0] ?? null;
|
|
124
138
|
}
|
|
139
|
+
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
125
140
|
export function signGatewayContext({ session, method, path, privateKey, keyId, now = Math.floor(Date.now() / 1000), capabilities = [], serviceAccountId = null, requestId = randomUUID() }) {
|
|
126
141
|
const context = artifactRequestContextSchema.parse({
|
|
127
142
|
v: 1, iss: 'atlas-artifact-gateway', aud: `artifact:${session.projectId}`,
|
|
128
143
|
sub: session.ownerId, projectId: session.projectId, environment: session.environment,
|
|
129
144
|
mode: session.kind === 'development' ? 'development' : session.published ? 'published' : 'preview',
|
|
130
|
-
deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId,
|
|
145
|
+
deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId, jti: randomUUID(),
|
|
131
146
|
method, path, iat: now, exp: now + 60, capabilities, serviceAccountId,
|
|
132
147
|
bridge: session.bridge, theme: 'system',
|
|
133
148
|
lattice: session.lattice ?? { runId: null, nodeId: null, stepId: null, processId: null },
|
package/dist/gateway/usage.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface GatewayUsageEvent {
|
|
2
2
|
eventId: string;
|
|
3
3
|
requestId: string;
|
|
4
|
+
/** The user the gateway acted for: the session owner Atlas authorized for this request. */
|
|
5
|
+
actorId: string;
|
|
4
6
|
projectId: string;
|
|
5
7
|
deploymentId?: string;
|
|
6
8
|
devSessionId?: string;
|
|
@@ -14,24 +16,55 @@ export interface GatewayUsageEvent {
|
|
|
14
16
|
outcome: 'success' | 'error' | 'cancelled';
|
|
15
17
|
httpStatus: number;
|
|
16
18
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
export type GatewayUsageFailure = {
|
|
20
|
+
kind: 'usage_queue_full' | 'usage_delivery_failed' | 'usage_delivery_rejected' | 'usage_events_rejected' | 'usage_lost_on_shutdown';
|
|
21
|
+
count: number;
|
|
22
|
+
};
|
|
23
|
+
export interface GatewayUsageLimits {
|
|
24
|
+
/** Events buffered or in delivery; later observations are dropped and counted. */
|
|
25
|
+
capacity: number;
|
|
26
|
+
/** Events per control-plane call. Atlas accepts at most this many per batch. */
|
|
27
|
+
maxBatch: number;
|
|
28
|
+
/** Longest an observation waits in the buffer before a partial batch is sent. */
|
|
29
|
+
flushIntervalMs: number;
|
|
30
|
+
/** Concurrent batch deliveries. */
|
|
31
|
+
concurrency: number;
|
|
32
|
+
}
|
|
33
|
+
export declare const GATEWAY_USAGE_LIMITS: Readonly<GatewayUsageLimits>;
|
|
34
|
+
/**
|
|
35
|
+
* Batched, bounded delivery of request observations: one control-plane call per `maxBatch` events or
|
|
36
|
+
* per flush interval, instead of one per request. Atlas deduplicates accepted records durably, so a
|
|
37
|
+
* retried batch is idempotent. Observations are telemetry, not billing totals. A crash loses at most
|
|
38
|
+
* the unflushed buffer (about one flush interval) and the batches in flight; a graceful drain flushes
|
|
39
|
+
* everything, and a forced exit reports `pending` as `usage_lost_on_shutdown`.
|
|
19
40
|
*/
|
|
20
41
|
export declare class GatewayUsageReporter {
|
|
21
42
|
private readonly options;
|
|
22
|
-
private readonly
|
|
23
|
-
private readonly
|
|
43
|
+
private readonly buffer;
|
|
44
|
+
private readonly inFlight;
|
|
45
|
+
private readonly limits;
|
|
46
|
+
private timer;
|
|
47
|
+
private dropped;
|
|
48
|
+
private closed;
|
|
24
49
|
constructor(options: {
|
|
25
|
-
send: (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
capacity?: number;
|
|
31
|
-
concurrency?: number;
|
|
50
|
+
send: (events: GatewayUsageEvent[]) => Promise<{
|
|
51
|
+
rejected: number;
|
|
52
|
+
}>;
|
|
53
|
+
onFailure: (failure: GatewayUsageFailure) => void;
|
|
54
|
+
limits?: Partial<GatewayUsageLimits>;
|
|
32
55
|
});
|
|
56
|
+
/** Observations accepted but not yet confirmed by Atlas. */
|
|
57
|
+
get pending(): number;
|
|
33
58
|
record(event: GatewayUsageEvent): void;
|
|
59
|
+
/**
|
|
60
|
+
* Stops accepting observations and delivers everything buffered; each batch is bounded by its
|
|
61
|
+
* retries. A process that must exit sooner reports `pending` as lost (see gateway-main).
|
|
62
|
+
*/
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
private drainAll;
|
|
65
|
+
private scheduleFlush;
|
|
66
|
+
private reportDropped;
|
|
67
|
+
/** Sends full batches, and a partial batch too when forced by the timer or shutdown. */
|
|
34
68
|
private pump;
|
|
35
69
|
private deliver;
|
|
36
|
-
flush(): Promise<void>;
|
|
37
70
|
}
|
package/dist/gateway/usage.js
CHANGED
|
@@ -1,46 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export const GATEWAY_USAGE_LIMITS = Object.freeze({ capacity: 10_000, maxBatch: 100, flushIntervalMs: 1_000, concurrency: 4 });
|
|
2
|
+
const ATTEMPTS = 3;
|
|
3
|
+
/**
|
|
4
|
+
* Batched, bounded delivery of request observations: one control-plane call per `maxBatch` events or
|
|
5
|
+
* per flush interval, instead of one per request. Atlas deduplicates accepted records durably, so a
|
|
6
|
+
* retried batch is idempotent. Observations are telemetry, not billing totals. A crash loses at most
|
|
7
|
+
* the unflushed buffer (about one flush interval) and the batches in flight; a graceful drain flushes
|
|
8
|
+
* everything, and a forced exit reports `pending` as `usage_lost_on_shutdown`.
|
|
3
9
|
*/
|
|
4
10
|
export class GatewayUsageReporter {
|
|
5
11
|
options;
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
buffer = [];
|
|
13
|
+
inFlight = new Map();
|
|
14
|
+
limits;
|
|
15
|
+
timer;
|
|
16
|
+
dropped = 0;
|
|
17
|
+
closed = false;
|
|
8
18
|
constructor(options) {
|
|
9
19
|
this.options = options;
|
|
20
|
+
this.limits = { ...GATEWAY_USAGE_LIMITS, ...options.limits };
|
|
21
|
+
for (const value of Object.values(this.limits)) {
|
|
22
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
23
|
+
throw new Error('Gateway usage limits must be positive integers');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Observations accepted but not yet confirmed by Atlas. */
|
|
27
|
+
get pending() {
|
|
28
|
+
let count = this.buffer.length;
|
|
29
|
+
for (const size of this.inFlight.values())
|
|
30
|
+
count += size;
|
|
31
|
+
return count;
|
|
10
32
|
}
|
|
11
33
|
record(event) {
|
|
12
|
-
if (this.
|
|
13
|
-
this.options.onFailure({ kind: '
|
|
34
|
+
if (this.closed) {
|
|
35
|
+
this.options.onFailure({ kind: 'usage_lost_on_shutdown', count: 1 });
|
|
14
36
|
return;
|
|
15
37
|
}
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
while (this.queue.length && this.pending.size < (this.options.concurrency ?? 8)) {
|
|
21
|
-
const event = this.queue.shift();
|
|
22
|
-
const pending = this.deliver(event).finally(() => { this.pending.delete(pending); this.pump(); });
|
|
23
|
-
this.pending.add(pending);
|
|
38
|
+
if (this.pending >= this.limits.capacity) {
|
|
39
|
+
this.dropped++;
|
|
40
|
+
this.scheduleFlush();
|
|
41
|
+
return;
|
|
24
42
|
}
|
|
43
|
+
this.buffer.push(event);
|
|
44
|
+
if (this.buffer.length >= this.limits.maxBatch)
|
|
45
|
+
this.pump();
|
|
46
|
+
else
|
|
47
|
+
this.scheduleFlush();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Stops accepting observations and delivers everything buffered; each batch is bounded by its
|
|
51
|
+
* retries. A process that must exit sooner reports `pending` as lost (see gateway-main).
|
|
52
|
+
*/
|
|
53
|
+
async close() {
|
|
54
|
+
this.closed = true;
|
|
55
|
+
clearTimeout(this.timer);
|
|
56
|
+
this.timer = undefined;
|
|
57
|
+
await this.drainAll();
|
|
58
|
+
this.reportDropped();
|
|
25
59
|
}
|
|
26
|
-
async
|
|
27
|
-
|
|
60
|
+
async drainAll() {
|
|
61
|
+
while (this.buffer.length || this.inFlight.size) {
|
|
62
|
+
this.pump({ force: true });
|
|
63
|
+
if (this.inFlight.size)
|
|
64
|
+
await Promise.race(this.inFlight.keys());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
scheduleFlush() {
|
|
68
|
+
if (this.timer || this.closed)
|
|
69
|
+
return;
|
|
70
|
+
this.timer = setTimeout(() => {
|
|
71
|
+
this.timer = undefined;
|
|
72
|
+
this.reportDropped();
|
|
73
|
+
this.pump({ force: true });
|
|
74
|
+
if (this.buffer.length)
|
|
75
|
+
this.scheduleFlush();
|
|
76
|
+
}, this.limits.flushIntervalMs);
|
|
77
|
+
this.timer.unref();
|
|
78
|
+
}
|
|
79
|
+
reportDropped() {
|
|
80
|
+
if (!this.dropped)
|
|
81
|
+
return;
|
|
82
|
+
this.options.onFailure({ kind: 'usage_queue_full', count: this.dropped });
|
|
83
|
+
this.dropped = 0;
|
|
84
|
+
}
|
|
85
|
+
/** Sends full batches, and a partial batch too when forced by the timer or shutdown. */
|
|
86
|
+
pump({ force = false } = {}) {
|
|
87
|
+
while (this.inFlight.size < this.limits.concurrency && (this.buffer.length >= this.limits.maxBatch || (force && this.buffer.length))) {
|
|
88
|
+
const batch = this.buffer.splice(0, this.limits.maxBatch);
|
|
89
|
+
const delivery = this.deliver(batch).finally(() => {
|
|
90
|
+
this.inFlight.delete(delivery);
|
|
91
|
+
this.pump({ force: this.closed });
|
|
92
|
+
});
|
|
93
|
+
this.inFlight.set(delivery, batch.length);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async deliver(batch) {
|
|
97
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
28
98
|
try {
|
|
29
|
-
await this.options.send(
|
|
99
|
+
const { rejected } = await this.options.send(batch);
|
|
100
|
+
if (rejected > 0)
|
|
101
|
+
this.options.onFailure({ kind: 'usage_events_rejected', count: rejected });
|
|
30
102
|
return;
|
|
31
103
|
}
|
|
32
104
|
catch (error) {
|
|
33
105
|
const status = typeof error === 'object' && error !== null && 'status' in error ? error.status : undefined;
|
|
34
|
-
if (typeof status === 'number' && status >= 400 && status < 500 && status !== 429)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
106
|
+
if (typeof status === 'number' && status >= 400 && status < 500 && status !== 429) {
|
|
107
|
+
this.options.onFailure({ kind: 'usage_delivery_rejected', count: batch.length });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (attempt < ATTEMPTS - 1)
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt));
|
|
38
112
|
}
|
|
39
113
|
}
|
|
40
|
-
this.options.onFailure({ kind: 'usage_delivery_failed',
|
|
41
|
-
}
|
|
42
|
-
async flush() {
|
|
43
|
-
while (this.pending.size)
|
|
44
|
-
await Promise.all(this.pending);
|
|
114
|
+
this.options.onFailure({ kind: 'usage_delivery_failed', count: batch.length });
|
|
45
115
|
}
|
|
46
116
|
}
|
package/dist/gateway-main.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
2
3
|
import { Redis } from 'ioredis';
|
|
3
4
|
import { ArtifactGatewayControl } from './gateway/control.js';
|
|
4
5
|
import { createArtifactGateway } from './gateway/server.js';
|
|
6
|
+
import { gatewaySessionSameSite } from './gateway/session.js';
|
|
5
7
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
6
8
|
import { ArtifactRequestAdmission } from './gateway/admission.js';
|
|
9
|
+
import { GatewayControlAdmission } from './gateway/control-admission.js';
|
|
7
10
|
function required(name) {
|
|
8
11
|
const value = process.env[name];
|
|
9
12
|
if (!value)
|
|
@@ -33,8 +36,21 @@ async function main() {
|
|
|
33
36
|
process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.redis_unavailable', nodeId })}\n`);
|
|
34
37
|
});
|
|
35
38
|
await Promise.all(clients.map(client => client.connect()));
|
|
39
|
+
const log = (event) => process.stderr.write(`${JSON.stringify({ ...event, nodeId })}\n`);
|
|
40
|
+
// A flood of refused requests or forged frames must not become a log flood: count and report every 10 s.
|
|
41
|
+
const refusals = new Map();
|
|
42
|
+
const refuse = (kind) => { refusals.set(kind, (refusals.get(kind) ?? 0) + 1); };
|
|
43
|
+
setInterval(() => {
|
|
44
|
+
for (const [kind, count] of refusals)
|
|
45
|
+
log({ event: 'artifact_gateway.request', kind, count });
|
|
46
|
+
refusals.clear();
|
|
47
|
+
}, 10_000).unref();
|
|
48
|
+
const sessionKey = required('ARTIFACT_NEXT_SESSION_KEY');
|
|
36
49
|
const ownership = new RedisTunnelOwnership({ command, namespace });
|
|
37
|
-
|
|
50
|
+
// Relay frames are authenticated with a subkey of the gateway-only session key, which every
|
|
51
|
+
// replica already shares and Atlas never holds.
|
|
52
|
+
const relay = new RedisTunnelRelay({ nodeId, namespace, ownership, publisher, subscriber, secret: sessionKey,
|
|
53
|
+
onRejectedFrame: () => refuse('relay_frame_rejected') });
|
|
38
54
|
await relay.start();
|
|
39
55
|
let stopping = false;
|
|
40
56
|
subscriber.on('ready', () => { if (!stopping)
|
|
@@ -45,29 +61,45 @@ async function main() {
|
|
|
45
61
|
runtimeDomain: required('ARTIFACT_NEXT_RUNTIME_DOMAIN'),
|
|
46
62
|
parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
|
|
47
63
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
48
|
-
sessionKey
|
|
64
|
+
sessionKey,
|
|
65
|
+
sessionCookieSameSite: gatewaySessionSameSite({ value: process.env.ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE, appEnv: namespace }),
|
|
49
66
|
contextPrivateKey: required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY'),
|
|
50
67
|
contextKeyId: required('ARTIFACT_NEXT_CONTEXT_KEY_ID'),
|
|
51
68
|
nodeId, routing: ownership, relay,
|
|
52
69
|
admission: new ArtifactRequestAdmission({ redis: command, namespace }),
|
|
70
|
+
controlAdmission: new GatewayControlAdmission({ redis: command, namespace }),
|
|
53
71
|
resolveLease: sessionId => ownership.resolveLease(sessionId),
|
|
54
72
|
renewLease: lease => ownership.renew(lease, nodeId),
|
|
55
73
|
ready: () => clients.every(client => client.status === 'ready') && relay.isReady(),
|
|
56
|
-
onError: event =>
|
|
74
|
+
onError: event => { if (event.kind === 'admission_rejected')
|
|
75
|
+
refuse(event.kind);
|
|
76
|
+
else
|
|
77
|
+
log({ event: 'artifact_gateway.request', ...event }); },
|
|
57
78
|
});
|
|
58
79
|
const port = Number(process.env.PORT ?? '8080');
|
|
59
80
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
60
81
|
throw new Error('Invalid gateway PORT');
|
|
82
|
+
// Every interface in its container; a laptop run (scripts/local-stack.sh) keeps it on loopback.
|
|
83
|
+
const host = process.env.ARTIFACT_NEXT_GATEWAY_HOST ?? '0.0.0.0';
|
|
84
|
+
if (isIP(host) === 0)
|
|
85
|
+
throw new Error('Invalid ARTIFACT_NEXT_GATEWAY_HOST: expected an IP address');
|
|
61
86
|
await new Promise((resolve, reject) => {
|
|
62
87
|
gateway.server.once('error', reject);
|
|
63
|
-
gateway.server.listen(port,
|
|
88
|
+
gateway.server.listen(port, host, resolve);
|
|
64
89
|
});
|
|
65
90
|
process.stdout.write(`${JSON.stringify({ event: 'artifact_gateway.ready', nodeId, port })}\n`);
|
|
66
91
|
const stop = async () => {
|
|
67
92
|
if (stopping)
|
|
68
93
|
return;
|
|
69
94
|
stopping = true;
|
|
70
|
-
const deadline = setTimeout(() => {
|
|
95
|
+
const deadline = setTimeout(() => {
|
|
96
|
+
// Account for observations a forced exit discards; a graceful drain reports its own losses.
|
|
97
|
+
const lost = gateway.pendingUsage;
|
|
98
|
+
if (lost)
|
|
99
|
+
log({ event: 'artifact_gateway.request', kind: 'usage_lost_on_shutdown', count: lost });
|
|
100
|
+
gateway.server.closeAllConnections();
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}, 9_000);
|
|
71
103
|
deadline.unref();
|
|
72
104
|
await gateway.drain();
|
|
73
105
|
await relay.close();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts a browser always resolves to this machine: the loopback literals, `localhost` and every
|
|
3
|
+
* `*.localhost` name (RFC 6761). Browsers treat plain HTTP on them as a secure context, which is
|
|
4
|
+
* what lets local development serve application origins without TLS.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
7
|
+
/** `local` and the per-worktree `local:<name>` environments seq-studio discovers. */
|
|
8
|
+
export declare function isLocalEnvironmentName(name: string): boolean;
|
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts a browser always resolves to this machine: the loopback literals, `localhost` and every
|
|
3
|
+
* `*.localhost` name (RFC 6761). Browsers treat plain HTTP on them as a secure context, which is
|
|
4
|
+
* what lets local development serve application origins without TLS.
|
|
5
|
+
*/
|
|
6
|
+
export function isLoopbackHostname(hostname) {
|
|
7
|
+
const host = hostname.toLowerCase();
|
|
8
|
+
return host === '127.0.0.1' || host === '[::1]' || host === 'localhost' || host.endsWith('.localhost');
|
|
9
|
+
}
|
|
10
|
+
/** `local` and the per-worktree `local:<name>` environments seq-studio discovers. */
|
|
11
|
+
export function isLocalEnvironmentName(name) {
|
|
12
|
+
return name === 'local' || name.startsWith('local:');
|
|
13
|
+
}
|