@@ -42,17 +42,23 @@ function getDb() {
4242 const db = new SQL . Database ( new Uint8Array ( catalogDbBytes ) ) ;
4343 db . run ( 'PRAGMA query_only = 1;' ) ;
4444 return db ;
45- } ) ( ) ;
45+ } ) ( ) . catch ( err => {
46+ // Don't poison the cache: a failed init shouldn't make every future
47+ // request fail with the same stale error. Clear the slot so the next
48+ // request retries from scratch.
49+ dbPromise = null ;
50+ throw err ;
51+ } ) ;
4652 return dbPromise ;
4753}
4854
4955// ── Tool implementation ───────────────────────────────────────
50- const FORBIDDEN_SQL = / \b ( i n s e r t | u p d a t e | d e l e t e | d r o p | a l t e r | c r e a t e | a t t a c h | d e t a c h | r e p l a c e | t r u n c a t e | v a c u u m | r e i n d e x | p r a g m a ) \b / i;
51-
56+ // Read-only is enforced at the engine level via `PRAGMA query_only = 1`
57+ // (set in getDb()). We don't try to filter SQL by regex — keyword-based
58+ // blocking has false positives (e.g. `name LIKE '%insert%'` literals) and
59+ // false negatives (comments, unicode, multi-statement). The engine flag
60+ // rejects mutations definitively; we trust it.
5261function runCatalogQuery ( db , sql ) {
53- if ( FORBIDDEN_SQL . test ( sql ) ) {
54- return { error : 'Only read-only SELECT statements are permitted.' } ;
55- }
5662 try {
5763 const rows = [ ] ;
5864 const stmt = db . prepare ( sql ) ;
@@ -199,9 +205,21 @@ async function runChat(env, userMessages) {
199205}
200206
201207// ── HTTP entry ────────────────────────────────────────────────
202- function corsHeaders ( env ) {
208+ // CORS: echo back the request Origin only if it's on the allowlist;
209+ // otherwise return a non-matching value so the browser blocks the
210+ // response. ALLOWED_ORIGINS is comma-separated in wrangler.toml.
211+ // `*` is still honoured (any origin) for emergency overrides.
212+ function pickAllowedOrigin ( env , req ) {
213+ const list = ( env . ALLOWED_ORIGINS || '' ) . split ( ',' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) ;
214+ if ( list . includes ( '*' ) ) return '*' ;
215+ const origin = req ?. headers ?. get ?. ( 'Origin' ) ;
216+ return origin && list . includes ( origin ) ? origin : 'null' ;
217+ }
218+
219+ function corsHeaders ( env , req ) {
203220 return {
204- 'Access-Control-Allow-Origin' : env . ALLOWED_ORIGIN || '*' ,
221+ 'Access-Control-Allow-Origin' : pickAllowedOrigin ( env , req ) ,
222+ 'Vary' : 'Origin' ,
205223 'Access-Control-Allow-Methods' : 'POST, OPTIONS' ,
206224 'Access-Control-Allow-Headers' : 'Content-Type' ,
207225 'Access-Control-Max-Age' : '86400'
@@ -211,7 +229,17 @@ function corsHeaders(env) {
211229export default {
212230 async fetch ( req , env ) {
213231 if ( req . method === 'OPTIONS' ) {
214- return new Response ( null , { headers : corsHeaders ( env ) } ) ;
232+ return new Response ( null , { headers : corsHeaders ( env , req ) } ) ;
233+ }
234+ // Health endpoint: no auth, no API calls, costs nothing. Used by
235+ // the post-deploy smoke test in CI.
236+ if ( req . method === 'GET' ) {
237+ return new Response ( JSON . stringify ( { ok : true , service : 'bbl-datenkatalog-chat' } ) , {
238+ headers : {
239+ 'Content-Type' : 'application/json; charset=utf-8' ,
240+ ...corsHeaders ( env , req )
241+ }
242+ } ) ;
215243 }
216244 if ( req . method !== 'POST' ) {
217245 return new Response ( 'Method not allowed' , { status : 405 } ) ;
@@ -221,35 +249,40 @@ export default {
221249 try {
222250 body = await req . json ( ) ;
223251 } catch {
224- return jsonError ( 'Invalid JSON body' , 400 , env ) ;
252+ return jsonError ( 'Invalid JSON body' , 400 , env , req ) ;
225253 }
226254 const messages = Array . isArray ( body . messages ) ? body . messages : null ;
227255 if ( ! messages || messages . length === 0 ) {
228- return jsonError ( 'Missing "messages" array' , 400 , env ) ;
256+ return jsonError ( 'Missing "messages" array' , 400 , env , req ) ;
229257 }
230258
231259 try {
232260 const result = await runChat ( env , messages ) ;
233261 return new Response ( JSON . stringify ( result ) , {
234262 headers : {
235263 'Content-Type' : 'application/json; charset=utf-8' ,
236- ...corsHeaders ( env )
264+ ...corsHeaders ( env , req )
237265 }
238266 } ) ;
239267 } catch ( e ) {
268+ // Always log full detail to the Worker console (visible in `wrangler tail`
269+ // / CF dashboard logs). Only echo stack traces back to clients when
270+ // DEBUG=1 — otherwise leak just a generic message.
240271 console . error ( 'chat error:' , e ?. stack || e ) ;
241- const detail = e ?. stack ? `${ e . message } \n${ e . stack } ` : ( e ?. message || String ( e ) ) ;
242- return jsonError ( detail , 500 , env ) ;
272+ const detail = env . DEBUG === '1'
273+ ? ( e ?. stack ? `${ e . message } \n${ e . stack } ` : ( e ?. message || String ( e ) ) )
274+ : 'Internal server error' ;
275+ return jsonError ( detail , 500 , env , req ) ;
243276 }
244277 }
245278} ;
246279
247- function jsonError ( message , status , env ) {
280+ function jsonError ( message , status , env , req ) {
248281 return new Response ( JSON . stringify ( { error : message } ) , {
249282 status,
250283 headers : {
251284 'Content-Type' : 'application/json; charset=utf-8' ,
252- ...corsHeaders ( env )
285+ ...corsHeaders ( env , req )
253286 }
254287 } ) ;
255288}
0 commit comments