payload-live-preview 1.4.0 → 1.5.0
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/README.md +112 -2
- package/dist/adapters/astro/index.js +1 -1
- package/dist/adapters/astro/index.js.map +1 -1
- package/dist/adapters/astro/middleware-entry.js +1 -1
- package/dist/adapters/astro/middleware-entry.js.map +1 -1
- package/dist/adapters/nextjs/index.js +1 -1
- package/dist/adapters/nextjs/index.js.map +1 -1
- package/dist/adapters/nuxt/index.js +1 -1
- package/dist/adapters/nuxt/index.js.map +1 -1
- package/dist/adapters/sveltekit/index.js +1 -1
- package/dist/adapters/sveltekit/index.js.map +1 -1
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +247 -2
- package/dist/core.d.ts +247 -2
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +34 -0
- package/dist/doctor-cli.js +3 -0
- package/dist/doctor-cli.js.map +1 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +2 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +248 -3
- package/dist/index.d.ts +248 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/probe-tMHdim5y.d.ts +190 -0
- package/package.json +9 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { D as DoctorFetch } from './probe-tMHdim5y.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `pll` CLI — currently one subcommand, `doctor`.
|
|
6
|
+
*
|
|
7
|
+
* npx pll doctor https://example.com/some-page --admin https://cms.example.com
|
|
8
|
+
*
|
|
9
|
+
* Exit codes:
|
|
10
|
+
* 0 — no error-level findings (warnings are reported and do not fail)
|
|
11
|
+
* 1 — usage error, or the URL could not be fetched
|
|
12
|
+
* 2 — at least one error-level finding
|
|
13
|
+
*
|
|
14
|
+
* `pll-codegen` stays its own binary; it predates this one and consumers have
|
|
15
|
+
* it in their scripts.
|
|
16
|
+
*
|
|
17
|
+
* @module @doctor/cli
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param fetchImpl Seam for tests, so the argument and output paths can be
|
|
22
|
+
* exercised without a server. The bin shim never passes it.
|
|
23
|
+
*/
|
|
24
|
+
declare function run(argv: readonly string[], fetchImpl?: DoctorFetch): Promise<number>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether this module was run as a program rather than imported.
|
|
27
|
+
*
|
|
28
|
+
* Matches the entry's basename, not the whole path: `includes('pll')` against
|
|
29
|
+
* the full path would auto-run the CLI on import for any project whose
|
|
30
|
+
* directory happens to contain those three letters.
|
|
31
|
+
*/
|
|
32
|
+
declare function isCliInvocation(argv?: readonly (string | undefined)[]): boolean;
|
|
33
|
+
|
|
34
|
+
export { isCliInvocation, run };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var e=Object.defineProperty,t=(t,n)=>e(t,"name",{value:n,configurable:!0}),n="__LIVE_PREVIEW_CONFIG__",r=/\bdata-payload-field\s*=/gu,i=/\bdata-payload-owner\s*=/gu,o={error:0,warning:1,info:2};function s(e,t){return e.match(t)?.length??0}function a(e){let t=e.search(/\bdata-payload-owner\s*=/u);return-1===t?0:s(e.slice(0,t),r)}function d(e,t){if(void 0===t)return!1;try{return new URL(e).origin===new URL(t).origin}catch{return!1}}function l(e){if(e.status<200||e.status>299)return{code:"LP0708",level:"error",title:`The preview request returned ${String(e.status)}, not a page`,detail:"Nothing below this can be judged: a status outside 2xx means the body is an error page, a redirect target, or empty.",remedy:"Check the URL. A redirect to a login usually means the route needs authentication that a plain probe cannot supply."};if(""===e.body.trim())return{code:"LP0708",level:"error",title:`The preview request returned ${String(e.status)} with an empty body`,detail:"A 2xx status with nothing in it — 204 and 205 are defined that way — is not a page, so its missing runtime and missing bindings say nothing.",remedy:"Point the audit at a route that renders a document."};let t=e.headers["content-type"];return void 0===t||t.toLowerCase().includes("html")?void 0:{code:"LP0708",level:"error",title:`The preview request returned ${t}, not HTML`,detail:"The audit reads a rendered page. A non-HTML response has no runtime and no bindings by definition, so reporting their absence would say nothing about the deployment.",remedy:"Point the audit at a page route rather than an API or an asset."}}function c(e){if(void 0!==e)for(let t of e.split(";")){let e=t.trim();if(e.toLowerCase().startsWith("frame-ancestors"))return e}}function u(e,t){let u=[],{publicResponse:h,previewResponse:p}=e,m=d(t.url,t.adminOrigin),g=l(p);if(void 0!==g)return{url:t.url,findings:[g],errors:1,warnings:0};let f=p.body.includes(n),v=h.body.includes(n),y=s(p.body,r),w=s(h.body,r),b=s(p.body,i);f||u.push({code:"LP0701",level:"warning",title:"No inline runtime in the preview response",detail:`A request carrying Sec-Fetch-Dest: iframe returned ${String(p.status)} without the inline runtime. Two readings, and this audit cannot tell them apart from the response alone: an adapter that did not recognise the request as a preview, or a consumer that starts LivePreviewClient itself and never wanted the inline build.`,remedy:"If you use an adapter, check its inject mode and whether a proxy strips Sec-Fetch-Dest. If you start the client yourself, this line is expected."});let O=c(p.headers["content-security-policy"]);f&&void 0===O?u.push({code:"LP0702",level:"warning",title:"Preview response declares no frame-ancestors",detail:"The runtime is injected but the response carries no frame-ancestors directive, so nothing states which origins may embed this page.",remedy:"Let the adapter manage CSP, or set frame-ancestors yourself to the admin origin. Without it you are relying on the absence of a policy."}):void 0!==O&&void 0!==t.adminOrigin&&!O.includes(t.adminOrigin)&&(!m||!O.includes("'self'"))&&u.push({code:"LP0702",level:"error",title:"frame-ancestors does not admit the admin origin",detail:`Served: "${O}". Expected it to allow ${t.adminOrigin}.`,remedy:"Add the admin origin to allowedOrigins so the adapter merges it into frame-ancestors, or add it to your own policy."});let S=p.headers["x-frame-options"];void 0!==S&&(/deny/iu.test(S)||/sameorigin/iu.test(S)&&!m)&&u.push({code:"LP0703",level:"error",title:`X-Frame-Options: ${S} blocks the preview iframe`,detail:"This header is older than CSP and browsers honour it independently. No frame-ancestors directive can override it.",remedy:"Remove X-Frame-Options for preview responses. It is usually set by a proxy or a security middleware rather than by the app."}),w>0&&u.push({code:"LP0704",level:"warning",title:`${String(w)} binding attribute(s) served to anonymous visitors`,detail:"A request with no preview intent received data-payload-field attributes. These disclose the CMS field taxonomy, and any CSS keyed on them changes public layout the moment emission is gated.",remedy:"Gate binding emission on an authorized preview context — see createPreviewBindings(), whose suppressed form emits nothing at all."}),y>50&&u.push({code:"LP0705",level:"warning",title:`${String(y)} bindings exceed the default visibility gate`,detail:`Above visibilityGateThreshold (${String(50)}) the scheduler stops writing offscreen elements and buffers them until they scroll into view. On a page nobody scrolls, that is never.`,remedy:"Raise visibilityGateThreshold if the whole page must stay live, or confirm that deferring below the fold is acceptable here."}),f&&0===y&&u.push({code:"LP0707",level:"error",title:"Preview response carries no bindings",detail:"The runtime is present but there is not one data-payload-field element for it to write into, so no edit can ever be visible on this page.",remedy:"Add binding attributes to the markup. pll-codegen --inventory lists every field the schema makes addressable."});let L=a(p.body);return b>0&&L>0&&u.push({code:"LP0706",level:"warning",title:`${String(L)} binding(s) outside every owner marker`,detail:`The page declares ${String(b)} owner marker(s), so owner scoping is in use. Bindings that belong to no owner receive nothing once scopeBindingsByOwner is on — they fail closed.`,remedy:"Give every binding an owning ancestor, or leave scopeBindingsByOwner off until they all have one. Counted before the first owner marker only, so the real number may be higher."}),v&&u.push({code:"LP0701",level:"info",title:"Runtime is also served to anonymous visitors",detail:"A request with no preview intent received the inline runtime. That is correct for inject: 'always' and unintended for 'preview-only'.",remedy:""}),u.sort((e,t)=>o[e.level]-o[t.level]),{url:t.url,findings:u,errors:u.filter(e=>"error"===e.level).length,warnings:u.filter(e=>"warning"===e.level).length}}function h(e){let t={};return e.forEach((e,n)=>{t[n.toLowerCase()]=e}),t}t(s,"count"),t(a,"unownedBindingsBeforeFirstOwner"),t(d,"isSameOrigin"),t(l,"describeNonPage"),t(c,"frameAncestorsOf"),t(u,"analyzeProbe"),t(h,"lowercaseHeaders");var p=t(async(e,t)=>{let n=await fetch(e,{headers:t.headers,redirect:"follow"});return{status:n.status,headers:h(n.headers),body:await n.text()}},"defaultFetch");async function m(e){let t=e.fetchImpl??p;return u({publicResponse:await t(e.url,{headers:{Accept:"text/html","Sec-Fetch-Dest":"document","Sec-Fetch-Mode":"navigate"}}),previewResponse:await t(e.url,{headers:{Accept:"text/html","Sec-Fetch-Dest":"iframe","Sec-Fetch-Mode":"navigate",...void 0!==e.adminOrigin?{Referer:`${e.adminOrigin}/`}:{}}})},{url:e.url,adminOrigin:e.adminOrigin})}t(m,"runDoctor");var g={error:"ERROR",warning:"WARN ",info:"INFO "};function f(e){let t=[`pll doctor — ${e.url}`,""];if(0===e.findings.length)return t.push("No findings. The preview response carries the runtime, a frame-ancestors"),t.push("policy, and bindings; the public response carries none of them.",""),t.join("\n");for(let n of e.findings)t.push(`${g[n.level]} ${n.code} ${n.title}`),t.push(` ${n.detail}`),""!==n.remedy&&t.push(` → ${n.remedy}`),t.push("");return t.push(`${String(e.errors)} error(s), ${String(e.warnings)} warning(s).`,""),t.join("\n")}function v(e){let t={url:void 0,adminOrigin:void 0,json:!1,showHelp:!1,unknown:[]};for(let n=0;n<e.length;n+=1){let r=e[n];if(void 0!==r){if("-h"===r||"--help"===r){t.showHelp=!0;continue}if("--json"===r){t.json=!0;continue}if("--admin"===r||"-a"===r){t.adminOrigin=e[n+1],n+=1;continue}if(r.startsWith("--admin=")){t.adminOrigin=r.slice(8);continue}if(r.startsWith("-")){t.unknown.push(r);continue}t.url??=r}}return t}t(f,"formatReport"),t(v,"parseArgs");var y="pll doctor — audit what a live-preview deployment actually serves\n\nUsage:\n pll doctor <url> [--admin <origin>] [--json]\n\nThe URL is fetched twice: once as an ordinary visitor and once with the\nheaders the Payload admin's iframe sends. Most findings come from the\ndifference between the two responses.\n\nOptions:\n -a, --admin <origin> Admin origin the preview is embedded from. Enables the\n frame-ancestors check to verify the origin is admitted,\n not merely that a policy exists.\n --json Emit the report as JSON instead of text\n -h, --help Show this help\n\nExit codes:\n 0 no error-level findings\n 1 usage error, or the URL could not be fetched\n 2 at least one error-level finding\n\nExamples:\n pll doctor https://example.com/\n pll doctor https://example.com/blog/hello --admin https://cms.example.com\n";async function w(e,t){let[n,...r]=e;if(void 0===n||"-h"===n||"--help"===n)return process.stdout.write(y),void 0===n?1:0;if("doctor"!==n)return process.stderr.write(`pll: unknown command "${n}". Try \`pll --help\`.\n`),1;let i,o=v(r);if(o.showHelp)return process.stdout.write(y),0;if(o.unknown.length>0)return process.stderr.write(`pll doctor: unknown option ${o.unknown.join(", ")}\n`),1;if(void 0===o.url)return process.stderr.write("pll doctor: a URL is required. Try `pll doctor --help`.\n"),1;try{i=await m({url:o.url,...void 0!==o.adminOrigin?{adminOrigin:o.adminOrigin}:{},...void 0!==t?{fetchImpl:t}:{}})}catch(e){let t=e instanceof Error?e.message:String(e);return process.stderr.write(`pll doctor: could not probe ${o.url}: ${t}\n`),1}return process.stdout.write(o.json?`${JSON.stringify(i,void 0,2)}\n`:f(i)),i.errors>0?2:0}function b(e=process.argv){if(typeof process>"u")return!1;let t=e[1];if(void 0===t||""===t)return!1;let n=t.split(/[\\/]/u).pop()??"";return"pll"===n||"pll.cmd"===n||n.startsWith("doctor-cli")}t(w,"run"),t(b,"isCliInvocation"),b()&&w(process.argv.slice(2)).then(e=>{process.exit(e)});export{b as isCliInvocation,w as run};
|
|
3
|
+
//# sourceMappingURL=doctor-cli.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor-cli.js","names":["S","Object","defineProperty","o","n","e","value","configurable","RUNTIME_MARKER","BINDING_ATTRIBUTE","OWNER_ATTRIBUTE","LEVEL_ORDER","error","warning","info","count","haystack","pattern","match","length","unownedBindingsBeforeFirstOwner","body","firstOwner","search","slice","isSameOrigin","pageUrl","adminOrigin","URL","origin","describeNonPage","preview","status","code","level","title","String","detail","remedy","trim","contentType","headers","toLowerCase","includes","frameAncestorsOf","csp","directive","split","trimmed","startsWith","analyzeProbe","probe","context","findings","publicResponse","pub","previewResponse","sameOrigin","url","notAPage","errors","warnings","runtimeInPreview","runtimeInPublic","previewBindings","publicBindings","owners","push","frameAncestors","frameOptions","test","unowned","sort","a","b","filter","f","lowercaseHeaders","out","forEach","key","__name","defaultFetch","async","init","response","fetch","redirect","text","runDoctor","options","fetchImpl","Accept","Referer","LEVEL_LABEL","formatReport","report","lines","join","finding","parseArgs","argv","parsed","json","showHelp","unknown","i","token","HELP_TEXT","run","subcommand","rest","process","stdout","write","stderr","args","message","Error","JSON","stringify","isCliInvocation","entry","name","pop","then","exit","H","C"],"sources":["../src/doctor/analyze.ts","../src/doctor/probe.ts","../src/doctor/cli.ts"],"sourcesContent":["/**\n * The `pll doctor` audit: what a deployment actually serves.\n *\n * Every check here reads a response, not a configuration file. That is the\n * whole point. A config can say `allowedOrigins: [...]` while a proxy strips\n * the header, an adapter runs in a mode nobody remembers choosing, or a build\n * emits binding attributes on public pages. The gap between what a project\n * believes it is configured to do and what it puts on the wire is where this\n * project's most expensive findings have lived.\n *\n * The audit compares two fetches of the same URL: one as an ordinary visitor,\n * one shaped the way the admin's iframe requests it. Most of what matters is\n * in the difference between them.\n *\n * HTML is inspected with regular expressions rather than a parser. This is a\n * deliberate limit: the audit counts attributes and looks for the runtime\n * marker, which regexes do reliably, and it never needs a tree. A malformed\n * page can therefore be miscounted — findings say what was observed so the\n * reader can check.\n *\n * Nothing here is sent anywhere. The audit makes exactly the two requests it\n * is asked to make, to the URL it is given.\n *\n * @module @doctor/analyze\n */\nimport type {\n DoctorContext,\n DoctorFinding,\n DoctorProbe,\n DoctorReport,\n DoctorResponse,\n} from './types';\n\n/**\n * Marker the inline runtime always carries.\n *\n * The generator emits `var __LIVE_PREVIEW_CONFIG__=...` ahead of the runtime\n * body, and `generateInlineScript()` keeps that identifier verbatim so the\n * bundle can read it. The banner comment is not usable here — minification\n * strips it.\n */\nconst RUNTIME_MARKER = '__LIVE_PREVIEW_CONFIG__';\n\n/** Any binding attribute from the public vocabulary. */\nconst BINDING_ATTRIBUTE = /\\bdata-payload-field\\s*=/gu;\n\n/** Owner markers, which only exist once a consumer has adopted owner scoping. */\nconst OWNER_ATTRIBUTE = /\\bdata-payload-owner\\s*=/gu;\n\n/** The runtime's own default, mirrored here so the audit can warn before it bites. */\nconst DEFAULT_VISIBILITY_GATE_THRESHOLD = 50;\n\nconst LEVEL_ORDER = { error: 0, warning: 1, info: 2 } as const;\n\nfunction count(haystack: string, pattern: RegExp): number {\n return haystack.match(pattern)?.length ?? 0;\n}\n\n/**\n * Bindings that sit outside every element carrying an owner marker.\n *\n * Approximated by segmenting on owner markers: everything before the first\n * one is certainly unowned. Nesting and sibling scopes are not modelled, so\n * this under-reports rather than over-reports — a finding here is real, an\n * absence is not proof.\n */\nfunction unownedBindingsBeforeFirstOwner(body: string): number {\n const firstOwner = body.search(/\\bdata-payload-owner\\s*=/u);\n if (firstOwner === -1) return 0;\n return count(body.slice(0, firstOwner), BINDING_ATTRIBUTE);\n}\n\n/**\n * Whether the admin is served from the same origin as the page being probed.\n *\n * This changes three verdicts, because a same-origin admin is admitted by\n * `'self'` and by `X-Frame-Options: SAMEORIGIN` without either naming it.\n * Getting this wrong turns a correct deployment into three red findings —\n * which is exactly what the first run against a real same-origin consumer did.\n */\nfunction isSameOrigin(pageUrl: string, adminOrigin: string | undefined): boolean {\n if (adminOrigin === undefined) return false;\n try {\n return new URL(pageUrl).origin === new URL(adminOrigin).origin;\n } catch {\n return false;\n }\n}\n\n/**\n * Why this response cannot be audited as a page, or `undefined` if it can.\n *\n * Deliberately only the preview probe: the public response being a redirect or\n * a 404 is a legitimate shape for a gated site, and judging it would be the\n * same mistake in the other direction.\n */\nfunction describeNonPage(preview: DoctorResponse): DoctorFinding | undefined {\n if (preview.status < 200 || preview.status > 299) {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${String(preview.status)}, not a page`,\n detail:\n 'Nothing below this can be judged: a status outside 2xx means the ' +\n 'body is an error page, a redirect target, or empty.',\n remedy:\n 'Check the URL. A redirect to a login usually means the route needs ' +\n 'authentication that a plain probe cannot supply.',\n };\n }\n if (preview.body.trim() === '') {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${String(preview.status)} with an empty body`,\n detail:\n 'A 2xx status with nothing in it — 204 and 205 are defined that way — ' +\n 'is not a page, so its missing runtime and missing bindings say nothing.',\n remedy: 'Point the audit at a route that renders a document.',\n };\n }\n const contentType = preview.headers['content-type'];\n if (contentType !== undefined && !contentType.toLowerCase().includes('html')) {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${contentType}, not HTML`,\n detail:\n 'The audit reads a rendered page. A non-HTML response has no runtime ' +\n 'and no bindings by definition, so reporting their absence would say ' +\n 'nothing about the deployment.',\n remedy: 'Point the audit at a page route rather than an API or an asset.',\n };\n }\n return undefined;\n}\n\nfunction frameAncestorsOf(csp: string | undefined): string | undefined {\n if (csp === undefined) return undefined;\n for (const directive of csp.split(';')) {\n const trimmed = directive.trim();\n if (trimmed.toLowerCase().startsWith('frame-ancestors')) return trimmed;\n }\n return undefined;\n}\n\n/**\n * Audit a probed deployment.\n *\n * Pure: the caller performs the two fetches and hands the results in.\n */\nexport function analyzeProbe(probe: DoctorProbe, context: DoctorContext): DoctorReport {\n const findings: DoctorFinding[] = [];\n const { publicResponse: pub, previewResponse: preview } = probe;\n const sameOrigin = isSameOrigin(context.url, context.adminOrigin);\n\n // Everything below reads a rendered page. A 404, a redirect to a login, a\n // 500 or a JSON endpoint would each be diagnosed as \"no inline runtime,\n // perhaps you start the client yourself\" — a plausible sentence about the\n // wrong problem, which is worse than saying nothing. Establish that there is\n // a page before judging its contents.\n const notAPage = describeNonPage(preview);\n if (notAPage !== undefined) {\n return {\n url: context.url,\n findings: [notAPage],\n errors: 1,\n warnings: 0,\n };\n }\n\n const runtimeInPreview = preview.body.includes(RUNTIME_MARKER);\n const runtimeInPublic = pub.body.includes(RUNTIME_MARKER);\n const previewBindings = count(preview.body, BINDING_ATTRIBUTE);\n const publicBindings = count(pub.body, BINDING_ATTRIBUTE);\n const owners = count(preview.body, OWNER_ATTRIBUTE);\n\n if (!runtimeInPreview) {\n findings.push({\n code: 'LP0701',\n level: 'warning',\n title: 'No inline runtime in the preview response',\n detail:\n `A request carrying Sec-Fetch-Dest: iframe returned ${String(preview.status)} ` +\n 'without the inline runtime. Two readings, and this audit cannot tell them ' +\n 'apart from the response alone: an adapter that did not recognise the ' +\n 'request as a preview, or a consumer that starts LivePreviewClient itself ' +\n 'and never wanted the inline build.',\n remedy:\n 'If you use an adapter, check its inject mode and whether a proxy strips ' +\n 'Sec-Fetch-Dest. If you start the client yourself, this line is expected.',\n });\n }\n\n const previewCsp = preview.headers['content-security-policy'];\n const frameAncestors = frameAncestorsOf(previewCsp);\n if (runtimeInPreview && frameAncestors === undefined) {\n findings.push({\n code: 'LP0702',\n level: 'warning',\n title: 'Preview response declares no frame-ancestors',\n detail:\n 'The runtime is injected but the response carries no frame-ancestors ' +\n 'directive, so nothing states which origins may embed this page.',\n remedy:\n 'Let the adapter manage CSP, or set frame-ancestors yourself to the admin ' +\n 'origin. Without it you are relying on the absence of a policy.',\n });\n } else if (\n frameAncestors !== undefined &&\n context.adminOrigin !== undefined &&\n !frameAncestors.includes(context.adminOrigin) &&\n // `'self'` names the admin without spelling it out when they share an origin.\n !(sameOrigin && frameAncestors.includes(\"'self'\"))\n ) {\n findings.push({\n code: 'LP0702',\n level: 'error',\n title: 'frame-ancestors does not admit the admin origin',\n detail: `Served: \"${frameAncestors}\". Expected it to allow ${context.adminOrigin}.`,\n remedy:\n 'Add the admin origin to allowedOrigins so the adapter merges it into ' +\n 'frame-ancestors, or add it to your own policy.',\n });\n }\n\n const frameOptions = preview.headers['x-frame-options'];\n const frameOptionsBlocks =\n frameOptions !== undefined &&\n (/deny/iu.test(frameOptions) || (/sameorigin/iu.test(frameOptions) && !sameOrigin));\n if (frameOptionsBlocks) {\n findings.push({\n code: 'LP0703',\n level: 'error',\n title: `X-Frame-Options: ${frameOptions} blocks the preview iframe`,\n detail:\n 'This header is older than CSP and browsers honour it independently. ' +\n 'No frame-ancestors directive can override it.',\n remedy:\n 'Remove X-Frame-Options for preview responses. It is usually set by a ' +\n 'proxy or a security middleware rather than by the app.',\n });\n }\n\n if (publicBindings > 0) {\n findings.push({\n code: 'LP0704',\n level: 'warning',\n title: `${String(publicBindings)} binding attribute(s) served to anonymous visitors`,\n detail:\n 'A request with no preview intent received data-payload-field attributes. ' +\n 'These disclose the CMS field taxonomy, and any CSS keyed on them changes ' +\n 'public layout the moment emission is gated.',\n remedy:\n 'Gate binding emission on an authorized preview context — see ' +\n 'createPreviewBindings(), whose suppressed form emits nothing at all.',\n });\n }\n\n if (previewBindings > DEFAULT_VISIBILITY_GATE_THRESHOLD) {\n findings.push({\n code: 'LP0705',\n level: 'warning',\n title: `${String(previewBindings)} bindings exceed the default visibility gate`,\n detail:\n `Above visibilityGateThreshold (${String(DEFAULT_VISIBILITY_GATE_THRESHOLD)}) ` +\n 'the scheduler stops writing offscreen elements and buffers them until they ' +\n 'scroll into view. On a page nobody scrolls, that is never.',\n remedy:\n 'Raise visibilityGateThreshold if the whole page must stay live, or confirm ' +\n 'that deferring below the fold is acceptable here.',\n });\n }\n\n if (runtimeInPreview && previewBindings === 0) {\n findings.push({\n code: 'LP0707',\n level: 'error',\n title: 'Preview response carries no bindings',\n detail:\n 'The runtime is present but there is not one data-payload-field element ' +\n 'for it to write into, so no edit can ever be visible on this page.',\n remedy:\n 'Add binding attributes to the markup. pll-codegen --inventory lists every ' +\n 'field the schema makes addressable.',\n });\n }\n\n const unowned = unownedBindingsBeforeFirstOwner(preview.body);\n if (owners > 0 && unowned > 0) {\n findings.push({\n code: 'LP0706',\n level: 'warning',\n title: `${String(unowned)} binding(s) outside every owner marker`,\n detail:\n `The page declares ${String(owners)} owner marker(s), so owner scoping is in ` +\n 'use. Bindings that belong to no owner receive nothing once ' +\n 'scopeBindingsByOwner is on — they fail closed.',\n remedy:\n 'Give every binding an owning ancestor, or leave scopeBindingsByOwner off ' +\n 'until they all have one. Counted before the first owner marker only, so ' +\n 'the real number may be higher.',\n });\n }\n\n if (runtimeInPublic) {\n findings.push({\n code: 'LP0701',\n level: 'info',\n title: 'Runtime is also served to anonymous visitors',\n detail:\n 'A request with no preview intent received the inline runtime. That is ' +\n \"correct for inject: 'always' and unintended for 'preview-only'.\",\n remedy: '',\n });\n }\n\n findings.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level]);\n\n return {\n url: context.url,\n findings,\n errors: findings.filter((f) => f.level === 'error').length,\n warnings: findings.filter((f) => f.level === 'warning').length,\n };\n}\n","/**\n * Fetching and rendering for `pll doctor`.\n *\n * Deliberately not the barrel: index files are excluded from the coverage\n * report, and this file holds the only code in the audit that touches the\n * network and the only place response headers are normalised — precisely the\n * parts whose silent failure would make every header check report a false\n * finding. Living here, they are measured like everything else.\n *\n * @module @doctor/probe\n */\nimport { analyzeProbe } from './analyze';\nimport type { DoctorReport, DoctorResponse } from './types';\n\n/** Injectable so tests can drive the audit without a server. */\nexport type DoctorFetch = (\n url: string,\n init: { readonly headers: Readonly<Record<string, string>> },\n) => Promise<DoctorResponse>;\n\nexport interface RunDoctorOptions {\n readonly url: string;\n /** Admin origin the preview is meant to be embedded from, when known. */\n readonly adminOrigin?: string | undefined;\n /** Defaults to `globalThis.fetch`. */\n readonly fetchImpl?: DoctorFetch | undefined;\n}\n\n/**\n * Normalise header names to lowercase.\n *\n * Exported because every header check downstream reads a lowercase key: if\n * this were wrong, `content-security-policy` and `x-frame-options` would both\n * read as absent and the audit would invent findings rather than report them.\n * A silent failure of exactly that shape is worth a test of its own.\n */\nexport function lowercaseHeaders(headers: Headers): Record<string, string> {\n const out: Record<string, string> = {};\n headers.forEach((value, key) => {\n out[key.toLowerCase()] = value;\n });\n return out;\n}\n\nconst defaultFetch: DoctorFetch = async (url, init) => {\n const response = await fetch(url, { headers: init.headers, redirect: 'follow' });\n return {\n status: response.status,\n headers: lowercaseHeaders(response.headers),\n body: await response.text(),\n };\n};\n\n/**\n * Fetch the URL twice — as a visitor and as the admin's iframe — and audit the\n * difference.\n */\nexport async function runDoctor(options: RunDoctorOptions): Promise<DoctorReport> {\n const fetchImpl = options.fetchImpl ?? defaultFetch;\n\n const publicResponse = await fetchImpl(options.url, {\n headers: {\n // A plain top-level navigation. Deliberately no referer: an admin\n // referer is itself a preview signal, which would defeat the comparison.\n Accept: 'text/html',\n 'Sec-Fetch-Dest': 'document',\n 'Sec-Fetch-Mode': 'navigate',\n },\n });\n\n const previewResponse = await fetchImpl(options.url, {\n headers: {\n Accept: 'text/html',\n // What the admin's iframe sends. This is the signal `'preview-only'`\n // injection keys on, so it is the honest way to ask for the preview\n // variant of the page.\n 'Sec-Fetch-Dest': 'iframe',\n 'Sec-Fetch-Mode': 'navigate',\n ...(options.adminOrigin !== undefined ? { Referer: `${options.adminOrigin}/` } : {}),\n },\n });\n\n return analyzeProbe(\n { publicResponse, previewResponse },\n { url: options.url, adminOrigin: options.adminOrigin },\n );\n}\n\nconst LEVEL_LABEL = { error: 'ERROR', warning: 'WARN ', info: 'INFO ' } as const;\n\n/** Render a report for a terminal. */\nexport function formatReport(report: DoctorReport): string {\n const lines: string[] = [`pll doctor — ${report.url}`, ''];\n if (report.findings.length === 0) {\n lines.push('No findings. The preview response carries the runtime, a frame-ancestors');\n lines.push('policy, and bindings; the public response carries none of them.', '');\n return lines.join('\\n');\n }\n for (const finding of report.findings) {\n lines.push(`${LEVEL_LABEL[finding.level]} ${finding.code} ${finding.title}`);\n lines.push(` ${finding.detail}`);\n if (finding.remedy !== '') lines.push(` → ${finding.remedy}`);\n lines.push('');\n }\n lines.push(`${String(report.errors)} error(s), ${String(report.warnings)} warning(s).`, '');\n return lines.join('\\n');\n}\n","#!/usr/bin/env node\n/**\n * `pll` CLI — currently one subcommand, `doctor`.\n *\n * npx pll doctor https://example.com/some-page --admin https://cms.example.com\n *\n * Exit codes:\n * 0 — no error-level findings (warnings are reported and do not fail)\n * 1 — usage error, or the URL could not be fetched\n * 2 — at least one error-level finding\n *\n * `pll-codegen` stays its own binary; it predates this one and consumers have\n * it in their scripts.\n *\n * @module @doctor/cli\n */\nimport { formatReport, runDoctor, type DoctorFetch } from './index';\n\ninterface ParsedArgs {\n url: string | undefined;\n adminOrigin: string | undefined;\n json: boolean;\n showHelp: boolean;\n unknown: string[];\n}\n\nfunction parseArgs(argv: readonly string[]): ParsedArgs {\n const parsed: ParsedArgs = {\n url: undefined,\n adminOrigin: undefined,\n json: false,\n showHelp: false,\n unknown: [],\n };\n for (let i = 0; i < argv.length; i += 1) {\n const token = argv[i];\n if (token === undefined) continue;\n if (token === '-h' || token === '--help') {\n parsed.showHelp = true;\n continue;\n }\n if (token === '--json') {\n parsed.json = true;\n continue;\n }\n if (token === '--admin' || token === '-a') {\n parsed.adminOrigin = argv[i + 1];\n i += 1;\n continue;\n }\n if (token.startsWith('--admin=')) {\n parsed.adminOrigin = token.slice('--admin='.length);\n continue;\n }\n if (token.startsWith('-')) {\n parsed.unknown.push(token);\n continue;\n }\n parsed.url ??= token;\n }\n return parsed;\n}\n\nconst HELP_TEXT = `pll doctor — audit what a live-preview deployment actually serves\n\nUsage:\n pll doctor <url> [--admin <origin>] [--json]\n\nThe URL is fetched twice: once as an ordinary visitor and once with the\nheaders the Payload admin's iframe sends. Most findings come from the\ndifference between the two responses.\n\nOptions:\n -a, --admin <origin> Admin origin the preview is embedded from. Enables the\n frame-ancestors check to verify the origin is admitted,\n not merely that a policy exists.\n --json Emit the report as JSON instead of text\n -h, --help Show this help\n\nExit codes:\n 0 no error-level findings\n 1 usage error, or the URL could not be fetched\n 2 at least one error-level finding\n\nExamples:\n pll doctor https://example.com/\n pll doctor https://example.com/blog/hello --admin https://cms.example.com\n`;\n\n/**\n * @param fetchImpl Seam for tests, so the argument and output paths can be\n * exercised without a server. The bin shim never passes it.\n */\nexport async function run(argv: readonly string[], fetchImpl?: DoctorFetch): Promise<number> {\n const [subcommand, ...rest] = argv;\n\n if (subcommand === undefined || subcommand === '-h' || subcommand === '--help') {\n process.stdout.write(HELP_TEXT);\n return subcommand === undefined ? 1 : 0;\n }\n if (subcommand !== 'doctor') {\n process.stderr.write(`pll: unknown command \"${subcommand}\". Try \\`pll --help\\`.\\n`);\n return 1;\n }\n\n const args = parseArgs(rest);\n if (args.showHelp) {\n process.stdout.write(HELP_TEXT);\n return 0;\n }\n if (args.unknown.length > 0) {\n process.stderr.write(`pll doctor: unknown option ${args.unknown.join(', ')}\\n`);\n return 1;\n }\n if (args.url === undefined) {\n process.stderr.write('pll doctor: a URL is required. Try `pll doctor --help`.\\n');\n return 1;\n }\n\n let report;\n try {\n report = await runDoctor({\n url: args.url,\n ...(args.adminOrigin !== undefined ? { adminOrigin: args.adminOrigin } : {}),\n ...(fetchImpl !== undefined ? { fetchImpl } : {}),\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n process.stderr.write(`pll doctor: could not probe ${args.url}: ${message}\\n`);\n return 1;\n }\n\n process.stdout.write(\n args.json ? `${JSON.stringify(report, undefined, 2)}\\n` : formatReport(report),\n );\n return report.errors > 0 ? 2 : 0;\n}\n\n/**\n * Whether this module was run as a program rather than imported.\n *\n * Matches the entry's basename, not the whole path: `includes('pll')` against\n * the full path would auto-run the CLI on import for any project whose\n * directory happens to contain those three letters.\n */\nexport function isCliInvocation(argv: readonly (string | undefined)[] = process.argv): boolean {\n if (typeof process === 'undefined') return false;\n const entry = argv[1];\n if (entry === undefined || entry === '') return false;\n const name = entry.split(/[\\\\/]/u).pop() ?? '';\n return name === 'pll' || name === 'pll.cmd' || name.startsWith('doctor-cli');\n}\n\nif (isCliInvocation()) {\n // Direct invocation via the bin shim — run and exit with the code.\n void run(process.argv.slice(2)).then((code) => {\n process.exit(code);\n });\n}\n"],"mappings":"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","ignoreList":[]}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { a as DoctorProbe, b as DoctorContext, c as DoctorReport } from './probe-tMHdim5y.js';
|
|
2
|
+
export { d as DIAGNOSTIC_CODES, e as DiagnosticCode, D as DoctorFetch, f as DoctorFinding, g as DoctorLevel, h as DoctorResponse, R as RunDoctorOptions, i as formatReport, l as lowercaseHeaders, r as runDoctor } from './probe-tMHdim5y.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `pll doctor` audit: what a deployment actually serves.
|
|
6
|
+
*
|
|
7
|
+
* Every check here reads a response, not a configuration file. That is the
|
|
8
|
+
* whole point. A config can say `allowedOrigins: [...]` while a proxy strips
|
|
9
|
+
* the header, an adapter runs in a mode nobody remembers choosing, or a build
|
|
10
|
+
* emits binding attributes on public pages. The gap between what a project
|
|
11
|
+
* believes it is configured to do and what it puts on the wire is where this
|
|
12
|
+
* project's most expensive findings have lived.
|
|
13
|
+
*
|
|
14
|
+
* The audit compares two fetches of the same URL: one as an ordinary visitor,
|
|
15
|
+
* one shaped the way the admin's iframe requests it. Most of what matters is
|
|
16
|
+
* in the difference between them.
|
|
17
|
+
*
|
|
18
|
+
* HTML is inspected with regular expressions rather than a parser. This is a
|
|
19
|
+
* deliberate limit: the audit counts attributes and looks for the runtime
|
|
20
|
+
* marker, which regexes do reliably, and it never needs a tree. A malformed
|
|
21
|
+
* page can therefore be miscounted — findings say what was observed so the
|
|
22
|
+
* reader can check.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here is sent anywhere. The audit makes exactly the two requests it
|
|
25
|
+
* is asked to make, to the URL it is given.
|
|
26
|
+
*
|
|
27
|
+
* @module @doctor/analyze
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Audit a probed deployment.
|
|
32
|
+
*
|
|
33
|
+
* Pure: the caller performs the two fetches and hands the results in.
|
|
34
|
+
*/
|
|
35
|
+
declare function analyzeProbe(probe: DoctorProbe, context: DoctorContext): DoctorReport;
|
|
36
|
+
|
|
37
|
+
export { DoctorContext, DoctorProbe, DoctorReport, analyzeProbe };
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=Object.defineProperty,t=(t,r)=>e(t,"name",{value:r,configurable:!0}),r="__LIVE_PREVIEW_CONFIG__",i=/\bdata-payload-field\s*=/gu,n=/\bdata-payload-owner\s*=/gu,a={error:0,warning:1,info:2};function s(e,t){return e.match(t)?.length??0}function o(e){let t=e.search(/\bdata-payload-owner\s*=/u);return-1===t?0:s(e.slice(0,t),i)}function d(e,t){if(void 0===t)return!1;try{return new URL(e).origin===new URL(t).origin}catch{return!1}}function l(e){if(e.status<200||e.status>299)return{code:"LP0708",level:"error",title:`The preview request returned ${String(e.status)}, not a page`,detail:"Nothing below this can be judged: a status outside 2xx means the body is an error page, a redirect target, or empty.",remedy:"Check the URL. A redirect to a login usually means the route needs authentication that a plain probe cannot supply."};if(""===e.body.trim())return{code:"LP0708",level:"error",title:`The preview request returned ${String(e.status)} with an empty body`,detail:"A 2xx status with nothing in it — 204 and 205 are defined that way — is not a page, so its missing runtime and missing bindings say nothing.",remedy:"Point the audit at a route that renders a document."};let t=e.headers["content-type"];return void 0===t||t.toLowerCase().includes("html")?void 0:{code:"LP0708",level:"error",title:`The preview request returned ${t}, not HTML`,detail:"The audit reads a rendered page. A non-HTML response has no runtime and no bindings by definition, so reporting their absence would say nothing about the deployment.",remedy:"Point the audit at a page route rather than an API or an asset."}}function u(e){if(void 0!==e)for(let t of e.split(";")){let e=t.trim();if(e.toLowerCase().startsWith("frame-ancestors"))return e}}function h(e,t){let h=[],{publicResponse:c,previewResponse:g}=e,p=d(t.url,t.adminOrigin),m=l(g);if(void 0!==m)return{url:t.url,findings:[m],errors:1,warnings:0};let f=g.body.includes(r),y=c.body.includes(r),v=s(g.body,i),w=s(c.body,i),b=s(g.body,n);f||h.push({code:"LP0701",level:"warning",title:"No inline runtime in the preview response",detail:`A request carrying Sec-Fetch-Dest: iframe returned ${String(g.status)} without the inline runtime. Two readings, and this audit cannot tell them apart from the response alone: an adapter that did not recognise the request as a preview, or a consumer that starts LivePreviewClient itself and never wanted the inline build.`,remedy:"If you use an adapter, check its inject mode and whether a proxy strips Sec-Fetch-Dest. If you start the client yourself, this line is expected."});let P=u(g.headers["content-security-policy"]);f&&void 0===P?h.push({code:"LP0702",level:"warning",title:"Preview response declares no frame-ancestors",detail:"The runtime is injected but the response carries no frame-ancestors directive, so nothing states which origins may embed this page.",remedy:"Let the adapter manage CSP, or set frame-ancestors yourself to the admin origin. Without it you are relying on the absence of a policy."}):void 0!==P&&void 0!==t.adminOrigin&&!P.includes(t.adminOrigin)&&(!p||!P.includes("'self'"))&&h.push({code:"LP0702",level:"error",title:"frame-ancestors does not admit the admin origin",detail:`Served: "${P}". Expected it to allow ${t.adminOrigin}.`,remedy:"Add the admin origin to allowedOrigins so the adapter merges it into frame-ancestors, or add it to your own policy."});let L=g.headers["x-frame-options"];void 0!==L&&(/deny/iu.test(L)||/sameorigin/iu.test(L)&&!p)&&h.push({code:"LP0703",level:"error",title:`X-Frame-Options: ${L} blocks the preview iframe`,detail:"This header is older than CSP and browsers honour it independently. No frame-ancestors directive can override it.",remedy:"Remove X-Frame-Options for preview responses. It is usually set by a proxy or a security middleware rather than by the app."}),w>0&&h.push({code:"LP0704",level:"warning",title:`${String(w)} binding attribute(s) served to anonymous visitors`,detail:"A request with no preview intent received data-payload-field attributes. These disclose the CMS field taxonomy, and any CSS keyed on them changes public layout the moment emission is gated.",remedy:"Gate binding emission on an authorized preview context — see createPreviewBindings(), whose suppressed form emits nothing at all."}),v>50&&h.push({code:"LP0705",level:"warning",title:`${String(v)} bindings exceed the default visibility gate`,detail:`Above visibilityGateThreshold (${String(50)}) the scheduler stops writing offscreen elements and buffers them until they scroll into view. On a page nobody scrolls, that is never.`,remedy:"Raise visibilityGateThreshold if the whole page must stay live, or confirm that deferring below the fold is acceptable here."}),f&&0===v&&h.push({code:"LP0707",level:"error",title:"Preview response carries no bindings",detail:"The runtime is present but there is not one data-payload-field element for it to write into, so no edit can ever be visible on this page.",remedy:"Add binding attributes to the markup. pll-codegen --inventory lists every field the schema makes addressable."});let O=o(g.body);return b>0&&O>0&&h.push({code:"LP0706",level:"warning",title:`${String(O)} binding(s) outside every owner marker`,detail:`The page declares ${String(b)} owner marker(s), so owner scoping is in use. Bindings that belong to no owner receive nothing once scopeBindingsByOwner is on — they fail closed.`,remedy:"Give every binding an owning ancestor, or leave scopeBindingsByOwner off until they all have one. Counted before the first owner marker only, so the real number may be higher."}),y&&h.push({code:"LP0701",level:"info",title:"Runtime is also served to anonymous visitors",detail:"A request with no preview intent received the inline runtime. That is correct for inject: 'always' and unintended for 'preview-only'.",remedy:""}),h.sort((e,t)=>a[e.level]-a[t.level]),{url:t.url,findings:h,errors:h.filter(e=>"error"===e.level).length,warnings:h.filter(e=>"warning"===e.level).length}}t(s,"count"),t(o,"unownedBindingsBeforeFirstOwner"),t(d,"isSameOrigin"),t(l,"describeNonPage"),t(u,"frameAncestorsOf"),t(h,"analyzeProbe");var c=Object.freeze({NoTrustedOrigin:"LP0101",ReferrerOnlyTrust:"LP0102",OrphanField:"LP0201",UnattributableUpdate:"LP0202",VisibilityGateDeferred:"LP0301",UnsafeAttributeWrite:"LP0401",TextTargetHasChildren:"LP0402",MissingArrayTemplate:"LP0403",MessageRejected:"LP0501",TokenRejected:"LP0502",HandlerThrew:"LP0601",TransformThrew:"LP0602",RendererThrew:"LP0603",StartupFailed:"LP0605",ReadyFailed:"LP0606",AuditRuntimeMissing:"LP0701",AuditNoFrameAncestors:"LP0702",AuditFrameOptionsBlocks:"LP0703",AuditBindingsExposed:"LP0704",AuditGateThresholdExceeded:"LP0705",AuditUnownedBindings:"LP0706",AuditNoBindings:"LP0707",AuditNotAPage:"LP0708"});function g(e){let t={};return e.forEach((e,r)=>{t[r.toLowerCase()]=e}),t}t(g,"lowercaseHeaders");var p=t(async(e,t)=>{let r=await fetch(e,{headers:t.headers,redirect:"follow"});return{status:r.status,headers:g(r.headers),body:await r.text()}},"defaultFetch");async function m(e){let t=e.fetchImpl??p;return h({publicResponse:await t(e.url,{headers:{Accept:"text/html","Sec-Fetch-Dest":"document","Sec-Fetch-Mode":"navigate"}}),previewResponse:await t(e.url,{headers:{Accept:"text/html","Sec-Fetch-Dest":"iframe","Sec-Fetch-Mode":"navigate",...void 0!==e.adminOrigin?{Referer:`${e.adminOrigin}/`}:{}}})},{url:e.url,adminOrigin:e.adminOrigin})}t(m,"runDoctor");var f={error:"ERROR",warning:"WARN ",info:"INFO "};function y(e){let t=[`pll doctor — ${e.url}`,""];if(0===e.findings.length)return t.push("No findings. The preview response carries the runtime, a frame-ancestors"),t.push("policy, and bindings; the public response carries none of them.",""),t.join("\n");for(let r of e.findings)t.push(`${f[r.level]} ${r.code} ${r.title}`),t.push(` ${r.detail}`),""!==r.remedy&&t.push(` → ${r.remedy}`),t.push("");return t.push(`${String(e.errors)} error(s), ${String(e.warnings)} warning(s).`,""),t.join("\n")}t(y,"formatReport");export{c as DIAGNOSTIC_CODES,h as analyzeProbe,y as formatReport,g as lowercaseHeaders,m as runDoctor};
|
|
2
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.js","names":["RUNTIME_MARKER","BINDING_ATTRIBUTE","OWNER_ATTRIBUTE","LEVEL_ORDER","error","warning","info","count","haystack","pattern","match","length","unownedBindingsBeforeFirstOwner","body","firstOwner","search","slice","isSameOrigin","pageUrl","adminOrigin","URL","origin","describeNonPage","preview","status","code","level","title","String","detail","remedy","trim","contentType","headers","toLowerCase","includes","frameAncestorsOf","csp","directive","split","trimmed","startsWith","analyzeProbe","probe","context","findings","publicResponse","pub","previewResponse","sameOrigin","url","notAPage","errors","warnings","runtimeInPreview","runtimeInPublic","previewBindings","publicBindings","owners","push","frameAncestors","frameOptions","test","unowned","sort","a","b","filter","f","__name","DIAGNOSTIC_CODES","Object","freeze","NoTrustedOrigin","ReferrerOnlyTrust","OrphanField","UnattributableUpdate","VisibilityGateDeferred","UnsafeAttributeWrite","TextTargetHasChildren","MissingArrayTemplate","MessageRejected","TokenRejected","HandlerThrew","TransformThrew","RendererThrew","StartupFailed","ReadyFailed","AuditRuntimeMissing","AuditNoFrameAncestors","AuditFrameOptionsBlocks","AuditBindingsExposed","AuditGateThresholdExceeded","AuditUnownedBindings","AuditNoBindings","AuditNotAPage","lowercaseHeaders","out","forEach","value","key","defaultFetch","async","init","response","fetch","redirect","text","runDoctor","options","fetchImpl","Accept","Referer","LEVEL_LABEL","formatReport","report","lines","join","finding","x","p","B","L","C"],"sources":["../src/doctor/analyze.ts","../src/core/diagnostic-codes.ts","../src/doctor/probe.ts"],"sourcesContent":["/**\n * The `pll doctor` audit: what a deployment actually serves.\n *\n * Every check here reads a response, not a configuration file. That is the\n * whole point. A config can say `allowedOrigins: [...]` while a proxy strips\n * the header, an adapter runs in a mode nobody remembers choosing, or a build\n * emits binding attributes on public pages. The gap between what a project\n * believes it is configured to do and what it puts on the wire is where this\n * project's most expensive findings have lived.\n *\n * The audit compares two fetches of the same URL: one as an ordinary visitor,\n * one shaped the way the admin's iframe requests it. Most of what matters is\n * in the difference between them.\n *\n * HTML is inspected with regular expressions rather than a parser. This is a\n * deliberate limit: the audit counts attributes and looks for the runtime\n * marker, which regexes do reliably, and it never needs a tree. A malformed\n * page can therefore be miscounted — findings say what was observed so the\n * reader can check.\n *\n * Nothing here is sent anywhere. The audit makes exactly the two requests it\n * is asked to make, to the URL it is given.\n *\n * @module @doctor/analyze\n */\nimport type {\n DoctorContext,\n DoctorFinding,\n DoctorProbe,\n DoctorReport,\n DoctorResponse,\n} from './types';\n\n/**\n * Marker the inline runtime always carries.\n *\n * The generator emits `var __LIVE_PREVIEW_CONFIG__=...` ahead of the runtime\n * body, and `generateInlineScript()` keeps that identifier verbatim so the\n * bundle can read it. The banner comment is not usable here — minification\n * strips it.\n */\nconst RUNTIME_MARKER = '__LIVE_PREVIEW_CONFIG__';\n\n/** Any binding attribute from the public vocabulary. */\nconst BINDING_ATTRIBUTE = /\\bdata-payload-field\\s*=/gu;\n\n/** Owner markers, which only exist once a consumer has adopted owner scoping. */\nconst OWNER_ATTRIBUTE = /\\bdata-payload-owner\\s*=/gu;\n\n/** The runtime's own default, mirrored here so the audit can warn before it bites. */\nconst DEFAULT_VISIBILITY_GATE_THRESHOLD = 50;\n\nconst LEVEL_ORDER = { error: 0, warning: 1, info: 2 } as const;\n\nfunction count(haystack: string, pattern: RegExp): number {\n return haystack.match(pattern)?.length ?? 0;\n}\n\n/**\n * Bindings that sit outside every element carrying an owner marker.\n *\n * Approximated by segmenting on owner markers: everything before the first\n * one is certainly unowned. Nesting and sibling scopes are not modelled, so\n * this under-reports rather than over-reports — a finding here is real, an\n * absence is not proof.\n */\nfunction unownedBindingsBeforeFirstOwner(body: string): number {\n const firstOwner = body.search(/\\bdata-payload-owner\\s*=/u);\n if (firstOwner === -1) return 0;\n return count(body.slice(0, firstOwner), BINDING_ATTRIBUTE);\n}\n\n/**\n * Whether the admin is served from the same origin as the page being probed.\n *\n * This changes three verdicts, because a same-origin admin is admitted by\n * `'self'` and by `X-Frame-Options: SAMEORIGIN` without either naming it.\n * Getting this wrong turns a correct deployment into three red findings —\n * which is exactly what the first run against a real same-origin consumer did.\n */\nfunction isSameOrigin(pageUrl: string, adminOrigin: string | undefined): boolean {\n if (adminOrigin === undefined) return false;\n try {\n return new URL(pageUrl).origin === new URL(adminOrigin).origin;\n } catch {\n return false;\n }\n}\n\n/**\n * Why this response cannot be audited as a page, or `undefined` if it can.\n *\n * Deliberately only the preview probe: the public response being a redirect or\n * a 404 is a legitimate shape for a gated site, and judging it would be the\n * same mistake in the other direction.\n */\nfunction describeNonPage(preview: DoctorResponse): DoctorFinding | undefined {\n if (preview.status < 200 || preview.status > 299) {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${String(preview.status)}, not a page`,\n detail:\n 'Nothing below this can be judged: a status outside 2xx means the ' +\n 'body is an error page, a redirect target, or empty.',\n remedy:\n 'Check the URL. A redirect to a login usually means the route needs ' +\n 'authentication that a plain probe cannot supply.',\n };\n }\n if (preview.body.trim() === '') {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${String(preview.status)} with an empty body`,\n detail:\n 'A 2xx status with nothing in it — 204 and 205 are defined that way — ' +\n 'is not a page, so its missing runtime and missing bindings say nothing.',\n remedy: 'Point the audit at a route that renders a document.',\n };\n }\n const contentType = preview.headers['content-type'];\n if (contentType !== undefined && !contentType.toLowerCase().includes('html')) {\n return {\n code: 'LP0708',\n level: 'error',\n title: `The preview request returned ${contentType}, not HTML`,\n detail:\n 'The audit reads a rendered page. A non-HTML response has no runtime ' +\n 'and no bindings by definition, so reporting their absence would say ' +\n 'nothing about the deployment.',\n remedy: 'Point the audit at a page route rather than an API or an asset.',\n };\n }\n return undefined;\n}\n\nfunction frameAncestorsOf(csp: string | undefined): string | undefined {\n if (csp === undefined) return undefined;\n for (const directive of csp.split(';')) {\n const trimmed = directive.trim();\n if (trimmed.toLowerCase().startsWith('frame-ancestors')) return trimmed;\n }\n return undefined;\n}\n\n/**\n * Audit a probed deployment.\n *\n * Pure: the caller performs the two fetches and hands the results in.\n */\nexport function analyzeProbe(probe: DoctorProbe, context: DoctorContext): DoctorReport {\n const findings: DoctorFinding[] = [];\n const { publicResponse: pub, previewResponse: preview } = probe;\n const sameOrigin = isSameOrigin(context.url, context.adminOrigin);\n\n // Everything below reads a rendered page. A 404, a redirect to a login, a\n // 500 or a JSON endpoint would each be diagnosed as \"no inline runtime,\n // perhaps you start the client yourself\" — a plausible sentence about the\n // wrong problem, which is worse than saying nothing. Establish that there is\n // a page before judging its contents.\n const notAPage = describeNonPage(preview);\n if (notAPage !== undefined) {\n return {\n url: context.url,\n findings: [notAPage],\n errors: 1,\n warnings: 0,\n };\n }\n\n const runtimeInPreview = preview.body.includes(RUNTIME_MARKER);\n const runtimeInPublic = pub.body.includes(RUNTIME_MARKER);\n const previewBindings = count(preview.body, BINDING_ATTRIBUTE);\n const publicBindings = count(pub.body, BINDING_ATTRIBUTE);\n const owners = count(preview.body, OWNER_ATTRIBUTE);\n\n if (!runtimeInPreview) {\n findings.push({\n code: 'LP0701',\n level: 'warning',\n title: 'No inline runtime in the preview response',\n detail:\n `A request carrying Sec-Fetch-Dest: iframe returned ${String(preview.status)} ` +\n 'without the inline runtime. Two readings, and this audit cannot tell them ' +\n 'apart from the response alone: an adapter that did not recognise the ' +\n 'request as a preview, or a consumer that starts LivePreviewClient itself ' +\n 'and never wanted the inline build.',\n remedy:\n 'If you use an adapter, check its inject mode and whether a proxy strips ' +\n 'Sec-Fetch-Dest. If you start the client yourself, this line is expected.',\n });\n }\n\n const previewCsp = preview.headers['content-security-policy'];\n const frameAncestors = frameAncestorsOf(previewCsp);\n if (runtimeInPreview && frameAncestors === undefined) {\n findings.push({\n code: 'LP0702',\n level: 'warning',\n title: 'Preview response declares no frame-ancestors',\n detail:\n 'The runtime is injected but the response carries no frame-ancestors ' +\n 'directive, so nothing states which origins may embed this page.',\n remedy:\n 'Let the adapter manage CSP, or set frame-ancestors yourself to the admin ' +\n 'origin. Without it you are relying on the absence of a policy.',\n });\n } else if (\n frameAncestors !== undefined &&\n context.adminOrigin !== undefined &&\n !frameAncestors.includes(context.adminOrigin) &&\n // `'self'` names the admin without spelling it out when they share an origin.\n !(sameOrigin && frameAncestors.includes(\"'self'\"))\n ) {\n findings.push({\n code: 'LP0702',\n level: 'error',\n title: 'frame-ancestors does not admit the admin origin',\n detail: `Served: \"${frameAncestors}\". Expected it to allow ${context.adminOrigin}.`,\n remedy:\n 'Add the admin origin to allowedOrigins so the adapter merges it into ' +\n 'frame-ancestors, or add it to your own policy.',\n });\n }\n\n const frameOptions = preview.headers['x-frame-options'];\n const frameOptionsBlocks =\n frameOptions !== undefined &&\n (/deny/iu.test(frameOptions) || (/sameorigin/iu.test(frameOptions) && !sameOrigin));\n if (frameOptionsBlocks) {\n findings.push({\n code: 'LP0703',\n level: 'error',\n title: `X-Frame-Options: ${frameOptions} blocks the preview iframe`,\n detail:\n 'This header is older than CSP and browsers honour it independently. ' +\n 'No frame-ancestors directive can override it.',\n remedy:\n 'Remove X-Frame-Options for preview responses. It is usually set by a ' +\n 'proxy or a security middleware rather than by the app.',\n });\n }\n\n if (publicBindings > 0) {\n findings.push({\n code: 'LP0704',\n level: 'warning',\n title: `${String(publicBindings)} binding attribute(s) served to anonymous visitors`,\n detail:\n 'A request with no preview intent received data-payload-field attributes. ' +\n 'These disclose the CMS field taxonomy, and any CSS keyed on them changes ' +\n 'public layout the moment emission is gated.',\n remedy:\n 'Gate binding emission on an authorized preview context — see ' +\n 'createPreviewBindings(), whose suppressed form emits nothing at all.',\n });\n }\n\n if (previewBindings > DEFAULT_VISIBILITY_GATE_THRESHOLD) {\n findings.push({\n code: 'LP0705',\n level: 'warning',\n title: `${String(previewBindings)} bindings exceed the default visibility gate`,\n detail:\n `Above visibilityGateThreshold (${String(DEFAULT_VISIBILITY_GATE_THRESHOLD)}) ` +\n 'the scheduler stops writing offscreen elements and buffers them until they ' +\n 'scroll into view. On a page nobody scrolls, that is never.',\n remedy:\n 'Raise visibilityGateThreshold if the whole page must stay live, or confirm ' +\n 'that deferring below the fold is acceptable here.',\n });\n }\n\n if (runtimeInPreview && previewBindings === 0) {\n findings.push({\n code: 'LP0707',\n level: 'error',\n title: 'Preview response carries no bindings',\n detail:\n 'The runtime is present but there is not one data-payload-field element ' +\n 'for it to write into, so no edit can ever be visible on this page.',\n remedy:\n 'Add binding attributes to the markup. pll-codegen --inventory lists every ' +\n 'field the schema makes addressable.',\n });\n }\n\n const unowned = unownedBindingsBeforeFirstOwner(preview.body);\n if (owners > 0 && unowned > 0) {\n findings.push({\n code: 'LP0706',\n level: 'warning',\n title: `${String(unowned)} binding(s) outside every owner marker`,\n detail:\n `The page declares ${String(owners)} owner marker(s), so owner scoping is in ` +\n 'use. Bindings that belong to no owner receive nothing once ' +\n 'scopeBindingsByOwner is on — they fail closed.',\n remedy:\n 'Give every binding an owning ancestor, or leave scopeBindingsByOwner off ' +\n 'until they all have one. Counted before the first owner marker only, so ' +\n 'the real number may be higher.',\n });\n }\n\n if (runtimeInPublic) {\n findings.push({\n code: 'LP0701',\n level: 'info',\n title: 'Runtime is also served to anonymous visitors',\n detail:\n 'A request with no preview intent received the inline runtime. That is ' +\n \"correct for inject: 'always' and unintended for 'preview-only'.\",\n remedy: '',\n });\n }\n\n findings.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level]);\n\n return {\n url: context.url,\n findings,\n errors: findings.filter((f) => f.level === 'error').length,\n warnings: findings.filter((f) => f.level === 'warning').length,\n };\n}\n","/**\n * Stable identifiers for everything the runtime reports.\n *\n * Prose gets reworded; a code does not. A consumer filtering logs, an alert\n * rule, or a bug report referring to `LP0301` keeps meaning the same thing\n * after the sentence around it is rewritten, and a code is greppable in a way\n * that a sentence fragment is not.\n *\n * Codes are grouped by the question they answer:\n *\n * - `LP01xx` — configuration and origin trust: the runtime is running, but it\n * was set up in a way that will bite.\n * - `LP02xx` — bindings and markup: an update had nowhere to land.\n * - `LP03xx` — updates and scheduling: the update landed somewhere other than\n * the DOM, or not yet.\n * - `LP04xx` — rendering: a value reached an element and was refused.\n * - `LP05xx` — protocol and messages: something arrived that was not accepted.\n * - `LP06xx` — consumer callbacks: code the consumer supplied threw.\n * - `LP07xx` — audit findings from `pll doctor`, which reports on a served\n * response rather than on a running runtime.\n *\n * A code is part of the public contract. Codes are never reused for a\n * different meaning and never renumbered; a retired code stays retired.\n * `LP0604` is unassigned: a throwing token validator is deliberately treated\n * as a rejection and reported as `LP0502`, so there is nothing distinct to\n * report yet. The number stays reserved rather than being handed to something\n * else.\n *\n * The runtime writes these as string literals at each reporting site rather\n * than reading them from this record, so the inline build pays for the six\n * characters it prints and nothing more. This record exists so consumers can\n * refer to a code by name instead of copying a literal.\n *\n * @module @core/diagnostic-codes\n */\n\n/**\n * Every diagnostic code the runtime can report, by name.\n *\n * ```ts\n * client.events.on('error', (e) => {\n * if (e.code === DIAGNOSTIC_CODES.TransformThrew) reportToSentry(e.error);\n * });\n * ```\n */\nexport const DIAGNOSTIC_CODES = Object.freeze({\n /** No trusted origin configured in production; nothing will be accepted. */\n NoTrustedOrigin: 'LP0101',\n /** Origin trust rests on `document.referrer`, so any framing site is trusted. */\n ReferrerOnlyTrust: 'LP0102',\n\n /** An update named a field with no binding anchor on the page. */\n OrphanField: 'LP0201',\n /** Owner scoping is on and the update names no document it could belong to. */\n UnattributableUpdate: 'LP0202',\n\n /** The visibility gate held offscreen writes back until they scroll into view. */\n VisibilityGateDeferred: 'LP0301',\n\n /** A value was refused because the attribute or the value itself is unsafe. */\n UnsafeAttributeWrite: 'LP0401',\n /** A text element has structured children, so replacing its text was skipped. */\n TextTargetHasChildren: 'LP0402',\n /** A structural container has no array template, so the update was skipped. */\n MissingArrayTemplate: 'LP0403',\n\n /** A message was rejected before it reached the update pipeline. */\n MessageRejected: 'LP0501',\n /** A preview token was rejected. */\n TokenRejected: 'LP0502',\n\n /** A consumer event handler threw. */\n HandlerThrew: 'LP0601',\n /** A consumer transform threw; the original value was kept. */\n TransformThrew: 'LP0602',\n /** A renderer threw while writing a value. */\n RendererThrew: 'LP0603',\n /** Runtime startup failed. */\n StartupFailed: 'LP0605',\n /** Sending the ready handshake failed. */\n ReadyFailed: 'LP0606',\n\n /** The audit found no runtime in a response that carried preview intent. */\n AuditRuntimeMissing: 'LP0701',\n /** The preview response declares no `frame-ancestors`. */\n AuditNoFrameAncestors: 'LP0702',\n /** `X-Frame-Options` forbids framing, which no CSP can undo. */\n AuditFrameOptionsBlocks: 'LP0703',\n /** Binding attributes are served to anonymous visitors. */\n AuditBindingsExposed: 'LP0704',\n /** More bindings than the default visibility gate will write eagerly. */\n AuditGateThresholdExceeded: 'LP0705',\n /** Owner markers exist, but some bindings are outside all of them. */\n AuditUnownedBindings: 'LP0706',\n /** The preview response carries no bindings at all. */\n AuditNoBindings: 'LP0707',\n /** The URL did not return an HTML page, so nothing else can be judged. */\n AuditNotAPage: 'LP0708',\n} as const);\n\n/** A diagnostic code, as it appears in log output and on the `error` event. */\nexport type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[keyof typeof DIAGNOSTIC_CODES];\n","/**\n * Fetching and rendering for `pll doctor`.\n *\n * Deliberately not the barrel: index files are excluded from the coverage\n * report, and this file holds the only code in the audit that touches the\n * network and the only place response headers are normalised — precisely the\n * parts whose silent failure would make every header check report a false\n * finding. Living here, they are measured like everything else.\n *\n * @module @doctor/probe\n */\nimport { analyzeProbe } from './analyze';\nimport type { DoctorReport, DoctorResponse } from './types';\n\n/** Injectable so tests can drive the audit without a server. */\nexport type DoctorFetch = (\n url: string,\n init: { readonly headers: Readonly<Record<string, string>> },\n) => Promise<DoctorResponse>;\n\nexport interface RunDoctorOptions {\n readonly url: string;\n /** Admin origin the preview is meant to be embedded from, when known. */\n readonly adminOrigin?: string | undefined;\n /** Defaults to `globalThis.fetch`. */\n readonly fetchImpl?: DoctorFetch | undefined;\n}\n\n/**\n * Normalise header names to lowercase.\n *\n * Exported because every header check downstream reads a lowercase key: if\n * this were wrong, `content-security-policy` and `x-frame-options` would both\n * read as absent and the audit would invent findings rather than report them.\n * A silent failure of exactly that shape is worth a test of its own.\n */\nexport function lowercaseHeaders(headers: Headers): Record<string, string> {\n const out: Record<string, string> = {};\n headers.forEach((value, key) => {\n out[key.toLowerCase()] = value;\n });\n return out;\n}\n\nconst defaultFetch: DoctorFetch = async (url, init) => {\n const response = await fetch(url, { headers: init.headers, redirect: 'follow' });\n return {\n status: response.status,\n headers: lowercaseHeaders(response.headers),\n body: await response.text(),\n };\n};\n\n/**\n * Fetch the URL twice — as a visitor and as the admin's iframe — and audit the\n * difference.\n */\nexport async function runDoctor(options: RunDoctorOptions): Promise<DoctorReport> {\n const fetchImpl = options.fetchImpl ?? defaultFetch;\n\n const publicResponse = await fetchImpl(options.url, {\n headers: {\n // A plain top-level navigation. Deliberately no referer: an admin\n // referer is itself a preview signal, which would defeat the comparison.\n Accept: 'text/html',\n 'Sec-Fetch-Dest': 'document',\n 'Sec-Fetch-Mode': 'navigate',\n },\n });\n\n const previewResponse = await fetchImpl(options.url, {\n headers: {\n Accept: 'text/html',\n // What the admin's iframe sends. This is the signal `'preview-only'`\n // injection keys on, so it is the honest way to ask for the preview\n // variant of the page.\n 'Sec-Fetch-Dest': 'iframe',\n 'Sec-Fetch-Mode': 'navigate',\n ...(options.adminOrigin !== undefined ? { Referer: `${options.adminOrigin}/` } : {}),\n },\n });\n\n return analyzeProbe(\n { publicResponse, previewResponse },\n { url: options.url, adminOrigin: options.adminOrigin },\n );\n}\n\nconst LEVEL_LABEL = { error: 'ERROR', warning: 'WARN ', info: 'INFO ' } as const;\n\n/** Render a report for a terminal. */\nexport function formatReport(report: DoctorReport): string {\n const lines: string[] = [`pll doctor — ${report.url}`, ''];\n if (report.findings.length === 0) {\n lines.push('No findings. The preview response carries the runtime, a frame-ancestors');\n lines.push('policy, and bindings; the public response carries none of them.', '');\n return lines.join('\\n');\n }\n for (const finding of report.findings) {\n lines.push(`${LEVEL_LABEL[finding.level]} ${finding.code} ${finding.title}`);\n lines.push(` ${finding.detail}`);\n if (finding.remedy !== '') lines.push(` → ${finding.remedy}`);\n lines.push('');\n }\n lines.push(`${String(report.errors)} error(s), ${String(report.warnings)} warning(s).`, '');\n return lines.join('\\n');\n}\n"],"mappings":"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","ignoreList":[]}
|