@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.
- package/README.md +254 -10
- package/VERSION-PIN.md +78 -1
- package/dist/gateway/admission.d.ts +23 -1
- package/dist/gateway/admission.js +30 -13
- package/dist/gateway/control.d.ts +40 -0
- package/dist/gateway/control.js +76 -7
- package/dist/gateway/hosted-proxy.d.ts +11 -8
- package/dist/gateway/hosted-proxy.js +99 -91
- package/dist/gateway/ingress-admission.d.ts +77 -0
- package/dist/gateway/ingress-admission.js +150 -0
- package/dist/gateway/ingress-audit.d.ts +46 -0
- package/dist/gateway/ingress-audit.js +84 -0
- package/dist/gateway/ingress-context.d.ts +57 -0
- package/dist/gateway/ingress-context.js +57 -0
- package/dist/gateway/ingress-development.d.ts +12 -0
- package/dist/gateway/ingress-development.js +23 -0
- package/dist/gateway/ingress-handler.d.ts +44 -0
- package/dist/gateway/ingress-handler.js +320 -0
- package/dist/gateway/ingress-pass.d.ts +83 -0
- package/dist/gateway/ingress-pass.js +143 -0
- package/dist/gateway/ingress-response.d.ts +74 -0
- package/dist/gateway/ingress-response.js +98 -0
- package/dist/gateway/ingress-runtime.d.ts +86 -0
- package/dist/gateway/ingress-runtime.js +126 -0
- package/dist/gateway/ingress-snapshot.d.ts +54 -0
- package/dist/gateway/ingress-snapshot.js +63 -0
- package/dist/gateway/ingress-stages.d.ts +60 -0
- package/dist/gateway/ingress-stages.js +387 -0
- package/dist/gateway/ingress.d.ts +259 -0
- package/dist/gateway/ingress.js +204 -0
- package/dist/gateway/lattice-invoke.js +2 -0
- package/dist/gateway/lattice-upstream.d.ts +3 -4
- package/dist/gateway/lattice-upstream.js +11 -9
- package/dist/gateway/runtime-transport.d.ts +17 -0
- package/dist/gateway/runtime-transport.js +26 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +21 -0
- package/dist/gateway/session-response.d.ts +15 -0
- package/dist/gateway/session-response.js +40 -0
- package/dist/gateway/usage.d.ts +8 -1
- package/dist/gateway-main.js +32 -8
- package/dist/manifest-next-browser-data.d.ts +17 -0
- package/dist/manifest-next-browser-data.js +46 -0
- package/dist/manifest-next-ingress.d.ts +5 -2
- package/dist/manifest-next-ingress.js +27 -2
- package/dist/manifest-next.d.ts +27 -10
- package/dist/manifest-next.js +16 -5
- package/dist/manifest.d.ts +13 -6
- package/dist/next-project.d.ts +4 -0
- package/dist/sdk.d.ts +12 -3
- package/dist/server/broker-contract.d.ts +62 -0
- package/dist/server/broker-contract.js +43 -1
- package/dist/server/client.js +38 -13
- package/dist/server/external-context.d.ts +8 -1
- package/dist/server/external-context.js +7 -2
- package/dist/server/internal/v1-context.js +17 -1
- package/dist/server/internal/verify-any.d.ts +25 -2
- package/dist/server/internal/verify-any.js +38 -4
- package/dist/server/process-run-sharing.d.ts +86 -0
- package/dist/server/process-run-sharing.js +29 -0
- package/dist/server/process-runs-page.d.ts +39 -0
- package/dist/server/process-runs-page.js +10 -0
- package/dist/server/processes.d.ts +8 -9
- package/dist/server/processes.js +4 -0
- package/dist/server/request-context.js +10 -3
- package/dist/server/runtime-config.d.ts +10 -1
- package/dist/server/runtime-config.js +11 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +18 -0
- package/dist/tunnel/broker.js +175 -17
- package/dist/tunnel/client.d.ts +13 -0
- package/dist/tunnel/client.js +40 -2
- package/dist/tunnel/external.d.ts +51 -0
- package/dist/tunnel/external.js +10 -0
- package/dist/tunnel/index.d.ts +1 -1
- package/dist/tunnel/protocol.d.ts +20 -2
- package/dist/tunnel/protocol.js +42 -6
- package/package.json +5 -5
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIPv6 } from 'node:net';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
|
|
5
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
6
|
+
/** The design's defaults; bindings may lower them in Atlas, never raise them here. */
|
|
7
|
+
export const INGRESS_ADMISSION_LIMITS = Object.freeze({
|
|
8
|
+
windowMs: 10_000,
|
|
9
|
+
// Sized for many borrowers behind one carrier-grade NAT; no Atlas call follows a preflight.
|
|
10
|
+
preflight: Object.freeze({ perClient: 600 }),
|
|
11
|
+
exchange: Object.freeze({ perClient: 30, perHost: 300, environment: 3000 }),
|
|
12
|
+
logout: Object.freeze({ perSubject: 5, perClient: 30 }),
|
|
13
|
+
subject: Object.freeze({ perSubject: 60 }),
|
|
14
|
+
app: Object.freeze({ perApp: 2000 }),
|
|
15
|
+
pools: Object.freeze({ 'ingress-public': 32, 'ingress-grant': 64 }),
|
|
16
|
+
});
|
|
17
|
+
const limit = z.number().int().min(1).max(1_000_000);
|
|
18
|
+
const limitsSchema = z.strictObject({
|
|
19
|
+
windowMs: z.number().int().min(1_000).max(3_600_000),
|
|
20
|
+
preflight: z.strictObject({ perClient: limit }),
|
|
21
|
+
exchange: z.strictObject({ perClient: limit, perHost: limit, environment: limit }),
|
|
22
|
+
logout: z.strictObject({ perSubject: limit, perClient: limit }),
|
|
23
|
+
subject: z.strictObject({ perSubject: limit }),
|
|
24
|
+
app: z.strictObject({ perApp: limit }),
|
|
25
|
+
pools: z.strictObject({ 'ingress-public': z.number().int().min(1).max(10_000), 'ingress-grant': z.number().int().min(1).max(10_000) }),
|
|
26
|
+
});
|
|
27
|
+
// Every key of one call shares the `{namespace}` hash tag, so the script stays single-slot on Redis
|
|
28
|
+
// Cluster. Limits are ARGV[1..n], the window ARGV[n+1]. A refused attempt consumes nothing, so one
|
|
29
|
+
// noisy client cannot spend a host's or the environment's budget.
|
|
30
|
+
const ADMIT = `-- artifact-gateway-ingress-admission-v1
|
|
31
|
+
local window = tonumber(ARGV[#KEYS + 1])
|
|
32
|
+
for index, key in ipairs(KEYS) do
|
|
33
|
+
if tonumber(redis.call('GET', key) or '0') >= tonumber(ARGV[index]) then
|
|
34
|
+
local ttl = redis.call('PTTL', key)
|
|
35
|
+
if ttl < 0 then
|
|
36
|
+
redis.call('PEXPIRE', key, window)
|
|
37
|
+
ttl = window
|
|
38
|
+
end
|
|
39
|
+
return {0, ttl}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
for _, key in ipairs(KEYS) do
|
|
43
|
+
if redis.call('INCR', key) == 1 or redis.call('PTTL', key) < 0 then redis.call('PEXPIRE', key, window) end
|
|
44
|
+
end
|
|
45
|
+
return {1, 0}`;
|
|
46
|
+
const replySchema = z.tuple([z.number().int().min(0).max(1), z.number().int().min(0)]);
|
|
47
|
+
/** A bounded, opaque Redis key segment: no caller-chosen bytes reach a key. */
|
|
48
|
+
function keyPart(...values) {
|
|
49
|
+
return createHash('sha256').update(values.join('\u0000')).digest('hex').slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The address an ingress caller is budgeted against: an IPv4 address as is, an IPv6 address as its
|
|
53
|
+
* /48 (design §(e)), since one IPv6 customer commonly controls a /48 or a /56.
|
|
54
|
+
*/
|
|
55
|
+
export function ingressClientBucket(address) {
|
|
56
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
|
|
57
|
+
if (mapped)
|
|
58
|
+
return mapped[1];
|
|
59
|
+
if (!isIPv6(address))
|
|
60
|
+
return address;
|
|
61
|
+
const [head = '', tail] = address.toLowerCase().split('::');
|
|
62
|
+
const leading = head ? head.split(':') : [];
|
|
63
|
+
const trailing = tail ? tail.split(':') : [];
|
|
64
|
+
const groups = tail === undefined ? leading : [...leading, ...Array(Math.max(0, 8 - leading.length - trailing.length)).fill('0'), ...trailing];
|
|
65
|
+
return `${groups.slice(0, 3).map((group) => group.padStart(4, '0')).join(':')}::/48`;
|
|
66
|
+
}
|
|
67
|
+
export class IngressAdmission {
|
|
68
|
+
options;
|
|
69
|
+
namespace;
|
|
70
|
+
limits;
|
|
71
|
+
inFlight = { 'ingress-public': 0, 'ingress-grant': 0 };
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.namespace = redisNamespaceSchema.parse(options.namespace);
|
|
75
|
+
this.limits = limitsSchema.parse(options.limits ?? INGRESS_ADMISSION_LIMITS);
|
|
76
|
+
const policy = options.redis.options;
|
|
77
|
+
if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
|
|
78
|
+
throw new Error('Ingress admission Redis must disable offline queues, command replay and retries');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** `ingress-preflight`: per client address only, no in-flight permit (no Atlas call follows). */
|
|
82
|
+
async preflight({ clientIp }) {
|
|
83
|
+
await this.count({ name: 'preflight', counters: [[`client:${keyPart(ingressClientBucket(clientIp))}`, this.limits.preflight.perClient]] });
|
|
84
|
+
}
|
|
85
|
+
/** `ingress-exchange`: per client, per host and environment-wide; holds an `ingress-public` permit. */
|
|
86
|
+
async exchange({ clientIp, host }) {
|
|
87
|
+
const { perClient, perHost, environment } = this.limits.exchange;
|
|
88
|
+
return this.withPermit('ingress-public', () => this.count({ name: 'exchange', counters: [
|
|
89
|
+
[`client:${keyPart(ingressClientBucket(clientIp))}`, perClient], [`host:${keyPart(host)}`, perHost], ['environment', environment],
|
|
90
|
+
] }));
|
|
91
|
+
}
|
|
92
|
+
/** `ingress-logout`: per subject and per client, after the pass verified; holds an `ingress-public` permit. */
|
|
93
|
+
async logout({ provider, subjectHash, clientIp }) {
|
|
94
|
+
const { perSubject, perClient } = this.limits.logout;
|
|
95
|
+
return this.withPermit('ingress-public', () => this.count({ name: 'logout', counters: [
|
|
96
|
+
[`subject:${keyPart(provider, subjectHash)}`, perSubject], [`client:${keyPart(ingressClientBucket(clientIp))}`, perClient],
|
|
97
|
+
] }));
|
|
98
|
+
}
|
|
99
|
+
/** `ingress-subject` and `ingress-app`, on verified pass fields; plain counters, no in-flight permit. */
|
|
100
|
+
async request({ provider, subjectHash, projectId, ingressId }) {
|
|
101
|
+
await this.count({ name: 'request', counters: [
|
|
102
|
+
[`subject:${keyPart(provider, subjectHash)}`, this.limits.subject.perSubject], [`app:${keyPart(projectId, ingressId)}`, this.limits.app.perApp],
|
|
103
|
+
] });
|
|
104
|
+
}
|
|
105
|
+
/** Runs one Atlas call under a permit from `pool`; a full pool is 503, never a queue. */
|
|
106
|
+
async withPool(pool, work) {
|
|
107
|
+
const release = this.take(pool);
|
|
108
|
+
try {
|
|
109
|
+
return await work();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
release();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async withPermit(pool, admit) {
|
|
116
|
+
const release = this.take(pool);
|
|
117
|
+
try {
|
|
118
|
+
await admit();
|
|
119
|
+
return release;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
release();
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
take(pool) {
|
|
127
|
+
if (this.inFlight[pool] >= this.limits.pools[pool])
|
|
128
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
129
|
+
this.inFlight[pool]++;
|
|
130
|
+
let released = false;
|
|
131
|
+
return () => {
|
|
132
|
+
if (released)
|
|
133
|
+
return;
|
|
134
|
+
released = true;
|
|
135
|
+
this.inFlight[pool]--;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async count({ name, counters }) {
|
|
139
|
+
const prefix = `artifact-gateway-ingress:{${this.namespace}}:${name}`;
|
|
140
|
+
let reply;
|
|
141
|
+
try {
|
|
142
|
+
reply = replySchema.parse(await this.options.redis.eval(ADMIT, counters.length, ...counters.map(([key]) => `${prefix}:${key}`), ...counters.map(([, maximum]) => maximum), this.limits.windowMs));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
146
|
+
}
|
|
147
|
+
if (reply[0] !== 1)
|
|
148
|
+
throw new ArtifactAdmissionError(429, 'capacity_exceeded', Math.max(1, Math.ceil(reply[1] / 1000)));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type IngressAuditBatch, type IngressAuditEvent } from '../server/ingress-control-contract.js';
|
|
2
|
+
export declare const INGRESS_AUDIT_FLUSH_INTERVAL_MS = 5000;
|
|
3
|
+
/** Refusals buffered between flushes; past this they are dropped and counted, never queued without bound. */
|
|
4
|
+
export declare const INGRESS_AUDIT_CAPACITY = 5000;
|
|
5
|
+
/** Shutdown spends at most this on the final batch, so audit delivery never holds up tunnel cleanup. */
|
|
6
|
+
export declare const INGRESS_AUDIT_CLOSE_BUDGET_MS = 2000;
|
|
7
|
+
export type IngressAuditFailure = {
|
|
8
|
+
kind: 'ingress_audit_dropped' | 'ingress_audit_delivery_failed';
|
|
9
|
+
count: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Batches the refusals the gateway decided itself (edge, client address, CORS, a refused authentic
|
|
13
|
+
* pass, freshness) to `control/ingress-audit` every 5 s, at most 500 per call. Garbage and
|
|
14
|
+
* admission refusals are counted elsewhere and never reach it. Refusals an Atlas control
|
|
15
|
+
* route returned are never recorded here: Atlas already audited them. Best effort and bounded: a
|
|
16
|
+
* refusal flood can cost Atlas at most one batch per interval per gateway process, and a failed
|
|
17
|
+
* delivery is counted, not retried into a backlog.
|
|
18
|
+
*/
|
|
19
|
+
export declare class IngressDenialAuditor {
|
|
20
|
+
private readonly options;
|
|
21
|
+
private buffer;
|
|
22
|
+
private dropped;
|
|
23
|
+
private timer;
|
|
24
|
+
private flushing;
|
|
25
|
+
private readonly intervalMs;
|
|
26
|
+
private readonly capacity;
|
|
27
|
+
constructor(options: {
|
|
28
|
+
send: (batch: IngressAuditBatch) => Promise<void>;
|
|
29
|
+
onFailure?: (failure: IngressAuditFailure) => void;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
capacity?: number;
|
|
32
|
+
closeBudgetMs?: number;
|
|
33
|
+
});
|
|
34
|
+
record(event: IngressAuditEvent): void;
|
|
35
|
+
/** Flushes every interval; the timer never keeps the process alive. */
|
|
36
|
+
start(): void;
|
|
37
|
+
/** One batch of at most 500 refusals, single-flight. Never rejects. */
|
|
38
|
+
flush(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Stops the timer and makes one final delivery attempt within the close budget: a slow or failing
|
|
41
|
+
* Atlas costs shutdown at most that long. Whatever is still buffered is dropped and counted.
|
|
42
|
+
*/
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
private deliver;
|
|
45
|
+
private reportDropped;
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { INGRESS_AUDIT_BATCH_MAX_EVENTS } from '../server/ingress-control-contract.js';
|
|
2
|
+
export const INGRESS_AUDIT_FLUSH_INTERVAL_MS = 5_000;
|
|
3
|
+
/** Refusals buffered between flushes; past this they are dropped and counted, never queued without bound. */
|
|
4
|
+
export const INGRESS_AUDIT_CAPACITY = 5_000;
|
|
5
|
+
/** Shutdown spends at most this on the final batch, so audit delivery never holds up tunnel cleanup. */
|
|
6
|
+
export const INGRESS_AUDIT_CLOSE_BUDGET_MS = 2_000;
|
|
7
|
+
/**
|
|
8
|
+
* Batches the refusals the gateway decided itself (edge, client address, CORS, a refused authentic
|
|
9
|
+
* pass, freshness) to `control/ingress-audit` every 5 s, at most 500 per call. Garbage and
|
|
10
|
+
* admission refusals are counted elsewhere and never reach it. Refusals an Atlas control
|
|
11
|
+
* route returned are never recorded here: Atlas already audited them. Best effort and bounded: a
|
|
12
|
+
* refusal flood can cost Atlas at most one batch per interval per gateway process, and a failed
|
|
13
|
+
* delivery is counted, not retried into a backlog.
|
|
14
|
+
*/
|
|
15
|
+
export class IngressDenialAuditor {
|
|
16
|
+
options;
|
|
17
|
+
buffer = [];
|
|
18
|
+
dropped = 0;
|
|
19
|
+
timer;
|
|
20
|
+
flushing;
|
|
21
|
+
intervalMs;
|
|
22
|
+
capacity;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
this.intervalMs = options.intervalMs ?? INGRESS_AUDIT_FLUSH_INTERVAL_MS;
|
|
26
|
+
this.capacity = options.capacity ?? INGRESS_AUDIT_CAPACITY;
|
|
27
|
+
}
|
|
28
|
+
record(event) {
|
|
29
|
+
if (this.buffer.length >= this.capacity) {
|
|
30
|
+
this.dropped++;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.buffer.push(event);
|
|
34
|
+
}
|
|
35
|
+
/** Flushes every interval; the timer never keeps the process alive. */
|
|
36
|
+
start() {
|
|
37
|
+
if (this.timer)
|
|
38
|
+
return;
|
|
39
|
+
this.timer = setInterval(() => { void this.flush(); }, this.intervalMs);
|
|
40
|
+
this.timer.unref();
|
|
41
|
+
}
|
|
42
|
+
/** One batch of at most 500 refusals, single-flight. Never rejects. */
|
|
43
|
+
flush() {
|
|
44
|
+
this.flushing ??= this.deliver().finally(() => { this.flushing = undefined; });
|
|
45
|
+
return this.flushing;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Stops the timer and makes one final delivery attempt within the close budget: a slow or failing
|
|
49
|
+
* Atlas costs shutdown at most that long. Whatever is still buffered is dropped and counted.
|
|
50
|
+
*/
|
|
51
|
+
async close() {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = undefined;
|
|
54
|
+
let expire;
|
|
55
|
+
const budget = new Promise((resolve) => { expire = setTimeout(resolve, this.options.closeBudgetMs ?? INGRESS_AUDIT_CLOSE_BUDGET_MS); });
|
|
56
|
+
try {
|
|
57
|
+
await Promise.race([(this.flushing ?? Promise.resolve()).then(() => this.flush()), budget]);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(expire);
|
|
61
|
+
}
|
|
62
|
+
this.dropped += this.buffer.length;
|
|
63
|
+
this.buffer = [];
|
|
64
|
+
this.reportDropped();
|
|
65
|
+
}
|
|
66
|
+
async deliver() {
|
|
67
|
+
this.reportDropped();
|
|
68
|
+
const events = this.buffer.splice(0, INGRESS_AUDIT_BATCH_MAX_EVENTS);
|
|
69
|
+
if (events.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
await this.options.send({ events });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
this.options.onFailure?.({ kind: 'ingress_audit_delivery_failed', count: events.length });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
reportDropped() {
|
|
79
|
+
if (!this.dropped)
|
|
80
|
+
return;
|
|
81
|
+
this.options.onFailure?.({ kind: 'ingress_audit_dropped', count: this.dropped });
|
|
82
|
+
this.dropped = 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { IngressPassPayload } from './ingress-pass.js';
|
|
2
|
+
/** What the gateway records of a caller's `User-Agent` (design §(f)): ≤ 512 characters, no control characters. */
|
|
3
|
+
export declare const OBSERVED_USER_AGENT_MAX_LENGTH = 512;
|
|
4
|
+
/**
|
|
5
|
+
* The user agent as the context carries it: control characters stripped, then truncated. The
|
|
6
|
+
* SHA-256 is of the header exactly as sent, so a truncated value still fingerprints the caller.
|
|
7
|
+
*/
|
|
8
|
+
export declare function observeUserAgent(raw: string | undefined): {
|
|
9
|
+
userAgent: string;
|
|
10
|
+
userAgentSha256: string;
|
|
11
|
+
};
|
|
12
|
+
/** The namespaced subject an external context and usage carry; ≤ 255 characters or refused at exchange. */
|
|
13
|
+
export declare function externalSubject({ provider, subject }: {
|
|
14
|
+
provider: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
}): string;
|
|
17
|
+
/**
|
|
18
|
+
* What an external context runs against: a published deployment, or (local development only,
|
|
19
|
+
* design §(j.3) step 5) the development session whose tunnel the gateway proxies over.
|
|
20
|
+
*/
|
|
21
|
+
export type ExternalContextRelease = {
|
|
22
|
+
mode: 'published';
|
|
23
|
+
deploymentId: string;
|
|
24
|
+
} | {
|
|
25
|
+
mode: 'development';
|
|
26
|
+
devSessionId: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Signs the external (v2) context for one admitted ingress request (design §(f), "Steady state"
|
|
30
|
+
* step 8), with the gateway's external key, never the browser one. Lifetime ≤ 60 s. Validated
|
|
31
|
+
* against the strict v2 schema before signing, so the gateway never emits a context the SDK,
|
|
32
|
+
* sidecar or broker would refuse.
|
|
33
|
+
*/
|
|
34
|
+
export declare function signExternalGatewayContext({ privateKey, keyId, projectId, environment, release, requestId, method, path, serviceAccountId, pass, passKid, client, now, ttlSeconds, }: {
|
|
35
|
+
privateKey: string;
|
|
36
|
+
keyId: string;
|
|
37
|
+
projectId: string;
|
|
38
|
+
environment: string;
|
|
39
|
+
release: ExternalContextRelease;
|
|
40
|
+
/** Gateway-generated, never client-supplied. */
|
|
41
|
+
requestId: string;
|
|
42
|
+
method: string;
|
|
43
|
+
/** The canonical path plus raw query, as matched. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** The grant's ingress service account. */
|
|
46
|
+
serviceAccountId: string;
|
|
47
|
+
pass: IngressPassPayload;
|
|
48
|
+
passKid: string;
|
|
49
|
+
client: {
|
|
50
|
+
ip: string;
|
|
51
|
+
userAgent: string;
|
|
52
|
+
userAgentSha256: string;
|
|
53
|
+
origin: string | null;
|
|
54
|
+
};
|
|
55
|
+
now?: number;
|
|
56
|
+
ttlSeconds?: number;
|
|
57
|
+
}): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, randomUUID, sign } from 'node:crypto';
|
|
2
|
+
import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX, ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS, requestContextV2ExternalSchema, } from '../server/request-context.js';
|
|
3
|
+
/** What the gateway records of a caller's `User-Agent` (design §(f)): ≤ 512 characters, no control characters. */
|
|
4
|
+
export const OBSERVED_USER_AGENT_MAX_LENGTH = 512;
|
|
5
|
+
/**
|
|
6
|
+
* The user agent as the context carries it: control characters stripped, then truncated. The
|
|
7
|
+
* SHA-256 is of the header exactly as sent, so a truncated value still fingerprints the caller.
|
|
8
|
+
*/
|
|
9
|
+
export function observeUserAgent(raw) {
|
|
10
|
+
const value = raw ?? '';
|
|
11
|
+
const stripped = value.replace(/[\u0000-\u001f\u007f]/g, '');
|
|
12
|
+
// Truncate on a code point boundary, never splitting a surrogate pair.
|
|
13
|
+
let userAgent = '';
|
|
14
|
+
for (const character of stripped) {
|
|
15
|
+
if (userAgent.length + character.length > OBSERVED_USER_AGENT_MAX_LENGTH)
|
|
16
|
+
break;
|
|
17
|
+
userAgent += character;
|
|
18
|
+
}
|
|
19
|
+
return { userAgent, userAgentSha256: createHash('sha256').update(value).digest('hex') };
|
|
20
|
+
}
|
|
21
|
+
/** The namespaced subject an external context and usage carry; ≤ 255 characters or refused at exchange. */
|
|
22
|
+
export function externalSubject({ provider, subject }) {
|
|
23
|
+
return `ext|${provider}|${subject}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Signs the external (v2) context for one admitted ingress request (design §(f), "Steady state"
|
|
27
|
+
* step 8), with the gateway's external key, never the browser one. Lifetime ≤ 60 s. Validated
|
|
28
|
+
* against the strict v2 schema before signing, so the gateway never emits a context the SDK,
|
|
29
|
+
* sidecar or broker would refuse.
|
|
30
|
+
*/
|
|
31
|
+
export function signExternalGatewayContext({ privateKey, keyId, projectId, environment, release, requestId, method, path, serviceAccountId, pass, passKid, client, now = Math.floor(Date.now() / 1000), ttlSeconds = ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS, }) {
|
|
32
|
+
if (!keyId.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX))
|
|
33
|
+
throw new Error('External contexts are signed only with an external- key');
|
|
34
|
+
const context = requestContextV2ExternalSchema.parse({
|
|
35
|
+
v: 2, iss: 'atlas-artifact-gateway', aud: `artifact:${projectId}`,
|
|
36
|
+
sub: externalSubject({ provider: pass.provider, subject: pass.subject }),
|
|
37
|
+
projectId, environment, mode: release.mode,
|
|
38
|
+
deploymentId: release.mode === 'published' ? release.deploymentId : null,
|
|
39
|
+
devSessionId: release.mode === 'development' ? release.devSessionId : null,
|
|
40
|
+
requestId, jti: randomUUID(), method, path, iat: now, exp: now + ttlSeconds, serviceAccountId,
|
|
41
|
+
principal: {
|
|
42
|
+
kind: 'external', ingress: pass.ingress, provider: pass.provider, issuer: pass.issuer, subject: pass.subject,
|
|
43
|
+
clientId: pass.clientId, email: pass.email, emailVerified: pass.emailVerified, authTime: pass.authTime, amr: pass.amr,
|
|
44
|
+
tokenIssuedAt: pass.tokenIssuedAt,
|
|
45
|
+
pass: { id: pass.jti, kid: passKid, issuedAt: pass.iat, exchangeIp: pass.exchangeIp },
|
|
46
|
+
client,
|
|
47
|
+
},
|
|
48
|
+
crit: [], ext: {},
|
|
49
|
+
});
|
|
50
|
+
const key = createPrivateKey(privateKey);
|
|
51
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
52
|
+
throw new Error('External context key must be Ed25519');
|
|
53
|
+
const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: keyId })).toString('base64url');
|
|
54
|
+
const payload = Buffer.from(JSON.stringify(context)).toString('base64url');
|
|
55
|
+
const signed = `${header}.${payload}`;
|
|
56
|
+
return `${signed}.${sign(null, Buffer.from(signed), key).toString('base64url')}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ArtifactTunnelBroker } from '../tunnel/index.js';
|
|
2
|
+
import type { IngressDevelopmentProxy } from './ingress.js';
|
|
3
|
+
/**
|
|
4
|
+
* The ingress proxy for a development target (design §(j.3) step 3): the admitted request goes
|
|
5
|
+
* over the development session's tunnel instead of to a hosted deployment, under the hosted
|
|
6
|
+
* ingress proxy's rules: ingress request sanitising, the route's response policy decided on the
|
|
7
|
+
* head, the request and response caps, the idle-write timeout and the error-contract writer.
|
|
8
|
+
* The gateway wires it only when its own APP_ENV is `development`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createDevelopmentIngressProxy({ broker }: {
|
|
11
|
+
broker: Pick<ArtifactTunnelBroker, 'handleExternalHttp'>;
|
|
12
|
+
}): IngressDevelopmentProxy;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
|
|
2
|
+
/**
|
|
3
|
+
* The ingress proxy for a development target (design §(j.3) step 3): the admitted request goes
|
|
4
|
+
* over the development session's tunnel instead of to a hosted deployment, under the hosted
|
|
5
|
+
* ingress proxy's rules: ingress request sanitising, the route's response policy decided on the
|
|
6
|
+
* head, the request and response caps, the idle-write timeout and the error-contract writer.
|
|
7
|
+
* The gateway wires it only when its own APP_ENV is `development`.
|
|
8
|
+
*/
|
|
9
|
+
export function createDevelopmentIngressProxy({ broker }) {
|
|
10
|
+
return ({ request, response, target, publicOrigin, requestContext, body, limits, ingress }) => broker.handleExternalHttp({
|
|
11
|
+
request, response, requestContext, body, publicOrigin,
|
|
12
|
+
sessionId: target.sessionId, projectId: target.projectId, environment: target.environment,
|
|
13
|
+
appHeaders: ingress.requestHeaders,
|
|
14
|
+
maxRequestBytes: limits.maxRequestBytes,
|
|
15
|
+
maxResponseBytes: limits.maxResponseBytes,
|
|
16
|
+
timeoutMs: Math.min(limits.timeoutMs, INGRESS_PROXY_LIMITS.maxTimeoutMs),
|
|
17
|
+
idleWriteTimeoutMs: ingress.idleWriteTimeoutMs ?? INGRESS_PROXY_LIMITS.idleWriteTimeoutMs,
|
|
18
|
+
writeHead: ({ status, headers, declaredLength }) => writeIngressResponseHead({
|
|
19
|
+
response, status, method: request.method ?? 'GET', headers, declaredLength, responses: ingress.responses, maxResponseBytes: limits.maxResponseBytes,
|
|
20
|
+
}),
|
|
21
|
+
fail: (status) => ingress.errorWriter({ status, code: INGRESS_PROXY_ERROR_CODES[status] }),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Duplex } from 'node:stream';
|
|
3
|
+
import { type ArtifactGatewayIngressOptions, type IngressDevelopmentProxy } from './ingress.js';
|
|
4
|
+
import type { GatewayUsageReporter } from './usage.js';
|
|
5
|
+
/**
|
|
6
|
+
* The ingress request pipeline (design §(e), "Steady state" steps 1-3 and 9). Order matters:
|
|
7
|
+
* canonical host, edge header, snapshot, client address, canonical path, declared route and
|
|
8
|
+
* CORS are all local, so nothing unauthenticated reaches Redis or Atlas before `handlers.admit`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createIngressHandler({ options, runtimeDomain, protocol, isDraining, onError, usage, proxyDevelopment }: {
|
|
11
|
+
/** Absent: the ingress branch is disabled and every ingress host answers 404. */
|
|
12
|
+
options: ArtifactGatewayIngressOptions | undefined;
|
|
13
|
+
runtimeDomain: string;
|
|
14
|
+
/** `https:` in every deployed environment; `http:` only for a loopback development gateway. */
|
|
15
|
+
protocol: string;
|
|
16
|
+
isDraining: () => boolean;
|
|
17
|
+
/** Counts refusal metrics and failures that are not a decided refusal (the gateway's own onError). */
|
|
18
|
+
onError?: (event: {
|
|
19
|
+
kind: string;
|
|
20
|
+
}) => void;
|
|
21
|
+
/** Records one observation per proxied ingress request, attributed to the external principal. */
|
|
22
|
+
usage?: Pick<GatewayUsageReporter, 'record'>;
|
|
23
|
+
/**
|
|
24
|
+
* Proxies over a development session's tunnel. Used only when `options.appEnv` is `development`
|
|
25
|
+
* (design §(j.3) step 3); anywhere else a development target is never proxied.
|
|
26
|
+
*/
|
|
27
|
+
proxyDevelopment?: IngressDevelopmentProxy;
|
|
28
|
+
}): {
|
|
29
|
+
handle: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
30
|
+
clientError: ({ error, socket }: {
|
|
31
|
+
error: NodeJS.ErrnoException & {
|
|
32
|
+
rawPacket?: Buffer;
|
|
33
|
+
};
|
|
34
|
+
socket: Duplex;
|
|
35
|
+
}) => boolean;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Installs `clientError` on the gateway server: an attributable ingress request gets the ingress
|
|
39
|
+
* reply, everything else Node's own. Tracks the response in flight on each socket for the check.
|
|
40
|
+
*/
|
|
41
|
+
export declare function handleClientErrors({ server, ingress }: {
|
|
42
|
+
server: Server;
|
|
43
|
+
ingress: Pick<ReturnType<typeof createIngressHandler>, 'clientError'>;
|
|
44
|
+
}): void;
|