@sequenceholdings/artifact-studio 0.6.4 → 0.6.7

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 (80) hide show
  1. package/README.md +254 -10
  2. package/VERSION-PIN.md +78 -1
  3. package/dist/gateway/admission.d.ts +23 -1
  4. package/dist/gateway/admission.js +30 -13
  5. package/dist/gateway/control.d.ts +40 -0
  6. package/dist/gateway/control.js +76 -7
  7. package/dist/gateway/hosted-proxy.d.ts +11 -8
  8. package/dist/gateway/hosted-proxy.js +99 -91
  9. package/dist/gateway/ingress-admission.d.ts +77 -0
  10. package/dist/gateway/ingress-admission.js +150 -0
  11. package/dist/gateway/ingress-audit.d.ts +46 -0
  12. package/dist/gateway/ingress-audit.js +84 -0
  13. package/dist/gateway/ingress-context.d.ts +57 -0
  14. package/dist/gateway/ingress-context.js +57 -0
  15. package/dist/gateway/ingress-development.d.ts +12 -0
  16. package/dist/gateway/ingress-development.js +23 -0
  17. package/dist/gateway/ingress-handler.d.ts +44 -0
  18. package/dist/gateway/ingress-handler.js +320 -0
  19. package/dist/gateway/ingress-pass.d.ts +83 -0
  20. package/dist/gateway/ingress-pass.js +143 -0
  21. package/dist/gateway/ingress-response.d.ts +74 -0
  22. package/dist/gateway/ingress-response.js +98 -0
  23. package/dist/gateway/ingress-runtime.d.ts +86 -0
  24. package/dist/gateway/ingress-runtime.js +126 -0
  25. package/dist/gateway/ingress-snapshot.d.ts +54 -0
  26. package/dist/gateway/ingress-snapshot.js +63 -0
  27. package/dist/gateway/ingress-stages.d.ts +60 -0
  28. package/dist/gateway/ingress-stages.js +387 -0
  29. package/dist/gateway/ingress.d.ts +259 -0
  30. package/dist/gateway/ingress.js +204 -0
  31. package/dist/gateway/lattice-invoke.js +2 -0
  32. package/dist/gateway/lattice-upstream.d.ts +3 -4
  33. package/dist/gateway/lattice-upstream.js +11 -9
  34. package/dist/gateway/runtime-transport.d.ts +17 -0
  35. package/dist/gateway/runtime-transport.js +26 -0
  36. package/dist/gateway/server.d.ts +3 -0
  37. package/dist/gateway/server.js +21 -0
  38. package/dist/gateway/session-response.d.ts +15 -0
  39. package/dist/gateway/session-response.js +40 -0
  40. package/dist/gateway/usage.d.ts +8 -1
  41. package/dist/gateway-main.js +32 -8
  42. package/dist/manifest-next-browser-data.d.ts +17 -0
  43. package/dist/manifest-next-browser-data.js +46 -0
  44. package/dist/manifest-next-ingress.d.ts +5 -2
  45. package/dist/manifest-next-ingress.js +27 -2
  46. package/dist/manifest-next.d.ts +27 -10
  47. package/dist/manifest-next.js +16 -5
  48. package/dist/manifest.d.ts +13 -6
  49. package/dist/next-project.d.ts +4 -0
  50. package/dist/sdk.d.ts +12 -3
  51. package/dist/server/broker-contract.d.ts +62 -0
  52. package/dist/server/broker-contract.js +43 -1
  53. package/dist/server/client.js +38 -13
  54. package/dist/server/external-context.d.ts +8 -1
  55. package/dist/server/external-context.js +7 -2
  56. package/dist/server/internal/v1-context.js +17 -1
  57. package/dist/server/internal/verify-any.d.ts +25 -2
  58. package/dist/server/internal/verify-any.js +38 -4
  59. package/dist/server/process-run-sharing.d.ts +86 -0
  60. package/dist/server/process-run-sharing.js +29 -0
  61. package/dist/server/process-runs-page.d.ts +39 -0
  62. package/dist/server/process-runs-page.js +10 -0
  63. package/dist/server/processes.d.ts +8 -9
  64. package/dist/server/processes.js +4 -0
  65. package/dist/server/request-context.js +10 -3
  66. package/dist/server/runtime-config.d.ts +10 -1
  67. package/dist/server/runtime-config.js +11 -1
  68. package/dist/server/testing.d.ts +1 -1
  69. package/dist/server.d.ts +2 -2
  70. package/dist/server.js +1 -1
  71. package/dist/tunnel/broker.d.ts +18 -0
  72. package/dist/tunnel/broker.js +175 -17
  73. package/dist/tunnel/client.d.ts +13 -0
  74. package/dist/tunnel/client.js +40 -2
  75. package/dist/tunnel/external.d.ts +51 -0
  76. package/dist/tunnel/external.js +10 -0
  77. package/dist/tunnel/index.d.ts +1 -1
  78. package/dist/tunnel/protocol.d.ts +20 -2
  79. package/dist/tunnel/protocol.js +42 -6
  80. package/package.json +5 -5
