@rynx-ai/server 0.1.11-beta.30 → 0.1.11-beta.32

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 (85) hide show
  1. package/dist/control-api.js +37 -0
  2. package/dist/control-web/assets/{abnfDiagram-VRR7QNED-COu02MlJ.js → abnfDiagram-VRR7QNED-8c99_3I5.js} +1 -1
  3. package/dist/control-web/assets/{arc-DqiFeYAM.js → arc-FWW96Tzo.js} +1 -1
  4. package/dist/control-web/assets/{architectureDiagram-ZJ3FMSHR-BWFGgUV4.js → architectureDiagram-ZJ3FMSHR-B5FrqoRB.js} +1 -1
  5. package/dist/control-web/assets/{blockDiagram-677ZJIJ3-ZC0bXDzB.js → blockDiagram-677ZJIJ3-DJRwOGMa.js} +1 -1
  6. package/dist/control-web/assets/{c4Diagram-LMCZKHZV-Uupq5I-s.js → c4Diagram-LMCZKHZV-B8FCUurl.js} +1 -1
  7. package/dist/control-web/assets/channel-Bnyhn1Qs.js +1 -0
  8. package/dist/control-web/assets/{chunk-2Q5K7J3B-C77vwuDM.js → chunk-2Q5K7J3B-DC5xexY2.js} +1 -1
  9. package/dist/control-web/assets/{chunk-32BRIVSS-BkfLE_TX.js → chunk-32BRIVSS-DAzT69zb.js} +1 -1
  10. package/dist/control-web/assets/{chunk-5VM5RSS4-By6zfDWW.js → chunk-5VM5RSS4-CXKpL77L.js} +1 -1
  11. package/dist/control-web/assets/{chunk-EX3LRPZG-CO439p_3.js → chunk-EX3LRPZG-DQKQvoPv.js} +1 -1
  12. package/dist/control-web/assets/{chunk-JWPE2WC7-B9BPEwug.js → chunk-JWPE2WC7-CWMqpJoT.js} +1 -1
  13. package/dist/control-web/assets/{chunk-MOJQB5TN-CYKygWZz.js → chunk-MOJQB5TN-Bd8jn7RZ.js} +1 -1
  14. package/dist/control-web/assets/{chunk-RYQCIY6F-ug-An4HG.js → chunk-RYQCIY6F-DKXEMFO6.js} +1 -1
  15. package/dist/control-web/assets/{chunk-V7JOEXUC-3UFQGnKX.js → chunk-V7JOEXUC-BUDLq5nU.js} +1 -1
  16. package/dist/control-web/assets/{chunk-VR4S4FIN-DjspjYUb.js → chunk-VR4S4FIN-C65l7VIz.js} +1 -1
  17. package/dist/control-web/assets/{chunk-XXDRQBXY-3znnutie.js → chunk-XXDRQBXY-Y2498HkC.js} +1 -1
  18. package/dist/control-web/assets/classDiagram-OUVF2IWQ-CBtXSdwW.js +1 -0
  19. package/dist/control-web/assets/classDiagram-v2-EOCWNBFH-CBtXSdwW.js +1 -0
  20. package/dist/control-web/assets/{cose-bilkent-JH36ORCC-Dr28goCd.js → cose-bilkent-JH36ORCC-C9aVXb8E.js} +1 -1
  21. package/dist/control-web/assets/{cynefin-VYW2F7L2-Be5uJhnw.js → cynefin-VYW2F7L2-BsyMhvG0.js} +1 -1
  22. package/dist/control-web/assets/{cynefinDiagram-TSTJHNR4-BLxtSvL6.js → cynefinDiagram-TSTJHNR4-DwY43f3-.js} +1 -1
  23. package/dist/control-web/assets/{dagre-VKFMJZFB-DPRuDV5p.js → dagre-VKFMJZFB-CP1xk7Ox.js} +1 -1
  24. package/dist/control-web/assets/{diagram-FQU43EPY-Cybg22U0.js → diagram-FQU43EPY-D_yRnH1b.js} +1 -1
  25. package/dist/control-web/assets/{diagram-G47NLZAW-CGeIKUfW.js → diagram-G47NLZAW-DpTmLmbm.js} +1 -1
  26. package/dist/control-web/assets/{diagram-NH7WQ7WH-BJq5MBRm.js → diagram-NH7WQ7WH-Wh_P26Dr.js} +1 -1
  27. package/dist/control-web/assets/{diagram-OA4YK3LP-DquOQGwg.js → diagram-OA4YK3LP-BMMaO78f.js} +1 -1
  28. package/dist/control-web/assets/{diagram-WEI45ONY-Cblb4xP2.js → diagram-WEI45ONY-D8smty9F.js} +1 -1
  29. package/dist/control-web/assets/{ebnfDiagram-CCIWWBDH-CQjHiOqj.js → ebnfDiagram-CCIWWBDH-BqVsmEYh.js} +1 -1
  30. package/dist/control-web/assets/{erDiagram-Q63AITRT-Bspr3uKJ.js → erDiagram-Q63AITRT-CxaumIzW.js} +1 -1
  31. package/dist/control-web/assets/{flowDiagram-23GEKE2U-CxcZyBA-.js → flowDiagram-23GEKE2U-DZhUBne-.js} +1 -1
  32. package/dist/control-web/assets/{ganttDiagram-NO4QXBWP-qBdh7A-P.js → ganttDiagram-NO4QXBWP-B20dY8SF.js} +1 -1
  33. package/dist/control-web/assets/{gitGraphDiagram-IHSO6WYX-BTYub2KT.js → gitGraphDiagram-IHSO6WYX-CAlnqFfW.js} +1 -1
  34. package/dist/control-web/assets/{highlighted-body-OFNGDK62-DyksUrwu.js → highlighted-body-OFNGDK62-vBlGjC8F.js} +1 -1
  35. package/dist/control-web/assets/{index-BDaSctIT.js → index-B3g4Kfko.js} +1 -1
  36. package/dist/control-web/assets/index-BKqZNg1a.js +718 -0
  37. package/dist/control-web/assets/index-CZrE_F_i.css +32 -0
  38. package/dist/control-web/assets/{infoDiagram-FWYZ7A6U-ChbQR3Zo.js → infoDiagram-FWYZ7A6U-PaEyeSke.js} +1 -1
  39. package/dist/control-web/assets/{ishikawaDiagram-FXEZZL3T-C-4_qGzn.js → ishikawaDiagram-FXEZZL3T-BwsgPXGC.js} +1 -1
  40. package/dist/control-web/assets/{journeyDiagram-5HDEW3XC-B090zN18.js → journeyDiagram-5HDEW3XC-BUEf1NEq.js} +1 -1
  41. package/dist/control-web/assets/{kanban-definition-HUTT4EX6-RlTcRbPs.js → kanban-definition-HUTT4EX6-CiTCwnJ7.js} +1 -1
  42. package/dist/control-web/assets/{linear-DTuNxDqu.js → linear-Cs4GSPvX.js} +1 -1
  43. package/dist/control-web/assets/{mermaid-GHXKKRXX-BkcV3dgg.js → mermaid-GHXKKRXX-C_lUqoOB.js} +6 -6
  44. package/dist/control-web/assets/{mindmap-definition-LN4V7U3C-DYby41E9.js → mindmap-definition-LN4V7U3C-B8TkylOR.js} +1 -1
  45. package/dist/control-web/assets/{pegDiagram-2B236MQR-CPK6GaEv.js → pegDiagram-2B236MQR-Bnge_jzz.js} +1 -1
  46. package/dist/control-web/assets/{pieDiagram-ENE6RG2P-DLCmXuji.js → pieDiagram-ENE6RG2P-DOP7FAKG.js} +1 -1
  47. package/dist/control-web/assets/{quadrantDiagram-ABIIQ3AL-RIS8r7CA.js → quadrantDiagram-ABIIQ3AL-Cqqu5uu-.js} +1 -1
  48. package/dist/control-web/assets/{railroadDiagram-RFXS5EU6-B1HB7xsg.js → railroadDiagram-RFXS5EU6-1bk56BMW.js} +1 -1
  49. package/dist/control-web/assets/{requirementDiagram-TGXJPOKE-Inh_4hU0.js → requirementDiagram-TGXJPOKE-BHVwG79A.js} +1 -1
  50. package/dist/control-web/assets/{sankeyDiagram-HTMAVEWB-DPF33kPB.js → sankeyDiagram-HTMAVEWB-ghjBFqfs.js} +1 -1
  51. package/dist/control-web/assets/{sequenceDiagram-DBY2YBRQ-iywKWoYc.js → sequenceDiagram-DBY2YBRQ-DChtyqq5.js} +1 -1
  52. package/dist/control-web/assets/{sizeCapture-X5ZJPWSS-DlnHubz9.js → sizeCapture-X5ZJPWSS-BNp94nCS.js} +1 -1
  53. package/dist/control-web/assets/{stateDiagram-2N3HPSRC-CShzll2N.js → stateDiagram-2N3HPSRC-CaFYkZZ2.js} +1 -1
  54. package/dist/control-web/assets/stateDiagram-v2-6OUMAXLB-CCvEZFYI.js +1 -0
  55. package/dist/control-web/assets/{swimlanes-5IMT3BWC-CxlQpFBv.js → swimlanes-5IMT3BWC-CV2YWeYv.js} +2 -2
  56. package/dist/control-web/assets/swimlanesDiagram-G3AALYLV-BFAdJHEB.js +8 -0
  57. package/dist/control-web/assets/{timeline-definition-FHXFAJF6-Dlx59Xc3.js → timeline-definition-FHXFAJF6-Cq32UWLH.js} +1 -1
  58. package/dist/control-web/assets/{vennDiagram-L72KCM5P-DLsv4i_t.js → vennDiagram-L72KCM5P-BgBoqPth.js} +1 -1
  59. package/dist/control-web/assets/{wardleyDiagram-EHGQE667-BazV8fpH.js → wardleyDiagram-EHGQE667-VsC2Gfpu.js} +1 -1
  60. package/dist/control-web/assets/{xychartDiagram-FW5EYKEG-Bx9uDlYu.js → xychartDiagram-FW5EYKEG-CyFiAunt.js} +1 -1
  61. package/dist/control-web/index.html +2 -2
  62. package/dist/desktop-browser-host.d.ts +3 -1
  63. package/dist/desktop-browser-host.js +39 -4
  64. package/dist/machine-session-service.d.ts +12 -0
  65. package/dist/machine-session-service.js +31 -7
  66. package/dist/remote-runtime-dispatcher.d.ts +1 -1
  67. package/dist/remote-runtime-dispatcher.js +26 -1
  68. package/dist/remote-runtime-session-projection.d.ts +1 -0
  69. package/dist/remote-runtime-session-projection.js +1 -0
  70. package/dist/server.d.ts +23 -1
  71. package/dist/server.js +94 -11
  72. package/dist/session-browser-service.d.ts +24 -4
  73. package/dist/session-browser-service.js +127 -1
  74. package/dist/session-pending-inputs.d.ts +40 -0
  75. package/dist/session-pending-inputs.js +84 -0
  76. package/dist/session-portal.d.ts +14 -2
  77. package/dist/session-portal.js +268 -31
  78. package/package.json +7 -7
  79. package/dist/control-web/assets/channel-3HG15wfa.js +0 -1
  80. package/dist/control-web/assets/classDiagram-OUVF2IWQ-WmU4L9YT.js +0 -1
  81. package/dist/control-web/assets/classDiagram-v2-EOCWNBFH-WmU4L9YT.js +0 -1
  82. package/dist/control-web/assets/index-B9SAq_63.js +0 -718
  83. package/dist/control-web/assets/index-CtaYRNgJ.css +0 -32
  84. package/dist/control-web/assets/stateDiagram-v2-6OUMAXLB-Cdx0fWna.js +0 -1
  85. package/dist/control-web/assets/swimlanesDiagram-G3AALYLV-CoX0jnUu.js +0 -8
