@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,92 @@
1
+ import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { DEFAULT_TUNNEL_LIMITS } from './protocol.js';
4
+ import { envelopeSchema } from './redis-types.js';
5
+ const WIRE_VERSION = 'v1';
6
+ /** Frames older (or further in the future) than this are replays or come from a stalled node. */
7
+ export const RELAY_FRAME_MAX_AGE_MS = 30_000;
8
+ /** An envelope quotes a frame as a JSON string: at most double its size, plus fixed fields and the MAC. */
9
+ export const RELAY_MESSAGE_MAX_BYTES = DEFAULT_TUNNEL_LIMITS.maxFrameBytes * 2 + 4096;
10
+ const PEER_RETENTION_MS = RELAY_FRAME_MAX_AGE_MS * 4;
11
+ const MAX_TRACKED_PEERS = 1024;
12
+ const wireSchema = z.object({
13
+ sequence: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
14
+ sentAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
15
+ envelope: envelopeSchema,
16
+ }).strict();
17
+ /**
18
+ * The relay's frame key: an HKDF-SHA256 subkey of the gateway-only session key, bound to one
19
+ * environment. Atlas and every other Redis client lack that key, so they cannot forge frames, and
20
+ * the session key itself is never used as a MAC key for anything but cookies.
21
+ */
22
+ export function deriveRelayFrameKey({ secret, namespace }) {
23
+ if (Buffer.byteLength(secret) < 32)
24
+ throw new Error('Tunnel relay authentication requires a gateway secret of at least 32 bytes');
25
+ return Buffer.from(hkdfSync('sha256', secret, 'atlas-artifact-next', `artifact-next/tunnel-relay/v1:${namespace}`, 32));
26
+ }
27
+ function mac({ key, channel, body }) {
28
+ return createHmac('sha256', key).update(`${WIRE_VERSION}\n${channel}\n${body}`).digest();
29
+ }
30
+ /**
31
+ * Authenticates relay messages: the MAC binds the destination channel, so a captured frame cannot be
32
+ * redirected to another node, and each source's strictly increasing sequence plus a freshness window
33
+ * rejects replays. A single publisher connection per node keeps Redis delivery in sequence order.
34
+ */
35
+ export class RelayFrameAuthenticator {
36
+ key;
37
+ now;
38
+ sequence = 0;
39
+ peers = new Map();
40
+ constructor(key, now = Date.now) {
41
+ this.key = key;
42
+ this.now = now;
43
+ if (key.length !== 32)
44
+ throw new Error('Invalid tunnel relay frame key');
45
+ }
46
+ seal({ channel, envelope }) {
47
+ const body = JSON.stringify({ sequence: ++this.sequence, sentAt: this.now(), envelope });
48
+ return `${WIRE_VERSION}.${mac({ key: this.key, channel, body }).toString('base64url')}.${body}`;
49
+ }
50
+ /** Null for any frame that is oversized, unauthenticated, stale, replayed or malformed. */
51
+ open({ channel, message }) {
52
+ if (Buffer.byteLength(message) > RELAY_MESSAGE_MAX_BYTES || !message.startsWith(`${WIRE_VERSION}.`))
53
+ return null;
54
+ const separator = message.indexOf('.', WIRE_VERSION.length + 1);
55
+ if (separator < 0)
56
+ return null;
57
+ const signature = message.slice(WIRE_VERSION.length + 1, separator), body = message.slice(separator + 1);
58
+ if (!/^[A-Za-z0-9_-]{43}$/.test(signature))
59
+ return null;
60
+ const expected = mac({ key: this.key, channel, body }), actual = Buffer.from(signature, 'base64url');
61
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
62
+ return null;
63
+ let wire;
64
+ try {
65
+ wire = wireSchema.parse(JSON.parse(body));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ const now = this.now();
71
+ if (Math.abs(now - wire.sentAt) > RELAY_FRAME_MAX_AGE_MS)
72
+ return null;
73
+ const source = wire.envelope.source;
74
+ const peer = this.peers.get(source);
75
+ if (peer && wire.sequence <= peer.sequence)
76
+ return null;
77
+ this.peers.delete(source);
78
+ this.peers.set(source, { sequence: wire.sequence, seenAt: now });
79
+ this.forgetIdlePeers(now);
80
+ return wire.envelope;
81
+ }
82
+ /** Forgotten peers can only send frames newer than the freshness window, which rejects replays. */
83
+ forgetIdlePeers(now) {
84
+ if (this.peers.size <= MAX_TRACKED_PEERS)
85
+ return;
86
+ for (const [source, peer] of this.peers) {
87
+ if (now - peer.seenAt <= PEER_RETENTION_MS)
88
+ break;
89
+ this.peers.delete(source);
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import type { TunnelLease, TunnelOwner, TunnelOwnershipStore } from './broker.js';
3
+ export interface RedisTunnelCommands {
4
+ get(key: string): Promise<string | null>;
5
+ eval(script: string, numberOfKeys: number, ...arguments_: (string | number)[]): Promise<unknown>;
6
+ }
7
+ export declare const redisIdentifier: z.ZodString;
8
+ /** An APP_ENV deployment identity (tenant ids like banksouth_mortgage, or <tenant_id>_staging), kept
9
+ * free of the ':' key delimiter and '{}' hash-tag braces used in Redis keys and channels. */
10
+ export declare const redisNamespaceSchema: z.ZodString;
11
+ export declare const redisLeaseSchema: z.ZodObject<{
12
+ sessionId: z.ZodString;
13
+ epoch: z.ZodNumber;
14
+ ownerId: z.ZodString;
15
+ projectId: z.ZodString;
16
+ environment: z.ZodString;
17
+ expiresAt: z.ZodNumber;
18
+ publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
19
+ }, z.core.$strict>;
20
+ /** Retain epochs longer than any lease, including after owner disconnect. Atlas
21
+ * must still authenticate every claim against the durable session's lifetime;
22
+ * Redis is a routing fence, not a substitute for session authorization. */
23
+ export declare class RedisTunnelOwnership implements TunnelOwnershipStore {
24
+ private readonly options;
25
+ readonly namespace: string;
26
+ constructor(options: {
27
+ command: RedisTunnelCommands;
28
+ namespace: string;
29
+ });
30
+ private key;
31
+ claim(input: TunnelLease, nodeId: string): Promise<boolean>;
32
+ private read;
33
+ lookup(sessionId: string): Promise<TunnelOwner | null>;
34
+ resolveLease(sessionId: string): Promise<TunnelLease | null>;
35
+ /** Call only after Atlas has authorized and renewed the backing session. */
36
+ renew(input: TunnelLease, nodeId: string): Promise<boolean>;
37
+ release(sessionId: string, epoch: number, nodeId: string): Promise<void>;
38
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod';
2
+ import { normalizePublicOrigin } from './protocol.js';
3
+ export const redisIdentifier = z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
4
+ /** An APP_ENV deployment identity (tenant ids like banksouth_mortgage, or <tenant_id>_staging), kept
5
+ * free of the ':' key delimiter and '{}' hash-tag braces used in Redis keys and channels. */
6
+ export const redisNamespaceSchema = z.string().max(63).regex(/^[a-z][a-z0-9_-]*$/);
7
+ export const redisLeaseSchema = z.object({
8
+ sessionId: redisIdentifier, epoch: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
9
+ ownerId: z.string().min(1).max(512), projectId: redisIdentifier, environment: redisNamespaceSchema,
10
+ expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
11
+ publicOrigin: z.string().max(2048).transform(normalizePublicOrigin),
12
+ }).strict();
13
+ const ownershipSchema = z.object({ nodeId: redisIdentifier, lease: redisLeaseSchema }).strict();
14
+ const MAX_LEASE_MS = 8 * 60 * 60 * 1000;
15
+ const EPOCH_RETENTION_MS = 9 * 60 * 60 * 1000;
16
+ const CLAIM = `-- artifact-tunnel-claim-v1
17
+ local previous = tonumber(redis.call('GET', KEYS[2]) or '0')
18
+ local epoch = tonumber(ARGV[1])
19
+ local now = redis.call('TIME')
20
+ local ttl = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000))
21
+ if previous >= epoch or ttl <= 0 then return 0 end
22
+ redis.call('SET', KEYS[2], ARGV[1], 'PX', ARGV[4])
23
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl)
24
+ return 1`;
25
+ const RELEASE = `-- artifact-tunnel-release-v1
26
+ local serialized = redis.call('GET', KEYS[1])
27
+ if not serialized then return 0 end
28
+ local owner = cjson.decode(serialized)
29
+ if owner.lease.epoch ~= tonumber(ARGV[1]) or owner.nodeId ~= ARGV[2] then return 0 end
30
+ redis.call('DEL', KEYS[1])
31
+ return 1`;
32
+ const RENEW = `-- artifact-tunnel-renew-v1
33
+ local serialized = redis.call('GET', KEYS[1])
34
+ if not serialized then return 0 end
35
+ local previous = cjson.decode(serialized)
36
+ local proposed = cjson.decode(ARGV[1])
37
+ if previous.nodeId ~= proposed.nodeId then return 0 end
38
+ for _, key in ipairs({'sessionId', 'epoch', 'ownerId', 'projectId', 'environment', 'publicOrigin'}) do
39
+ if previous.lease[key] ~= proposed.lease[key] then return 0 end
40
+ end
41
+ if previous.lease.expiresAt > proposed.lease.expiresAt then return 0 end
42
+ local now = redis.call('TIME')
43
+ local ttl = proposed.lease.expiresAt - (tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000))
44
+ if ttl <= 0 then return 0 end
45
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl)
46
+ redis.call('PEXPIRE', KEYS[2], ARGV[2])
47
+ return 1`;
48
+ /** Retain epochs longer than any lease, including after owner disconnect. Atlas
49
+ * must still authenticate every claim against the durable session's lifetime;
50
+ * Redis is a routing fence, not a substitute for session authorization. */
51
+ export class RedisTunnelOwnership {
52
+ options;
53
+ namespace;
54
+ constructor(options) {
55
+ this.options = options;
56
+ this.namespace = redisNamespaceSchema.parse(options.namespace);
57
+ }
58
+ key(sessionId) {
59
+ return `artifact-tunnel:${this.namespace}:{${redisIdentifier.parse(sessionId)}}:owner`;
60
+ }
61
+ async claim(input, nodeId) {
62
+ const lease = redisLeaseSchema.parse(input);
63
+ redisIdentifier.parse(nodeId);
64
+ if (lease.environment !== this.namespace || lease.expiresAt <= Date.now() || lease.expiresAt - Date.now() > MAX_LEASE_MS)
65
+ throw new Error('Invalid tunnel lease environment or expiry');
66
+ const key = this.key(lease.sessionId);
67
+ return await this.options.command.eval(CLAIM, 2, key, `${key}:epoch`, lease.epoch, JSON.stringify({ nodeId, lease }), lease.expiresAt, EPOCH_RETENTION_MS) === 1;
68
+ }
69
+ async read(sessionId) {
70
+ const value = await this.options.command.get(this.key(sessionId));
71
+ if (!value)
72
+ return null;
73
+ if (Buffer.byteLength(value) > 8192)
74
+ throw new Error('Invalid stored tunnel ownership');
75
+ const owner = ownershipSchema.parse(JSON.parse(value));
76
+ if (owner.lease.environment !== this.namespace || owner.lease.sessionId !== sessionId)
77
+ throw new Error('Mismatched tunnel ownership');
78
+ return owner.lease.expiresAt > Date.now() ? owner : null;
79
+ }
80
+ async lookup(sessionId) {
81
+ const owner = await this.read(sessionId);
82
+ return owner ? { nodeId: owner.nodeId, epoch: owner.lease.epoch, expiresAt: owner.lease.expiresAt } : null;
83
+ }
84
+ async resolveLease(sessionId) { return (await this.read(sessionId))?.lease ?? null; }
85
+ /** Call only after Atlas has authorized and renewed the backing session. */
86
+ async renew(input, nodeId) {
87
+ const lease = redisLeaseSchema.parse(input);
88
+ redisIdentifier.parse(nodeId);
89
+ if (lease.environment !== this.namespace || lease.expiresAt <= Date.now() || lease.expiresAt - Date.now() > MAX_LEASE_MS)
90
+ throw new Error('Invalid tunnel lease renewal');
91
+ const key = this.key(lease.sessionId);
92
+ return await this.options.command.eval(RENEW, 2, key, `${key}:epoch`, JSON.stringify({ nodeId, lease }), EPOCH_RETENTION_MS) === 1;
93
+ }
94
+ async release(sessionId, epoch, nodeId) {
95
+ redisIdentifier.parse(nodeId);
96
+ if (!Number.isSafeInteger(epoch) || epoch < 1)
97
+ throw new Error('Invalid tunnel epoch');
98
+ await this.options.command.eval(RELEASE, 1, this.key(sessionId), epoch, nodeId);
99
+ }
100
+ }
@@ -0,0 +1,13 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { TunnelSocket } from './transport.js';
3
+ export declare class RelaySocket extends EventEmitter implements TunnelSocket {
4
+ private readonly transmit;
5
+ private readonly disconnect;
6
+ readyState: number;
7
+ bufferedAmount: number;
8
+ private sending;
9
+ constructor(transmit: (data: string) => Promise<void>, disconnect: () => void);
10
+ send(data: string, callback: (error?: Error) => void): void;
11
+ terminate(): void;
12
+ finish(): void;
13
+ }
@@ -0,0 +1,48 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { DEFAULT_TUNNEL_LIMITS, parseTunnelFrame } from './protocol.js';
3
+ export class RelaySocket extends EventEmitter {
4
+ transmit;
5
+ disconnect;
6
+ readyState = 0;
7
+ bufferedAmount = 0;
8
+ sending = Promise.resolve();
9
+ constructor(transmit, disconnect) {
10
+ super();
11
+ this.transmit = transmit;
12
+ this.disconnect = disconnect;
13
+ }
14
+ send(data, callback) {
15
+ if (this.readyState !== 1) {
16
+ callback(new Error('Tunnel disconnected'));
17
+ return;
18
+ }
19
+ const bytes = Buffer.byteLength(data);
20
+ if (bytes > DEFAULT_TUNNEL_LIMITS.maxFrameBytes || this.bufferedAmount + bytes > DEFAULT_TUNNEL_LIMITS.maxBufferedBytes) {
21
+ this.terminate();
22
+ callback(new Error('Tunnel relay send limit exceeded'));
23
+ return;
24
+ }
25
+ this.bufferedAmount += bytes;
26
+ this.sending = this.sending.then(async () => {
27
+ if (this.readyState !== 1)
28
+ throw new Error('Tunnel disconnected');
29
+ parseTunnelFrame(data);
30
+ await this.transmit(data);
31
+ }).then(() => callback(), (error) => {
32
+ this.terminate();
33
+ callback(error instanceof Error ? error : new Error('Tunnel relay failed'));
34
+ }).finally(() => { this.bufferedAmount -= bytes; });
35
+ }
36
+ terminate() {
37
+ if (this.readyState === 3)
38
+ return;
39
+ this.finish();
40
+ this.disconnect();
41
+ }
42
+ finish() {
43
+ if (this.readyState === 3)
44
+ return;
45
+ this.readyState = 3;
46
+ this.emit('close', 1006, Buffer.alloc(0));
47
+ }
48
+ }
@@ -0,0 +1,75 @@
1
+ import type { EventEmitter } from 'node:events';
2
+ import { z } from 'zod';
3
+ import type { TunnelLease } from './broker.js';
4
+ import type { TunnelSocket } from './transport.js';
5
+ import { type RedisTunnelOwnership } from './redis-ownership.js';
6
+ import type { RelaySocket } from './redis-socket.js';
7
+ export interface RedisTunnelPublisher {
8
+ readonly options: {
9
+ enableOfflineQueue?: boolean;
10
+ autoResendUnfulfilledCommands?: boolean;
11
+ maxRetriesPerRequest?: number | null;
12
+ };
13
+ publish(channel: string, message: string): Promise<number>;
14
+ }
15
+ export interface RedisTunnelSubscriber extends EventEmitter {
16
+ subscribe(channel: string): Promise<unknown>;
17
+ unsubscribe(channel: string): Promise<unknown>;
18
+ }
19
+ export declare const envelopeSchema: z.ZodObject<{
20
+ type: z.ZodEnum<{
21
+ ready: "ready";
22
+ close: "close";
23
+ open: "open";
24
+ frame: "frame";
25
+ revoke: "revoke";
26
+ heartbeat: "heartbeat";
27
+ }>;
28
+ destination: z.ZodEnum<{
29
+ owner: "owner";
30
+ requester: "requester";
31
+ }>;
32
+ source: z.ZodString;
33
+ connection: z.ZodString;
34
+ sessionId: z.ZodString;
35
+ epoch: z.ZodNumber;
36
+ frame: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strict>;
38
+ export type Envelope = z.infer<typeof envelopeSchema>;
39
+ export type Remote = {
40
+ nodeId: string;
41
+ connection: string;
42
+ lastSeen: number;
43
+ };
44
+ export type Attached = {
45
+ lease: TunnelLease;
46
+ socket: TunnelSocket;
47
+ remotes: Map<string, Remote>;
48
+ streams: Map<string, Remote>;
49
+ timer: NodeJS.Timeout;
50
+ queuedBytes: number;
51
+ receiving: Promise<void>;
52
+ stopped: boolean;
53
+ };
54
+ export type Connected = {
55
+ lease: TunnelLease;
56
+ ownerNode: string;
57
+ socket: RelaySocket;
58
+ timer: NodeJS.Timeout;
59
+ heartbeat: NodeJS.Timeout;
60
+ resolve: (socket: TunnelSocket) => void;
61
+ reject: (error: Error) => void;
62
+ };
63
+ export interface RedisTunnelRelayOptions {
64
+ nodeId: string;
65
+ namespace: string;
66
+ ownership: RedisTunnelOwnership;
67
+ publisher: RedisTunnelPublisher;
68
+ subscriber: RedisTunnelSubscriber;
69
+ connectTimeoutMs?: number;
70
+ /** Gateway-only key material shared by every replica (the session key). Frames are authenticated
71
+ * with an HKDF subkey of it; the value itself never leaves the process. */
72
+ secret: string;
73
+ /** Called for each relay message dropped as unauthenticated, stale, replayed or oversized. */
74
+ onRejectedFrame?: () => void;
75
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ import { DEFAULT_TUNNEL_LIMITS } from './protocol.js';
3
+ import { redisIdentifier as identifier } from './redis-ownership.js';
4
+ export const envelopeSchema = z.object({
5
+ type: z.enum(['open', 'ready', 'frame', 'close', 'revoke', 'heartbeat']),
6
+ destination: z.enum(['owner', 'requester']), source: identifier, connection: identifier,
7
+ sessionId: identifier, epoch: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
8
+ frame: z.string().max(DEFAULT_TUNNEL_LIMITS.maxFrameBytes).optional(),
9
+ }).strict().refine((message) => (message.type === 'frame') === (message.frame !== undefined));
@@ -0,0 +1,56 @@
1
+ import type { TunnelLease } from './broker.js';
2
+ import type { TunnelRelay, TunnelSocket } from './transport.js';
3
+ import type { RedisTunnelRelayOptions } from './redis-types.js';
4
+ export { RedisTunnelOwnership } from './redis-ownership.js';
5
+ export type { RedisTunnelCommands } from './redis-ownership.js';
6
+ export type { RedisTunnelPublisher, RedisTunnelSubscriber, RedisTunnelRelayOptions } from './redis-types.js';
7
+ /** Internal Redis channels contain authorized application traffic. Deploy them
8
+ * on the environment's private, authenticated Redis and disable command replay.
9
+ * Every message is authenticated with a gateway-only key: other Redis clients
10
+ * can neither inject, redirect nor replay tunnel frames. */
11
+ export declare class RedisTunnelRelay implements TunnelRelay {
12
+ private readonly options;
13
+ private started;
14
+ private closed;
15
+ private readonly attached;
16
+ private readonly connected;
17
+ private readonly frames;
18
+ private publishing;
19
+ private receiving;
20
+ private outgoingBytes;
21
+ private incomingBytes;
22
+ private generation;
23
+ private starting?;
24
+ constructor(options: RedisTunnelRelayOptions);
25
+ private channel;
26
+ private assertStarted;
27
+ isReady(): boolean;
28
+ start(): Promise<void>;
29
+ private subscribe;
30
+ private removeListeners;
31
+ private readonly onRedisFailure;
32
+ /**
33
+ * A local overload or malformed envelope resets relayed traffic like a Redis failure (in-flight
34
+ * streams fail closed), but the subscriber is still connected and emits no 'ready' event for
35
+ * gateway-main to restart on. Resubscribe now so a transient burst cannot leave the node unready.
36
+ */
37
+ private readonly onRelayOverload;
38
+ private readonly onMessage;
39
+ private publish;
40
+ private envelope;
41
+ private current;
42
+ private verifiedLease;
43
+ attach(input: TunnelLease, socket: TunnelSocket): Promise<void>;
44
+ private attachOwned;
45
+ connect(input: TunnelLease): Promise<TunnelSocket>;
46
+ private receive;
47
+ private toRequester;
48
+ private toDeveloper;
49
+ private writeDeveloper;
50
+ private fromDeveloper;
51
+ private closeRemote;
52
+ private closeOwner;
53
+ private closeConnection;
54
+ disconnect(sessionId: string): void;
55
+ close(): Promise<void>;
56
+ }