@@ -2,6 +2,7 @@ import { WebSocketServer } from 'ws';
2
2
  import { TunnelPeer, tunnelLimits } from './peer.js';
3
3
  import { assertSafeRequest, encodeTunnelFrame, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL, TUNNEL_PROTOCOL } from './protocol.js';
4
4
  import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
5
+ import { declaredLength, isExternalFailure } from './external.js';
5
6
  const UPGRADE_REASONS = { 403: 'Forbidden', 429: 'Too Many Requests', 503: 'Service Unavailable' };
6
7
  function rejectUpgrade(socket, status) {
7
8
  socket.end(`HTTP/1.1 ${status} ${UPGRADE_REASONS[status] ?? 'Service Unavailable'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
@@ -194,6 +195,91 @@ export class ArtifactTunnelBroker {
194
195
  rejectHttp(response, 403);
195
196
  }
196
197
  }
198
+ /** Proxies an admitted external ingress request over the session's tunnel. Never throws. */
199
+ async handleExternalHttp(input) {
200
+ const { request, response, sessionId } = input;
201
+ const method = request.method ?? 'GET';
202
+ const maxRequestBytes = Math.min(input.maxRequestBytes, this.limits.maxBodyBytes);
203
+ try {
204
+ assertSafeRequest(method, request.url ?? '/');
205
+ if (!input.requestContext || /[\r\n]/.test(input.requestContext))
206
+ throw new Error('Invalid request context');
207
+ }
208
+ catch {
209
+ input.fail(400);
210
+ return;
211
+ }
212
+ const length = request.headers['content-length'];
213
+ if (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || Number(length) > maxRequestBytes)) {
214
+ input.fail(413);
215
+ return;
216
+ }
217
+ let connection;
218
+ try {
219
+ connection = await this.externalConnection(input);
220
+ }
221
+ catch {
222
+ connection = null;
223
+ }
224
+ // No tunnel, or one for another project: the session ended or never connected.
225
+ if (!connection) {
226
+ input.fail(503);
227
+ return;
228
+ }
229
+ let headers;
230
+ try {
231
+ headers = sanitizeRequestHeaders(request.headers, input.publicOrigin, { mode: 'ingress', appHeaders: input.appHeaders });
232
+ }
233
+ catch {
234
+ input.fail(400);
235
+ return;
236
+ }
237
+ headers['x-sequence-artifact-context'] = input.requestContext;
238
+ let id;
239
+ try {
240
+ id = connection.peer.open();
241
+ }
242
+ catch {
243
+ input.fail(503);
244
+ return;
245
+ }
246
+ const active = connection;
247
+ const streamId = id;
248
+ const timer = setTimeout(() => this.abort(active, streamId, 504), Math.min(input.timeoutMs, this.limits.requestTimeoutMs));
249
+ timer.unref();
250
+ connection.streams.set(id, {
251
+ request, response, timer, bytes: 0, receivedHeaders: false,
252
+ message: new WebSocketMessage(this.limits.maxWebSocketMessageBytes, connection.messageBudget),
253
+ external: {
254
+ writeHead: input.writeHead, fail: input.fail, idleWriteTimeoutMs: input.idleWriteTimeoutMs,
255
+ maxResponseBytes: Math.min(input.maxResponseBytes, this.limits.maxBodyBytes),
256
+ },
257
+ });
258
+ // Even a response the gateway finished itself (its admission timeout's 504) stops the stream;
259
+ // a stream that completed normally is already gone, so this is a no-op for it.
260
+ response.once('close', () => this.abort(active, streamId));
261
+ request.once('aborted', () => this.abort(active, streamId));
262
+ try {
263
+ await connection.peer.send({ type: 'request', id, method, path: request.url ?? '/', headers, websocket: false });
264
+ let total = 0;
265
+ for await (const chunk of input.body ?? request) {
266
+ if (!connection.streams.has(id))
267
+ return;
268
+ const bytes = Buffer.from(chunk);
269
+ total += bytes.length;
270
+ if (total > maxRequestBytes) {
271
+ this.abort(connection, id, 413);
272
+ return;
273
+ }
274
+ await connection.peer.data(id, bytes);
275
+ }
276
+ if (connection.streams.has(id))
277
+ await connection.peer.send({ type: 'end', id });
278
+ }
279
+ catch {
280
+ this.abort(connection, id, 503);
281
+ }
282
+ }
197
283
  async handleWebSocketUpgrade(request, socket, head, sessionId) {
198
284
  socket.pause();
199
285
  try {
@@ -255,6 +341,29 @@ export class ArtifactTunnelBroker {
255
341
  return null;
256
342
  return { connection, headers };
257
343
  }
344
+ /**
345
+ * The live tunnel for an external request: the session's current lease, for the granted project
346
+ * and environment, still valid. Null otherwise. No browser owner is compared: Atlas's grant
347
+ * already checked the session and its owner's grant on the ingress service account.
348
+ */
349
+ async externalConnection({ sessionId, projectId, environment }) {
350
+ if (this.closing)
351
+ return null;
352
+ const granted = (lease) => lease.sessionId === sessionId && lease.projectId === projectId && lease.environment === environment;
353
+ let connection = this.connections.get(sessionId);
354
+ if (this.options.relay && this.options.resolveLease) {
355
+ const lease = await this.options.resolveLease(sessionId);
356
+ if (!lease || !granted(lease) || !(await this.options.validateLease(lease)))
357
+ return null;
358
+ if (!connection || connection.lease.epoch !== lease.epoch)
359
+ connection = await this.remoteConnection(lease);
360
+ else
361
+ connection.lease.expiresAt = lease.expiresAt;
362
+ }
363
+ if (!connection || !granted(connection.lease) || !(await this.valid(connection)))
364
+ return null;
365
+ return connection;
366
+ }
258
367
  async valid(connection) {
259
368
  const owner = await this.options.routing.lookup(connection.lease.sessionId);
260
369
  if (this.options.resolveLease) {
@@ -330,10 +439,7 @@ export class ArtifactTunnelBroker {
330
439
  if (frame.type === 'request')
331
440
  throw new Error('Developer cannot initiate gateway requests');
332
441
  if (frame.type === 'response') {
333
- if (!stream.response || stream.receivedHeaders)
334
- throw new Error('Unexpected response');
335
- stream.receivedHeaders = true;
336
- stream.response.writeHead(frame.status, developmentResponseHeaders({ response: stream.response, headers: sanitizeResponseHeaders(frame.headers) }));
442
+ this.receiveHead(connection, stream, frame);
337
443
  return;
338
444
  }
339
445
  if (frame.type === 'data') {
@@ -348,17 +454,8 @@ export class ArtifactTunnelBroker {
348
454
  }
349
455
  }
350
456
  }
351
- else {
352
- if (!stream.response || !stream.receivedHeaders || stream.request.method === 'HEAD')
353
- throw new Error('Unexpected response body');
354
- const bytes = Buffer.from(frame.data, 'base64');
355
- stream.bytes += bytes.length;
356
- if (stream.bytes > this.limits.maxBodyBytes) {
357
- this.abort(connection, frame.id, 502);
358
- return;
359
- }
360
- await writeChunk(stream.response, bytes);
361
- }
457
+ else
458
+ await this.receiveBody(connection, stream, frame);
362
459
  return;
363
460
  }
364
461
  if (frame.type === 'end') {
@@ -399,6 +496,62 @@ export class ArtifactTunnelBroker {
399
496
  upgrade.socket.resume();
400
497
  }
401
498
  }
499
+ receiveHead(connection, stream, frame) {
500
+ if (!stream.response || stream.receivedHeaders)
501
+ throw new Error('Unexpected response');
502
+ const headers = sanitizeResponseHeaders(frame.headers);
503
+ if (stream.external) {
504
+ // Decided on the head, before any body byte. The tunnel client carries the app's validated
505
+ // content-length on the frame; sanitising has already removed it from what the caller gets.
506
+ const declared = declaredLength(frame.headers['content-length']);
507
+ if (!stream.external.writeHead({ status: frame.status, headers, declaredLength: declared })) {
508
+ this.abort(connection, frame.id, 502);
509
+ return;
510
+ }
511
+ // The body may not outgrow the length the head was judged on.
512
+ stream.external.declaredLength = declared;
513
+ stream.receivedHeaders = true;
514
+ return;
515
+ }
516
+ stream.receivedHeaders = true;
517
+ stream.response.writeHead(frame.status, developmentResponseHeaders({ response: stream.response, headers }));
518
+ }
519
+ async receiveBody(connection, stream, frame) {
520
+ if (!stream.response || !stream.receivedHeaders || stream.request.method === 'HEAD')
521
+ throw new Error('Unexpected response body');
522
+ const bytes = Buffer.from(frame.data, 'base64');
523
+ stream.bytes += bytes.length;
524
+ const cap = stream.external
525
+ ? Math.min(stream.external.maxResponseBytes, stream.external.declaredLength ?? Number.POSITIVE_INFINITY)
526
+ : this.limits.maxBodyBytes;
527
+ if (stream.bytes > cap) {
528
+ this.abort(connection, frame.id, 502);
529
+ return;
530
+ }
531
+ if (stream.external)
532
+ await this.writeExternalChunk(connection, frame.id, stream.response, bytes, stream.external.idleWriteTimeoutMs);
533
+ else
534
+ await writeChunk(stream.response, bytes);
535
+ }
536
+ /**
537
+ * Backpressure from an external caller abandons the request after the idle timeout instead of
538
+ * holding its stream (and the caller's admission permit) until the request timeout. A write the
539
+ * abort interrupted is not a tunnel failure, so the session's other streams carry on.
540
+ */
541
+ async writeExternalChunk(connection, id, response, bytes, idleWriteTimeoutMs) {
542
+ const timer = setTimeout(() => this.abort(connection, id, 504), idleWriteTimeoutMs);
543
+ timer.unref();
544
+ try {
545
+ await writeChunk(response, bytes);
546
+ }
547
+ catch (error) {
548
+ if (connection.streams.has(id))
549
+ throw error;
550
+ }
551
+ finally {
552
+ clearTimeout(timer);
553
+ }
554
+ }
402
555
  finish(connection, id, status) {
403
556
  const stream = connection.streams.get(id);
404
557
  if (!stream)
@@ -408,8 +561,13 @@ export class ArtifactTunnelBroker {
408
561
  clearTimeout(stream.timer);
409
562
  stream.message.reset();
410
563
  if (stream.response) {
411
- if (status || !stream.receivedHeaders)
412
- rejectHttp(stream.response, status ?? 502);
564
+ if (status || !stream.receivedHeaders) {
565
+ const failure = status ?? 502;
566
+ if (stream.external)
567
+ stream.external.fail(isExternalFailure(failure) ? failure : 502);
568
+ else
569
+ rejectHttp(stream.response, failure);
570
+ }
413
571
  else
414
572
  stream.response.end();
415
573
  }
@@ -1,4 +1,5 @@
1
1
  import { type TunnelLimits } from './peer.js';
2
+ import { type TunnelFrame } from './protocol.js';
2
3
  export interface LocalTunnelOptions {
3
4
  url: string;
4
5
  token: string;
@@ -20,3 +21,15 @@ export interface LocalTunnelConnection {
20
21
  /** One connection/epoch only. The caller obtains a fresh lease to reconnect;
21
22
  * no HTTP request or mutation is ever retained or replayed. */
22
23
  export declare function connectLocalTunnel(options: LocalTunnelOptions): Promise<LocalTunnelConnection>;
24
+ /**
25
+ * The origin a forwarded request is pinned to. An external ingress request (its context signed
26
+ * by an `external-` kid, which only the gateway puts on a frame) keeps the ingress origin the
27
+ * broker set, so the app sees the same host as on a hosted release. Everything else is pinned to
28
+ * this session's own origin.
29
+ */
30
+ export declare function tunnelFrameOrigin({ headers, sessionOrigin }: {
31
+ headers: Extract<TunnelFrame, {
32
+ type: 'request';
33
+ }>['headers'];
34
+ sessionOrigin: string;
35
+ }): string;
@@ -3,7 +3,9 @@ import WebSocket from 'ws';
3
3
  import { TunnelPeer, tunnelLimits } from './peer.js';
4
4
  import { assertSafeRequest, normalizePublicOrigin, parseTunnelFrame, sanitizeRequestHeaders, sanitizeResponseHeaders, TUNNEL_PROTOCOL } from './protocol.js';
5
5
  import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
6
+ import { declaredLength } from './external.js';
6
7
  import { isLoopbackHostname } from '../loopback.js';
8
+ import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX } from '../server/internal/signed-token.js';
7
9
  /** One connection/epoch only. The caller obtains a fresh lease to reconnect;
8
10
  * no HTTP request or mutation is ever retained or replayed. */
9
11
  export async function connectLocalTunnel(options) {
@@ -52,7 +54,7 @@ export async function connectLocalTunnel(options) {
52
54
  const handle = async (frame) => {
53
55
  if (frame.type === 'request') {
54
56
  assertSafeRequest(frame.method, frame.path);
55
- const headers = sanitizeRequestHeaders(frame.headers, publicOrigin);
57
+ const headers = sanitizeRequestHeaders(frame.headers, tunnelFrameOrigin({ headers: frame.headers, sessionOrigin: publicOrigin }));
56
58
  // Only the already authenticated gateway may mint this signed context.
57
59
  const context = frame.headers['x-sequence-artifact-context'];
58
60
  if (typeof context === 'string')
@@ -91,7 +93,14 @@ export async function connectLocalTunnel(options) {
91
93
  local.once('response', (response) => {
92
94
  void (async () => {
93
95
  try {
94
- await peer.send({ type: 'response', id: frame.id, status: response.statusCode ?? 502, headers: sanitizeResponseHeaders(response.headers) });
96
+ // Sanitising drops content-length as a hop header. The validated length the app declared
97
+ // rides on the frame so the gateway can decide an external response on its head as hosted
98
+ // ingress does (an empty 200 needs no content type); the broker never passes it on.
99
+ const declared = declaredLength(response.headers['content-length']);
100
+ const headers = sanitizeResponseHeaders(response.headers);
101
+ if (declared !== null)
102
+ headers['content-length'] = String(declared);
103
+ await peer.send({ type: 'response', id: frame.id, status: response.statusCode ?? 502, headers });
95
104
  let total = 0;
96
105
  for await (const chunk of response) {
97
106
  const bytes = Buffer.from(chunk);
@@ -182,3 +191,32 @@ export async function connectLocalTunnel(options) {
182
191
  });
183
192
  return { closed, close: abort };
184
193
  }
194
+ /**
195
+ * The origin a forwarded request is pinned to. An external ingress request (its context signed
196
+ * by an `external-` kid, which only the gateway puts on a frame) keeps the ingress origin the
197
+ * broker set, so the app sees the same host as on a hosted release. Everything else is pinned to
198
+ * this session's own origin.
199
+ */
200
+ export function tunnelFrameOrigin({ headers, sessionOrigin }) {
201
+ const context = headers['x-sequence-artifact-context'];
202
+ const host = headers['x-forwarded-host'];
203
+ const proto = headers['x-forwarded-proto'];
204
+ if (typeof context !== 'string' || typeof host !== 'string' || typeof proto !== 'string' || !signedByExternalKid(context))
205
+ return sessionOrigin;
206
+ try {
207
+ return normalizePublicOrigin(`${proto}://${host}`);
208
+ }
209
+ catch {
210
+ return sessionOrigin;
211
+ }
212
+ }
213
+ function signedByExternalKid(token) {
214
+ try {
215
+ const header = JSON.parse(Buffer.from(token.split('.')[0] ?? '', 'base64url').toString('utf8'));
216
+ return typeof header === 'object' && header !== null && 'kid' in header && typeof header.kid === 'string' &&
217
+ header.kid.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX);
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ }
@@ -0,0 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** The failures an external request's writer answers itself (the gateway's error contract). */
3
+ export type TunnelExternalFailure = 400 | 413 | 502 | 503 | 504;
4
+ /**
5
+ * An external ingress request proxied over a development session's tunnel (local development
6
+ * only, design §(j.3) step 3). No browser session or cookie is involved: the gateway admitted the
7
+ * caller and signed the context. The broker only checks that the session's tunnel belongs to the
8
+ * granted project and environment, forwards ingress-sanitised headers, and leaves every response
9
+ * decision to the caller's `writeHead` and every failure to its `fail`.
10
+ */
11
+ export interface TunnelExternalRequest {
12
+ request: IncomingMessage;
13
+ response: ServerResponse;
14
+ sessionId: string;
15
+ projectId: string;
16
+ environment: string;
17
+ /**
18
+ * The ingress host's canonical origin. The app sees it as `host` / `x-forwarded-host`, as it sees
19
+ * the ingress origin on a hosted release, not the development session's own origin.
20
+ */
21
+ publicOrigin: string;
22
+ /** The signed external context, forwarded as the only platform header. */
23
+ requestContext: string;
24
+ /** Declared request headers forwarded on top of the fixed ingress allowlist. */
25
+ appHeaders: readonly string[];
26
+ /** The request body, when the caller buffered it; otherwise the request stream is forwarded. */
27
+ body?: AsyncIterable<Uint8Array>;
28
+ maxRequestBytes: number;
29
+ maxResponseBytes: number;
30
+ timeoutMs: number;
31
+ /** A response whose writes stay blocked this long is abandoned (504). */
32
+ idleWriteTimeoutMs: number;
33
+ /** Applies the response policy to the head and sets it; false refuses the response (502, no body byte sent). */
34
+ writeHead(input: {
35
+ status: number;
36
+ headers: Record<string, string | string[]>;
37
+ declaredLength: number | null;
38
+ }): boolean;
39
+ /** Writes a broker-decided failure; destroys the response when its head was already sent. */
40
+ fail(status: TunnelExternalFailure): void;
41
+ }
42
+ export type ExternalStream = Pick<TunnelExternalRequest, 'writeHead' | 'fail' | 'maxResponseBytes' | 'idleWriteTimeoutMs'> & {
43
+ /** The content-length the accepted head declared; the body is cut off once it passes it. */
44
+ declaredLength?: number | null;
45
+ };
46
+ export declare function isExternalFailure(status: number): status is TunnelExternalFailure;
47
+ /**
48
+ * A declared body length as a plain decimal, else null. Read from a raw head: the shared
49
+ * sanitising removes `content-length` as a hop header.
50
+ */
51
+ export declare function declaredLength(value: string | string[] | undefined): number | null;
@@ -0,0 +1,10 @@
1
+ export function isExternalFailure(status) {
2
+ return status === 400 || status === 413 || status === 502 || status === 503 || status === 504;
3
+ }
4
+ /**
5
+ * A declared body length as a plain decimal, else null. Read from a raw head: the shared
6
+ * sanitising removes `content-length` as a hop header.
7
+ */
8
+ export function declaredLength(value) {
9
+ return typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value) ? Number(value) : null;
10
+ }
@@ -1,5 +1,5 @@
1
1
  export { connectLocalTunnel, type LocalTunnelOptions, type LocalTunnelConnection } from './client.js';
