Setting the file. One moment.
Raw Request Guard · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page const journalPath
— line 128
This file
Number 45.129
Position 129 of 182
Type JavaScript
Size 18 KB
Lines 279 lib/ raw-request-guard.js
JavaScript · 279 lines · 18 KB
(
'node:path'
);
12 const crypto = require ( 'node:crypto' );
13
14 const HOST = 'www.wixapis.com' ;
15 // Toolkit-only services: a raw request must never unmute notifications, install an app, change
16 // a CMS collection schema or the site's business properties. Setup owns those.
17 const RESERVED_PREFIXES = Object. freeze ([ '/notification-preferences' , '/apps-installer-service' , '/wix-data/v2/collections' , '/site-properties' ]);
18 const domainsDir = path. resolve (__dirname, '../resources/rp-target-wix/domains' );
19 const toolkitDirs = [path. resolve (__dirname, '../resources/rp-target-wix/lib' ), __dirname];
20
21 function fail ( code , detail ) { throw Object. assign ( new Error (detail ? `${ code }:${ detail }` : code), { code, detail: detail || null , requestSent: false , outcomeKnown: true }); }
22 const digest = value => crypto. createHash ( 'sha256' ). update ( JSON . stringify (value === undefined ? null : value)). digest ( 'hex' );
23 // Prefixes are compared lower-case with no trailing slash; the path is checked for that form too.
24 const normal = prefix => prefix. toLowerCase (). replace ( / \/ +$ / , '' );
25
26 // The static part of every "METHOD /path" an entity's knowledge names, cut at the first `{`.
27 function endpointPrefixes ( k ) {
28 const text = [k.preferredWrite?.endpoint, ... (k.preferredWrite?.writerScope?.rawEndpoints || []), ... Object. values (k.operationContracts || {}). map ( c => c?.endpoint)]. filter ( v => typeof v === 'string' ). join ( ' ' );
29 const out = new Set ();
30 for ( const match of text. matchAll ( / \b (?:GET | POST | PUT | PATCH | DELETE) \s + (?:https: \/\/ www \. wixapis \. com) ? ( \/ [ ^ \s,;)] + )/ g )) {
31 const prefix = normal (match[ 1 ]. split ( '{' )[ 0 ]. split ( '?' )[ 0 ]);
32 if (prefix. length > 1 ) out. add (prefix);
33 }
34 return [ ... out];
35 }
36 function allEntityRefs () {
37 const index = JSON . parse (fs. readFileSync (path. join (domainsDir, 'index.json' ), 'utf8' ));
38 return Object. entries (index.domains || {}). flatMap (([ domain , row ]) => Object. keys (row.entities || {}). map ( entity => `${ domain }/${ entity }` )). sort ();
39 }
40 function readKnowledge ( ref ) {
41 try { return require ( './dependency-proofs' ). domain (ref); } catch { return null ; }
42 }
43 // Every www.wixapis.com path the toolkit's own code builds, by the entity its builder marks
44 // ('toolkit' when no mark names one). Read from the source, like the registry contract test, so a
45 // new builder joins automatically. A base constant (`const BASE = '…/stores/v3'`) is expanded
46 // where it is used; only prefixes of three or more segments count, so a bare base never covers a
47 // whole service.
48 function builderUrlPrefixes () {
49 const out = [];
50 for ( const dir of toolkitDirs) for ( const name of fs. readdirSync (dir). filter ( file => file. endsWith ( '.js' ) && file !== path. basename (__filename))) {
51 const source = fs. readFileSync (path. join (dir, name), 'utf8' );
52 const bases = new Map ();
53 for ( const m of source. matchAll ( /const ( [A-Z_] + ) = ( ['"`] )https: \/\/ www \. wixapis \. com( [ ^ '"`$] * ) \2 / g )) bases. set (m[ 1 ], m[ 3 ]);
54 for ( const m of source. matchAll ( /const ( [A-Z_] + ) = ` \$\{ ( [A-Z_] + ) \} ( [ ^ `$] * )`/ g )) if (bases. has (m[ 2 ])) bases. set (m[ 1 ], bases. get (m[ 2 ]) + m[ 3 ]);
55 // Which entity a function's requests are for: its own mark, or the marked builder wrapping it.
56 const entityOf = new Map ();
57 for ( const m of source. matchAll ( /const ( \w + ) = \(\.\.\. args \) => markRequest \( ( \w + ) \(\.\.\. args \) , \{ ( [ ^ }] * ) \}\) / g )) {
58 const entity = m[ 3 ]. match ( / ["'] ? entity ["'] ? : \s * ["'] ( [a-z-] + \/ [a-z-] + ) ["'] / )?.[ 1 ];
59 if (entity && ! entity. startsWith ( 'internal/' )) { entityOf. set (m[ 1 ], entity); entityOf. set (m[ 2 ], entity); }
60 }
61 const defs = [ ... source. matchAll ( / ^ (?:async ) ? (?:const ( \w + ) = | function ( \w + ) \( )/ gm )]. map ( m => ({ index: m.index, name: m[ 1 ] || m[ 2 ] }));
62 const owner = index => { let name = null ; for ( const def of defs) { if (def.index > index) break ; name = def.name; } return entityOf. get (name) || 'toolkit' ; };
63 const add = ( raw , index ) => {
64 const prefix = normal (raw. split ( '${' )[ 0 ]. split ( '?' )[ 0 ]);
65 if (prefix. split ( '/' ). filter (Boolean). length >= 3 ) out. push ({ entityRef: owner (index), prefix });
66 };
67 for ( const m of source. matchAll ( /` \$\{ ( [A-Z_] + ) \} ( \/ [ ^ `] * )`/ g )) if (bases. has (m[ 1 ])) add (bases. get (m[ 1 ]) + m[ 2 ], m.index);
68 for ( const m of source. matchAll ( /( ['"`] )https: \/\/ www \. wixapis \. com( \/ [ ^ '"`] * ) \1 / g )) add (m[ 2 ], m.index);
69 for ( const [, value ] of bases) add (value, 0 );
70 }
71 return out;
72 }
73 let ownedCache = null ;
74 // Every URL prefix that belongs to a reviewed writer: the knowledge endpoints of each entity that
75 // has one, and every path the toolkit's builders send. Rebuilt from code, never hand-kept.
76 function reviewedWriterPrefixes () {
77 if (ownedCache) return ownedCache;
78 const support = require ( './target-import-support' );
79 const rows = [ ... allEntityRefs (). filter ( ref => support. hasReviewedWriter (ref))
80 . flatMap ( ref => endpointPrefixes ( readKnowledge (ref) || {}). map ( prefix => ({ entityRef: ref, prefix }))), ... builderUrlPrefixes ()];
81 const unique = new Map (rows. map ( row => [ `${ row . entityRef } ${ row . prefix }` , Object. freeze (row)]));
82 ownedCache = Object. freeze ([ ... unique. values ()]);
83 return ownedCache;
84 }
85 const under = ( pathname , prefix ) => pathname === prefix || pathname. startsWith ( `${ prefix }/` );
86 // A delete exposed as a POST (`…/bulk/delete`, `…/delete-by-filter`, `…:delete`) is a delete.
87 const DELETE_SEGMENT = /(?: ^| [-_:.] )delete(?: $| [-_:.] )/ i ;
88
89 // Null when the request may be sent for `entityRef`; otherwise the refusal reason. A write is
90 // POST, PUT or PATCH; a read (`read: true`) is GET only.
91 function refusal ({ entityRef , method , url , body } = {}, { read = false } = {}) {
92 const support = require ( './target-import-support' );
93 if ( typeof entityRef !== 'string' || ! readKnowledge (entityRef)) return 'unknown-entity' ;
94 if ( ! support. rawRequestEntity (entityRef)) return support. hasReviewedWriter (entityRef) ? 'reviewed-writer-exists' : 'not-native' ;
95 // A scoped reviewed writer leaves only the records it does not cover to the raw path.
96 const scoped = support. rawScopeRefusal (entityRef, { url, body });
97 if (scoped) return scoped;
98 if ( ! read && support. importUnsafe (entityRef)) return 'import-unsafe' ;
99 const verb = String (method || '' ). toUpperCase ();
100 if (verb === 'DELETE' ) return 'delete' ;
101 if ( ! (read ? [ 'GET' ] : [ 'POST' , 'PUT' , 'PATCH' ]). includes (verb)) return 'method' ;
102 let parsed;
103 try { parsed = new URL (url); } catch { return 'url' ; }
104 if (parsed.protocol !== 'https:' || parsed.host !== HOST || parsed.username || parsed.password || parsed.port) return 'host' ;
105 // Only a plain path is compared: an encoded byte, a matrix parameter or an empty segment could
106 // read differently at the gateway than here.
107 const segments = parsed.pathname. replace ( / \/ +$ / , '' ). split ( '/' ). slice ( 1 );
108 if ( / [%;] / . test (parsed.pathname) || ! segments. length || segments. some ( segment => ! segment)) return 'path' ;
109 const pathname = `/${ segments . join ( '/' ) }` . toLowerCase ();
110 if ( RESERVED_PREFIXES . some ( prefix => under (pathname, prefix))) return 'reserved' ;
111 if (read) return null ;
112 if (segments. some ( segment => DELETE_SEGMENT . test (segment))) return 'delete' ;
113 // The longest matching prefix decides. The entity's own knowledge endpoint wins a tie: it is
114 // that entity's documented write path even when a toolkit helper calls the same service.
115 const own = endpointPrefixes ( readKnowledge (entityRef)). filter ( prefix => under (pathname, prefix)). reduce (( max , p ) => Math. max (max, p. length ), 0 );
116 const other = reviewedWriterPrefixes (). find ( row => row.entityRef !== entityRef && under (pathname, row.prefix) && row.prefix. length > own);
117 return other ? `other-entity:${ other . entityRef }` : null ;
118 }
119 function assertAllowed ( request , options ) {
120 const reason = refusal (request, options);
121 if (reason) fail ( 'RAW_REQUEST_REFUSED' , reason);
122 }
123
124 // --- run journal ----------------------------------------------------------------------------
125 // One row before each raw call (`started`) and one after (`succeeded`, `failed`, `refused`). A
126 // `started` row with no later outcome is an unknown outcome: the record is never retried
127 // automatically. The journal sits beside the write-attempt journal; it is never pruned.
128 const journalPath = projectDir => path. join (projectDir, 'state' , 'attempts' , 'raw-requests.ndjson' );
129 const canaryPath = projectDir => path. join (projectDir, 'state' , 'attempts' , 'raw-request-canaries.ndjson' );
130 // Rows are read incrementally: each call reads only the bytes appended since the last one, so a
131 // long import does not re-read its whole journal per request.
132 const tails = new Map ();
133 function readRows ( file ) {
134 let tail = tails. get (file);
135 let size;
136 try { size = fs. statSync (file).size; } catch (error) { if (error.code === 'ENOENT' ) { tails. delete (file); return []; } throw error; }
137 if ( ! tail || size < tail.offset) tail = { offset: 0 , partial: '' , rows: [] };
138 if (size > tail.offset) {
139 const fd = fs. openSync (file, 'r' ), bytes = Buffer. alloc (size - tail.offset);
140 try { fs. readSync (fd, bytes, 0 , bytes. length , tail.offset); } finally { fs. closeSync (fd); }
141 const lines = (tail.partial + bytes. toString ( 'utf8' )). split ( ' \n ' );
142 tail.partial = lines. pop ();
143 for ( const line of lines) if (line) tail.rows. push ( JSON . parse (line));
144 tail.offset = size;
145 }
146 tails. set (file, tail);
147 return tail.rows;
148 }
149 function appendRow ( file , row ) {
150 fs. mkdirSync (path. dirname (file), { recursive: true });
151 fs. appendFileSync (file, `${ JSON . stringify ({ schemaVersion: 1 , timestamp: new Date (). toISOString () , ... row }) } \n ` );
152 }
153 const keysOf = request => request.sourceStableKeys !== undefined ? [ ... request.sourceStableKeys] : [request.sourceStableKey];
154 function journalStarted ( ctx , entityRef , request ) {
155 const callId = crypto. randomUUID ();
156 appendRow ( journalPath (ctx.projectDir), { kind: 'raw-request' , status: 'started' , callId, runId: ctx.runId, taskId: ctx.taskId, entityRef,
157 sourceStableKeys: keysOf (request), method: String (request.method). toUpperCase (), url: request.url, bodyDigest: digest (request.body) });
158 return callId;
159 }
160 function journalOutcome ( ctx , callId , entityRef , status , extra = {}) {
161 appendRow ( journalPath (ctx.projectDir), { kind: 'raw-request' , status, callId, runId: ctx.runId, taskId: ctx.taskId, entityRef, ... extra });
162 }
163 // The journal folded into calls (in order), resolutions, and the first live call per run and
164 // entity. Folded incrementally, like the rows.
165 const folds = new Map ();
166 function fold ( projectDir ) {
167 const file = journalPath (projectDir), rows = readRows (file);
168 let state = folds. get (file);
169 if ( ! state || state.rows !== rows || state.seen > rows. length ) state = { rows, seen: 0 , byId: new Map (), resolved: new Set (), open: new Map () };
170 for (; state.seen < rows. length ; state.seen ++ ) {
171 const row = rows[state.seen];
172 if (row.status === 'started' ) { const call = { ... row, outcome: null }; state.byId. set (row.callId, call); state.open. set (row.callId, call); }
173 else if (row.status === 'resolved' ) { state.resolved. add (row.callId); state.open. delete (row.callId); }
174 else if (state.byId. has (row.callId)) { state.byId. get (row.callId).outcome = row; state.open. delete (row.callId); }
175 }
176 folds. set (file, state);
177 return state;
178 }
179 const calls = projectDir => [ ... fold (projectDir).byId. values ()];
180 // Source keys with a raw call of unknown outcome, per entity. Cleared only by a recorded
181 // resolution (the operator checked the site), never by a retry.
182 function unknownKeys ( projectDir , entityRef ) {
183 const out = new Set ();
184 for ( const call of fold (projectDir).open. values ()) if (call.entityRef === entityRef) for ( const key of call.sourceStableKeys) out. add (key);
185 return out;
186 }
187 function resolveUnknownOutcome ( projectDir , { callId , resolution , evidence }) {
188 if ( ! [ 'written' , 'not-written' ]. includes (resolution) || typeof evidence !== 'string' || ! evidence. trim ()) fail ( 'RAW_REQUEST_RESOLUTION_INVALID' );
189 const { byId , resolved } = fold (projectDir), call = byId. get (callId);
190 if ( ! call || call.outcome || resolved. has (callId)) fail ( 'RAW_REQUEST_RESOLUTION_INVALID' , 'no-unknown-call' );
191 appendRow ( journalPath (projectDir), { kind: 'raw-request' , status: 'resolved' , callId, entityRef: call.entityRef, resolution, evidence });
192 }
193
194 // --- first record (canary) ------------------------------------------------------------------
195 // Per run and entity: the first source key written is the canary. Until its read-back passes,
196 // only the canary key may be written; a mismatch holds the entity for the rest of the run.
197 function canaryVerdict ( projectDir , runId , entityRef ) {
198 return readRows ( canaryPath (projectDir)). filter ( row => row.runId === runId && row.entityRef === entityRef). at ( - 1 ) || null ;
199 }
200 function canaryKey ( projectDir , runId , entityRef ) {
201 const first = calls (projectDir). find ( call => call.runId === runId && call.entityRef === entityRef && ( ! call.outcome || call.outcome.status === 'succeeded' ));
202 return first ? first.sourceStableKeys[ 0 ] : null ;
203 }
204 function assertWritable ( ctx , entityRef , request ) {
205 const keys = keysOf (request);
206 if (keys. some ( key => typeof key !== 'string' || ! key)) fail ( 'RAW_REQUEST_REFUSED' , 'source-key' );
207 const unknown = unknownKeys (ctx.projectDir, entityRef);
208 const blocked = keys. find ( key => unknown. has (key));
209 if (blocked) fail ( 'RAW_REQUEST_OUTCOME_UNKNOWN' , `${ entityRef }:${ blocked }` );
210 const verdict = canaryVerdict (ctx.projectDir, ctx.runId, entityRef);
211 if (verdict?.verdict === 'mismatch' ) fail ( 'RAW_REQUEST_ENTITY_HELD' , entityRef);
212 if (verdict?.verdict === 'passed' ) return ;
213 const canary = canaryKey (ctx.projectDir, ctx.runId, entityRef);
214 if (keys. length !== 1 || (canary && keys[ 0 ] !== canary)) fail ( 'RAW_REQUEST_CANARY_PENDING' , entityRef);
215 }
216 // Every leaf the canary sent must read back equal at the same path, except `ignorePaths`, each
217 // with a reason (a write-only field, a server-normalized value). Returns the differing paths.
218 // Every leaf path of a body, in the syntax `compare` reports (`a.b[0].c`).
219 function leafPaths ( value , at = '' ) {
220 if ( ! value || typeof value !== 'object' ) return at ? [at] : [];
221 return Object. entries (value). flatMap (([ key , child ]) => leafPaths (child, Array. isArray (value) ? `${ at }[${ key }]` : at ? `${ at }.${ key }` : key));
222 }
223 function compare ( sent , readBack , ignorePaths = []) {
224 const ignored = new Set (ignorePaths. map ( row => row.path));
225 const out = [];
226 const walk = ( value , actual , at ) => {
227 if (ignored. has (at)) return ;
228 if (value && typeof value === 'object' ) {
229 if ( ! actual || typeof actual !== 'object' || Array. isArray (value) !== Array. isArray (actual)) { out. push (at || '$' ); return ; }
230 for ( const [ key , child ] of Object. entries (value)) walk (child, actual[key], Array. isArray (value) ? `${ at }[${ key }]` : at ? `${ at }.${ key }` : key);
231 return ;
232 }
233 if (value !== actual) out. push (at || '$' );
234 };
235 walk (sent, readBack, '' );
236 return out;
237 }
238 function recordCanary ( ctx , entityRef , { sourceStableKey , sentBody , sentPath = null , readBack , ignorePaths = [] }) {
239 if (sentPath !== null && ( typeof sentPath !== 'string' || ! sentPath)) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'sent-path' );
240 if ( ! Array. isArray (ignorePaths) || ignorePaths. some ( row => ! row || typeof row.path !== 'string' || typeof row.reason !== 'string' || ! row.reason. trim ())) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'ignore-paths' );
241 if ( canaryVerdict (ctx.projectDir, ctx.runId, entityRef)) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'already-recorded' );
242 if (sourceStableKey !== canaryKey (ctx.projectDir, ctx.runId, entityRef)) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'not-the-canary' );
243 // The body compared is the one the client sent and journaled, never a body supplied afterwards.
244 const sent = calls (ctx.projectDir). find ( call => call.runId === ctx.runId && call.entityRef === entityRef && call.sourceStableKeys[ 0 ] === sourceStableKey
245 && call.outcome?.status === 'succeeded' && call.bodyDigest === digest (sentBody));
246 if ( ! sent) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'not-sent' );
247 const compared = sentPath ? sentPath. split ( '.' ). reduce (( value , key ) => value?.[key], sentBody) : sentBody;
248 if ( ! compared || typeof compared !== 'object' ) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'sent-path' );
249 // An ignore path names one leaf of the compared body, never the root or a whole branch, and
250 // something must be left to compare: a canary that compares nothing has not checked anything.
251 const leaves = leafPaths (compared);
252 if (ignorePaths. some ( row => ! leaves. includes (row.path))) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'ignore-path-not-a-leaf' );
253 if ( ! leaves. some ( leaf => ! ignorePaths. some ( row => row.path === leaf))) fail ( 'RAW_REQUEST_CANARY_INVALID' , 'nothing-compared' );
254 const mismatches = compare (compared, readBack, ignorePaths);
255 const row = { runId: ctx.runId, taskId: ctx.taskId, entityRef, sourceStableKey, verdict: mismatches. length ? 'mismatch' : 'passed' , mismatches, sentPath, ignorePaths };
256 appendRow ( canaryPath (ctx.projectDir), row);
257 return row;
258 }
259
260 // What cutover and the completion report need, per entity, from the journal alone.
261 function summary ( projectDir ) {
262 const all = calls (projectDir), resolvedRows = readRows ( journalPath (projectDir)). filter ( row => row.status === 'resolved' );
263 const canaries = readRows ( canaryPath (projectDir));
264 const entities = [ ...new Set (all. map ( call => call.entityRef))]. sort ();
265 return entities. map ( entityRef => {
266 const own = all. filter ( call => call.entityRef === entityRef), resolved = new Set (resolvedRows. map ( row => row.callId));
267 const latest = canaries. filter ( row => row.entityRef === entityRef). at ( - 1 ) || null ;
268 return {
269 entityRef, writer: 'raw-request' , calls: own. length ,
270 succeeded: own. filter ( call => call.outcome?.status === 'succeeded' ). length ,
271 failed: own. filter ( call => [ 'failed' , 'refused' ]. includes (call.outcome?.status)). length ,
272 unknownOutcome: own. filter ( call => ! call.outcome && ! resolved. has (call.callId)). map ( call => ({ callId: call.callId, sourceStableKeys: call.sourceStableKeys, method: call.method, url: call.url })),
273 canary: latest ? { runId: latest.runId, verdict: latest.verdict, mismatches: latest.mismatches } : null ,
274 };
275 });
276 }
277
278 module . exports = { refusal, assertAllowed, reviewedWriterPrefixes, builderUrlPrefixes, endpointPrefixes, journalPath, canaryPath, journalStarted, journalOutcome,
279 assertWritable, recordCanary, compare, leafPaths, summary, resolveUnknownOutcome, unknownKeys, RESERVED_PREFIXES, digest };