@sequenceholdings/artifact-studio 0.2.6 → 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 (190) hide show
  1. package/VERSION-PIN.md +12 -1
  2. package/dist/api.d.ts +11 -1
  3. package/dist/api.js +26 -9
  4. package/dist/build.d.ts +2 -2
  5. package/dist/build.js +4 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +134 -13
  8. package/dist/dev-link.d.ts +1 -1
  9. package/dist/dev-link.js +2 -1
  10. package/dist/gateway/admission.d.ts +56 -0
  11. package/dist/gateway/admission.js +134 -0
  12. package/dist/gateway/authorization-cache.d.ts +52 -0
  13. package/dist/gateway/authorization-cache.js +111 -0
  14. package/dist/gateway/control-admission.d.ts +48 -0
  15. package/dist/gateway/control-admission.js +127 -0
  16. package/dist/gateway/control.d.ts +94 -0
  17. package/dist/gateway/control.js +116 -0
  18. package/dist/gateway/hosted-proxy.d.ts +36 -0
  19. package/dist/gateway/hosted-proxy.js +344 -0
  20. package/dist/gateway/release-admission.d.ts +23 -0
  21. package/dist/gateway/release-admission.js +259 -0
  22. package/dist/gateway/server.d.ts +38 -0
  23. package/dist/gateway/server.js +382 -0
  24. package/dist/gateway/session.d.ts +72 -0
  25. package/dist/gateway/session.js +144 -0
  26. package/dist/gateway/usage.d.ts +70 -0
  27. package/dist/gateway/usage.js +116 -0
  28. package/dist/gateway-main.d.ts +1 -0
  29. package/dist/gateway-main.js +114 -0
  30. package/dist/git-clone.d.ts +23 -1
  31. package/dist/git-clone.js +32 -15
  32. package/dist/manifest-next.d.ts +217 -0
  33. package/dist/manifest-next.js +132 -0
  34. package/dist/manifest-static.d.ts +76 -0
  35. package/dist/manifest-static.js +124 -0
  36. package/dist/manifest.d.ts +118 -13
  37. package/dist/manifest.js +12 -119
  38. package/dist/next/bridge.d.ts +47 -0
  39. package/dist/next/bridge.js +236 -0
  40. package/dist/next/browser-logs.d.ts +7 -0
  41. package/dist/next/browser-logs.js +51 -0
  42. package/dist/next/browser.d.ts +6 -0
  43. package/dist/next/browser.js +57 -0
  44. package/dist/next/protocol.d.ts +73 -0
  45. package/dist/next/protocol.js +56 -0
  46. package/dist/next/release.d.ts +15 -0
  47. package/dist/next/release.js +46 -0
  48. package/dist/next/requests.d.ts +15 -0
  49. package/dist/next/requests.js +59 -0
  50. package/dist/next/routes.d.ts +4 -0
  51. package/dist/next/routes.js +21 -0
  52. package/dist/next/streams.d.ts +16 -0
  53. package/dist/next/streams.js +132 -0
  54. package/dist/next/theme.d.ts +10 -0
  55. package/dist/next/theme.js +45 -0
  56. package/dist/next/transport.d.ts +15 -0
  57. package/dist/next/transport.js +13 -0
  58. package/dist/next-build.d.ts +29 -0
  59. package/dist/next-build.js +129 -0
  60. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  61. package/dist/next-cache/adapter-runtime.js +73 -0
  62. package/dist/next-cache/component-handler.d.ts +3 -0
  63. package/dist/next-cache/component-handler.js +10 -0
  64. package/dist/next-cache/components.d.ts +20 -0
  65. package/dist/next-cache/components.js +113 -0
  66. package/dist/next-cache/config.d.ts +9 -0
  67. package/dist/next-cache/config.js +105 -0
  68. package/dist/next-cache/incremental-handler.d.ts +18 -0
  69. package/dist/next-cache/incremental-handler.js +17 -0
  70. package/dist/next-cache/incremental.d.ts +27 -0
  71. package/dist/next-cache/incremental.js +99 -0
  72. package/dist/next-cache/index.d.ts +18 -0
  73. package/dist/next-cache/index.js +16 -0
  74. package/dist/next-cache/memory-store.d.ts +27 -0
  75. package/dist/next-cache/memory-store.js +118 -0
  76. package/dist/next-cache/redis-scripts.d.ts +3 -0
  77. package/dist/next-cache/redis-scripts.js +85 -0
  78. package/dist/next-cache/redis-store.d.ts +18 -0
  79. package/dist/next-cache/redis-store.js +56 -0
  80. package/dist/next-cache/runtime.d.ts +29 -0
  81. package/dist/next-cache/runtime.js +63 -0
  82. package/dist/next-cache/serialization.d.ts +39 -0
  83. package/dist/next-cache/serialization.js +333 -0
  84. package/dist/next-cache/store.d.ts +54 -0
  85. package/dist/next-cache/store.js +45 -0
  86. package/dist/next-cache/valkey-transport.d.ts +52 -0
  87. package/dist/next-cache/valkey-transport.js +189 -0
  88. package/dist/next-dev-control.d.ts +48 -0
  89. package/dist/next-dev-control.js +63 -0
  90. package/dist/next-dev-process.d.ts +17 -0
  91. package/dist/next-dev-process.js +110 -0
  92. package/dist/next-dev.d.ts +12 -0
  93. package/dist/next-dev.js +267 -0
  94. package/dist/next-image-config.d.ts +9 -0
  95. package/dist/next-image-config.js +101 -0
  96. package/dist/next-project.d.ts +22 -0
  97. package/dist/next-project.js +64 -0
  98. package/dist/next-public-fonts-local.d.ts +7 -0
  99. package/dist/next-public-fonts-local.js +64 -0
  100. package/dist/next-public-fonts.d.ts +13 -0
  101. package/dist/next-public-fonts.js +49 -0
  102. package/dist/next-source-hash.d.ts +14 -0
  103. package/dist/next-source-hash.js +18 -0
  104. package/dist/next-source.d.ts +35 -0
  105. package/dist/next-source.js +155 -0
  106. package/dist/next.d.ts +9 -0
  107. package/dist/next.js +5 -0
  108. package/dist/project.d.ts +3 -0
  109. package/dist/project.js +9 -1
  110. package/dist/runtime-contracts.d.ts +229 -0
  111. package/dist/runtime-contracts.js +224 -0
  112. package/dist/sdk.d.ts +2 -2
  113. package/dist/server/broker-contract.d.ts +68 -0
  114. package/dist/server/broker-contract.js +33 -0
  115. package/dist/server/client.d.ts +81 -0
  116. package/dist/server/client.js +159 -0
  117. package/dist/server/logger.d.ts +26 -0
  118. package/dist/server/logger.js +24 -0
  119. package/dist/server/release-forms.d.ts +13 -0
  120. package/dist/server/release-forms.js +124 -0
  121. package/dist/server/request-context.d.ts +75 -0
  122. package/dist/server/request-context.js +107 -0
  123. package/dist/server/runtime-config.d.ts +54 -0
  124. package/dist/server/runtime-config.js +64 -0
  125. package/dist/server.d.ts +8 -0
  126. package/dist/server.js +4 -0
  127. package/dist/templates/nextjs/.artifactignore +14 -0
  128. package/dist/templates/nextjs/README.md +157 -0
  129. package/dist/templates/nextjs/app/about/page.tsx +26 -0
  130. package/dist/templates/nextjs/app/actions.ts +18 -0
  131. package/dist/templates/nextjs/app/api/health/route.ts +6 -0
  132. package/dist/templates/nextjs/app/components/counter.tsx +19 -0
  133. package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
  134. package/dist/templates/nextjs/app/globals.css +4 -0
  135. package/dist/templates/nextjs/app/layout.tsx +27 -0
  136. package/dist/templates/nextjs/app/page.tsx +51 -0
  137. package/dist/templates/nextjs/app/providers.tsx +107 -0
  138. package/dist/templates/nextjs/artifact.bundle.yml +55 -0
  139. package/dist/templates/nextjs/gitignore +9 -0
  140. package/dist/templates/nextjs/next.config.mjs +27 -0
  141. package/dist/templates/nextjs/package.json +32 -0
  142. package/dist/templates/nextjs/pnpm-workspace.yaml +22 -0
  143. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  144. package/dist/templates/nextjs/tsconfig.json +20 -0
  145. package/dist/templates/react-vite/package.json +1 -1
  146. package/dist/trusted-install.js +62 -2
  147. package/dist/tunnel/broker.d.ts +75 -0
  148. package/dist/tunnel/broker.js +463 -0
  149. package/dist/tunnel/client.d.ts +22 -0
  150. package/dist/tunnel/client.js +177 -0
  151. package/dist/tunnel/index.d.ts +6 -0
  152. package/dist/tunnel/index.js +4 -0
  153. package/dist/tunnel/io.d.ts +33 -0
  154. package/dist/tunnel/io.js +108 -0
  155. package/dist/tunnel/peer.d.ts +25 -0
  156. package/dist/tunnel/peer.js +147 -0
  157. package/dist/tunnel/protocol.d.ts +78 -0
  158. package/dist/tunnel/protocol.js +210 -0
  159. package/dist/tunnel/redis-auth.d.ts +37 -0
  160. package/dist/tunnel/redis-auth.js +92 -0
  161. package/dist/tunnel/redis-ownership.d.ts +38 -0
  162. package/dist/tunnel/redis-ownership.js +100 -0
  163. package/dist/tunnel/redis-socket.d.ts +13 -0
  164. package/dist/tunnel/redis-socket.js +48 -0
  165. package/dist/tunnel/redis-types.d.ts +75 -0
  166. package/dist/tunnel/redis-types.js +9 -0
  167. package/dist/tunnel/redis.d.ts +56 -0
  168. package/dist/tunnel/redis.js +404 -0
  169. package/dist/tunnel/transport.d.ts +17 -0
  170. package/dist/tunnel/transport.js +1 -0
  171. package/package.json +67 -4
  172. package/templates/nextjs/.artifactignore +14 -0
  173. package/templates/nextjs/README.md +157 -0
  174. package/templates/nextjs/app/about/page.tsx +26 -0
  175. package/templates/nextjs/app/actions.ts +18 -0
  176. package/templates/nextjs/app/api/health/route.ts +6 -0
  177. package/templates/nextjs/app/components/counter.tsx +19 -0
  178. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  179. package/templates/nextjs/app/globals.css +4 -0
  180. package/templates/nextjs/app/layout.tsx +27 -0
  181. package/templates/nextjs/app/page.tsx +51 -0
  182. package/templates/nextjs/app/providers.tsx +107 -0
  183. package/templates/nextjs/artifact.bundle.yml +55 -0
  184. package/templates/nextjs/gitignore +9 -0
  185. package/templates/nextjs/next.config.mjs +27 -0
  186. package/templates/nextjs/package.json +32 -0
  187. package/templates/nextjs/pnpm-workspace.yaml +22 -0
  188. package/templates/nextjs/postcss.config.mjs +5 -0
  189. package/templates/nextjs/tsconfig.json +20 -0
  190. package/templates/react-vite/package.json +1 -1