2
- export { ArtifactTunnelBroker, SingleProcessTunnelOwnership, type TunnelBrokerOptions, type TunnelLease, type TunnelOwner, type TunnelOwnershipStore, type GatewayAuthorization, } from './broker.js';
2
+ export { ArtifactTunnelBroker, SingleProcessTunnelOwnership, type TunnelBrokerOptions, type TunnelLease, type TunnelOwner, type TunnelOwnershipStore, type GatewayAuthorization, type TunnelExternalFailure, type TunnelExternalRequest, } from './broker.js';
3
3
  export { DEFAULT_TUNNEL_LIMITS, TUNNEL_PROTOCOL, type TunnelFrame } from './protocol.js';
4
4
  export type { TunnelLimits } from './peer.js';
5
5
  export type { TunnelRelay, TunnelSocket } from './transport.js';
@@ -63,8 +63,26 @@ export type TunnelFrame = z.infer<typeof tunnelFrameSchema>;
63
63
  export declare function parseTunnelFrame(input: string | Buffer | ArrayBuffer | Buffer[]): TunnelFrame;
64
64
  export declare function encodeTunnelFrame(frame: TunnelFrame): string;
65
65
  export declare function normalizePublicOrigin(value: string): string;
66
- /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
67
- export declare function sanitizeRequestHeaders(headers: HeaderInput, publicOrigin: string): TunnelHeaders;
66
+ /**
67
+ * The request headers an external ingress forwards (design §(e) "Request sanitising"): a short
68
+ * fixed set plus the `x-` headers the ingress declared in its manifest. Everything else is
69
+ * dropped, so a cross-origin caller cannot reach framework-internal, routing-override,
70
+ * conditional or client-address headers the app might trust.
71
+ */
72
+ export declare const INGRESS_FORWARDED_REQUEST_HEADERS: readonly string[];
73
+ /** `session`: same-origin iframe traffic. `ingress`: an external caller; `appHeaders` are the ingress's declared headers. */
74
+ export type RequestHeaderMode = {
75
+ mode?: 'session';
76
+ } | {
77
+ mode: 'ingress';
78
+ appHeaders: readonly string[];
79
+ };
80
+ /**
81
+ * Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. A session
82
+ * request must come from its own origin; an `ingress` request is cross-origin by design, so it
83
+ * keeps only its allowlisted headers and its `Origin` is dropped instead.
84
+ */
85
+ export declare function sanitizeRequestHeaders(headers: HeaderInput, publicOrigin: string, mode?: RequestHeaderMode): TunnelHeaders;
68
86
  /** Response headers through which application output could opt into CDN or proxy reuse. */
