@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.
- package/dist/apps/gateway.js +26 -10
- package/dist/apps/inspector.js +4 -1
- package/dist/apps/preview-shell.js +3 -1
- package/package.json +1 -1
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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),
|
|
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);
|
package/dist/apps/inspector.js
CHANGED
|
@@ -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
|
-
|
|
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