@zixcel/view-delivery 0.10.2 → 0.10.5

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 CHANGED
@@ -40,3 +40,9 @@ This workspace owns only the host and delivery contracts.
40
40
  ## Release provenance
41
41
 
42
42
  Starting with 0.10.1, releases use GitHub Actions OIDC trusted publishing from `zixcel/view-delivery` (`publish-npm.yml`), without a stored npm publishing token. The workflow verifies reviewed archive hashes and requires npm provenance metadata. Check the published version on npm and verify registry signatures and provenance with `npm audit signatures` in an installed consumer project. Package runtime behavior and API contracts are unchanged by this release.
43
+
44
+ ## Node.js security baseline
45
+
46
+ Use a supported Node.js security release for Node-based build, delivery and consumer processes: at least 22.23.4, 24.22.1 or 26.12.1. The renderer runs in the browser and does not bundle Node. Updating an npm library does not update the caller's Node executable. The October 21, 2026 security fixes include TLS, DNS, HTTP and Brotli handling; operator-managed runtimes must be updated independently. GitHub Actions pins select the shell/build runtime, not the GitHub-managed runtime of JavaScript Actions.
47
+
48
+ Official advisory: https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@zixcel/view-delivery",
3
- "version": "0.10.2",
3
+ "version": "0.10.5",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": "^22.23.4 || ^24.22.1 || ^26.12.1"
7
+ },
5
8
  "license": "Apache-2.0",
6
9
  "description": "Bounded trusted document and asset delivery; existing Crowsi STATE transport only",
7
10
  "files": [
@@ -18,15 +21,12 @@
18
21
  }
19
22
  },
20
23
  "dependencies": {
21
- "@zixcel/view-delivery-contracts": "0.10.2",
22
- "@crowsi/transport-foundation": "0.10.0",
23
24
  "@crowsi/browser-security": "0.10.0",
24
- "ws": "8.21.2",
25
+ "@crowsi/transport-foundation": "0.10.0",
25
26
  "@types/node": "24.12.0",
26
- "@zixcel/projection-contracts": "0.10.3"
27
- },
28
- "scripts": {
29
- "test": "node --test test/*.test.mjs"
27
+ "@zixcel/projection-contracts": "0.10.4",
28
+ "@zixcel/view-delivery-contracts": "0.10.4",
29
+ "ws": "8.22.0"
30
30
  },
31
31
  "types": "./src/index.d.mts",