69
87
  export declare function isSharedCacheDirective(name: string): boolean;
70
88
  /** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { z } from 'zod';
3
3
  import { isLoopbackHostname } from '../loopback.js';
4
- import { isPrivateRequestHeader } from './reserved-headers.js';
4
+ import { INGRESS_CLIENT_ADDRESS_HEADERS, isPrivateRequestHeader } from './reserved-headers.js';
5
5
  /** Version 1 is negotiated by the tunnel connection, not by individual streams. */
6
6
  export const TUNNEL_PROTOCOL = 'artifact-tunnel.v1';
7
7
  export const DEFAULT_TUNNEL_LIMITS = Object.freeze({
@@ -148,13 +148,49 @@ function removePrivateHeaders(headers) {
148
148
  headers.delete(name);
149
149
  }
150
150
  }
151
- /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
152
- export function sanitizeRequestHeaders(headers, publicOrigin) {
151
+ /**
152
+ * The request headers an external ingress forwards (design §(e) "Request sanitising"): a short
153
+ * fixed set plus the `x-` headers the ingress declared in its manifest. Everything else is
154
+ * dropped, so a cross-origin caller cannot reach framework-internal, routing-override,
155
+ * conditional or client-address headers the app might trust.
156
+ */
157
+ export const INGRESS_FORWARDED_REQUEST_HEADERS = Object.freeze([
158
+ // Content-Length is a hop header the proxy re-frames, so it is not listed.
159
+ 'content-type', 'accept', 'accept-encoding', 'user-agent',
160
+ ]);
161
+ /**
162
+ * Dropped on an ingress even if an ingress declared one (the manifest already refuses them):
163
+ * browser credentials and the caller's `Origin` (the gateway matched it against the binding),
164
+ * the edge secret, and every client-address header. The client address an app sees comes only
165
+ * from the signed context, never from a header the caller or another Cloudflare zone could set.
166
+ */
167
+ const INGRESS_DROPPED_REQUEST_HEADERS = new Set([
168
+ 'cookie', 'origin', 'authorization', 'x-sequence-edge', ...INGRESS_CLIENT_ADDRESS_HEADERS,
169
+ ]);
170
+ function isIngressForwardedRequestHeader(name, declared) {
171
+ if (INGRESS_DROPPED_REQUEST_HEADERS.has(name) || name.startsWith('cf-'))
172
+ return false;
173
+ return INGRESS_FORWARDED_REQUEST_HEADERS.includes(name) || declared.has(name);
174
+ }
175
+ /**
176
+ * Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. A session
177
+ * request must come from its own origin; an `ingress` request is cross-origin by design, so it
178
+ * keeps only its allowlisted headers and its `Origin` is dropped instead.
179
+ */
180
+ export function sanitizeRequestHeaders(headers, publicOrigin, mode = {}) {
153
181
  const origin = normalizePublicOrigin(publicOrigin);
154
182
  const normalized = normalizedHeaders(headers, browserRequestHeadersSchema);
155
- const suppliedOrigin = normalized.get('origin');
156
- if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
157
- throw new Error('Tunnel request origin mismatch');
183
+ if (mode.mode === 'ingress') {
184
+ const declared = new Set(mode.appHeaders);
185
+ for (const name of [...normalized.keys()])
186
+ if (!isIngressForwardedRequestHeader(name, declared))
187
+ normalized.delete(name);
188
+ }
189
+ else {
190
+ const suppliedOrigin = normalized.get('origin');
191
+ if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
192
+ throw new Error('Tunnel request origin mismatch');
193
+ }
158
194
  removePrivateHeaders(normalized);
159
195
  const cookie = normalized.get('cookie');
160
196
  if (cookie !== undefined) {
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "publishConfig": {
10
10
  "access": "public"
11
11
  },
12
- "version": "0.6.4",
12
+ "version": "0.6.7",
13
13
  "description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
14
14
  "type": "module",
15
15
  "exports": {
@@ -148,7 +148,7 @@
148
148
  "VERSION-PIN.md"
149
149
  ],
150
150
  "dependencies": {
151
- "@sequenceholdings/atlas-ui": "3.3.0",
151
+ "@sequenceholdings/atlas-ui": "3.4.1",
152
152
  "@tailwindcss/postcss": "^4.1.8",
153
153
  "@tailwindcss/vite": "^4.1.8",
154
154
  "@tanstack/react-query": "^4.36.1",
@@ -166,9 +166,9 @@
166
166
  "google-auth-library": "^10.5.0",
167
167
  "server-only": "0.0.1",
168
168
  "parse5": "7.3.0",
169
- "@sequenceholdings/lattice-form-renderer": "0.2.0",
170
- "@sequenceholdings/storage": "0.1.0",
171
- "@sequenceholdings/orm": "2.4.0"
169
+ "@sequenceholdings/storage": "0.1.1",
170
+ "@sequenceholdings/orm": "2.5.0",
171
+ "@sequenceholdings/lattice-form-renderer": "0.2.0"
172
172
  },
173
173
  "devDependencies": {
174
174
  "@types/js-yaml": "^4.0.9",