@@ -0,0 +1,134 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { z } from 'zod';
4
+ import { artifactNextResourceLimitsSchema } from '../manifest-next.js';
5
+ import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
6
+ export class ArtifactAdmissionError extends Error {
7
+ status;
8
+ code;
9
+ retryAfterSeconds;
10
+ constructor(status, code, retryAfterSeconds) {
11
+ super(status === 429 ? 'Application request capacity exceeded' : 'Application request admission unavailable');
12
+ this.status = status;
13
+ this.code = code;
14
+ this.retryAfterSeconds = retryAfterSeconds;
15
+ this.name = 'ArtifactAdmissionError';
16
+ }
17
+ }
18
+ // The tunnel's APP_ENV namespace format, so both Redis key spaces accept the same deployment identities.
19
+ const namespace = redisNamespaceSchema;
20
+ const identifier = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/);
21
+ const resourceFields = artifactNextResourceLimitsSchema.unwrap().shape;
22
+ const resources = z.object({
23
+ concurrency: resourceFields.concurrency,
24
+ max_instances: resourceFields.max_instances,
25
+ timeout_seconds: resourceFields.timeout_seconds,
26
+ });
27
+ // Both keys have the same Redis Cluster hash tag. A lease can never exist in
28
+ // only one set, and previews/retained releases consume the same application cap.
29
+ const ACQUIRE = `-- artifact-request-admission-acquire-v1
30
+ local time = redis.call('TIME')
31
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
32
+ local maximum = tonumber(ARGV[2])
33
+ local deadline = now + tonumber(ARGV[3])
34
+ for _, key in ipairs(KEYS) do
35
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
36
+ if redis.call('ZCARD', key) >= maximum then return {0, now} end
37
+ end
38
+ for _, key in ipairs(KEYS) do
39
+ redis.call('ZADD', key, deadline, ARGV[1])
40
+ local latest = redis.call('ZREVRANGE', key, 0, 0, 'WITHSCORES')
41
+ redis.call('PEXPIRE', key, tonumber(latest[2]) - now + 1000)
42
+ end
43
+ return {1, deadline}`;
44
+ const RELEASE = `-- artifact-request-admission-release-v1
45
+ for _, key in ipairs(KEYS) do
46
+ redis.call('ZREM', key, ARGV[1])
47
+ if redis.call('ZCARD', key) == 0 then redis.call('DEL', key) end
48
+ end
49
+ return 1`;
50
+ /** Fail-closed admission with no command retries. An uncertain acquisition is
51
+ * never used; its unique permit expires even if Redis accepted it before loss. */
52
+ export class ArtifactRequestAdmission {
53
+ options;
54
+ namespace;
55
+ ceiling;
56
+ constructor(options) {
57
+ this.options = options;
58
+ this.namespace = namespace.parse(options.namespace);
59
+ this.ceiling = z.number().int().min(1).max(256).parse(options.platformCeiling ?? 256);
60
+ const policy = options.redis.options;
61
+ if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
62
+ throw new Error('Admission Redis must disable offline queues, command replay and retries');
63
+ }
64
+ }
65
+ async acquire(target) {
66
+ if (namespace.parse(target.environment) !== this.namespace)
67
+ throw new Error('Admission environment mismatch');
68
+ const projectId = identifier.parse(target.projectId), deploymentId = identifier.parse(target.deploymentId);
69
+ const limits = resources.parse(target.resources ?? {});
70
+ const maximum = Math.min(limits.concurrency * limits.max_instances, this.ceiling);
71
+ const timeoutMs = limits.timeout_seconds * 1000;
72
+ const requestId = randomUUID();
73
+ const prefix = `artifact-admission:${this.namespace}:{${this.namespace}:${projectId}}`;
74
+ const keys = [`${prefix}:app`, `${prefix}:deployment:${deploymentId}`];
75
+ const began = performance.now();
76
+ let result;
77
+ try {
78
+ result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE, 2, ...keys, requestId, maximum, timeoutMs));
79
+ }
80
+ catch {
81
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
82
+ }
83
+ if (result[0] === 0)
84
+ throw new ArtifactAdmissionError(429, 'capacity_exceeded');
85
+ // Never start work under an admission whose local request budget already
86
+ // elapsed while Redis was unavailable or stalled. Redis TTL reclaims it.
87
+ if (performance.now() - began >= timeoutMs)
88
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
89
+ let releasePromise;
90
+ let cleanup;
91
+ let bound = false;
92
+ const release = () => {
93
+ cleanup?.();
94
+ // Repeated finish/close/error notifications must never free another
95
+ // request's permit, nor cause a failed command to be retried.
96
+ releasePromise ??= this.options.redis.eval(RELEASE, 2, ...keys, requestId).then(() => undefined, () => { throw new ArtifactAdmissionError(503, 'admission_unavailable'); });
97
+ return releasePromise;
98
+ };
99
+ const releaseQuietly = () => {
100
+ void release().catch(() => this.options.onError?.({ kind: 'admission_release_failed' }));
101
+ };
102
+ return {
103
+ requestId, deadline: result[1], timeoutMs, release,
104
+ bind: (response, onTimeout) => {
105
+ if (bound || releasePromise)
106
+ throw new Error('Admission lease already bound or released');
107
+ bound = true;
108
+ const timer = setTimeout(() => {
109
+ try {
110
+ onTimeout();
111
+ }
112
+ catch {
113
+ this.options.onError?.({ kind: 'admission_timeout_callback_failed' });
114
+ }
115
+ finally {
116
+ releaseQuietly();
117
+ }
118
+ }, Math.max(0, timeoutMs - (performance.now() - began)));
119
+ timer.unref();
120
+ cleanup = () => {
121
+ clearTimeout(timer);
122
+ response.off('finish', releaseQuietly);
123
+ response.off('close', releaseQuietly);
124
+ };
125
+ response.once('finish', releaseQuietly);
126
+ response.once('close', releaseQuietly);
127
+ if (response.writableFinished || response.destroyed)
128
+ releaseQuietly();
129
+ // Detaching also releases; callers cannot strand an admitted permit.
130
+ return releaseQuietly;
131
+ },
132
+ };
133
+ }
134
+ }
@@ -0,0 +1,52 @@
1
+ import type { GatewaySession } from './session.js';
2
+ /**
3
+ * How long the gateway reuses an Atlas "allow" for the same session and target. Revocation,
4
+ * suspension, rollback, retirement, rollout disablement and actor deactivation reach browsers
5
+ * that are already admitted within this window. Denials are never cached.
6
+ */
7
+ export declare const GATEWAY_AUTHORIZATION_TTL_MS = 5000;
8
+ type Lookup<T> = {
9
+ key: string;
10
+ load: () => Promise<T>;
11
+ notAfter: (value: T) => number;
12
+ };
13
+ /**
14
+ * Per-process, positive-only decision cache with single-flight loading: the burst of chunk
15
+ * requests behind one page load costs one control-plane call per gateway replica per window.
16
+ * An entry never outlives the decision's own expiry. Every answer is ordered by when it was
17
+ * asked, so an older allow that resolves late can never replace a newer answer, and a refusal
18
+ * also detaches any older lookup still in flight.
19
+ */
20
+ export declare class PositiveDecisionCache<T> {
21
+ private readonly entries;
22
+ private readonly pending;
23
+ private readonly ttlMs;
24
+ private readonly maxEntries;
25
+ private readonly now;
26
+ private sequence;
27
+ constructor(options?: {
28
+ ttlMs?: number;
29
+ maxEntries?: number;
30
+ now?: () => number;
31
+ });
32
+ /** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
33
+ get(lookup: Lookup<T>): Promise<T>;
34
+ /**
35
+ * Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
36
+ * cached entry; a refusal evicts it and fences older lookups, so the next read asks again too.
37
+ */
38
+ refresh(lookup: Lookup<T>): Promise<T>;
39
+ clear(): void;
40
+ private ask;
41
+ private record;
42
+ }
43
+ /** Every session field Atlas authorizes or the gateway checks the answer against. Tab-local
44
+ * bridge values and the cookie's own expiry do not change the decision. */
45
+ export declare function gatewayAuthorizationKey(session: GatewaySession): string;
46
+ /** A tunnel credential is keyed by its hash, never retained in the key itself. */
47
+ export declare function tunnelAuthorizationKey({ token, sessionId, epoch }: {
48
+ token: string;
49
+ sessionId: string;
50
+ epoch: number;
51
+ }): string;
52
+ export {};
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * How long the gateway reuses an Atlas "allow" for the same session and target. Revocation,
4
+ * suspension, rollback, retirement, rollout disablement and actor deactivation reach browsers
5
+ * that are already admitted within this window. Denials are never cached.
6
+ */
7
+ export const GATEWAY_AUTHORIZATION_TTL_MS = 5_000;
8
+ const DEFAULT_MAX_ENTRIES = 10_000;
9
+ /**
10
+ * Per-process, positive-only decision cache with single-flight loading: the burst of chunk
11
+ * requests behind one page load costs one control-plane call per gateway replica per window.
12
+ * An entry never outlives the decision's own expiry. Every answer is ordered by when it was
13
+ * asked, so an older allow that resolves late can never replace a newer answer, and a refusal
14
+ * also detaches any older lookup still in flight.
15
+ */
16
+ export class PositiveDecisionCache {
17
+ entries = new Map();
18
+ pending = new Map();
19
+ ttlMs;
20
+ maxEntries;
21
+ now;
22
+ sequence = 0;
23
+ constructor(options = {}) {
24
+ this.ttlMs = options.ttlMs ?? GATEWAY_AUTHORIZATION_TTL_MS;
25
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
26
+ this.now = options.now ?? (() => Date.now());
27
+ if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs < 0 || this.ttlMs > 30_000)
28
+ throw new Error('Authorization cache TTL must be between 0 and 30 seconds');
29
+ if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1)
30
+ throw new Error('Authorization cache size must be positive');
31
+ }
32
+ /** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
33
+ get(lookup) {
34
+ const cached = this.entries.get(lookup.key);
35
+ if (cached && !cached.refused && cached.expiresAt > this.now())
36
+ return Promise.resolve(cached.value);
37
+ const inFlight = this.pending.get(lookup.key);
38
+ if (inFlight)
39
+ return inFlight.promise;
40
+ return this.ask({ lookup, share: true });
41
+ }
42
+ /**
43
+ * Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
44
+ * cached entry; a refusal evicts it and fences older lookups, so the next read asks again too.
45
+ */
46
+ refresh(lookup) {
47
+ return this.ask({ lookup, share: false });
48
+ }
49
+ clear() {
50
+ this.entries.clear();
51
+ this.pending.clear();
52
+ }
53
+ ask({ lookup: { key, load, notAfter }, share }) {
54
+ const sequence = ++this.sequence;
55
+ const askedAt = this.now();
56
+ const promise = load().then((value) => {
57
+ // Expiry counts from when the question was asked, so an answer is never reused past the TTL.
58
+ this.record({ key, entry: { sequence, refused: false, value, expiresAt: Math.min(askedAt + this.ttlMs, notAfter(value)) } });
59
+ return value;
60
+ }, (error) => {
61
+ this.record({ key, entry: { sequence, refused: true, expiresAt: this.now() + this.ttlMs } });
62
+ throw error;
63
+ });
64
+ if (share) {
65
+ const flight = { sequence, promise };
66
+ this.pending.set(key, flight);
67
+ void promise.finally(() => { if (this.pending.get(key) === flight)
68
+ this.pending.delete(key); }).catch(() => undefined);
69
+ }
70
+ return promise;
71
+ }
72
+ record({ key, entry }) {
73
+ const current = this.entries.get(key);
74
+ // A newer answer, allow or refusal, always wins over one that was asked earlier.
75
+ if (current && current.sequence > entry.sequence)
76
+ return;
77
+ this.entries.delete(key);
78
+ if (entry.refused) {
79
+ // New readers must not join a lookup that started before this refusal.
80
+ const flight = this.pending.get(key);
81
+ if (flight && flight.sequence < entry.sequence)
82
+ this.pending.delete(key);
83
+ // An older allow could only have been reused for one TTL, so the fence lives that long.
84
+ if (this.ttlMs === 0)
85
+ return;
86
+ }
87
+ else if (entry.expiresAt <= this.now())
88
+ return;
89
+ this.entries.set(key, entry);
90
+ // Maps iterate in insertion order, so the first key is the least recently stored.
91
+ while (this.entries.size > this.maxEntries) {
92
+ const oldest = this.entries.keys().next().value;
93
+ if (oldest === undefined)
94
+ break;
95
+ this.entries.delete(oldest);
96
+ }
97
+ }
98
+ }
99
+ function digest(parts) {
100
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
101
+ }
102
+ /** Every session field Atlas authorizes or the gateway checks the answer against. Tab-local
103
+ * bridge values and the cookie's own expiry do not change the decision. */
104
+ export function gatewayAuthorizationKey(session) {
105
+ return digest(['session-v1', session.kind, session.projectId, session.ownerId, session.environment, session.publicOrigin,
106
+ session.sessionId, session.deploymentId, session.frameworkBuildId, session.published, session.lattice ?? null, session.taskRoutes ?? null]);
107
+ }
108
+ /** A tunnel credential is keyed by its hash, never retained in the key itself. */
109
+ export function tunnelAuthorizationKey({ token, sessionId, epoch }) {
110
+ return digest(['tunnel-v1', sessionId, epoch, createHash('sha256').update(token).digest('hex')]);
111
+ }
@@ -0,0 +1,48 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { type AdmissionRedis } from './admission.js';
3
+ export type GatewayControlEndpoint = 'session' | 'tunnel';
4
+ type EndpointLimits = {
5
+ perClient: number;
6
+ global: number;
7
+ };
8
+ export interface GatewayControlAdmissionLimits {
9
+ /** Fixed Redis-clock window shared by the per-client and environment-wide counters. */
10
+ windowMs: number;
11
+ session: EndpointLimits;
12
+ tunnel: EndpointLimits;
13
+ /** Unauthenticated control calls one gateway process may have outstanding against Atlas. */
14
+ maxInFlight: number;
15
+ }
16
+ /**
17
+ * Budgets for the only gateway endpoints that reach Atlas before a session exists. The per-client
18
+ * allowance tolerates many users behind one corporate egress address; the environment-wide budget
19
+ * bounds what any number of sources can cost Atlas, whatever their addresses.
20
+ */
21
+ export declare const GATEWAY_CONTROL_ADMISSION_LIMITS: Readonly<GatewayControlAdmissionLimits>;
22
+ /**
23
+ * Admits `/_sequence/session` and `/_sequence/tunnel` before they call Atlas. Fails closed, with no
24
+ * command retries: when Redis cannot answer, nothing unauthenticated reaches the control plane.
25
+ */
26
+ export declare class GatewayControlAdmission {
27
+ private readonly options;
28
+ private readonly namespace;
29
+ private readonly limits;
30
+ private inFlight;
31
+ constructor(options: {
32
+ redis: AdmissionRedis;
33
+ namespace: string;
34
+ limits?: GatewayControlAdmissionLimits;
35
+ });
36
+ /** Resolves to the in-flight permit's release; throws ArtifactAdmissionError (429 or 503). */
37
+ admit({ endpoint, client }: {
38
+ endpoint: GatewayControlEndpoint;
39
+ client: string;
40
+ }): Promise<() => void>;
41
+ }
42
+ /**
43
+ * The address a control call is budgeted against. Cloudflare sets CF-Connecting-IP and the edge
44
+ * Cloud Armor policy admits only Cloudflare. If that policy were bypassed, a forged address could
45
+ * only move a caller between per-client buckets: the environment-wide budget still applies.
46
+ */
47
+ export declare function gatewayClientAddress(request: IncomingMessage): string;
48
+ export {};
@@ -0,0 +1,127 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isIP, isIPv6 } from 'node:net';
3
+ import { z } from 'zod';
4
+ import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
5
+ import { ArtifactAdmissionError } from './admission.js';
6
+ /**
7
+ * Budgets for the only gateway endpoints that reach Atlas before a session exists. The per-client
8
+ * allowance tolerates many users behind one corporate egress address; the environment-wide budget
9
+ * bounds what any number of sources can cost Atlas, whatever their addresses.
10
+ */
11
+ export const GATEWAY_CONTROL_ADMISSION_LIMITS = Object.freeze({
12
+ windowMs: 10_000,
13
+ session: Object.freeze({ perClient: 60, global: 600 }),
14
+ tunnel: Object.freeze({ perClient: 30, global: 300 }),
15
+ maxInFlight: 64,
16
+ });
17
+ // Both keys share one hash tag, so the script stays single-slot on Redis Cluster. A rejected
18
+ // attempt consumes nothing: one noisy client cannot spend the environment-wide budget.
19
+ const ADMIT = `-- artifact-gateway-control-admission-v1
20
+ local window = tonumber(ARGV[3])
21
+ for index, key in ipairs(KEYS) do
22
+ if tonumber(redis.call('GET', key) or '0') >= tonumber(ARGV[index]) then
23
+ local ttl = redis.call('PTTL', key)
24
+ if ttl < 0 then
25
+ redis.call('PEXPIRE', key, window)
26
+ ttl = window
27
+ end
28
+ return {0, ttl}
29
+ end
30
+ end
31
+ for _, key in ipairs(KEYS) do
32
+ if redis.call('INCR', key) == 1 or redis.call('PTTL', key) < 0 then redis.call('PEXPIRE', key, window) end
33
+ end
34
+ return {1, 0}`;
35
+ const limitSchema = z.number().int().min(1).max(1_000_000);
36
+ const limitsSchema = z.object({
37
+ windowMs: z.number().int().min(1_000).max(3_600_000),
38
+ session: z.object({ perClient: limitSchema, global: limitSchema }),
39
+ tunnel: z.object({ perClient: limitSchema, global: limitSchema }),
40
+ maxInFlight: z.number().int().min(1).max(10_000),
41
+ });
42
+ const replySchema = z.tuple([z.number().int().min(0).max(1), z.number().int().min(0)]);
43
+ /**
44
+ * Admits `/_sequence/session` and `/_sequence/tunnel` before they call Atlas. Fails closed, with no
45
+ * command retries: when Redis cannot answer, nothing unauthenticated reaches the control plane.
46
+ */
47
+ export class GatewayControlAdmission {
48
+ options;
49
+ namespace;
50
+ limits;
51
+ inFlight = 0;
52
+ constructor(options) {
53
+ this.options = options;
54
+ this.namespace = redisNamespaceSchema.parse(options.namespace);
55
+ this.limits = limitsSchema.parse(options.limits ?? GATEWAY_CONTROL_ADMISSION_LIMITS);
56
+ const policy = options.redis.options;
57
+ if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
58
+ throw new Error('Control admission Redis must disable offline queues, command replay and retries');
59
+ }
60
+ }
61
+ /** Resolves to the in-flight permit's release; throws ArtifactAdmissionError (429 or 503). */
62
+ async admit({ endpoint, client }) {
63
+ if (this.inFlight >= this.limits.maxInFlight)
64
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
65
+ this.inFlight++;
66
+ let released = false;
67
+ const release = () => {
68
+ if (released)
69
+ return;
70
+ released = true;
71
+ this.inFlight--;
72
+ };
73
+ try {
74
+ const limits = this.limits[endpoint];
75
+ const prefix = `artifact-gateway-control:{${this.namespace}}:${endpoint}`;
76
+ const bucket = createHash('sha256').update(clientBucket(client)).digest('hex').slice(0, 32);
77
+ let reply;
78
+ try {
79
+ reply = replySchema.parse(await this.options.redis.eval(ADMIT, 2, `${prefix}:client:${bucket}`, `${prefix}:global`, limits.perClient, limits.global, this.limits.windowMs));
80
+ }
81
+ catch {
82
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
83
+ }
84
+ if (reply[0] !== 1) {
85
+ throw new ArtifactAdmissionError(429, 'capacity_exceeded', Math.max(1, Math.ceil(reply[1] / 1000)));
86
+ }
87
+ return release;
88
+ }
89
+ catch (error) {
90
+ release();
91
+ throw error;
92
+ }
93
+ }
94
+ }
95
+ /** One IPv6 host usually controls a whole /64, so it shares one allowance. */
96
+ function clientBucket(address) {
97
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
98
+ if (mapped)
99
+ return mapped[1];
100
+ if (!isIPv6(address))
101
+ return address;
102
+ const [head = '', tail] = address.toLowerCase().split('::');
103
+ const leading = head ? head.split(':') : [];
104
+ const trailing = tail ? tail.split(':') : [];
105
+ const groups = tail === undefined ? leading : [...leading, ...Array(Math.max(0, 8 - leading.length - trailing.length)).fill('0'), ...trailing];
106
+ return `${groups.slice(0, 4).map((group) => group.padStart(4, '0')).join(':')}::/64`;
107
+ }
108
+ function forwardedClient(request) {
109
+ const value = request.headers['x-forwarded-for'];
110
+ if (typeof value !== 'string')
111
+ return undefined;
112
+ // Google's external load balancer appends "<connecting client>, <load balancer>".
113
+ const hops = value.split(',').map((hop) => hop.trim()).filter(Boolean);
114
+ const candidate = hops.at(-2);
115
+ return candidate && isIP(candidate) ? candidate : undefined;
116
+ }
117
+ /**
118
+ * The address a control call is budgeted against. Cloudflare sets CF-Connecting-IP and the edge
119
+ * Cloud Armor policy admits only Cloudflare. If that policy were bypassed, a forged address could
120
+ * only move a caller between per-client buckets: the environment-wide budget still applies.
121
+ */
122
+ export function gatewayClientAddress(request) {
123
+ const cloudflare = request.headers['cf-connecting-ip'];
124
+ if (typeof cloudflare === 'string' && isIP(cloudflare.trim()))
125
+ return cloudflare.trim();
126
+ return forwardedClient(request) ?? request.socket.remoteAddress ?? 'unknown';
127
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import type { TunnelLease } from '../tunnel/index.js';
3
+ import type { GatewaySession } from './session.js';
4
+ import type { GatewayUsageEvent } from './usage.js';
5
+ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
+ kind: z.ZodLiteral<"development">;
7
+ sessionId: z.ZodString;
8
+ epoch: z.ZodNumber;
9
+ projectId: z.ZodString;
10
+ ownerId: z.ZodString;
11
+ environment: z.ZodString;
12
+ publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
13
+ expiresAt: z.ZodNumber;
14
+ maxExpiresAt: z.ZodOptional<z.ZodNumber>;
15
+ capabilities: z.ZodDefault<z.ZodArray<z.ZodString>>;
16
+ serviceAccountId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
17
+ lattice: z.ZodOptional<z.ZodObject<{
18
+ runId: z.ZodNullable<z.ZodString>;
19
+ nodeId: z.ZodNullable<z.ZodString>;
20
+ stepId: z.ZodNullable<z.ZodString>;
21
+ processId: z.ZodNullable<z.ZodString>;
22
+ }, z.core.$strict>>;
23
+ taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
+ manifest: z.ZodOptional<z.ZodObject<{
25
+ server: z.ZodObject<{
26
+ resources: z.ZodObject<{
27
+ concurrency: z.ZodNumber;
28
+ max_instances: z.ZodNumber;
29
+ timeout_seconds: z.ZodNumber;
30
+ }, z.core.$strip>;
31
+ }, z.core.$strip>;
32
+ }, z.core.$strip>>;
33
+ }, z.core.$strip>, z.ZodObject<{
34
+ kind: z.ZodLiteral<"hosted">;
35
+ deploymentId: z.ZodString;
36
+ frameworkBuildId: z.ZodString;
37
+ upstreamUrl: z.ZodString;
38
+ upstreamAudience: z.ZodOptional<z.ZodString>;
39
+ active: z.ZodBoolean;
40
+ published: z.ZodOptional<z.ZodBoolean>;
41
+ projectId: z.ZodString;
42
+ ownerId: z.ZodString;
43
+ environment: z.ZodString;
44
+ publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
45
+ expiresAt: z.ZodNumber;
46
+ maxExpiresAt: z.ZodOptional<z.ZodNumber>;
47
+ capabilities: z.ZodDefault<z.ZodArray<z.ZodString>>;
48
+ serviceAccountId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
49
+ lattice: z.ZodOptional<z.ZodObject<{
50
+ runId: z.ZodNullable<z.ZodString>;
51
+ nodeId: z.ZodNullable<z.ZodString>;
52
+ stepId: z.ZodNullable<z.ZodString>;
53
+ processId: z.ZodNullable<z.ZodString>;
54
+ }, z.core.$strict>>;
55
+ taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
56
+ manifest: z.ZodOptional<z.ZodObject<{
57
+ server: z.ZodObject<{
58
+ resources: z.ZodObject<{
59
+ concurrency: z.ZodNumber;
60
+ max_instances: z.ZodNumber;
61
+ timeout_seconds: z.ZodNumber;
62
+ }, z.core.$strip>;
63
+ }, z.core.$strip>;
64
+ }, z.core.$strip>>;
65
+ }, z.core.$strip>], "kind">;
66
+ export type GatewayTarget = z.infer<typeof gatewayTargetSchema>;
67
+ export declare class GatewayControlError extends Error {
68
+ readonly status: number;
69
+ constructor(status: number);
70
+ }
71
+ export declare class ArtifactGatewayControl {
72
+ private readonly options;
73
+ private readonly origin;
74
+ constructor(options: {
75
+ url: string;
76
+ token: string;
77
+ fetch?: typeof fetch;
78
+ });
79
+ private call;
80
+ /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
81
+ usage(events: GatewayUsageEvent[]): Promise<{
82
+ rejected: number;
83
+ }>;
84
+ bootstrap(ticket: string): Promise<{
85
+ session: GatewaySession;
86
+ target: GatewayTarget;
87
+ }>;
88
+ authorize(session: GatewaySession): Promise<GatewayTarget>;
89
+ tunnel({ token, sessionId, epoch }: {
90
+ token: string;
91
+ sessionId: string;
92
+ epoch: number;
93
+ }): Promise<TunnelLease>;
94
+ }