32
32
  "repository": {
@@ -37,5 +37,19 @@
37
37
  "publishConfig": {
38
38
  "access": "public",
39
39
  "registry": "https://registry.npmjs.org"
40
+ },
41
+ "keywords": [
42
+ "data-presentation",
43
+ "artifact-distribution",
44
+ "api-server",
45
+ "typescript"
46
+ ],
47
+ "devDependencies": {
48
+ "typescript": "7.0.2"
49
+ },
50
+ "scripts": {
51
+ "test": "node --test --test-concurrency=1 test/*.test.mts",
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "typecheck": "tsc -p tsconfig.build.json --noEmit"
40
54
  }
41
- }
55
+ }
@@ -0,0 +1,2 @@
1
+ import type { Observation, DeliveryFailure } from './types.mjs';
2
+ export declare function sourceFailure(value: unknown, operation: string, observation: Observation): DeliveryFailure;
@@ -0,0 +1,32 @@
1
+ import { requirement } from '@zixcel/view-delivery-contracts';
2
+ import { record } from './wire.mjs';
3
+ const accessCodes = new Set(['host-rejected', 'origin-rejected', 'origin-required', 'fetch-site-rejected', 'session-capacity-reached', 'session-rejected', 'session-expired', 'csrf-rejected',
4
+ 'request-nonce-invalid', 'request-replayed', 'request-nonce-capacity-reached'].map(c => 'crowsi-browser-security-' + c));
5
+ const deliveryCodes = new Set(['SessionRejected', 'InvalidInput', 'DeliveryLimitExceeded', 'DeliveryCancelled', 'DeliveryTimedOut', 'InvalidDeliveryData',
6
+ 'InvalidAsset', 'NotFound', 'MethodNotAllowed', 'ProjectionCancelled', 'ProjectionRuntimeClosed', 'ProjectionQueueFull',
7
+ 'ProjectionStoreBusy', 'ProjectionStoreCorrupt', 'SourceUnavailable', 'SourceNotRetained', 'SourceRevisionMismatch',
8
+ 'ProjectionUnavailable', 'ProjectionLimitExceeded', 'ProjectionExecutionTimeout', 'StaleSceneAction', 'SceneUnavailable',
9
+ 'InteractionUnavailable', 'InvalidProjection', 'OriginRequired', 'SocketUnavailable']);
10
+ function classifyFailure(value, operation, observation) {
11
+ const error = record(value);
12
+ const message = error.message, errorCode = error.code, typed = record(error.failure), typedCode = typed.code;
13
+ const access = typeof message === 'string' && accessCodes.has(message);
14
+ let original = null;
15
+ try {
16
+ original = requirement({ ref: typed.requirementRef, owner: typed.owner, state: 'Unavailable', failure: typed, setup: null }).failure;
17
+ }
18
+ catch { }
19
+ const code = original?.code ?? (access ? String(message) : typeof errorCode === 'string' && deliveryCodes.has(errorCode) ? errorCode :
20
+ typeof typedCode === 'string' && /^[a-z][a-z0-9-]{2,95}$/.test(typedCode) ? typedCode : 'UnhandledDeliveryFault');
21
+ return { code, canonical: { state: observation.receiptRef ? 'Committed' : ['DELIVERY_REJECTED', 'NOT_DISPATCHED'].includes(observation.stage) ? 'NotDispatched' : observation.stage === 'OWNER_REJECTED' ? 'Rejected' : 'Unknown', receiptRef: observation.receiptRef }, ownerFailure: { owner: original?.owner ?? (access ? 'crowsi/browser-security' : observation.owner), operation: original?.operation ?? observation.operation ?? operation,
22
+ code, requirementRef: original?.requirementRef ?? 'delivery:server', outcomeStage: observation.stage, uncertain: observation.stage === 'OUTCOME_UNCERTAIN',
23
+ requestRef: observation.requestRef, managementRequestRef: observation.managementRequestRef ?? null, receiptRef: observation.receiptRef, detail: original?.detail ?? null } };
24
+ }
25
+ export function sourceFailure(value, operation, observation) {
26
+ try {
27
+ return classifyFailure(value, operation, observation);
28
+ }
29
+ catch {
30
+ return classifyFailure(undefined, operation, observation);
31
+ }
32
+ }
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DeliveryServerOptions, Observation } from './types.mjs';
3
+ import type { Prepared } from './prepare.mjs';
4
+ export declare function createHandler(options: DeliveryServerOptions, prepared: Prepared): {
5
+ authorize: (request: IncomingMessage) => void;
6
+ authenticate: (request: IncomingMessage) => string;
7
+ handle: (request: IncomingMessage, response: ServerResponse, signal: AbortSignal, observation: Observation) => Promise<void>;
8
+ };
@@ -0,0 +1,121 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { assessRequestBoundary } from '@crowsi/browser-security/boundary';
3
+ import { bounds, validateDelivery } from '@zixcel/view-delivery-contracts';
4
+ import { body, headers, header, record, sessionId, safeDocument, send, fail } from './wire.mjs';
5
+ export function createHandler(options, prepared) {
6
+ const { origin, site, readinessToken } = options, { ownerPolicy, boundary, authority, assetMap } = prepared;
7
+ const authorize = (request) => {
8
+ const reason = assessRequestBoundary(boundary, { method: request.method ?? '', host: header(request, 'host'), origin: header(request, 'origin') || undefined, fetchSite: header(request, 'sec-fetch-site') || undefined });
9
+ if (reason)
10
+ fail(reason);
11
+ };
12
+ const authenticate = (request) => { const id = sessionId(request, ownerPolicy.cookieName); authority.authenticate(id); return id; };
13
+ const connection = (request, response) => {
14
+ let current;
15
+ try {
16
+ const id = authenticate(request);
17
+ current = { sessionId: id, ...authority.authenticate(id) };
18
+ }
19
+ catch {
20
+ current = authority.issue();
21
+ }
22
+ response.setHeader('set-cookie', `${ownerPolicy.cookieName}=${current.sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800${boundary.protocol === 'https:' ? '; Secure' : ''}`);
23
+ return { csrf: current.csrfToken, expiresAtUnixMs: current.expiresAt };
24
+ };
25
+ async function handle(request, response, signal, observation) {
26
+ for (const [key, value] of Object.entries(headers))
27
+ response.setHeader(key, value);
28
+ authorize(request);
29
+ if (typeof request.url !== 'string' || request.url.length > 4096)
30
+ fail('InvalidInput');
31
+ const url = new URL(request.url, origin), pathname = url.pathname;
32
+ if (request.method === 'GET' && pathname === '/api/ready') {
33
+ const provided = Buffer.from(String(request.headers[ownerPolicy.readinessHeader] ?? '')), expected = Buffer.from(readinessToken ?? '');
34
+ if (!expected.length || provided.length !== expected.length || !timingSafeEqual(provided, expected))
35
+ return send(response, 404, { error: { code: 'NotFound' } });
36
+ observation.stage = 'NOT_DISPATCHED';
37
+ response.writeHead(204);
38
+ response.end();
39
+ return; // liveness only, never ProductReady
40
+ }
41
+ if (request.method === 'GET' && assetMap.has(pathname)) {
42
+ observation.stage = 'NOT_DISPATCHED';
43
+ const asset = assetMap.get(pathname);
44
+ response.writeHead(200, { 'content-type': asset.descriptor.kind === 'script' ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8', 'content-length': asset.content.length });
45
+ response.end(asset.content);
46
+ return;
47
+ }
48
+ if (request.method === 'POST' && pathname === '/api/connection') {
49
+ const value = await body(request, signal);
50
+ if (!value || Array.isArray(value) || Object.keys(value).length)
51
+ fail('InvalidInput');
52
+ observation.stage = 'NOT_DISPATCHED';
53
+ send(response, 200, connection(request, response));
54
+ return;
55
+ }
56
+ if (request.method === 'GET' && !pathname.startsWith('/api/')) {
57
+ observation.stage = 'NOT_DISPATCHED';
58
+ const csrf = connection(request, response);
59
+ if (!site.document)
60
+ fail('SourceUnavailable');
61
+ const { envelope, bootstrap } = await site.document(url, { signal, assets: [...assetMap.values()].map(a => a.descriptor) });
62
+ validateDelivery(envelope, ownerPolicy.contract);
63
+ if (JSON.stringify(envelope.assets) !== JSON.stringify([...assetMap.values()].map(a => a.descriptor)))
64
+ fail('InvalidAsset');
65
+ const document = safeDocument(envelope, { ...bootstrap, ...csrf }, ownerPolicy.title);
66
+ if (document.length > bounds.documentBytes)
67
+ fail('DeliveryLimitExceeded');
68
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': document.length });
69
+ response.end(document);
70
+ return;
71
+ }
72
+ authenticate(request);
73
+ if (request.method === 'GET') {
74
+ if (!site.read)
75
+ fail('SourceUnavailable');
76
+ observation.stage = 'NOT_DISPATCHED';
77
+ send(response, 200, await site.read(url, { signal }));
78
+ return;
79
+ }
80
+ if (request.method !== 'POST')
81
+ return send(response, 405, { error: { code: 'MethodNotAllowed' } });
82
+ authority.authorizeMutation(sessionId(request, ownerPolicy.cookieName), header(request, ownerPolicy.csrfHeader), header(request, ownerPolicy.nonceHeader));
83
+ const value = await body(request, signal);
84
+ observation.stage = 'OUTCOME_UNCERTAIN';
85
+ // The site was entered, not proof of physical dispatch or canonical commit.
86
+ // Only exact owner evidence may refine this transient observation.
87
+ const observe = (input) => {
88
+ const value = record(input);
89
+ // Trusted site supplies evidence only. Ignore malformed observations; never
90
+ // change execution branching, mint identity, or persist a competing receipt.
91
+ if (typeof value.stage !== 'string' || !['NOT_DISPATCHED', 'OWNER_REJECTED', 'OUTCOME_UNCERTAIN', 'OWNER_COMMITTED', 'PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage))
92
+ return;
93
+ if (typeof value.owner !== 'string' || !ownerPolicy.allowedOwners.includes(value.owner))
94
+ return;
95
+ if (typeof value.operation !== 'string' || !/^[a-z][a-z0-9]*(?:\/[a-z][a-zA-Z0-9]*)+$/.test(value.operation))
96
+ return;
97
+ if (value.receiptRef !== undefined && value.receiptRef !== null && (typeof value.receiptRef !== 'string' || !/^[a-f0-9]{64}$/.test(value.receiptRef)))
98
+ return;
99
+ if (['OWNER_COMMITTED', 'PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage) && !value.receiptRef)
100
+ return;
101
+ if (observation.receiptRef && value.receiptRef !== observation.receiptRef)
102
+ return;
103
+ if (value.managementRequestRef !== undefined && value.managementRequestRef !== null && (typeof value.managementRequestRef !== 'number' || !Number.isSafeInteger(value.managementRequestRef) || value.managementRequestRef < 1))
104
+ return;
105
+ Object.assign(observation, { stage: value.stage, owner: value.owner, operation: value.operation, receiptRef: value.receiptRef ?? null });
106
+ if (value.managementRequestRef !== undefined)
107
+ observation.managementRequestRef = value.managementRequestRef;
108
+ };
109
+ if (!site.command)
110
+ fail('SourceUnavailable');
111
+ const result = await site.command(url, value, { signal, observe, requestRef: observation.requestRef });
112
+ const receipt = record(record(result).canonical).receiptRef;
113
+ if (typeof receipt === 'string' && /^[a-f0-9]{64}$/.test(receipt)) {
114
+ observation.receiptRef = receipt;
115
+ observation.stage = record(record(result).projection).state === 'FailedTyped' ? 'PUBLICATION_FAILED_AFTER_COMMIT' : 'OWNER_COMMITTED';
116
+ }
117
+ if (!response.writableEnded && !response.destroyed)
118
+ send(response, 200, result);
119
+ }
120
+ return { authorize, authenticate, handle };
121
+ }
package/src/index.d.mts CHANGED
@@ -1,51 +1,3 @@
1
- import type {Server} from 'node:http'
2
- import type {Asset,Delivery} from '@zixcel/view-delivery-contracts'
3
- import type {Json} from '@zixcel/projection-contracts/client'
4
-
5
- export interface RequestContext {readonly signal:AbortSignal}
6
- export interface DocumentContext extends RequestContext {readonly assets:readonly Asset[]}
7
- export interface CommandContext extends RequestContext {
8
- readonly requestRef:string
9
- observe(value:unknown):void
10
- }
11
- export interface DeliverySite {
12
- document(url:URL,context:DocumentContext):Promise<{envelope:Delivery;bootstrap:Readonly<Record<string,Json>>}>|{envelope:Delivery;bootstrap:Readonly<Record<string,Json>>}
13
- read(url:URL,context:RequestContext):unknown|Promise<unknown>
14
- command(url:URL,value:unknown,context:CommandContext):unknown|Promise<unknown>
15
- live(request:unknown):unknown|Promise<unknown>
16
- close?():void|Promise<void>
17
- }
18
- export interface TrustedAsset {
19
- readonly path:string
20
- readonly bytes:Uint8Array
21
- readonly bytesLength:number
22
- readonly digest:string
23
- readonly kind:'script'|'style'
24
- }
25
- export interface DeliveryServer {
26
- readonly server:Server
27
- notify():void
28
- inspect():{readonly alive:boolean;readonly activeRequests:number;readonly transport:unknown}
29
- listen():Promise<void>
30
- close():Promise<void>
31
- }
32
- export interface DeliveryPolicy {
33
- readonly owner:string
34
- readonly title:string
35
- readonly contract:string
36
- readonly allowedOwners:readonly string[]
37
- readonly cookieName?:string
38
- readonly csrfHeader?:string
39
- readonly nonceHeader?:string
40
- readonly readinessHeader?:string
41
- }
42
- export interface DeliveryServerOptions {
43
- readonly policy:DeliveryPolicy
44
- readonly origin:string
45
- readonly site:DeliverySite
46
- readonly assets:readonly TrustedAsset[]
47
- readonly readinessToken?:string|null
48
- readonly onFailure?:((failure:unknown)=>void)|null
49
- readonly onOutcome?:((outcome:unknown)=>void)|null
50
- }
51
- export function createDeliveryServer(options:DeliveryServerOptions):DeliveryServer
1
+ import type { DeliveryServerOptions, DeliveryServer } from './types.mjs';
2
+ export type * from './types.mjs';
3
+ export declare function createDeliveryServer(options: DeliveryServerOptions): DeliveryServer;
package/src/index.mjs CHANGED
@@ -1,225 +1,144 @@
1
- // Caller-owned snapshot delivery. Crowsi owns framing/session/ACK/recovery mechanisms.
2
- import http from 'node:http'
3
- import {createHash,timingSafeEqual,randomUUID} from 'node:crypto'
4
- import {WebSocketServer} from 'ws'
5
- import {StateSocketServer} from '@crowsi/transport-foundation/state-socket'
6
- import {Budget,limitsFor} from '@crowsi/transport-foundation'
7
- import {SessionAuthority} from '@crowsi/browser-security/access'
8
- import {parseOwnerOrigin,assessRequestBoundary} from '@crowsi/browser-security/boundary'
9
- import {validateDelivery,bounds,requirement} from '@zixcel/view-delivery-contracts'
10
-
11
- const fail=code=>{throw Object.assign(Error(code),{code})}
12
- const defaultCookieName='view_delivery_session'
13
- const headers={
14
- 'content-security-policy':"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'",
15
- 'referrer-policy':'no-referrer','x-content-type-options':'nosniff','x-frame-options':'DENY',
16
- 'permissions-policy':'camera=(), microphone=(), geolocation=(), payment=()','cache-control':'no-store'
17
- }
18
- const encode=(value,maximum=bounds.documentBytes)=>{
19
- // Site/owner outputs are already bounded data. Refuse expansion before sending.
20
- let nodes=0,total=0
21
- const add=bytes=>{total+=bytes;if(total>maximum)fail('DeliveryLimitExceeded')}
22
- function check(v,depth){
23
- if(++nodes>32768||depth>20)fail('DeliveryLimitExceeded')
24
- if(typeof v==='string'){if(v.length>maximum)fail('DeliveryLimitExceeded');add(Buffer.byteLength(v)+2);return}
25
- if(v===null||typeof v==='boolean'){add(5);return}
26
- if(typeof v==='number'&&Number.isFinite(v)){add(24);return}
27
- if(!v||typeof v!=='object'||!Array.isArray(v)&&Object.getPrototypeOf(v)!==Object.prototype)fail('InvalidDeliveryData')
28
- const keys=Object.keys(v);if(keys.length>4096)fail('DeliveryLimitExceeded');add(keys.length+2)
29
- for(const key of keys){const d=Object.getOwnPropertyDescriptor(v,key);if(d.get||d.set||key==='toJSON')fail('InvalidDeliveryData');add(Buffer.byteLength(key)+3);check(d.value,depth+1)}
30
- }
31
- check(value,0);const bytes=Buffer.from(JSON.stringify(value));if(bytes.length>maximum)fail('DeliveryLimitExceeded');return bytes
32
- }
33
- async function body(request,signal){
34
- const h=request.headers,raw=h['content-length']
35
- if(h['content-encoding']||h['transfer-encoding']||!/^application\/json(?:\s*;.*)?$/i.test(h['content-type']??'')||!(/^[1-9][0-9]{0,5}$/).test(raw??''))fail('InvalidInput')
36
- const size=Number(raw);if(size>bounds.requestBytes)fail('DeliveryLimitExceeded')
37
- const data=Buffer.alloc(size);let used=0
38
- for await(const chunk of request){if(signal.aborted)fail('DeliveryCancelled');if(used+chunk.length>size)fail('DeliveryLimitExceeded');chunk.copy(data,used);used+=chunk.length}
39
- if(used!==size)fail('InvalidInput')
40
- try{return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data))}catch{fail('InvalidInput')}
41
- }
42
- function sessionId(request,cookieName){
43
- const cookie=request.headers.cookie??'';if(cookie.length>8192)fail('SessionRejected')
44
- const values=cookie.split(';').map(s=>s.trim()).filter(s=>s.startsWith(cookieName+'='))
45
- if(values.length!==1)fail('SessionRejected');return values[0].slice(cookieName.length+1)
46
- }
47
- // Crowsi's access contract currently uses closed Error messages, not .code.
48
- // Recognize only those published constants; never expose arbitrary exception text.
49
- const accessCodes=new Set(['host-rejected','origin-rejected','origin-required','fetch-site-rejected','session-capacity-reached','session-rejected','session-expired','csrf-rejected',
50
- 'request-nonce-invalid','request-replayed','request-nonce-capacity-reached'].map(c=>'crowsi-browser-security-'+c))
51
- const deliveryCodes=new Set(['SessionRejected','InvalidInput','DeliveryLimitExceeded','DeliveryCancelled','DeliveryTimedOut','InvalidDeliveryData',
52
- 'InvalidAsset','NotFound','MethodNotAllowed','ProjectionCancelled','ProjectionRuntimeClosed','ProjectionQueueFull',
53
- 'ProjectionStoreBusy','ProjectionStoreCorrupt','SourceUnavailable','SourceNotRetained','SourceRevisionMismatch',
54
- 'ProjectionUnavailable','ProjectionLimitExceeded','ProjectionExecutionTimeout','StaleSceneAction','SceneUnavailable',
55
- 'InteractionUnavailable','InvalidProjection','OriginRequired','SocketUnavailable'])
56
- function sourceFailure(error,operation,observation){
57
- const access=accessCodes.has(error?.message),typed=error?.failure
58
- let original
59
- try{original=requirement({ref:typed.requirementRef,owner:typed.owner,state:'Unavailable',failure:typed,setup:null}).failure}catch{}
60
- const code=original?.code??(access?error.message:deliveryCodes.has(error?.code)?error.code:
61
- typeof typed?.code==='string'&&/^[a-z][a-z0-9-]{2,95}$/.test(typed.code)?typed.code:'UnhandledDeliveryFault')
62
- return {code,canonical:{state:observation.receiptRef?'Committed':['DELIVERY_REJECTED','NOT_DISPATCHED'].includes(observation.stage)?'NotDispatched':observation.stage==='OWNER_REJECTED'?'Rejected':'Unknown',receiptRef:observation.receiptRef},ownerFailure:{owner:original?.owner??(access?'crowsi/browser-security':observation.owner),operation:original?.operation??observation.operation??operation,
63
- code,requirementRef:original?.requirementRef??'delivery:server',outcomeStage:observation.stage,uncertain:observation.stage==='OUTCOME_UNCERTAIN',
64
- requestRef:observation.requestRef,managementRequestRef:observation.managementRequestRef??null,receiptRef:observation.receiptRef,detail:original?.detail??null}}
65
- }
66
- function safeDocument(envelope,bootstrap,title){
67
- const serialized=encode({envelope,bootstrap}).toString().replace(/</g,'\\u003c').replace(/>/g,'\\u003e').replace(/&/g,'\\u0026')
68
- const styles=envelope.assets.filter(a=>a.kind==='style').map(a=>`<link rel="stylesheet" href="${a.path}">`).join('')
69
- const scripts=envelope.assets.filter(a=>a.kind==='script').map(a=>`<script type="module" src="${a.path}"></script>`).join('')
70
- return Buffer.from(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>${styles}</head><body><main id="app" aria-busy="true"><p role="status">Loading exact state…</p></main><script id="delivery-state" type="application/json">${serialized}</script>${scripts}</body></html>`)
71
- }
72
- export function createDeliveryServer({origin,site,assets,readinessToken=null,onFailure=null,onOutcome=null,policy}){
73
- if(!policy||typeof policy.owner!=='string'||!policy.owner||policy.owner.length>128||
74
- typeof policy.contract!=='string'||!policy.contract||policy.contract.length>128||typeof policy.title!=='string'||!policy.title||policy.title.length>128||/[<>&]/.test(policy.title)||
75
- !Array.isArray(policy.allowedOwners)||policy.allowedOwners.length>16||policy.allowedOwners.some(owner=>typeof owner!=='string'||!owner||owner.length>128))fail('InvalidDeliveryPolicy')
76
- const headerPolicy={cookieName:policy.cookieName??defaultCookieName,csrfHeader:policy.csrfHeader??'x-delivery-csrf',nonceHeader:policy.nonceHeader??'x-delivery-request-nonce',readinessHeader:policy.readinessHeader??'x-delivery-readiness'}
77
- if(Object.values(headerPolicy).some(value=>typeof value!=='string'||value.length>64||!/[a-z]/.test(value)||!/^[a-z0-9_-]+$/.test(value)))fail('InvalidDeliveryPolicy')
78
- const ownerPolicy=Object.freeze({owner:policy.owner,title:policy.title,contract:policy.contract,allowedOwners:Object.freeze([...policy.allowedOwners]),...headerPolicy})
79
- const boundary=parseOwnerOrigin(origin),authority=new SessionAuthority(),inflight=new Set(),controllers=new Set()
80
- const budget=new Budget(limitsFor(bounds.documentBytes,{pendingMessages:bounds.requests})),assetMap=new Map()
81
- for(const asset of assets){
82
- const {bytes,...descriptor}=asset,content=Buffer.from(bytes)
83
- if(content.length!==descriptor.bytesLength||content.length>bounds.assetBytes)fail('InvalidAsset')
84
- const digest=createHash('sha256').update(content).digest('hex')
85
- if(digest!==descriptor.digest||assetMap.has(descriptor.path)||!/^\/assets\/[a-zA-Z0-9._-]{1,100}$/.test(descriptor.path)||!['script','style'].includes(descriptor.kind))fail('InvalidAsset')
86
- assetMap.set(descriptor.path,{content,descriptor:{path:descriptor.path,digest,bytes:content.length,kind:descriptor.kind}})
87
- }
88
- if(assetMap.size<1||assetMap.size>bounds.assets)fail('InvalidAsset')
89
- let closing=false,closePromise=null
90
- const authorize=request=>{
91
- const reason=assessRequestBoundary(boundary,{method:request.method,host:request.headers.host,origin:request.headers.origin,fetchSite:request.headers['sec-fetch-site']})
92
- if(reason)fail(reason)
93
- }
94
- const authenticate=request=>{const id=sessionId(request,ownerPolicy.cookieName);authority.authenticate(id);return id}
95
- const connection=(request,response)=>{
96
- let current
97
- try{const id=authenticate(request);current={sessionId:id,...authority.authenticate(id)}}catch{current=authority.issue()}
98
- response.setHeader('set-cookie',`${ownerPolicy.cookieName}=${current.sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800${boundary.protocol==='https:'?'; Secure':''}`)
99
- return {csrf:current.csrfToken,expiresAtUnixMs:current.expiresAt}
100
- }
101
- const hub=new StateSocketServer({authorize:peer=>{authorize(peer.request);if(peer.request.headers.origin!==origin)fail('OriginRequired');return authenticate(peer.request)},read:request=>site.live(request)})
102
- const sockets=new WebSocketServer({noServer:true,maxPayload:1048578,perMessageDeflate:false,clientTracking:true})
103
- const send=(response,status,value)=>{const bytes=encode(value);response.writeHead(status,{'content-type':'application/json; charset=utf-8','content-length':bytes.length});response.end(bytes)}
104
- async function handle(request,response,signal,observation){
105
- for(const [key,value]of Object.entries(headers))response.setHeader(key,value)
106
- authorize(request)
107
- if(request.url.length>4096)fail('InvalidInput')
108
- const url=new URL(request.url,origin),pathname=url.pathname
109
- if(request.method==='GET'&&pathname==='/api/ready'){
110
- const provided=Buffer.from(String(request.headers[ownerPolicy.readinessHeader]??'')),expected=Buffer.from(readinessToken??'')
111
- if(!expected.length||provided.length!==expected.length||!timingSafeEqual(provided,expected))return send(response,404,{error:{code:'NotFound'}})
112
- observation.stage='NOT_DISPATCHED';response.writeHead(204);response.end();return // liveness only, never ProductReady
113
- }
114
- if(request.method==='GET'&&assetMap.has(pathname)){
115
- observation.stage='NOT_DISPATCHED'
116
- const asset=assetMap.get(pathname);response.writeHead(200,{'content-type':asset.descriptor.kind==='script'?'text/javascript; charset=utf-8':'text/css; charset=utf-8','content-length':asset.content.length});response.end(asset.content);return
117
- }
118
- if(request.method==='POST'&&pathname==='/api/connection'){
119
- const value=await body(request,signal);if(!value||Array.isArray(value)||Object.keys(value).length)fail('InvalidInput')
120
- observation.stage='NOT_DISPATCHED';send(response,200,connection(request,response));return
121
- }
122
- if(request.method==='GET'&&!pathname.startsWith('/api/')){
123
- observation.stage='NOT_DISPATCHED'
124
- const csrf=connection(request,response)
125
- const {envelope,bootstrap}=await site.document(url,{signal,assets:[...assetMap.values()].map(a=>a.descriptor)})
126
- validateDelivery(envelope,ownerPolicy.contract)
127
- if(JSON.stringify(envelope.assets)!==JSON.stringify([...assetMap.values()].map(a=>a.descriptor)))fail('InvalidAsset')
128
- const document=safeDocument(envelope,{...bootstrap,...csrf},ownerPolicy.title)
129
- if(document.length>bounds.documentBytes)fail('DeliveryLimitExceeded')
130
- response.writeHead(200,{'content-type':'text/html; charset=utf-8','content-length':document.length});response.end(document);return
131
- }
132
- authenticate(request)
133
- if(request.method==='GET'){observation.stage='NOT_DISPATCHED';send(response,200,await site.read(url,{signal}));return}
134
- if(request.method!=='POST')return send(response,405,{error:{code:'MethodNotAllowed'}})
135
- authority.authorizeMutation(sessionId(request,ownerPolicy.cookieName),request.headers[ownerPolicy.csrfHeader],request.headers[ownerPolicy.nonceHeader])
136
- const value=await body(request,signal)
137
- observation.stage='OUTCOME_UNCERTAIN'
138
- // The site was entered, not proof of physical dispatch or canonical commit.
139
- // Only exact owner evidence may refine this transient observation.
140
- const observe=value=>{
141
- // Trusted site supplies evidence only. Ignore malformed observations; never
142
- // change execution branching, mint identity, or persist a competing receipt.
143
- if(!value||!['NOT_DISPATCHED','OWNER_REJECTED','OUTCOME_UNCERTAIN','OWNER_COMMITTED','PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage))return
144
- if(typeof value.owner!=='string'||!ownerPolicy.allowedOwners.includes(value.owner))return
145
- if(typeof value.operation!=='string'||!/^[a-z][a-z0-9]*(?:\/[a-z][a-zA-Z0-9]*)+$/.test(value.operation))return
146
- if(value.receiptRef!==undefined&&value.receiptRef!==null&&!/^[a-f0-9]{64}$/.test(value.receiptRef))return
147
- if(['OWNER_COMMITTED','PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage)&&!value.receiptRef)return
148
- if(observation.receiptRef&&value.receiptRef!==observation.receiptRef)return
149
- if(value.managementRequestRef!==undefined&&value.managementRequestRef!==null&&(!Number.isSafeInteger(value.managementRequestRef)||value.managementRequestRef<1))return
150
- Object.assign(observation,{stage:value.stage,owner:value.owner,operation:value.operation,receiptRef:value.receiptRef??null})
151
- if(value.managementRequestRef!==undefined)observation.managementRequestRef=value.managementRequestRef
152
- }
153
- const result=await site.command(url,value,{signal,observe,requestRef:observation.requestRef})
154
- const receipt=result?.canonical?.receiptRef
155
- if(typeof receipt==='string'&&/^[a-f0-9]{64}$/.test(receipt)){
156
- observation.receiptRef=receipt;observation.stage=result.projection?.state==='FailedTyped'?'PUBLICATION_FAILED_AFTER_COMMIT':'OWNER_COMMITTED'
157
- }
158
- if(!response.writableEnded&&!response.destroyed)send(response,200,result)
159
- }
160
- const server=http.createServer({maxHeaderSize:16384,headersTimeout:5000,requestTimeout:5000,keepAliveTimeout:2000},(request,response)=>{
161
- if(closing){response.writeHead(503);response.end();return}
162
- let release;try{release=budget.reserve(0)}catch{send(response,503,{error:{code:'DeliveryQueueFull'}});return}
163
- const controller=new AbortController();controllers.add(controller)
164
- const abort=()=>controller.abort();response.once('close',abort)
165
- const observation={requestRef:randomUUID(),owner:ownerPolicy.owner,stage:'DELIVERY_REJECTED',receiptRef:null}
166
- // Paths and query strings are caller input, not safe diagnostic labels.
167
- const path=request.url?.split('?')[0],operation=['/api/subjects','/api/interactions','/api/site-actions','/api/setup','/api/connection','/api/document'].includes(path)?path:'delivery/request'
168
- const recordFailure=error=>{
169
- const failure=sourceFailure(error,operation,observation)
170
- try{onFailure?.(failure)}catch{}
171
- return failure
172
- }
173
- // Preserve the finite HTTP observer deadline, but send a truthful outcome
174
- // rather than resetting a reused socket (browsers may retransmit that POST).
175
- // This does not cancel an accepted owner action or renew its execution budget.
176
- const timer=setTimeout(()=>{
177
- if(!response.writableEnded&&!response.destroyed){
178
- const failure=recordFailure(Object.assign(Error('DeliveryTimedOut'),{code:'DeliveryTimedOut'}))
179
- send(response,504,{error:failure})
180
- }
181
- controller.abort()
182
- },bounds.bodyMs)
183
- const work=handle(request,response,controller.signal,observation).catch(error=>{
184
- const failure=recordFailure(error)
185
- // Read-only diagnostic callback: no inherited preload, payload, credentials or
186
- // stack. A diagnostic consumer cannot alter the execution branch by throwing.
187
- if(!response.headersSent&&!response.destroyed)send(response,400,{error:failure})
188
- else if(!response.writableEnded&&!response.destroyed)response.destroy()
189
- }).finally(()=>{
190
- try{onOutcome?.({...observation,operation:observation.operation??operation,httpStatus:response.headersSent?response.statusCode:null})}catch{}
191
- clearTimeout(timer);response.off('close',abort);release();controllers.delete(controller);inflight.delete(work)
192
- });inflight.add(work)
193
- })
194
- server.maxConnections=32;server.maxRequestsPerSocket=128
195
- server.on('upgrade',(request,socket,head)=>{
196
- try{if(closing||request.url!=='/api/projection-live'||hub.inspect().connections>=8)fail('SocketUnavailable');authorize(request);authenticate(request);if(request.headers.origin!==origin)fail('OriginRequired')}
197
- catch{socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return}
198
- sockets.handleUpgrade(request,socket,head,websocket=>{
199
- const peer={request,websocket,close:(code,reason)=>websocket.close(code,reason)}
200
- websocket.on('message',data=>hub.message(peer,data));websocket.on('close',()=>hub.disconnect(peer));websocket.on('error',()=>hub.disconnect(peer));hub.open(peer)
201
- })
202
- })
203
- return {server,notify:()=>hub.notify(),inspect:()=>({alive:server.listening,activeRequests:inflight.size,transport:hub.inspect()}),
204
- async listen(){await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(Number(boundary.port),'127.0.0.1',resolve)})},
205
- close(){
206
- if(closePromise)return closePromise
207
- closing=true
208
- // Closing delivery revokes intake and releases transport, not physical work.
209
- // Let the owning site close its resources BEFORE awaiting its pending calls:
210
- // their completion may depend on that close. Join one lifecycle promise even
211
- // for concurrent close callers, and preserve an owner cleanup failure.
212
- closePromise=Promise.resolve().then(async()=>{
213
- for(const c of controllers)c.abort()
214
- hub.close();for(const ws of sockets.clients)ws.terminate()
215
- const transportClosed=new Promise(resolve=>sockets.close(resolve))
216
- const httpClosed=new Promise(resolve=>server.close(resolve))
217
- server.closeIdleConnections();server.closeAllConnections()
218
- const ownerClosed=Promise.resolve().then(()=>site.close?.())
219
- const results=await Promise.allSettled([transportClosed,httpClosed,ownerClosed,...inflight])
220
- const failures=results.filter(result=>result.status==='rejected').map(result=>result.reason)
221
- if(failures.length)throw new AggregateError(failures,'Delivery shutdown failed')
222
- })
223
- return closePromise
224
- }}
1
+ // Caller-owned delivery and lifecycle. Crowsi retains transport/session/ACK/recovery.
2
+ import http from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { WebSocketServer } from 'ws';
5
+ import { StateSocketServer } from '@crowsi/transport-foundation/state-socket';
6
+ import { bounds } from '@zixcel/view-delivery-contracts';
7
+ import { prepareDelivery } from './prepare.mjs';
8
+ import { createHandler } from './handler.mjs';
9
+ import { send, fail, record } from './wire.mjs';
10
+ import { sourceFailure } from './failure.mjs';
11
+ export function createDeliveryServer(options) {
12
+ const { site, origin, onFailure, onOutcome } = options;
13
+ const prepared = prepareDelivery(options), { ownerPolicy, boundary, budget } = prepared;
14
+ const { handle, authorize, authenticate } = createHandler(options, prepared);
15
+ const inflight = new Set(), controllers = new Set(), requests = new WeakMap();
16
+ let closing = false, closePromise = null;
17
+ const hub = new StateSocketServer({ authorize: peer => { const request = requests.get(peer); if (!request)
18
+ fail('SessionRejected'); authorize(request); if (request.headers.origin !== origin)
19
+ fail('OriginRequired'); return authenticate(request); }, read: async (request) => {
20
+ if (!site.live)
21
+ fail('SourceUnavailable');
22
+ const value = record(await site.live(request));
23
+ if (value.kind === 'RecoveryUnavailable')
24
+ return { kind: 'RecoveryUnavailable', detail: value.detail };
25
+ if (value.kind === 'NoChange' && typeof value.revision === 'string')
26
+ return { kind: 'NoChange', revision: value.revision };
27
+ if (value.kind === 'Snapshot' && typeof value.revision === 'string' && value.payload instanceof Uint8Array)
28
+ return { kind: 'Snapshot', revision: value.revision, payload: value.payload };
29
+ return fail('InvalidDeliveryData');
30
+ } });
31
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: 1048578, perMessageDeflate: false, clientTracking: true });
32
+ const server = http.createServer({ maxHeaderSize: 16384, headersTimeout: 5000, requestTimeout: 5000, keepAliveTimeout: 2000 }, (request, response) => {
33
+ if (closing) {
34
+ response.writeHead(503);
35
+ response.end();
36
+ return;
37
+ }
38
+ let release;
39
+ try {
40
+ release = budget.reserve(0);
41
+ }
42
+ catch {
43
+ send(response, 503, { error: { code: 'DeliveryQueueFull' } });
44
+ return;
45
+ }
46
+ const controller = new AbortController();
47
+ controllers.add(controller);
48
+ const abort = () => controller.abort();
49
+ response.once('close', abort);
50
+ const observation = { requestRef: randomUUID(), owner: ownerPolicy.owner, stage: 'DELIVERY_REJECTED', receiptRef: null };
51
+ // Paths and query strings are caller input, not safe diagnostic labels.
52
+ const path = request.url?.split('?')[0], operation = ['/api/subjects', '/api/interactions', '/api/site-actions', '/api/setup', '/api/connection', '/api/document'].includes(path ?? '') ? path : 'delivery/request';
53
+ const recordFailure = (error) => {
54
+ const failure = sourceFailure(error, operation, observation);
55
+ try {
56
+ Promise.resolve(onFailure?.(failure)).catch(() => { });
57
+ }
58
+ catch { }
59
+ return failure;
60
+ };
61
+ // Preserve the finite HTTP observer deadline, but send a truthful outcome
62
+ // rather than resetting a reused socket (browsers may retransmit that POST).
63
+ // This does not cancel an accepted owner action or renew its execution budget.
64
+ const timer = setTimeout(() => {
65
+ if (!response.writableEnded && !response.destroyed) {
66
+ const failure = recordFailure(Object.assign(Error('DeliveryTimedOut'), { code: 'DeliveryTimedOut' }));
67
+ send(response, 504, { error: failure });
68
+ }
69
+ controller.abort();
70
+ }, bounds.bodyMs);
71
+ const work = handle(request, response, controller.signal, observation).catch(error => {
72
+ const failure = recordFailure(error);
73
+ // Read-only diagnostic callback: no inherited preload, payload, credentials or
74
+ // stack. A diagnostic consumer cannot alter the execution branch by throwing.
75
+ if (!response.headersSent && !response.destroyed)
76
+ send(response, 400, { error: failure });
77
+ else if (!response.writableEnded && !response.destroyed)
78
+ response.destroy();
79
+ }).finally(() => {
80
+ try {
81
+ Promise.resolve(onOutcome?.({ ...observation, operation: observation.operation ?? operation, httpStatus: response.headersSent ? response.statusCode : null })).catch(() => { });
82
+ }
83
+ catch { }
84
+ clearTimeout(timer);
85
+ response.off('close', abort);
86
+ release();
87
+ controllers.delete(controller);
88
+ inflight.delete(work);
89
+ });
90
+ inflight.add(work);
91
+ });
92
+ server.maxConnections = 32;
93
+ server.maxRequestsPerSocket = 128;
94
+ server.on('upgrade', (request, socket, head) => {
95
+ try {
96
+ if (closing || request.url !== '/api/projection-live' || hub.inspect().connections >= 8)
97
+ fail('SocketUnavailable');
98
+ authorize(request);
99
+ authenticate(request);
100
+ if (request.headers.origin !== origin)
101
+ fail('OriginRequired');
102
+ }
103
+ catch {
104
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
105
+ return;
106
+ }
107
+ sockets.handleUpgrade(request, socket, head, websocket => {
108
+ const peer = { request: { headers: new Headers() }, websocket, close: (code, reason) => websocket.close(code, reason) };
109
+ requests.set(peer, request);
110
+ websocket.on('message', data => hub.message(peer, Array.isArray(data) ? Buffer.concat(data) : data));
111
+ websocket.on('close', () => hub.disconnect(peer));
112
+ websocket.on('error', () => hub.disconnect(peer));
113
+ hub.open(peer);
114
+ });
115
+ });
116
+ return { server, notify: () => hub.notify(), inspect: () => ({ alive: server.listening, activeRequests: inflight.size, transport: hub.inspect() }),
117
+ async listen() { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(boundary.port), '127.0.0.1', resolve); }); },
118
+ close() {
119
+ if (closePromise)
120
+ return closePromise;
121
+ closing = true;
122
+ // Closing delivery revokes intake and releases transport, not physical work.
123
+ // Let the owning site close its resources BEFORE awaiting its pending calls:
124
+ // their completion may depend on that close. Join one lifecycle promise even
125
+ // for concurrent close callers, and preserve an owner cleanup failure.
126
+ closePromise = Promise.resolve().then(async () => {
127
+ for (const c of controllers)
128
+ c.abort();
129
+ hub.close();
130
+ for (const ws of sockets.clients)
131
+ ws.terminate();
132
+ const transportClosed = new Promise(resolve => sockets.close(() => resolve()));
133
+ const httpClosed = new Promise(resolve => server.close(() => resolve()));
134
+ server.closeIdleConnections();
135
+ server.closeAllConnections();
136
+ const ownerClosed = Promise.resolve().then(() => site.close?.());
137
+ const results = await Promise.allSettled([transportClosed, httpClosed, ownerClosed, ...inflight]);
138
+ const failures = results.filter(result => result.status === 'rejected').map(result => result.reason);
139
+ if (failures.length)
140
+ throw new AggregateError(failures, 'Delivery shutdown failed');
141
+ });
142
+ return closePromise;
143
+ } };
225
144
  }
@@ -0,0 +1,24 @@
1
+ import { Budget } from '@crowsi/transport-foundation';
2
+ import { SessionAuthority } from '@crowsi/browser-security/access';
3
+ import type { Asset } from '@zixcel/view-delivery-contracts';
4
+ import type { DeliveryServerOptions } from './types.mjs';
5
+ export declare function prepareDelivery({ origin, assets, policy }: DeliveryServerOptions): {
6
+ boundary: Readonly<import("@crowsi/browser-security/boundary").OwnerOrigin>;
7
+ authority: SessionAuthority;
8
+ budget: Budget;
9
+ assetMap: Map<string, {
10
+ content: Buffer;
11
+ descriptor: Asset;
12
+ }>;
13
+ ownerPolicy: Readonly<{
14
+ cookieName: string;
15
+ csrfHeader: string;
16
+ nonceHeader: string;
17
+ readinessHeader: string;
18
+ owner: string;
19
+ title: string;
20
+ contract: string;
21
+ allowedOwners: readonly any[];
22
+ }>;
23
+ };
24
+ export type Prepared = ReturnType<typeof prepareDelivery>;
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Budget, limitsFor } from '@crowsi/transport-foundation';
3
+ import { SessionAuthority } from '@crowsi/browser-security/access';
4
+ import { parseOwnerOrigin } from '@crowsi/browser-security/boundary';
5
+ import { bounds } from '@zixcel/view-delivery-contracts';
6
+ import { fail } from './wire.mjs';
7
+ const defaultCookieName = 'view_delivery_session';
8
+ export function prepareDelivery({ origin, assets, policy }) {
9
+ if (!policy || typeof policy.owner !== 'string' || !policy.owner || policy.owner.length > 128 ||
10
+ typeof policy.contract !== 'string' || !policy.contract || policy.contract.length > 128 || typeof policy.title !== 'string' || !policy.title || policy.title.length > 128 || /[<>&]/.test(policy.title) ||
11
+ !Array.isArray(policy.allowedOwners) || policy.allowedOwners.length > 16 || policy.allowedOwners.some(owner => typeof owner !== 'string' || !owner || owner.length > 128))
12
+ fail('InvalidDeliveryPolicy');
13
+ const headerPolicy = { cookieName: policy.cookieName ?? defaultCookieName, csrfHeader: policy.csrfHeader ?? 'x-delivery-csrf', nonceHeader: policy.nonceHeader ?? 'x-delivery-request-nonce', readinessHeader: policy.readinessHeader ?? 'x-delivery-readiness' };
14
+ if (Object.values(headerPolicy).some(value => typeof value !== 'string' || value.length > 64 || !/[a-z]/.test(value) || !/^[a-z0-9_-]+$/.test(value)))
15
+ fail('InvalidDeliveryPolicy');
16
+ const ownerPolicy = Object.freeze({ owner: policy.owner, title: policy.title, contract: policy.contract, allowedOwners: Object.freeze([...policy.allowedOwners]), ...headerPolicy });
17
+ const boundary = parseOwnerOrigin(origin), authority = new SessionAuthority();
18
+ const budget = new Budget(limitsFor(bounds.documentBytes, { pendingMessages: bounds.requests })), assetMap = new Map();
19
+ for (const asset of assets) {
20
+ const { bytes, ...descriptor } = asset, content = Buffer.from(bytes);
21
+ if (content.length !== descriptor.bytesLength || content.length > bounds.assetBytes)
22
+ fail('InvalidAsset');
23
+ const digest = createHash('sha256').update(content).digest('hex');
24
+ if (digest !== descriptor.digest || assetMap.has(descriptor.path) || !/^\/assets\/[a-zA-Z0-9._-]{1,100}$/.test(descriptor.path) || !['script', 'style'].includes(descriptor.kind))
25
+ fail('InvalidAsset');
26
+ assetMap.set(descriptor.path, { content, descriptor: { path: descriptor.path, digest, bytes: content.length, kind: descriptor.kind } });
27
+ }
28
+ if (assetMap.size < 1 || assetMap.size > bounds.assets)
29
+ fail('InvalidAsset');
30
+ return { boundary, authority, budget, assetMap, ownerPolicy };
31
+ }
@@ -0,0 +1,101 @@
1
+ import type { StateReadRequest } from '@crowsi/transport-foundation/state-socket';
2
+ import type { Server } from 'node:http';
3
+ import type { Asset, Delivery } from '@zixcel/view-delivery-contracts';
4
+ import type { Json } from '@zixcel/projection-contracts/client';
5
+ export interface RequestContext {
6
+ readonly signal: AbortSignal;
7
+ }
8
+ export interface DocumentContext extends RequestContext {
9
+ readonly assets: readonly Asset[];
10
+ }
11
+ export interface CommandContext extends RequestContext {
12
+ readonly requestRef: string;
13
+ observe(value: unknown): void;
14
+ }
15
+ export interface DeliverySite {
16
+ document?(url: URL, context: DocumentContext): Promise<{
17
+ envelope: Delivery;
18
+ bootstrap: Readonly<Record<string, Json>>;
19
+ }> | {
20
+ envelope: Delivery;
21
+ bootstrap: Readonly<Record<string, Json>>;
22
+ };
23
+ read?(url: URL, context: RequestContext): unknown | Promise<unknown>;
24
+ command?(url: URL, value: unknown, context: CommandContext): unknown | Promise<unknown>;
25
+ live?(request: StateReadRequest): unknown | Promise<unknown>;
26
+ close?(): void | Promise<void>;
27
+ }
28
+ export interface TrustedAsset {
29
+ readonly path: string;
30
+ readonly bytes: Uint8Array;
31
+ readonly bytesLength: number;
32
+ readonly digest: string;
33
+ readonly kind: 'script' | 'style';
34
+ }
35
+ export interface DeliveryServer {
36
+ readonly server: Server;
37
+ notify(): void;
38
+ inspect(): {
39
+ readonly alive: boolean;
40
+ readonly activeRequests: number;
41
+ readonly transport: {
42
+ connections: number;
43
+ sessions: number;
44
+ subscriptions: number;
45
+ pendingBytes: number;
46
+ };
47
+ };
48
+ listen(): Promise<void>;
49
+ close(): Promise<void>;
50
+ }
51
+ export interface DeliveryPolicy {
52
+ readonly owner: string;
53
+ readonly title: string;
54
+ readonly contract: string;
55
+ readonly allowedOwners: readonly string[];
56
+ readonly cookieName?: string;
57
+ readonly csrfHeader?: string;
58
+ readonly nonceHeader?: string;
59
+ readonly readinessHeader?: string;
60
+ }
61
+ export interface DeliveryServerOptions {
62
+ readonly policy: DeliveryPolicy;
63
+ readonly origin: string;
64
+ readonly site: DeliverySite;
65
+ readonly assets: readonly TrustedAsset[];
66
+ readonly readinessToken?: string | null;
67
+ readonly onFailure?: ((failure: DeliveryFailure) => void) | null;
68
+ readonly onOutcome?: ((outcome: DeliveryOutcome) => void) | null;
69
+ }
70
+ export type OutcomeStage = 'DELIVERY_REJECTED' | 'NOT_DISPATCHED' | 'OWNER_REJECTED' | 'OUTCOME_UNCERTAIN' | 'OWNER_COMMITTED' | 'PUBLICATION_FAILED_AFTER_COMMIT';
71
+ export interface Observation {
72
+ requestRef: string;
73
+ owner: string;
74
+ stage: OutcomeStage;
75
+ receiptRef: string | null;
76
+ operation?: string;
77
+ managementRequestRef?: number | null;
78
+ }
79
+ export interface DeliveryOutcome extends Observation {
80
+ operation: string;
81
+ httpStatus: number | null;
82
+ }
83
+ export interface DeliveryFailure {
84
+ code: string;
85
+ canonical: {
86
+ state: string;
87
+ receiptRef: string | null;
88
+ };
89
+ ownerFailure: {
90
+ owner: string;
91
+ operation: string;
92
+ code: string;
93
+ requirementRef: string;
94
+ outcomeStage: OutcomeStage;
95
+ uncertain: boolean;
96
+ requestRef: string;
97
+ managementRequestRef: number | null;
98
+ receiptRef: string | null;
99
+ detail: Json;
100
+ };
101
+ }
package/src/types.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};
package/src/wire.d.mts ADDED
@@ -0,0 +1,18 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Delivery } from '@zixcel/view-delivery-contracts';
3
+ export declare function fail(code: string): never;
4
+ export declare const record: (v: unknown) => Record<string, unknown>;
5
+ export declare function header(request: IncomingMessage, name: string): string;
6
+ export declare const headers: {
7
+ 'content-security-policy': string;
8
+ 'referrer-policy': string;
9
+ 'x-content-type-options': string;
10
+ 'x-frame-options': string;
11
+ 'permissions-policy': string;
12
+ 'cache-control': string;
13
+ };
14
+ export declare const encode: (value: unknown, maximum?: number) => Buffer<ArrayBuffer>;
15
+ export declare function body(request: IncomingMessage, signal: AbortSignal): Promise<unknown>;
16
+ export declare function sessionId(request: IncomingMessage, cookieName: string): string;
17
+ export declare function safeDocument(envelope: Delivery, bootstrap: unknown, title: string): Buffer<ArrayBuffer>;
18
+ export declare function send(response: ServerResponse, status: number, value: unknown): void;
package/src/wire.mjs ADDED
@@ -0,0 +1,100 @@
1
+ import { bounds } from '@zixcel/view-delivery-contracts';
2
+ export function fail(code) { throw Object.assign(Error(code), { code }); }
3
+ export const record = (v) => v !== null && typeof v === 'object' ? v : {};
4
+ export function header(request, name) { const v = request.headers[name]; if (v !== undefined && typeof v !== 'string')
5
+ fail('InvalidInput'); return v ?? ''; }
6
+ export const headers = {
7
+ 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'",
8
+ 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY',
9
+ 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()', 'cache-control': 'no-store'
10
+ };
11
+ export const encode = (value, maximum = bounds.documentBytes) => {
12
+ // Site/owner outputs are already bounded data. Refuse expansion before sending.
13
+ let nodes = 0, total = 0;
14
+ const add = (bytes) => { total += bytes; if (total > maximum)
15
+ fail('DeliveryLimitExceeded'); };
16
+ function check(v, depth) {
17
+ if (++nodes > 32768 || depth > 20)
18
+ fail('DeliveryLimitExceeded');
19
+ if (typeof v === 'string') {
20
+ if (v.length > maximum)
21
+ fail('DeliveryLimitExceeded');
22
+ add(Buffer.byteLength(v) + 2);
23
+ return;
24
+ }
25
+ if (v === null || typeof v === 'boolean') {
26
+ add(5);
27
+ return;
28
+ }
29
+ if (typeof v === 'number' && Number.isFinite(v)) {
30
+ add(24);
31
+ return;
32
+ }
33
+ if (!v || typeof v !== 'object' || Object.getPrototypeOf(v) !== (Array.isArray(v) ? Array.prototype : Object.prototype) || Object.hasOwn(v, 'toJSON'))
34
+ fail('InvalidDeliveryData');
35
+ if (Array.isArray(v) && v.length > 4096)
36
+ fail('DeliveryLimitExceeded');
37
+ const keys = Object.keys(v);
38
+ if (Array.isArray(v) && (keys.length !== v.length || keys.some((key, index) => key !== String(index))))
39
+ fail('InvalidDeliveryData');
40
+ if (keys.length > 4096)
41
+ fail('DeliveryLimitExceeded');
42
+ add(keys.length + 2);
43
+ for (const key of keys) {
44
+ const d = Object.getOwnPropertyDescriptor(v, key);
45
+ if (!d || d.get || d.set || key === 'toJSON')
46
+ fail('InvalidDeliveryData');
47
+ add(Buffer.byteLength(key) + 3);
48
+ check(d.value, depth + 1);
49
+ }
50
+ }
51
+ check(value, 0);
52
+ const bytes = Buffer.from(JSON.stringify(value));
53
+ if (bytes.length > maximum)
54
+ fail('DeliveryLimitExceeded');
55
+ return bytes;
56
+ };
57
+ export async function body(request, signal) {
58
+ const h = request.headers, raw = h['content-length'];
59
+ if (h['content-encoding'] || h['transfer-encoding'] || !/^application\/json(?:\s*;.*)?$/i.test(h['content-type'] ?? '') || !(/^[1-9][0-9]{0,5}$/).test(raw ?? ''))
60
+ fail('InvalidInput');
61
+ const size = Number(raw);
62
+ if (size > bounds.requestBytes)
63
+ fail('DeliveryLimitExceeded');
64
+ const data = Buffer.alloc(size);
65
+ let used = 0;
66
+ for await (const chunk of request) {
67
+ if (!Buffer.isBuffer(chunk))
68
+ fail('InvalidInput');
69
+ if (signal.aborted)
70
+ fail('DeliveryCancelled');
71
+ if (used + chunk.length > size)
72
+ fail('DeliveryLimitExceeded');
73
+ chunk.copy(data, used);
74
+ used += chunk.length;
75
+ }
76
+ if (used !== size)
77
+ fail('InvalidInput');
78
+ try {
79
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
80
+ }
81
+ catch {
82
+ fail('InvalidInput');
83
+ }
84
+ }
85
+ export function sessionId(request, cookieName) {
86
+ const cookie = request.headers.cookie ?? '';
87
+ if (cookie.length > 8192)
88
+ fail('SessionRejected');
89
+ const values = cookie.split(';').map(s => s.trim()).filter(s => s.startsWith(cookieName + '='));
90
+ if (values.length !== 1)
91
+ fail('SessionRejected');
92
+ return values[0].slice(cookieName.length + 1);
93
+ }
94
+ export function safeDocument(envelope, bootstrap, title) {
95
+ const serialized = encode({ envelope, bootstrap }).toString().replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
96
+ const styles = envelope.assets.filter(a => a.kind === 'style').map(a => `<link rel="stylesheet" href="${a.path}">`).join('');
97
+ const scripts = envelope.assets.filter(a => a.kind === 'script').map(a => `<script type="module" src="${a.path}"></script>`).join('');
98
+ return Buffer.from(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>${styles}</head><body><main id="app" aria-busy="true"><p role="status">Loading exact state…</p></main><script id="delivery-state" type="application/json">${serialized}</script>${scripts}</body></html>`);
99
+ }
100
+ export function send(response, status, value) { const bytes = encode(value); response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': bytes.length }); response.end(bytes); }