@bivy/bivy 0.18.0-staging.1 → 0.18.1-staging.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.
@@ -137,6 +137,10 @@ const poll=async()=>{try{const r=await fetch(location.href,{method:'HEAD',cache:
137
137
  }
138
138
  /** Copied links are reusable until revoked, capped so a forgotten one lapses. */
139
139
  export const SHARE_TTL = 24 * HOUR;
140
+ /** Bivy's packaged apps load the client from their own scheme, so a preview
141
+ * framed there has this ancestor instead of a web origin. (Node's URL gives
142
+ * these "null" origins, so they cannot be configured as return origins.) */
143
+ const PACKAGED_CLIENT_ORIGINS = ["capacitor://localhost"];
140
144
  const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
141
145
  /** Separate per-app origins are mandatory. TLS terminates at the deployment's
142
146
  * preview ingress. Delivery reaches only this gateway, never the node API. */
@@ -160,11 +164,20 @@ function upstreamHeaders(req, origin) {
160
164
  headers["x-forwarded-proto"] = "https";
161
165
  return headers;
162
166
  }
163
- function responseHeaders(headers, ancestors, inspector) {
167
+ /** Framed inside Bivy, an app's own cookies are third-party too, and browsers
168
+ * that block those (Safari) keep only Partitioned ones: key them to Bivy's
169
+ * top-level site like the preview cookie, so sign-ins and carts work there. */
170
+ function partitionCookie(cookie) {
171
+ const [pair, ...attributes] = cookie.split(";");
172
+ const kept = attributes.filter((attribute) => !["samesite", "secure", "partitioned"].includes(attribute.split("=", 1)[0].trim().toLowerCase()));
173
+ return [pair, ...kept, " Secure", " SameSite=None", " Partitioned"].join(";");
174
+ }
175
+ function responseHeaders(headers, ancestors, inspector, partition = false) {
164
176
  const result = cleanHeaders(headers);
165
177
  if (headers["set-cookie"]) {
166
178
  result["set-cookie"] = headers["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== COOKIE)
167
- .map((cookie) => cookie.replace(/;\s*domain=[^;]*/ig, ""));
179
+ .map((cookie) => cookie.replace(/;\s*domain=[^;]*/ig, ""))
180
+ .map((cookie) => partition ? partitionCookie(cookie) : cookie);
168
181
  }
169
182
  // Preview data must not become a shared proxy cache entry. Apps cannot frame
170
183
  // Bivy or retain an opener; service workers are refused (see handle).
@@ -193,7 +206,8 @@ export class AppGateway {
193
206
  ["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
194
207
  ["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
195
208
  ]);
196
- /** `reviewer`: the browser session came from a copied (shared) link. */
209
+ /** `reviewer`: the browser session came from a copied (shared) link.
210
+ * `embedded`: it runs framed inside a Bivy client (a third-party frame). */
197
211
  sessions = new Map();
198
212
  sockets = new Map();
199
213
  displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
@@ -214,7 +228,9 @@ export class AppGateway {
214
228
  origin(id) { return this.template.replace("{app}", id); }
215
229
  shellOrigin(id) { return this.template.replace("{app}", `view-${id}`); }
216
230
  /** Bivy clients may frame the shell (Peek), so app content allows both. */
217
- ancestors(id) { return [this.shellOrigin(id), ...this.returnOrigins()].join(" "); }
231
+ ancestors(id) { return [this.shellOrigin(id), ...this.clientOrigins()].join(" "); }
232
+ /** Where a Bivy client runs: configured web origins, and the packaged apps. */
233
+ clientOrigins() { const web = this.returnOrigins(); return web.length ? [...web, ...PACKAGED_CLIENT_ORIGINS] : []; }
218
234
  open(id, returnTo) {
219
235
  const entry = this.requireWeb(id);
220
236
  if (returnTo) {
@@ -315,7 +331,7 @@ export class AppGateway {
315
331
  const origin = this.shellOrigin(id);
316
332
  const nonce = randomBytes(16).toString("hex");
317
333
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
318
- res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self' ${this.origin(id)}; img-src blob:; frame-src ${this.origin(id)}; frame-ancestors ${this.returnOrigins().join(" ") || "'none'"}; base-uri 'none'; form-action 'none'`);
334
+ res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self' ${this.origin(id)}; img-src blob:; frame-src ${this.origin(id)}; frame-ancestors ${this.clientOrigins().join(" ") || "'none'"}; base-uri 'none'; form-action 'none'`);
319
335
  if (req.method === "GET" && this.styles.has(req.url ?? "")) {
320
336
  res.setHeader("Content-Type", "text/css; charset=utf-8");
321
337
  res.end(this.styles.get(req.url));
@@ -414,7 +430,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
414
430
  // A browser session never outlives the link that created it.
415
431
  const expires = Math.min(Date.now() + HOUR, grant.expires);
416
432
  const token = randomBytes(32).toString("hex");
417
- this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true });
433
+ this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true, ...(embedded ? { embedded } : {}) });
418
434
  // Framed inside Bivy, the cookie is third-party: it must be SameSite=None,
419
435
  // and Partitioned keys it to Bivy's top-level site so no other site can use it.
420
436
  const scope = embedded ? "SameSite=None; Partitioned" : "SameSite=Lax";
@@ -423,7 +439,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
423
439
  res.end();
424
440
  return;
425
441
  }
426
- const expires = this.authorize(req, id);
442
+ const session = this.session(req, id), expires = session?.expires;
427
443
  // A home-screen visit without access goes through Bivy to sign in, then
428
444
  // comes back to the same page. Framed loads keep the plain message.
429
445
  const signIn = !expires && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
@@ -464,7 +480,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
464
480
  }
465
481
  if (req.url === INSPECTOR_PATH && req.method === "GET") {
466
482
  res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
467
- res.end(inspectorScript(this.shellOrigin(id), this.session(req, id)?.reviewer === true));
483
+ res.end(inspectorScript(this.shellOrigin(id), session?.reviewer === true, session?.embedded === true));
468
484
  return;
469
485
  }
470
486
  if (req.url === NOTES_PATH && req.method === "POST") {
@@ -533,7 +549,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
533
549
  response.on("error", () => res.destroy());
534
550
  const html = inspect && /^text\/html/i.test(response.headers["content-type"] ?? "") && !response.headers["content-encoding"] && Number(response.headers["content-length"] ?? 0) <= MAX_INJECT_BYTES;
535
551
  if (!html) {
536
- res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.ancestors(id)));
552
+ res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.ancestors(id), undefined, session?.embedded));
537
553
  response.pipe(res);
538
554
  return;
539
555
  }
@@ -546,7 +562,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
546
562
  } chunks.push(chunk); });