@@ -6,6 +6,9 @@ import { WebSocket, WebSocketServer } from "ws";
6
6
  import { sendSpaFile } from "./control-web-dist.js";
7
7
  export const SESSION_PORTAL_HEADER = "x-rynx-session-portal";
8
8
  export const SESSION_PORTAL_QUERY = "portal";
9
+ export const SESSION_PORTAL_SECRET_HEADER = "x-rynx-session-portal-secret";
10
+ export const SESSION_PORTAL_SECRET_QUERY = "portal_secret";
11
+ export const SESSION_PORTAL_EMBED_ORIGIN_QUERY = "embed_origin";
9
12
  const MAX_TICKET_BYTES = 256;
10
13
  const MAX_FILTERED_RESPONSE_BYTES = 16 * 1024 * 1024;
11
14
  const MAX_PENDING_SOCKET_FRAMES = 128;
@@ -29,20 +32,63 @@ const HOP_BY_HOP_HEADERS = new Set([
29
32
  "transfer-encoding",
30
33
  "upgrade",
31
34
  ]);
35
+ class SessionPortalTransportRegistry {
36
+ transports = new Map();
37
+ track(portalId, close) {
38
+ let finish;
39
+ const done = new Promise((resolve) => { finish = resolve; });
40
+ const entry = { close, done, finish };
41
+ const entries = this.transports.get(portalId) ?? new Set();
42
+ entries.add(entry);
43
+ this.transports.set(portalId, entries);
44
+ let finished = false;
45
+ return () => {
46
+ if (finished)
47
+ return;
48
+ finished = true;
49
+ entries.delete(entry);
50
+ if (entries.size === 0)
51
+ this.transports.delete(portalId);
52
+ finish();
53
+ };
54
+ }
55
+ async revoke(portalIds) {
56
+ const draining = [];
57
+ for (const portalId of portalIds) {
58
+ const entries = [...(this.transports.get(portalId) ?? [])];
59
+ this.transports.delete(portalId);
60
+ for (const entry of entries) {
61
+ draining.push(entry.done);
62
+ entry.close();
63
+ }
64
+ }
65
+ await Promise.all(draining);
66
+ }
67
+ }
32
68
  /** Session Portal is a narrow adapter in front of the one Control service. It
33
69
  * serves the native SPA, validates Portal grants, and forwards only authorized
34
70
  * Session HTTP calls to Control; it owns no Session or Runtime handlers. */
