@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.
Files changed (43) hide show
  1. package/VERSION-PIN.md +4 -0
  2. package/dist/cli.js +29 -3
  3. package/dist/gateway/admission.d.ts +2 -1
  4. package/dist/gateway/admission.js +3 -1
  5. package/dist/gateway/authorization-cache.d.ts +52 -0
  6. package/dist/gateway/authorization-cache.js +111 -0
  7. package/dist/gateway/control-admission.d.ts +48 -0
  8. package/dist/gateway/control-admission.js +127 -0
  9. package/dist/gateway/control.d.ts +4 -1
  10. package/dist/gateway/control.js +5 -2
  11. package/dist/gateway/hosted-proxy.js +4 -7
  12. package/dist/gateway/server.d.ts +9 -0
  13. package/dist/gateway/server.js +101 -17
  14. package/dist/gateway/session.d.ts +1 -0
  15. package/dist/gateway/session.js +2 -1
  16. package/dist/gateway/usage.d.ts +45 -12
  17. package/dist/gateway/usage.js +96 -26
  18. package/dist/gateway-main.js +35 -5
  19. package/dist/manifest-next.js +13 -1
  20. package/dist/next/bridge.js +8 -1
  21. package/dist/next-cache/config.js +31 -4
  22. package/dist/next-image-config.js +1 -1
  23. package/dist/server/client.d.ts +16 -2
  24. package/dist/server/request-context.d.ts +1 -0
  25. package/dist/server/request-context.js +4 -0
  26. package/dist/server/runtime-config.d.ts +1 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/templates/nextjs/README.md +6 -0
  29. package/dist/templates/nextjs/gitignore +9 -0
  30. package/dist/templates/nextjs/pnpm-workspace.yaml +14 -0
  31. package/dist/trusted-install.js +62 -2
  32. package/dist/tunnel/broker.js +34 -22
  33. package/dist/tunnel/protocol.d.ts +9 -0
  34. package/dist/tunnel/protocol.js +31 -17
  35. package/dist/tunnel/redis-auth.d.ts +37 -0
  36. package/dist/tunnel/redis-auth.js +92 -0
  37. package/dist/tunnel/redis-types.d.ts +5 -0
  38. package/dist/tunnel/redis.d.ts +5 -2
  39. package/dist/tunnel/redis.js +19 -6
  40. package/package.json +1 -1
  41. package/templates/nextjs/README.md +6 -0
  42. package/templates/nextjs/gitignore +9 -0
  43. package/templates/nextjs/pnpm-workspace.yaml +14 -0
@@ -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
- const usage = new GatewayUsageReporter({ send: event => options.control.usage(event), onFailure: event => options.onError?.(event) });
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
- if ((!['GET', 'HEAD', 'OPTIONS'].includes(method) || header(request, 'upgrade')) && header(request, 'origin') !== origin)
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
- const target = await options.control.authorize(session);
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
- const lease = await options.control.tunnel({ token, sessionId, epoch });
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
- const current = await options.control.tunnel({ token: credential.token, sessionId: lease.sessionId, epoch: lease.epoch });
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 ((request.url === '/_sequence/health' || request.url === '/health') && request.method === 'GET') {
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
- if (header(request, 'origin') !== origin)
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' : 'request_failed' });
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.flush();
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;
@@ -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 },
@@ -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
- /** Bounded delivery of observations; accepted records are deduplicated durably by Atlas.
18
- * Process loss can drop pending observations, so these are not provider billing totals.
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 queue;
23
- private readonly pending;
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: (event: GatewayUsageEvent) => Promise<void>;
26
- onFailure: (event: {
27
- kind: string;
28
- requestId: string;
29
- }) => void;
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
  }
@@ -1,46 +1,116 @@
1
- /** Bounded delivery of observations; accepted records are deduplicated durably by Atlas.
2
- * Process loss can drop pending observations, so these are not provider billing totals.
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
- queue = [];
7
- pending = new Set();
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.queue.length + this.pending.size >= (this.options.capacity ?? 1024)) {
13
- this.options.onFailure({ kind: 'usage_queue_full', requestId: event.requestId });
34
+ if (this.closed) {
35
+ this.options.onFailure({ kind: 'usage_lost_on_shutdown', count: 1 });
14
36
  return;
15
37
  }
16
- this.queue.push(event);
17
- this.pump();
18
- }
19
- pump() {
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 deliver(event) {
27
- for (let attempt = 0; attempt < 3; attempt++) {
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(event);
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
- break;
36
- if (attempt < 2)
37
- await new Promise(resolve => setTimeout(resolve, 100 * 2 ** attempt));
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', requestId: event.requestId });
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
  }
@@ -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
- const relay = new RedisTunnelRelay({ nodeId, namespace, ownership, publisher, subscriber });
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: required('ARTIFACT_NEXT_SESSION_KEY'),
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 => process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.request', ...event, nodeId })}\n`),
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, '0.0.0.0', resolve);
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(() => { gateway.server.closeAllConnections(); process.exit(1); }, 9_000);
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();
@@ -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(boundedRef).max(256).default([]), write: z.array(boundedRef).max(256).default([]) }).prefault({}),
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
  };
@@ -103,7 +103,14 @@ export function createNextArtifactBridge(options) {
103
103
  if (ready)
104
104
  return ready;
105
105
  if (!transport) {
106
- transport = createNextArtifactWindowTransport();
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; });