547
563
  response.on("end", () => {
548
564
  const body = withInspector(Buffer.concat(chunks));
549
- const headers = responseHeaders(response.headers, this.ancestors(id), `${this.origin(id)}${INSPECTOR_PATH}`);
565
+ const headers = responseHeaders(response.headers, this.ancestors(id), `${this.origin(id)}${INSPECTOR_PATH}`, session?.embedded);
550
566
  delete headers["content-length"];
551
567
  res.writeHead(response.statusCode ?? 200, { ...headers, "content-length": body.length });
552
568
  res.end(body);
@@ -4,11 +4,14 @@
4
4
  * to the trusted shell that frames it, and everything it reports is untrusted
5
5
  * app data: the shell shows it and turns it into drafts, never into actions.
6
6
  * Agent-neutral: it observes the browser, not the framework or the agent. */
7
- export function inspectorScript(shellOrigin, reviewer = false) {
7
+ export function inspectorScript(shellOrigin, reviewer = false, embedded = false) {
8
8
  return `(()=>{
9
9
  // Framed by the shell: report to it. Opened from a shared link: offer notes.
10
10
  const framed=parent!==window,REVIEWER=${reviewer};
11
11
  if(window.__bivyInspector||(!framed&&!REVIEWER))return;window.__bivyInspector=1;
12
+ // Framed inside Bivy: cookies the page sets are partitioned, like the ones its
13
+ // server sets (see partitionCookie in the gateway), or Safari drops them.
14
+ if(${embedded}){const d=Object.getOwnPropertyDescriptor(Document.prototype,'cookie');if(d&&d.set)Object.defineProperty(document,'cookie',{configurable:true,get(){return d.get.call(document);},set(v){const [pair,...rest]=String(v).split(';');d.set.call(document,[pair,...rest.filter(a=>!['samesite','secure','partitioned'].includes(a.split('=',1)[0].trim().toLowerCase())),' Secure',' SameSite=None',' Partitioned'].join(';'));}});}
12
15
  const SHELL=${JSON.stringify(shellOrigin)};
13
16
  const post=m=>{if(!framed)return;try{parent.postMessage(Object.assign({source:'bivy-inspector'},m),SHELL);}catch{}};
14
17
  const fmt=v=>{if(v instanceof Error)return v.name+': '+v.message;if(typeof v==='string')return v;try{return JSON.stringify(v);}catch{return String(v);}};
@@ -131,7 +131,9 @@ const [ticket,startPage='']=location.hash.slice(1).split('~');history.replaceSta
131
131
  let metadata,currentPath='/';
132
132
  // Peek: framed by a Bivy client, which owns closing and the composer.
133
133
  const embedded=parent!==window;
134
- const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),new URL(metadata.returnTo).origin);
134
+ // The framing client's own origin: a packaged app's differs from returnTo's.
135
+ // (frame-ancestors already limits who that can be.)
136
+ const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),location.ancestorOrigins?.[0]||new URL(metadata.returnTo).origin);
135
137
  const storageKey='bivy-preview';
136
138
  const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
137
139
  const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.0-staging.1",
3
+ "version": "0.18.1-staging.1",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",