35
- export function createSessionPortalApp(options) {
71
+ export function createSessionPortalApp(options, transports = new SessionPortalTransportRegistry()) {
36
72
  const app = new Koa();
37
73
  const bootstrapRouter = new Router();
38
74
  app.use(async (ctx, next) => {
39
75
  ctx.set("Cache-Control", "no-store");
40
76
  ctx.set("Referrer-Policy", "no-referrer");
41
77
  ctx.set("X-Content-Type-Options", "nosniff");
42
- ctx.set("X-Frame-Options", "DENY");
78
+ const embedded = embeddedPageBootstrap(ctx, options.authorization);
79
+ if (embedded)
80
+ ctx.state.sessionPortalEmbed = embedded;
81
+ // The redeem shell contains no Session data and must be frameable before
82
+ // its fragment ticket can be posted. A real Session page is frameable only
83
+ // after its portal id resolves to the exact still-active embedded grant.
84
+ const frameOrigin = ctx.path === "/session-portal/redeem"
85
+ ? sessionPortalEmbedOrigin(ctx)
86
+ : embedded?.frameOrigin;
87
+ if (!frameOrigin)
88
+ ctx.set("X-Frame-Options", "DENY");
43
89
  ctx.set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " +
44
90
  "connect-src 'self' ws: wss:; img-src 'self' data: blob:; font-src 'self' data:; " +
45
- "base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
91
+ `base-uri 'none'; form-action 'self'; frame-ancestors ${frameOrigin ?? "'none'"}`);
46
92
  if (options.shutdownSignal?.aborted) {
47
93
  serviceUnavailable(ctx);
48
94
  return;
@@ -64,7 +110,8 @@ export function createSessionPortalApp(options) {
64
110
  ctx.body = { error: "invalid_ticket" };
65
111
  return;
66
112
  }
67
- const grant = options.authorization.redeem(ticket);
113
+ const requestedEmbedOrigin = sessionPortalEmbedOrigin(ctx);
114
+ const grant = options.authorization.redeem(ticket, requestedEmbedOrigin);
68
115
  if (!grant) {
69
116
  ctx.status = 401;
70
117
  ctx.body = { error: "ticket_expired_or_used" };
@@ -81,9 +128,22 @@ export function createSessionPortalApp(options) {
81
128
  const path = grant.scope === "single_session"
82
129
  ? `/runtimes/local/sessions/${encodeURIComponent(grant.sessionId)}`
83
130
  : "/runtimes/local/sessions";
131
+ const location = new URL(path, "http://session-portal.invalid");
132
+ location.searchParams.set(SESSION_PORTAL_QUERY, grant.portalId);
133
+ if (grant.embedOrigin) {
134
+ location.searchParams.set(SESSION_PORTAL_EMBED_ORIGIN_QUERY, grant.embedOrigin);
135
+ }
84
136
  ctx.body = {
85
- location: `${path}?${SESSION_PORTAL_QUERY}=${encodeURIComponent(grant.portalId)}`,
137
+ location: `${location.pathname}${location.search}`,
86
138
  expiresAt: grant.expiresAt,
139
+ ...(grant.embedOrigin
140
+ ? {
141
+ authorization: {
142
+ ...publicAuthorization(grant),
143
+ secret: grant.secret,
144
+ },
145
+ }
146
+ : {}),
87
147
  };
88
148
  });
89
149
  app.use(bootstrapRouter.routes());
@@ -91,24 +151,51 @@ export function createSessionPortalApp(options) {
91
151
  app.use(async (ctx, next) => {
92
152
  if (isStaticAssetRequest(ctx))
93
153
  return next();
154
+ const embedded = ctx.state.sessionPortalEmbed;
155
+ if (embedded) {
156
+ ctx.state.sessionPortalEmbed = embedded;
157
+ return next();
158
+ }
94
159
  const grant = authorizeHttp(ctx, options.authorization);
95
160
  if (!grant)
96
161
  return;
97
162
  ctx.state.sessionPortal = grant;
98
- expireHttpResponseAtGrantDeadline(ctx, grant.expiresAt);
99
- if (ctx.path === "/api/session-portal/access") {
100
- if (ctx.method !== "GET")
101
- return methodNotAllowed(ctx);
102
- ctx.body = publicAuthorization(grant);
103
- return;
163
+ const revocation = new AbortController();
164
+ ctx.state.sessionPortalRevocationSignal = revocation.signal;
165
+ const untrack = transports.track(grant.portalId, () => {
166
+ revocation.abort(new Error("Session Portal grant revoked"));
167
+ if (!ctx.res.writableEnded)
168
+ ctx.res.destroy(new Error("Session Portal grant revoked"));
169
+ });
170
+ try {
171
+ // Close the authorize -> registry race: if revoke committed between
172
+ // those steps, its notification may have had no transport to close yet.
173
+ if (!options.authorization.inspect(grant.portalId)) {
174
+ ctx.status = 401;
175
+ ctx.body = { error: "session_portal_grant_expired_or_invalid" };
176
+ return;
177
+ }
178
+ expireHttpResponseAtGrantDeadline(ctx, grant.expiresAt);
179
+ if (ctx.path === "/api/session-portal/access") {
180
+ if (ctx.method !== "GET")
181
+ return methodNotAllowed(ctx);
182
+ ctx.body = publicAuthorization(grant);
183
+ return;
184
+ }
185
+ if (!authorizePortalRoute(ctx, grant, options.authorization))
186
+ return;
187
+ if (ctx.path.startsWith("/api/")) {
188
+ await proxyControlHttp(ctx, grant, options);
189
+ return;
190
+ }
191
+ await next();
104
192
  }
105
- if (!authorizePortalRoute(ctx, grant, options.authorization))
106
- return;
107
- if (ctx.path.startsWith("/api/")) {
108
- await proxyControlHttp(ctx, grant, options);
109
- return;
193
+ finally {
194
+ // This is operation completion, not merely client disconnect. Revoke
195
+ // waits for it, so an already-admitted Control call cannot outlive the
196
+ // Host RPC that revoked its grant.
197
+ untrack();
110
198
  }
111
- await next();
112
199
  });
113
200
  app.use(async (ctx, next) => {
114
201
  if (ctx.method !== "GET" && ctx.method !== "HEAD")
@@ -116,9 +203,12 @@ export function createSessionPortalApp(options) {
116
203
  if (ctx.path.startsWith("/api/") || ctx.path.startsWith("/session-portal/"))
117
204
  return next();
118
205
  const grant = ctx.state.sessionPortal;
206
+ const embedded = ctx.state.sessionPortalEmbed;
119
207
  await sendSpaFile(ctx, options.controlWebDist, "/", grant
120
208
  ? (html) => injectPortalBootstrap(html, grant)
121
- : undefined);
209
+ : embedded
210
+ ? (html) => injectEmbeddedPortalBootstrap(html, embedded.portalId)
211
+ : undefined);
122
212
  });
123
213
  return app;
124
214
  }
@@ -132,7 +222,9 @@ export async function startSessionPortal(options) {
132
222
  void shutdownPortal?.().catch(() => undefined);
133
223
  };
134
224
  options.shutdownSignal?.addEventListener("abort", stopForControlShutdown, { once: true });
135
- const app = createSessionPortalApp({ ...options, shutdownSignal: lifecycle.signal });
225
+ const transports = new SessionPortalTransportRegistry();
226
+ const unsubscribeRevocations = options.authorization.subscribeRevocations?.((portalIds) => transports.revoke(portalIds));
227
+ const app = createSessionPortalApp({ ...options, shutdownSignal: lifecycle.signal }, transports);
136
228
  let server;
137
229
  try {
138
230
  server = await new Promise((resolve, reject) => {
@@ -144,6 +236,7 @@ export async function startSessionPortal(options) {
144
236
  });
145
237
  }
146
238
  catch (error) {
239
+ unsubscribeRevocations?.();
147
240
  options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
148
241
  throw error;
149
242
  }
@@ -153,9 +246,11 @@ export async function startSessionPortal(options) {
153
246
  authorization: options.authorization,
154
247
  controlOrigin: options.controlOrigin,
155
248
  shutdownSignal: lifecycle.signal,
249
+ transports,
156
250
  });
157
251
  }
158
252
  catch (error) {
253
+ unsubscribeRevocations?.();
159
254
  options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
160
255
  await closeHttpServerImmediately(server);
161
256
  throw error;
@@ -173,6 +268,7 @@ export async function startSessionPortal(options) {
173
268
  closeWebSocketServer(sockets.external),
174
269
  closeHttpServerImmediately(server),
175
270
  ]).then(() => {
271
+ unsubscribeRevocations?.();
176
272
  options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
177
273
  });
178
274
  return shutdownPromise;
@@ -199,7 +295,8 @@ export function authorizeSessionPortalWebSocket(request, ports, runtimeSelector,
199
295
  const portalId = url.searchParams.get(SESSION_PORTAL_QUERY) ?? "";
200
296
  if (!PORTAL_ID_PATTERN.test(portalId))
201
297
  return undefined;
202
- const secret = cookieValue(request.headers.cookie, cookieName(portalId));
298
+ const secret = cookieValue(request.headers.cookie, cookieName(portalId)) ||
299
+ url.searchParams.get(SESSION_PORTAL_SECRET_QUERY) || undefined;
203
300
  if (!secret)
204
301
  return undefined;
205
302
  const grant = ports.authorize(portalId, secret);
@@ -225,7 +322,8 @@ function authorizeHttp(ctx, ports) {
225
322
  : queryPortal;
226
323
  const portalId = candidate.trim();
227
324
  const secret = PORTAL_ID_PATTERN.test(portalId)
228
- ? ctx.cookies.get(cookieName(portalId))
325
+ ? ctx.cookies.get(cookieName(portalId)) ||
326
+ ctx.get(SESSION_PORTAL_SECRET_HEADER).trim() || undefined
229
327
  : undefined;
230
328
  const grant = secret ? ports.authorize(portalId, secret) : undefined;
231
329
  if (!grant) {
@@ -268,6 +366,11 @@ function authorizePortalRoute(ctx, grant, ports) {
268
366
  return notFound(ctx);
269
367
  if (!ports.ownsSession(grant.pluginId, sessionId))
270
368
  return notFound(ctx);
369
+ if (isBrowserRequestHeadersRoute(ctx, apiMatch[1])) {
370
+ if (ctx.method !== "GET" && ctx.method !== "PUT")
371
+ return methodNotAllowed(ctx);
372
+ return denyMutation(ctx, grant);
373
+ }
271
374
  if (ctx.method === "GET" || ctx.method === "HEAD")
272
375
  return true;
273
376
  if (!isAllowedSessionMutation(ctx, apiMatch[1]))
@@ -277,20 +380,30 @@ function authorizePortalRoute(ctx, grant, ports) {
277
380
  return notFound(ctx);
278
381
  }
279
382
  async function proxyControlHttp(ctx, grant, options) {
383
+ const revocationSignal = ctx.state.sessionPortalRevocationSignal;
384
+ // Reads/streams can be aborted immediately. An already-admitted mutation is
385
+ // different: keep its Control request alive and let the registry wait for
386
+ // the response, so revoke cannot return while a write is still executing.
387
+ const mutating = ctx.method !== "GET" && ctx.method !== "HEAD";
388
+ const signal = mutating
389
+ ? options.shutdownSignal
390
+ : options.shutdownSignal && revocationSignal
391
+ ? AbortSignal.any([options.shutdownSignal, revocationSignal])
392
+ : options.shutdownSignal ?? revocationSignal;
280
393
  let response;
281
394
  try {
282
- response = await openControlRequest(ctx, options.controlOrigin, options.shutdownSignal);
395
+ response = await openControlRequest(ctx, options.controlOrigin, signal);
283
396
  }
284
397
  catch (error) {
285
- if (options.shutdownSignal?.aborted)
286
- return serviceUnavailable(ctx);
398
+ if (signal?.aborted)
399
+ return;
287
400
  ctx.status = 502;
288
401
  ctx.body = { error: "control_service_unavailable" };
289
402
  return;
290
403
  }
291
404
  if (shouldFilterControlResponse(ctx)) {
292
405
  try {
293
- const data = await readBoundedResponse(response, MAX_FILTERED_RESPONSE_BYTES, options.shutdownSignal);
406
+ const data = await readBoundedResponse(response, MAX_FILTERED_RESPONSE_BYTES, signal);
294
407
  applyControlResponseHeaders(ctx, response.headers, true);
295
408
  ctx.status = response.statusCode ?? 502;
296
409
  const body = JSON.parse(data.toString("utf8"));
@@ -298,8 +411,8 @@ async function proxyControlHttp(ctx, grant, options) {
298
411
  ctx.body = body;
299
412
  }
300
413
  catch {
301
- if (options.shutdownSignal?.aborted)
302
- return serviceUnavailable(ctx);
414
+ if (signal?.aborted)
415
+ return;
303
416
  ctx.status = 502;
304
417
  ctx.body = { error: "invalid_control_response" };
305
418
  }
@@ -310,8 +423,8 @@ async function proxyControlHttp(ctx, grant, options) {
310
423
  ctx.respond = false;
311
424
  ctx.res.statusCode = ctx.status;
312
425
  try {
313
- await pipeline(response, ctx.res, ...(options.shutdownSignal
314
- ? [{ signal: options.shutdownSignal }]
426
+ await pipeline(response, ctx.res, ...(signal
427
+ ? [{ signal }]
315
428
  : []));
316
429
  }
317
430
  catch {
@@ -322,6 +435,8 @@ async function proxyControlHttp(ctx, grant, options) {
322
435
  function openControlRequest(ctx, controlOrigin, signal) {
323
436
  const target = new URL(`${ctx.path}${ctx.search}`, controlOrigin);
324
437
  target.searchParams.delete(SESSION_PORTAL_QUERY);
438
+ target.searchParams.delete(SESSION_PORTAL_SECRET_QUERY);
439
+ target.searchParams.delete(SESSION_PORTAL_EMBED_ORIGIN_QUERY);
325
440
  return new Promise((resolve, reject) => {
326
441
  const request = httpRequest(target, {
327
442
  method: ctx.method,
@@ -345,6 +460,7 @@ function controlRequestHeaders(headers, target) {
345
460
  lower === "referer" ||
346
461
  lower === "accept-encoding" ||
347
462
  lower === SESSION_PORTAL_HEADER ||
463
+ lower === SESSION_PORTAL_SECRET_HEADER ||
348
464
  lower.startsWith("forwarded") ||
349
465
  lower.startsWith("x-forwarded-"))
350
466
  continue;
@@ -397,6 +513,9 @@ function attachPortalWebSocketProxy(server, options) {
397
513
  const route = portalSocketRoute(request.url);
398
514
  if (!route)
399
515
  return rejectUpgrade(socket, 404, "Session Portal socket not found");
516
+ const credential = sessionPortalSocketCredential(request);
517
+ if (!credential)
518
+ return rejectSessionPortalUpgrade(socket);
400
519
  const grant = authorizeSessionPortalWebSocket(request, options.authorization, route.runtimeSelector, route.sessionId);
401
520
  if (!grant || !readOnlySocketRoleAllowed(route.kind, request.url, grant)) {
402
521
  rejectSessionPortalUpgrade(socket);
@@ -408,6 +527,9 @@ function attachPortalWebSocketProxy(server, options) {
408
527
  controlOrigin: options.controlOrigin,
409
528
  shutdownSignal: options.shutdownSignal,
410
529
  upstreamSockets,
530
+ transports: options.transports,
531
+ portalId: grant.portalId,
532
+ reauthorize: () => options.authorization.authorize(credential.portalId, credential.secret),
411
533
  });
412
534
  });
413
535
  }
@@ -422,6 +544,8 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
422
544
  const target = new URL(`${externalTarget.pathname}${externalTarget.search}`, options.controlOrigin);
423
545
  target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
424
546
  target.searchParams.delete(SESSION_PORTAL_QUERY);
547
+ target.searchParams.delete(SESSION_PORTAL_SECRET_QUERY);
548
+ target.searchParams.delete(SESSION_PORTAL_EMBED_ORIGIN_QUERY);
425
549
  const upstream = new WebSocket(target, {
426
550
  origin: options.controlOrigin,
427
551
  headers: { "sec-fetch-site": "same-origin" },
@@ -429,6 +553,27 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
429
553
  maxPayload: MAX_SOCKET_BUFFERED_BYTES,
430
554
  });
431
555
  options.upstreamSockets.add(upstream);
556
+ const untrack = options.transports.track(options.portalId, () => {
557
+ closeSocket(client, 4403, "Session Portal grant revoked");
558
+ upstream.terminate();
559
+ const forceClose = setTimeout(() => client.terminate(), 100);
560
+ forceClose.unref?.();
561
+ client.once("close", () => clearTimeout(forceClose));
562
+ });
563
+ // Close the authorize -> transport-registry race just like the HTTP path.
564
+ // Revoke may have committed and returned while handleUpgrade was accepting
565
+ // this socket but before it became visible to the registry; in that case the
566
+ // fresh lookup must fail before the upstream can receive any frame.
567
+ if (!options.reauthorize()) {
568
+ closeSocket(client, 4403, "Session Portal grant revoked");
569
+ // `ws` emits an error when a CONNECTING client is terminated; this early
570
+ // branch returns before the normal upstream error handler is installed.
571
+ upstream.on("error", () => undefined);
572
+ upstream.terminate();
573
+ options.upstreamSockets.delete(upstream);
574
+ untrack();
575
+ return;
576
+ }
432
577
  const pending = [];
433
578
  let pendingBytes = 0;
434
579
  let closed = false;
@@ -449,10 +594,17 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
449
594
  pending.length = 0;
450
595
  pendingBytes = 0;
451
596
  options.upstreamSockets.delete(upstream);
597
+ untrack();
452
598
  options.shutdownSignal.removeEventListener("abort", stopForShutdown);
453
599
  };
454
600
  client.on("message", (data, isBinary) => {
455
- if (grant.access === "read_only") {
601
+ const current = options.reauthorize();
602
+ if (!current) {
603
+ closeSocket(client, 4403, "Session Portal grant revoked");
604
+ closeSocket(upstream, 4403, "Session Portal grant revoked");
605
+ return;
606
+ }
607
+ if (current.access === "read_only") {
456
608
  if (kind === "terminal")
457
609
  return;
458
610
  if (!allowedReadOnlySocketFrame(kind, data, isBinary)) {
@@ -476,6 +628,11 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
476
628
  pendingBytes += frame.byteLength;
477
629
  });
478
630
  upstream.on("open", () => {
631
+ if (!options.reauthorize()) {
632
+ closeSocket(client, 4403, "Session Portal grant revoked");
633
+ closeSocket(upstream, 4403, "Session Portal grant revoked");
634
+ return;
635
+ }
479
636
  for (const frame of pending.splice(0)) {
480
637
  pendingBytes -= frame.data.byteLength;
481
638
  if (!forwardSocketFrame(client, upstream, frame.data, frame.isBinary))
@@ -483,6 +640,11 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
483
640
  }
484
641
  });
485
642
  upstream.on("message", (data, isBinary) => {
643
+ if (!options.reauthorize()) {
644
+ closeSocket(client, 4403, "Session Portal grant revoked");
645
+ closeSocket(upstream, 4403, "Session Portal grant revoked");
646
+ return;
647
+ }
486
648
  forwardSocketFrame(upstream, client, data, isBinary);
487
649
  });
488
650
  client.on("close", (code, reason) => {
@@ -501,6 +663,21 @@ function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
501
663
  closeSocket(client, 1011, "Control service unavailable");
502
664
  });
503
665
  }
666
+ function sessionPortalSocketCredential(request) {
667
+ let url;
668
+ try {
669
+ url = new URL(request.url ?? "", "http://localhost");
670
+ }
671
+ catch {
672
+ return undefined;
673
+ }
674
+ const portalId = url.searchParams.get(SESSION_PORTAL_QUERY) ?? "";
675
+ if (!PORTAL_ID_PATTERN.test(portalId))
676
+ return undefined;
677
+ const secret = cookieValue(request.headers.cookie, cookieName(portalId)) ||
678
+ url.searchParams.get(SESSION_PORTAL_SECRET_QUERY) || undefined;
679
+ return secret ? { portalId, secret } : undefined;
680
+ }
504
681
  function portalSocketRoute(rawUrl) {
505
682
  let pathname;
506
683
  try {
@@ -607,8 +784,15 @@ function isAllowedSessionMutation(ctx, encodedSessionId) {
607
784
  if (ctx.method === "POST") {
608
785
  return /^(?:\/interrupt|\/messages(?:\/queue)?|\/terminal\/start|\/resources|\/interactions\/[^/]+\/resolve|\/browser\/(?:open|close|pages)|\/browser\/pages\/[^/]+\/(?:close|activate|back|forward|reload|navigate)|\/emulator\/(?:attach|detach|tap|button|type|rotate|launch|gesture))$/.test(suffix);
609
786
  }
787
+ if (ctx.method === "PUT") {
788
+ return suffix === "/execution" || suffix === "/browser/request-headers";
789
+ }
610
790
  return ctx.method === "DELETE" && /^\/resources\/[^/]+$/.test(suffix);
611
791
  }
792
+ function isBrowserRequestHeadersRoute(ctx, encodedSessionId) {
793
+ const prefix = `${SESSION_COLLECTION_API_PATH}/${encodedSessionId}`;
794
+ return ctx.path.slice(prefix.length) === "/browser/request-headers";
795
+ }
612
796
  function denyMutation(ctx, grant) {
613
797
  if (grant.access === "read_write")
614
798
  return true;
@@ -635,10 +819,63 @@ function publicAuthorization(grant) {
635
819
  portalId: grant.portalId,
636
820
  scope: grant.scope,
637
821
  access: grant.access,
822
+ ...(grant.embedOrigin ? { embedOrigin: grant.embedOrigin } : {}),
638
823
  ...(grant.sessionId ? { sessionId: grant.sessionId } : {}),
639
824
  expiresAt: grant.expiresAt,
640
825
  };
641
826
  }
827
+ function injectEmbeddedPortalBootstrap(html, portalId) {
828
+ const id = JSON.stringify(portalId).replace(/</g, "\\u003c");
829
+ const script = `<script>(()=>{try{const raw=sessionStorage.getItem('rynx_session_portal_'+${id});` +
830
+ `const value=raw?JSON.parse(raw):null;if(value&&value.portalId===${id})window.rynxSessionPortal=value}` +
831
+ `catch{}})()</script>`;
832
+ return html.replace("</head>", `${script}</head>`);
833
+ }
834
+ function embeddedPageBootstrap(ctx, ports) {
835
+ if (ctx.method !== "GET" && ctx.method !== "HEAD")
836
+ return undefined;
837
+ const match = RUNTIME_SESSION_PATH.exec(ctx.path);
838
+ if (!match)
839
+ return undefined;
840
+ const portalId = typeof ctx.query[SESSION_PORTAL_QUERY] === "string"
841
+ ? ctx.query[SESSION_PORTAL_QUERY].trim()
842
+ : "";
843
+ const frameOrigin = sessionPortalEmbedOrigin(ctx);
844
+ if (!PORTAL_ID_PATTERN.test(portalId) || !frameOrigin)
845
+ return undefined;
846
+ const grant = ports.inspect(portalId);
847
+ if (!grant || grant.embedOrigin !== frameOrigin)
848
+ return undefined;
849
+ const requestedSessionId = decoded(match[1]);
850
+ if (grant.scope === "single_session" &&
851
+ requestedSessionId !== grant.sessionId)
852
+ return undefined;
853
+ if (requestedSessionId && !ports.ownsSession(grant.pluginId, requestedSessionId)) {
854
+ return undefined;
855
+ }
856
+ return { portalId, frameOrigin };
857
+ }
858
+ function sessionPortalEmbedOrigin(ctx) {
859
+ const value = typeof ctx.query[SESSION_PORTAL_EMBED_ORIGIN_QUERY] === "string"
860
+ ? ctx.query[SESSION_PORTAL_EMBED_ORIGIN_QUERY]
861
+ : "";
862
+ return normalizedFrameOrigin(value);
863
+ }
864
+ function normalizedFrameOrigin(value) {
865
+ const candidate = value.trim();
866
+ if (!candidate || candidate.length > 512)
867
+ return undefined;
868
+ try {
869
+ const parsed = new URL(candidate);
870
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
871
+ parsed.origin !== candidate)
872
+ return undefined;
873
+ return parsed.origin;
874
+ }
875
+ catch {
876
+ return undefined;
877
+ }
878
+ }
642
879
  function injectPortalBootstrap(html, grant) {
643
880
  const value = JSON.stringify(publicAuthorization(grant)).replace(/</g, "\\u003c");
644
881
  return html.replace("</head>", `<script>window.rynxSessionPortal=${value}</script></head>`);
@@ -742,5 +979,5 @@ function closeHttpServerImmediately(server) {
742
979
  });
743
980
  }
744
981
  function redeemPage() {
745
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rynx Sessions</title><style>body{font:14px Inter,system-ui;margin:0;display:grid;min-height:100vh;place-items:center;background:#f6f6f8;color:#17171b}.box{text-align:center}.spin{width:22px;height:22px;margin:0 auto 14px;border:2px solid #d8d8df;border-top-color:#6d5ce7;border-radius:50%;animation:s .8s linear infinite}@keyframes s{to{transform:rotate(360deg)}}</style></head><body><main class="box"><div class="spin"></div><p id="status">正在打开 Rynx Sessions…</p></main><script>(()=>{const el=document.getElementById('status');const p=new URLSearchParams(location.hash.slice(1));const ticket=p.get('ticket');history.replaceState(null,'',location.pathname);if(!ticket){el.textContent='访问链接缺少票据。';return}fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ticket})}).then(async r=>{const b=await r.json();if(!r.ok)throw new Error(b.error||'票据兑换失败');location.replace(b.location)}).catch(e=>{el.textContent='无法打开:'+e.message})})();</script></body></html>`;
982
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rynx Sessions</title><style>body{font:14px Inter,system-ui;margin:0;display:grid;min-height:100vh;place-items:center;background:#f6f6f8;color:#17171b}.box{text-align:center}.spin{width:22px;height:22px;margin:0 auto 14px;border:2px solid #d8d8df;border-top-color:#6d5ce7;border-radius:50%;animation:s .8s linear infinite}@keyframes s{to{transform:rotate(360deg)}}</style></head><body><main class="box"><div class="spin"></div><p id="status">正在打开 Rynx Sessions…</p></main><script>(()=>{const el=document.getElementById('status');const p=new URLSearchParams(location.hash.slice(1));const ticket=p.get('ticket');history.replaceState(null,'',location.pathname+location.search);if(!ticket){el.textContent='访问链接缺少票据。';return}fetch(location.pathname+location.search,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ticket})}).then(async r=>{const b=await r.json();if(!r.ok)throw new Error(b.error||'票据兑换失败');if(b.authorization){sessionStorage.setItem('rynx_session_portal_'+b.authorization.portalId,JSON.stringify(b.authorization))}location.replace(b.location)}).catch(e=>{el.textContent='无法打开:'+e.message})})();</script></body></html>`;
746
983
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/server",
3
- "version": "0.1.11-beta.30",
3
+ "version": "0.1.11-beta.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,15 +27,15 @@
27
27
  "@koa/router": "^15.4.0",
28
28
  "koa": "^3.2.0",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.30",
31
- "@rynx-ai/plugin-sdk": "0.1.11-beta.30",
32
- "@rynx-ai/protocol": "0.1.11-beta.30",
33
- "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.30",
34
- "@rynx-ai/runtime": "0.1.11-beta.30"
30
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.32",
31
+ "@rynx-ai/protocol": "0.1.11-beta.32",
32
+ "@rynx-ai/core": "0.1.11-beta.32",
33
+ "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.32",
34
+ "@rynx-ai/runtime": "0.1.11-beta.32"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/ws": "^8.18.1",
38
- "@rynx-ai/control-web": "0.1.11-beta.30"
38
+ "@rynx-ai/control-web": "0.1.11-beta.32"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",
@@ -1 +0,0 @@
1
- import{aj as o,ak as n}from"./mermaid-GHXKKRXX-BkcV3dgg.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-V7JOEXUC-3UFQGnKX.js";import{_ as i}from"./mermaid-GHXKKRXX-BkcV3dgg.js";import"./chunk-5VM5RSS4-By6zfDWW.js";import"./chunk-XXDRQBXY-3znnutie.js";import"./chunk-VR4S4FIN-DjspjYUb.js";import"./chunk-32BRIVSS-BkfLE_TX.js";import"./index-B9SAq_63.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-V7JOEXUC-3UFQGnKX.js";import{_ as i}from"./mermaid-GHXKKRXX-BkcV3dgg.js";import"./chunk-5VM5RSS4-By6zfDWW.js";import"./chunk-XXDRQBXY-3znnutie.js";import"./chunk-VR4S4FIN-DjspjYUb.js";import"./chunk-32BRIVSS-BkfLE_TX.js";import"./index-B9SAq_63.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};