@sequenceholdings/artifact-studio 0.3.0 → 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.
- package/VERSION-PIN.md +4 -0
- package/dist/cli.js +29 -3
- 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 +9 -0
- package/dist/gateway/server.js +101 -17
- package/dist/gateway/session.d.ts +1 -0
- package/dist/gateway/session.js +2 -1
- package/dist/gateway/usage.d.ts +45 -12
- package/dist/gateway/usage.js +96 -26
- package/dist/gateway-main.js +35 -5
- package/dist/manifest-next.js +13 -1
- package/dist/next/bridge.js +8 -1
- package/dist/next-cache/config.js +31 -4
- package/dist/next-image-config.js +1 -1
- package/dist/server/client.d.ts +16 -2
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +4 -0
- package/dist/server/runtime-config.d.ts +1 -0
- 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/trusted-install.js +62 -2
- package/dist/tunnel/broker.js +34 -22
- package/dist/tunnel/protocol.d.ts +9 -0
- package/dist/tunnel/protocol.js +31 -17
- 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 +1 -1
- package/templates/nextjs/README.md +6 -0
- package/templates/nextjs/gitignore +9 -0
- package/templates/nextjs/pnpm-workspace.yaml +14 -0
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') }));
|
|
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)) },
|
|
@@ -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
|
}
|
|
@@ -57,6 +57,7 @@ export declare function decodeGatewaySession(input: {
|
|
|
57
57
|
deploymentId?: string;
|
|
58
58
|
frameworkBuildId?: string;
|
|
59
59
|
}): GatewaySession | null;
|
|
60
|
+
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
60
61
|
export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId }: {
|
|
61
62
|
session: GatewaySession;
|
|
62
63
|
method: string;
|
package/dist/gateway/session.js
CHANGED
|
@@ -122,12 +122,13 @@ export function decodeGatewaySession(input) {
|
|
|
122
122
|
// admitted session deterministically; every candidate is reauthorized below.
|
|
123
123
|
return matches.sort((left, right) => right.expiresAt - left.expiresAt || (left.deploymentId ?? '').localeCompare(right.deploymentId ?? ''))[0] ?? null;
|
|
124
124
|
}
|
|
125
|
+
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
125
126
|
export function signGatewayContext({ session, method, path, privateKey, keyId, now = Math.floor(Date.now() / 1000), capabilities = [], serviceAccountId = null, requestId = randomUUID() }) {
|
|
126
127
|
const context = artifactRequestContextSchema.parse({
|
|
127
128
|
v: 1, iss: 'atlas-artifact-gateway', aud: `artifact:${session.projectId}`,
|
|
128
129
|
sub: session.ownerId, projectId: session.projectId, environment: session.environment,
|
|
129
130
|
mode: session.kind === 'development' ? 'development' : session.published ? 'published' : 'preview',
|
|
130
|
-
deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId,
|
|
131
|
+
deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId, jti: randomUUID(),
|
|
131
132
|
method, path, iat: now, exp: now + 60, capabilities, serviceAccountId,
|
|
132
133
|
bridge: session.bridge, theme: 'system',
|
|
133
134
|
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,11 @@
|
|
|
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';
|
|
5
6
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
6
7
|
import { ArtifactRequestAdmission } from './gateway/admission.js';
|
|
8
|
+
import { GatewayControlAdmission } from './gateway/control-admission.js';
|
|
7
9
|
function required(name) {
|
|
8
10
|
const value = process.env[name];
|
|
9
11
|
if (!value)
|
|
@@ -33,8 +35,21 @@ async function main() {
|
|
|
33
35
|
process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.redis_unavailable', nodeId })}\n`);
|
|
34
36
|
});
|
|
35
37
|
await Promise.all(clients.map(client => client.connect()));
|
|
38
|
+
const log = (event) => process.stderr.write(`${JSON.stringify({ ...event, nodeId })}\n`);
|
|
39
|
+
// A flood of refused requests or forged frames must not become a log flood: count and report every 10 s.
|
|
40
|
+
const refusals = new Map();
|
|
41
|
+
const refuse = (kind) => { refusals.set(kind, (refusals.get(kind) ?? 0) + 1); };
|
|
42
|
+
setInterval(() => {
|
|
43
|
+
for (const [kind, count] of refusals)
|
|
44
|
+
log({ event: 'artifact_gateway.request', kind, count });
|
|
45
|
+
refusals.clear();
|
|
46
|
+
}, 10_000).unref();
|
|
47
|
+
const sessionKey = required('ARTIFACT_NEXT_SESSION_KEY');
|
|
36
48
|
const ownership = new RedisTunnelOwnership({ command, namespace });
|
|
37
|
-
|
|
49
|
+
// Relay frames are authenticated with a subkey of the gateway-only session key, which every
|
|
50
|
+
// replica already shares and Atlas never holds.
|
|
51
|
+
const relay = new RedisTunnelRelay({ nodeId, namespace, ownership, publisher, subscriber, secret: sessionKey,
|
|
52
|
+
onRejectedFrame: () => refuse('relay_frame_rejected') });
|
|
38
53
|
await relay.start();
|
|
39
54
|
let stopping = false;
|
|
40
55
|
subscriber.on('ready', () => { if (!stopping)
|
|
@@ -45,29 +60,44 @@ async function main() {
|
|
|
45
60
|
runtimeDomain: required('ARTIFACT_NEXT_RUNTIME_DOMAIN'),
|
|
46
61
|
parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
|
|
47
62
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
48
|
-
sessionKey
|
|
63
|
+
sessionKey,
|
|
49
64
|
contextPrivateKey: required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY'),
|
|
50
65
|
contextKeyId: required('ARTIFACT_NEXT_CONTEXT_KEY_ID'),
|
|
51
66
|
nodeId, routing: ownership, relay,
|
|
52
67
|
admission: new ArtifactRequestAdmission({ redis: command, namespace }),
|
|
68
|
+
controlAdmission: new GatewayControlAdmission({ redis: command, namespace }),
|
|
53
69
|
resolveLease: sessionId => ownership.resolveLease(sessionId),
|
|
54
70
|
renewLease: lease => ownership.renew(lease, nodeId),
|
|
55
71
|
ready: () => clients.every(client => client.status === 'ready') && relay.isReady(),
|
|
56
|
-
onError: event =>
|
|
72
|
+
onError: event => { if (event.kind === 'admission_rejected')
|
|
73
|
+
refuse(event.kind);
|
|
74
|
+
else
|
|
75
|
+
log({ event: 'artifact_gateway.request', ...event }); },
|
|
57
76
|
});
|
|
58
77
|
const port = Number(process.env.PORT ?? '8080');
|
|
59
78
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
60
79
|
throw new Error('Invalid gateway PORT');
|
|
80
|
+
// Every interface in its container; a laptop run (scripts/local-stack.sh) keeps it on loopback.
|
|
81
|
+
const host = process.env.ARTIFACT_NEXT_GATEWAY_HOST ?? '0.0.0.0';
|
|
82
|
+
if (isIP(host) === 0)
|
|
83
|
+
throw new Error('Invalid ARTIFACT_NEXT_GATEWAY_HOST: expected an IP address');
|
|
61
84
|
await new Promise((resolve, reject) => {
|
|
62
85
|
gateway.server.once('error', reject);
|
|
63
|
-
gateway.server.listen(port,
|
|
86
|
+
gateway.server.listen(port, host, resolve);
|
|
64
87
|
});
|
|
65
88
|
process.stdout.write(`${JSON.stringify({ event: 'artifact_gateway.ready', nodeId, port })}\n`);
|
|
66
89
|
const stop = async () => {
|
|
67
90
|
if (stopping)
|
|
68
91
|
return;
|
|
69
92
|
stopping = true;
|
|
70
|
-
const deadline = setTimeout(() => {
|
|
93
|
+
const deadline = setTimeout(() => {
|
|
94
|
+
// Account for observations a forced exit discards; a graceful drain reports its own losses.
|
|
95
|
+
const lost = gateway.pendingUsage;
|
|
96
|
+
if (lost)
|
|
97
|
+
log({ event: 'artifact_gateway.request', kind: 'usage_lost_on_shutdown', count: lost });
|
|
98
|
+
gateway.server.closeAllConnections();
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}, 9_000);
|
|
71
101
|
deadline.unref();
|
|
72
102
|
await gateway.drain();
|
|
73
103
|
await relay.close();
|
package/dist/manifest-next.js
CHANGED
|
@@ -18,9 +18,21 @@ const apiPath = z.string().min(1).max(512).refine((value) => {
|
|
|
18
18
|
return false;
|
|
19
19
|
return value.split('/').slice(1).every((part, index, parts) => part !== '.' && part !== '..' && (part !== '' || index === parts.length - 1));
|
|
20
20
|
}, 'API capability paths must be scoped below /api/ without encoded paths, traversal, queries, or fragments');
|
|
21
|
+
// ORM references are what a delegated data token covers (orm-data-plane checkDataTokenScope):
|
|
22
|
+
// a query needs namespace/table for every table it reads, and a mutation needs its
|
|
23
|
+
// namespace/ops/Operation (or table coverage). Any other shape can never match, so it is
|
|
24
|
+
// a typo to report here rather than a 403 at request time. The patterns mirror the ORM's
|
|
25
|
+
// NAMESPACE_PATTERN, IDENTIFIER_PATTERN and GraphQL operation names; they are kept local so
|
|
26
|
+
// browser code that parses manifests does not load the ORM package. platformUsers is the
|
|
27
|
+
// ORM's built-in platform-user entity (PLATFORM_USER_TABLE), a logical key outside the
|
|
28
|
+
// identifier pattern that queries traversing user relations read.
|
|
29
|
+
const ormTableRef = /^[a-z][a-z0-9_]{0,40}\/(?:[a-z][a-z0-9_]{0,62}|platformUsers)$/;
|
|
30
|
+
const ormOperationRef = /^[a-z][a-z0-9_]{0,40}\/ops\/[_A-Za-z][_0-9A-Za-z]*$/;
|
|
31
|
+
const dataReadRef = boundedRef.refine((ref) => ormTableRef.test(ref), 'ORM read references use namespace/table');
|
|
32
|
+
const dataWriteRef = boundedRef.refine((ref) => ormTableRef.test(ref) || ormOperationRef.test(ref), 'ORM write references use namespace/ops/Operation or namespace/table');
|
|
21
33
|
const reachShape = {
|
|
22
34
|
storage: storageScopesSchema.optional(),
|
|
23
|
-
data: z.strictObject({ read: z.array(
|
|
35
|
+
data: z.strictObject({ read: z.array(dataReadRef).max(256).default([]), write: z.array(dataWriteRef).max(256).default([]) }).prefault({}),
|
|
24
36
|
api: z.strictObject({ read: z.array(apiPath).max(256).default([]), write: z.array(apiPath).max(256).default([]) }).prefault({}),
|
|
25
37
|
functions: z.strictObject({ invoke: z.array(boundedRef).max(256).default([]) }).prefault({}),
|
|
26
38
|
};
|
package/dist/next/bridge.js
CHANGED
|
@@ -103,7 +103,14 @@ export function createNextArtifactBridge(options) {
|
|
|
103
103
|
if (ready)
|
|
104
104
|
return ready;
|
|
105
105
|
if (!transport) {
|
|
106
|
-
|
|
106
|
+
// Report a missing Atlas parent frame like every other connection failure,
|
|
107
|
+
// so a caller's promise handler sees it instead of an uncaught throw.
|
|
108
|
+
try {
|
|
109
|
+
transport = createNextArtifactWindowTransport();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return Promise.reject(error instanceof Error ? error : new Error('Artifact bridge transport unavailable'));
|
|
113
|
+
}
|
|
107
114
|
uninstallReleaseGuard = installArtifactReleaseGuard({ deploymentId: bootstrap.deploymentId ?? null });
|
|
108
115
|
}
|
|
109
116
|
ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|