From 64c187cdbeea6985f814493c6bf0155387b69bef Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:33:25 +1100
Subject: [PATCH 01/12] Keep app launch links within the configured bot
---
public/app.js | 3 ++-
public/index.html | 2 +-
scripts/check-worker.mjs | 58 +++++++++++++++++++++++++++++++++++-----
src/worker.js | 23 +++++++++++++---
4 files changed, 74 insertions(+), 12 deletions(-)
diff --git a/public/app.js b/public/app.js
index 45d34ac..44d6577 100644
--- a/public/app.js
+++ b/public/app.js
@@ -797,7 +797,8 @@ $('another-event').onclick = () => go('events');
if (!initData) {
notice('This launch did not include your Telegram login. Reopen using App in the bot menu or the App button in a message.');
const empty=element('div','Your events are private. Use the authenticated App button to load them.','empty');
- empty.append(action('App',()=>{const url='https://t.me/XEvents_bot?start=app';if(tg?.openTelegramLink)tg.openTelegramLink(url);else window.open(url,'_blank','noopener');}));
+ const botLink=$('telegram-bot-link')?.getAttribute('href') || '';
+ if(/^https:\/\/t\.me\/[A-Za-z0-9_]{5,32}$/.test(botLink))empty.append(action('App',()=>{const url=botLink+'?start=app';if(tg?.openTelegramLink)tg.openTelegramLink(url);else window.open(url,'_blank','noopener');}));
$('event-list').replaceChildren(empty);
$('home-view').hidden=true;$('events-view').hidden=false;
$('save-event').disabled = true; $('save-zone').disabled = true; $('refresh').disabled = true;
diff --git a/public/index.html b/public/index.html
index d8b4f4c..c8f75b2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -83,7 +83,7 @@
YOUR PROFILE
Your profile.
Your name, optional photo and phone number, and local timezone.
-
Everything stays together.
Respond to invitations in the bot chat. Guest lists and media options depend on the organiser’s settings. Confirmed guests can find their invitation ticket here and in chat.
Respond to invitations in the bot chat. Guest lists and media options depend on the organiser’s settings. Confirmed guests can find their invitation ticket here and in chat.
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index 1c1e073..222be76 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -4,16 +4,22 @@ import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
const telegramCalls=[];
+const serveAssets = async request => {
+ const pathname=new URL(request.url).pathname;
+ const file=pathname==='/' ? 'index.html' : pathname.slice(1);
+ if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','style.css'].includes(file))return new Response('Not found',{status:404});
+ const headers={'Content-Type':file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'};
+ if(file==='index.html') {
+ headers.ETag='"fixture-index"';headers['Last-Modified']='Thu, 01 Oct 2026 00:00:00 GMT';
+ if(request.headers.get('If-None-Match')===headers.ETag || request.headers.get('If-Modified-Since')===headers['Last-Modified'])return new Response(null,{status:304,headers});
+ }
+ return new Response(await readFile('public/'+file),{headers});
+};
const mf = new Miniflare(convertV4MiniflareOptions({
workers: [{ name: 'test',
modules: true, scriptPath: '.wrangler/build/worker.js', compatibilityDate: '2026-10-05', compatibilityFlags: ['nodejs_compat'],
d1Databases: ['DB'], bindings: { SUPER_ADMIN_ID: '999001', BOT_USERNAME: 'XEvents_bot', APP_URL: 'https://test/app', TELEGRAM_BOT_TOKEN: 'fake', TELEGRAM_WEBHOOK_SECRET: 'test-secret' },
- serviceBindings: { ASSETS: async request => {
- const pathname=new URL(request.url).pathname;
- const file=pathname==='/' ? 'index.html' : pathname.slice(1);
- if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','style.css'].includes(file))return new Response('Not found',{status:404});
- return new Response(await readFile('public/'+file),{headers:{'Content-Type':file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'}});
- } },
+ serviceBindings: { ASSETS: serveAssets },
outboundService: async request => {
if(request.method==='POST' && request.headers.get('Content-Type')?.includes('application/json'))telegramCalls.push({method:new URL(request.url).pathname.split('/').at(-1),params:await request.clone().json()});
if (request.url.includes('/file/bot')) return new Response(new Uint8Array([255,216,255]), { headers: { 'Content-Type':'image/jpeg' } });
@@ -30,7 +36,45 @@ try {
await db.exec(await readFile('migrations/0003_mini_app.sql', 'utf8'));
assert.equal((await mf.dispatchFetch('https://test/')).status, 200);
const appPage=await mf.dispatchFetch('https://test/app');
- assert.equal(appPage.status,200);assert.match(await appPage.text(),/id="event-end-dialog"/);
+ const appHtml=await appPage.text();
+ assert.equal(appPage.status,200);assert.match(appHtml,/id="event-end-dialog"/);
+ assert.match(appHtml,/id="telegram-bot-link"[^>]*href="https:\/\/t\.me\/XEvents_bot"/);
+ // A separate Worker must use its own bot even before Telegram authentication.
+ const development = new Miniflare(convertV4MiniflareOptions({ workers: [{
+ name:'development-links', modules:true, scriptPath:'.wrangler/build/worker.js', compatibilityDate:'2026-10-05', compatibilityFlags:['nodejs_compat'],
+ bindings:{BOT_USERNAME:'fictionalDevelopmentBot',APP_URL:'https://development.test/app'},
+ serviceBindings:{ASSETS:serveAssets},
+ outboundService:async()=>{throw new Error('Development link checks must not make outbound requests.');}
+ }] }));
+ try {
+ const validators={'If-None-Match':'"fixture-index"','If-Modified-Since':'Thu, 01 Oct 2026 00:00:00 GMT'};
+ assert.equal((await serveAssets(new Request('https://assets.test/',{headers:validators}))).status,304);
+ let developmentBotLink;
+ for(const path of ['/app','/app/']) {
+ const page=await development.dispatchFetch('https://development.test'+path,{headers:validators});
+ assert.equal(page.status,200);
+ assert.equal(page.headers.get('ETag'),null);assert.equal(page.headers.get('Last-Modified'),null);
+ for(const name of ['Content-Type','Cache-Control','X-Content-Type-Options','Referrer-Policy','Content-Security-Policy'])assert.equal(page.headers.get(name),appPage.headers.get(name));
+ const html=await page.text();
+ developmentBotLink=html.match(/id="telegram-bot-link"[^>]*href="([^"]+)"/)?.[1];
+ assert.equal(developmentBotLink,'https://t.me/fictionalDevelopmentBot');
+ assert.doesNotMatch(html,/https:\/\/t\.me\/XEvents_bot/);
+ }
+ const module=await development.dispatchFetch('https://development.test/app.js');
+ assert.equal(module.status,200);
+ const source=await module.text();
+ assert.doesNotMatch(source,/https:\/\/t\.me\/XEvents_bot/);
+ const fallback=source.match(/^if \(!initData\) \{\r?\n([\s\S]*?)\r?\n\} else \{/m)?.[1];
+ assert.ok(fallback,'The unauthenticated launcher must remain available.');
+ const launched=[],buttons=[],nodes=new Map();
+ const node=id=>{if(!nodes.has(id))nodes.set(id,{replaceChildren(){},getAttribute:()=>developmentBotLink});return nodes.get(id);};
+ const runFallback=new Function('$','notice','element','action','tg','window','options','deviceZone',fallback);
+ const launch=()=>runFallback(node,()=>{},()=>({append:button=>buttons.push(button)}),(label,onclick)=>({label,onclick}),{openTelegramLink:url=>launched.push(url)},{},()=>{},'UTC');
+ launch();assert.equal(buttons.length,1);assert.equal(buttons[0].label,'App');buttons[0].onclick();
+ assert.deepEqual(launched,['https://t.me/fictionalDevelopmentBot?start=app']);
+ developmentBotLink='https://t.me/';buttons.length=0;
+ launch();assert.equal(buttons.length,0,'The unconfigured static page must not send guests to another bot.');
+ } finally { await development.dispose(); }
const appModule=await mf.dispatchFetch('https://test/app.js');
assert.equal(appModule.status,200);
const imports=[...(await appModule.text()).matchAll(/import\s+.*?from\s+['"]\.\/([^'"]+)['"]/g)].map(match=>match[1]);
diff --git a/src/worker.js b/src/worker.js
index d45045e..753f7f0 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -82,14 +82,31 @@ export default {
}
if (request.method === 'GET' && (url.pathname === '/app' || url.pathname === '/app/' || ['/app.js', '/errors.js', '/gallery.js', '/event-actions.js', '/style.css'].includes(url.pathname))) {
const target = new URL(request.url);
- if (url.pathname === '/app' || url.pathname === '/app/') target.pathname = '/';
- const asset = await env.ASSETS.fetch(new Request(target, request));
+ const appPage = url.pathname === '/app' || url.pathname === '/app/';
+ if (appPage) target.pathname = '/';
+ const assetRequest = new Request(target, request);
+ if (appPage) {
+ assetRequest.headers.delete('If-None-Match');
+ assetRequest.headers.delete('If-Modified-Since');
+ }
+ const asset = await env.ASSETS.fetch(assetRequest);
const headers = new Headers(asset.headers);
+ if (appPage) {
+ headers.delete('ETag');
+ headers.delete('Last-Modified');
+ }
headers.set('Cache-Control', 'no-cache');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'no-referrer');
headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; base-uri 'self'; object-src 'none'");
- return new Response(asset.body, { status: asset.status, headers });
+ const response = new Response(asset.body, { status: asset.status, headers });
+ if (appPage && asset.ok && typeof env.BOT_USERNAME === 'string' && /^[A-Za-z0-9_]{5,32}$/.test(env.BOT_USERNAME)) {
+ const botUrl = `https://t.me/${env.BOT_USERNAME}`;
+ return new HTMLRewriter().on('#telegram-bot-link', {
+ element(element) { element.setAttribute('href', botUrl); }
+ }).transform(response);
+ }
+ return response;
}
if (request.method === 'GET' && url.pathname === '/') return Response.json({ service: 'XEvents', status: 'running' });
if (url.pathname === '/setup' && request.method === 'POST') {
From ce2a6ecf474dba973ca1459ce97c9e466265c385 Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:43:38 +1100
Subject: [PATCH 02/12] Restrict development bot and App access to configured
testers
---
.dev.vars.example | 6 ++
docs/development.md | 25 +++++-
scripts/check-worker.mjs | 148 ++++++++++++++++++++++++++++++++++
scripts/deployment-config.mjs | 7 +-
src/mini-api.js | 2 +
src/test-access.js | 82 +++++++++++++++++++
src/worker-store.js | 9 ++-
src/worker.js | 31 ++++++-
test/deployment.test.js | 20 ++++-
test/telegram-menu.test.js | 22 +++++
test/test-access.test.js | 131 ++++++++++++++++++++++++++++++
11 files changed, 474 insertions(+), 9 deletions(-)
create mode 100644 src/test-access.js
create mode 100644 test/test-access.test.js
diff --git a/.dev.vars.example b/.dev.vars.example
index 161837c..4aa5754 100644
--- a/.dev.vars.example
+++ b/.dev.vars.example
@@ -5,3 +5,9 @@ TELEGRAM_WEBHOOK_SECRET=replace_with_a_random_development_secret
# Your numeric Telegram user ID; blank disables the admin panel.
SUPER_ADMIN_ID=
+
+# Development access restriction. An empty whitelist permits everyone.
+# Production always ignores this feature. IDs remain private Worker secrets.
+APP_ENV=development
+TEST_WHITELIST_ENABLED=false
+WHITELIST_USER_IDS=
diff --git a/docs/development.md b/docs/development.md
index 9eedd0b..14d9a71 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -57,7 +57,8 @@ Keep the real development configuration in an ignored local file, such as `wrang
"preview_urls": false,
"vars": {
"BOT_USERNAME": "YOUR_DEVELOPMENT_BOT_USERNAME",
- "APP_URL": "https://YOUR_DEVELOPMENT_WORKER.YOUR_SUBDOMAIN.workers.dev/app"
+ "APP_URL": "https://YOUR_DEVELOPMENT_WORKER.YOUR_SUBDOMAIN.workers.dev/app",
+ "TEST_WHITELIST_ENABLED": "false"
},
"assets": {
"directory": "./public",
@@ -88,6 +89,7 @@ Store these secrets on the **development Cloudflare Worker**, using private inte
- `TELEGRAM_BOT_TOKEN`: the development bot's token.
- `TELEGRAM_WEBHOOK_SECRET`: a new random secret for the development webhook.
- `SUPER_ADMIN_ID`: your administrator Telegram identity, if needed.
+- `WHITELIST_USER_IDS`: optional initial allowed Telegram IDs for development, separated by commas or whitespace. Leave it blank to permit everyone.
These bot secrets do not belong in GitHub source or `WRANGLER_CONFIG_JSON`, and the deployment workflow does not need copies of them in GitHub. Set `vars.BOT_USERNAME` to the development bot and `vars.APP_URL` to the development Mini App URL. The [self-hosting guide](self-hosting.md#3-set-worker-secrets) explains interactive secret setup and webhook registration.
@@ -95,6 +97,27 @@ Do not connect production's bot to the development Worker. Telegram uses a singl
Stars payments use Telegram's real Bot API endpoints in the current application. A separate regular bot does not create a payment sandbox. Keep payment/refund verification mocked unless deliberately performing a controlled real transaction; never reuse production charge IDs or payment records in development.
+## Restrict development access
+
+Set `vars.TEST_WHITELIST_ENABLED` to `"true"` in the private development configuration to enable the test whitelist. The deployment helper sets `APP_ENV` to `development` and defaults this flag to `"false"` when omitted. Production always ignores the whitelist, and the deployment helper writes `"false"` for production even if its input enables the flag. For local testing, set `APP_ENV=development` and `TEST_WHITELIST_ENABLED=true` in the ignored `.dev.vars` file.
+
+An empty whitelist permits everyone. Once it contains IDs, the development bot and authenticated Mini App APIs allow only those users and the superadmin configured by the `SUPER_ADMIN_ID` Worker secret. The superadmin always retains access so they can manage the list.
+
+Send these commands to the development bot as the configured superadmin:
+
+| Command | Effect |
+| --- | --- |
+| `/whitelist` | Show the current access setting and allowed IDs. |
+| `/whitelist add ID...` | Add one or more numeric Telegram user IDs. |
+| `/whitelist remove ID...` | Remove one or more IDs. |
+| `/whitelist clear` | Empty the list and permit everyone. |
+
+Keep real IDs in private Worker secrets or bot commands. `WHITELIST_USER_IDS` supplies the initial list and is never serialized into generated deployment configuration. The first command that changes the list saves an override in that environment's D1 database; subsequent requests and commands use that override instead of the secret seed. `/whitelist clear` saves an empty override, so it keeps access open even if the seed secret still contains IDs. Development and production use separate D1 databases, so these settings do not cross environments.
+
+Removing the final tester also opens access to everyone; every change confirms whether access is restricted or open. Invalid configuration restricts access until the superadmin resets it. Payment and refund confirmations already issued by Telegram are still recorded after a tester is removed. Existing queued notifications are preserved.
+
+This restriction applies to the Cloudflare Worker, including its webhook and Mini App APIs. Use `wrangler dev` for local access-control testing; the standalone `npm start` polling process does not enforce this hosted feature. The whitelist command is advertised only in the configured superadmin's private chat.
+
## Sync production to dev
Run **Sync production code to development** manually from GitHub Actions on `main`. It merges production code into `dev` without resetting the branch or force-pushing. It preserves development work and stops if there is a merge conflict. Resolve that conflict in a normal checkout and push the reviewed result. After the merge, it explicitly starts development checks because a push using GitHub's workflow token does not start another push workflow.
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index 222be76..1280253 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -15,6 +15,153 @@ const serveAssets = async request => {
}
return new Response(await readFile('public/'+file),{headers});
};
+
+async function checkTestAccess() {
+ // Each fixture has its own D1 and mock transport; no live bot or user data is used.
+ const admin=710001,seedUser=710101,blockedUser=710102,otherUser=710103;
+ const authData=uid=>{
+ const params=new URLSearchParams({auth_date:String(Math.floor(Date.now()/1000)),user:JSON.stringify({id:uid,first_name:'Access tester'}),query_id:'access-query'});
+ const data=[...params.entries()].sort(([a],[b])=>a.localeCompare(b)).map(([key,value])=>`${key}=${value}`).join('\n');
+ const key=createHmac('sha256','WebAppData').update('access-test-token').digest();
+ params.set('hash',createHmac('sha256',key).update(data).digest('hex'));return params.toString();
+ };
+ async function fixture(bindings={}) {
+ const calls=[];
+ const worker=new Miniflare(convertV4MiniflareOptions({workers:[{
+ name:'test-access',modules:true,scriptPath:'.wrangler/build/worker.js',compatibilityDate:'2026-10-05',compatibilityFlags:['nodejs_compat'],d1Databases:['DB'],
+ bindings:{APP_ENV:'development',TEST_WHITELIST_ENABLED:'true',SUPER_ADMIN_ID:String(admin),BOT_USERNAME:'fictionalAccessBot',APP_URL:'https://access.test/app',TELEGRAM_BOT_TOKEN:'access-test-token',TELEGRAM_WEBHOOK_SECRET:'access-test-secret',...bindings},
+ serviceBindings:{ASSETS:serveAssets},
+ outboundService:async request=>{
+ const method=new URL(request.url).pathname.split('/').at(-1);
+ const params=request.headers.get('Content-Type')?.includes('application/json')?await request.clone().json():null;
+ calls.push({method,params});
+ if(request.url.includes('/file/bot'))return new Response(new Uint8Array([255,216,255]),{headers:{'Content-Type':'image/jpeg'}});
+ return Response.json({ok:true,result:method==='getFile'?{file_path:'photos/access.jpg'}:{}});
+ }
+ }]}));
+ try {
+ const db=await worker.getD1Database('DB');
+ await db.exec((await readFile('migrations/0001_initial.sql','utf8')).replace(/\n/g,' '));
+ for(const path of ['migrations/0002_delivery_lease.sql','migrations/0003_mini_app.sql'])await db.exec(await readFile(path,'utf8'));
+ const api=(path,uid=blockedUser,method='GET',input)=>worker.dispatchFetch('https://access.test/api/'+path,{method,headers:{Authorization:'tma '+authData(uid),'Content-Type':'application/json'},...(input===undefined?{}:{body:JSON.stringify(input)})});
+ const update=async body=>{
+ for(let attempt=0;attempt<12;attempt++){
+ const response=await worker.dispatchFetch('https://access.test/telegram',{method:'POST',headers:{'X-Telegram-Bot-Api-Secret-Token':'access-test-secret'},body:JSON.stringify(body)});
+ const text=await response.text();
+ if(response.status===503 && attempt<11){await new Promise(resolve=>setTimeout(resolve,Math.min(25*(attempt+1),200)));continue;}
+ assert.equal(response.status,200,text);return;
+ }
+ };
+ const message=(update_id,text,uid=admin,extra={})=>update({update_id,message:{from:{id:uid,first_name:'Access tester'},chat:{id:uid,type:'private'},text,...extra}});
+ const setting=async()=>{const row=await db.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first();return row?JSON.parse(row.value):null;};
+ const saveSetting=value=>db.prepare("INSERT INTO app_settings(key,value) VALUES ('test-whitelist',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(value).run();
+ const records=async()=>(await db.prepare('SELECT kind,id,data FROM records ORDER BY kind,id').all()).results;
+ return {worker,db,calls,api,update,message,setting,saveSetting,records};
+ }catch(error){await worker.dispose();throw error;}
+ }
+ const access=await fixture({WHITELIST_USER_IDS:String(seedUser)});
+ try {
+ const {worker,db,calls,api,update,message,setting,saveSetting,records}=access;
+ assert.equal((await worker.dispatchFetch('https://access.test/api/bootstrap')).status,401);
+ assert.equal((await api('bootstrap',seedUser)).status,200);
+ assert.equal((await api('bootstrap',admin)).status,200,'The configured admin must remain able to manage testers.');
+ // Hold delivery so command retries can be checked against the exact durable outbox.
+ await db.prepare("INSERT INTO delivery_lease(id,owner,expires) VALUES (1,'access-fixture',unixepoch()+3600)").run();
+ const eventId='7100000000000001',inviteToken='71000000000000000000000000000001',cohostToken='71000000000000000000000000000002',mediaId='710000000001';
+ const event={id:eventId,owner:admin,title:'Private access fixture',when:'Future test event',startsAt:'2099-12-05T18:00:00Z',timezone:'UTC',location:'Fictional venue',description:'',invitationMode:'named',oneTimeInvite:true,guests:{[blockedUser]:{name:'Existing tester',status:'yes',invitationToken:inviteToken}},invitees:{[inviteToken]:{name:'Named tester',participants:1,participantMode:'preset'}},permissions:{guestList:true,uploadMedia:true,viewMedia:true},banner:'access-banner',media:[{id:mediaId,fileId:'access-image',type:'photo',name:'fictional.jpg',user:blockedUser}],cohostLinks:[{id:'7100000000000002',token:cohostToken,label:'Test co-host',status:'pending',cohost:null,createdAt:'2026-01-01T00:00:00Z'}]};
+ await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(eventId,JSON.stringify(event)).run();
+ await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(blockedUser),JSON.stringify({profilePhoto:'access-profile',timezone:'UTC'})).run();
+ const beforeDenied=await records(),beforeCalls=calls.length;
+ for(const [path,method,input] of [
+ ['bootstrap','GET'],['explore?timezone=UTC','GET'],['profile/photo','GET'],['profile/photo','DELETE'],['profile/photo','POST',{}],['branding/icon','GET'],
+ [`events/${eventId}`,'GET'],[`events/${eventId}/banner`,'GET'],[`events/${eventId}/banner`,'POST',{}],
+ [`events/${eventId}/gallery`,'GET'],[`events/${eventId}/upload-qr`,'GET'],[`events/${eventId}/media/${mediaId}`,'GET'],[`events/${eventId}/media/${mediaId}/thumbnail`,'GET'],[`events/${eventId}/media/${mediaId}/send`,'POST',{}],
+ ['preferences','POST',{timezone:'Europe/London'}],['events','POST',{}],['admin/overview','GET']
+ ]){
+ const response=await api(path,blockedUser,method,input);
+ assert.equal(response.status,403,path+' must enforce test access before its route handler.');
+ assert.equal(response.headers.get('Cache-Control'),'no-store');
+ const body=await response.json();assert.match(body.error,/test|access/i);
+ assert.doesNotMatch(JSON.stringify(body),/710001|710101|Private access fixture|Fictional venue|access-profile/);
+ }
+ assert.equal(calls.length,beforeCalls,'Denied profile, banner and media requests must not call Telegram.');
+ assert.deepEqual(await records(),beforeDenied,'Denied API requests must not change profiles, events or sessions.');
+ for(const [offset,text] of ['/new',`/start e_${eventId}`,`/start i_${eventId}_${inviteToken}`,`/start c_${eventId}_${cohostToken}`].entries())await message(710010+offset,text,blockedUser);
+ await update({update_id:710020,callback_query:{id:'blocked-callback',from:{id:blockedUser,first_name:'Access tester'},data:`r:${eventId}:no:0`}});
+ assert.deepEqual(await records(),beforeDenied,'Old callbacks and deep links must not bypass test access or claim invitations.');
+ const callbackDenial=await db.prepare("SELECT params FROM outbox WHERE method='answerCallbackQuery' AND json_extract(params,'$.callback_query_id')='blocked-callback'").first();
+ assert.ok(callbackDenial);assert.match(JSON.parse(callbackDenial.params).text,/test|access/i);
+ await update({update_id:710021,pre_checkout_query:{id:'blocked-checkout',from:{id:blockedUser},invoice_payload:'fictional-order',currency:'XTR',total_amount:10}});
+ const deniedCheckout=calls.find(call=>call.method==='answerPreCheckoutQuery' && call.params?.pre_checkout_query_id==='blocked-checkout');
+ assert.equal(deniedCheckout?.params.ok,false);assert.deepEqual(await records(),beforeDenied);
+ await message(710030,'/whitelist add '+blockedUser,seedUser);
+ assert.equal(await setting(),null,'A permitted tester must not gain administrator commands.');
+ await message(710031,'/whitelist add '+blockedUser,admin,{chat:{id:-710001,type:'group'}});
+ assert.equal(await setting(),null,'Whitelist identities must never be managed or listed in group chats.');
+ await message(710032,'/whitelist list');
+ assert.equal(await setting(),null,'Listing seeded access must not create an override.');
+ await message(710033,`/whitelist add ${blockedUser} ${otherUser}`);
+ assert.deepEqual((await setting()).sort(),[seedUser,blockedUser,otherUser].map(String).sort());
+ assert.equal((await api('bootstrap',blockedUser)).status,200);
+ const commandRows=async()=>(await db.prepare("SELECT id,method,params FROM outbox WHERE id LIKE '710033:%' ORDER BY id").all()).results;
+ const beforeReplay=await commandRows();assert.ok(beforeReplay.length);
+ await message(710033,`/whitelist add ${blockedUser} ${otherUser}`);
+ assert.deepEqual(await commandRows(),beforeReplay,'Telegram redelivery must not duplicate the command response.');
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM processed WHERE id=710033').first()).n,1);
+ await message(710034,`/whitelist remove ${seedUser} ${blockedUser}`);
+ assert.deepEqual(await setting(),[String(otherUser)]);
+ assert.equal((await api('bootstrap',seedUser)).status,403);assert.equal((await api('bootstrap',blockedUser)).status,403);
+ await message(710035,'/whitelist clear');assert.deepEqual(await setting(),[]);
+ assert.equal((await api('bootstrap',blockedUser)).status,200,'An explicit empty override must open access even with a nonempty seed.');
+ await message(710036,'/whitelist add '+blockedUser);assert.deepEqual(await setting(),[String(blockedUser)]);
+ await message(710037,'/whitelist add invalid 0 9007199254740992');assert.deepEqual(await setting(),[String(blockedUser)],'Invalid input must never silently open or partially alter access.');
+ await Promise.all([message(710038,'/whitelist add '+seedUser),message(710039,'/whitelist add '+otherUser)]);
+ assert.deepEqual((await setting()).sort(),[seedUser,blockedUser,otherUser].map(String).sort(),'Concurrent commands must preserve both changes.');
+ await message(710040,`/whitelist remove ${seedUser} ${blockedUser} ${otherUser}`);assert.deepEqual(await setting(),[]);
+ assert.equal((await api('bootstrap',otherUser)).status,200,'Removing the last tester must restore the documented open mode.');
+ for(const invalid of ['not-json','{}','["710101","invalid"]','["0"]','["9007199254740992"]']){
+ await saveSetting(invalid);assert.equal((await api('bootstrap',seedUser)).status,403,'Malformed stored access settings must fail closed.');
+ assert.equal((await api('bootstrap',admin)).status,200);
+ }
+ await message(710041,'/whitelist clear');assert.deepEqual(await setting(),[],'The admin must be able to recover invalid stored access.');
+ // Incoming successful/refunded payment service messages remain auditable after access is revoked.
+ const orderId='71000000000000000000000000000003',charge='fictional-access-charge';
+ const paidEvent={...event,id:'7100000000000003',invitationMode:'tickets',starPrice:10,starPricing:'group',paymentTerms:'Fictional fixture refund terms.',guests:{[blockedUser]:{name:'Paying tester',status:'yes',participants:1,payment:{order:orderId,status:'processing'}}}};
+ const order={id:orderId,event:paidEvent.id,owner:admin,title:paidEvent.title,amount:10,unitPrice:10,pricing:'group',terms:paidEvent.paymentTerms,participants:1,status:'pending',createdAt:'2026-01-01T00:00:00Z',checkoutId:'earlier-checkout'};
+ await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(paidEvent.id,JSON.stringify(paidEvent)).run();
+ await db.prepare("UPDATE records SET data=? WHERE kind='preferences' AND id=?").bind(JSON.stringify({starOrders:{[orderId]:order}}),String(blockedUser)).run();
+ await saveSetting(JSON.stringify([String(seedUser)]));
+ const payment={currency:'XTR',total_amount:10,invoice_payload:orderId,telegram_payment_charge_id:charge};
+ await message(710050,undefined,blockedUser,{successful_payment:payment});
+ const storedOrder=async()=>JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(blockedUser)).first()).data).starOrders[orderId];
+ assert.equal((await storedOrder()).status,'paid');assert.equal((await storedOrder()).charge,charge);
+ await message(710050,undefined,blockedUser,{successful_payment:payment});assert.equal((await storedOrder()).status,'paid');
+ await message(710051,undefined,blockedUser,{refunded_payment:payment});assert.equal((await storedOrder()).status,'refunded');
+ assert.equal((await api('bootstrap',blockedUser)).status,403,'Payment bookkeeping must not grant normal app access.');
+ }finally{await access.worker.dispose();}
+ for(const scenario of [
+ {name:'empty seed',bindings:{WHITELIST_USER_IDS:''},status:200},
+ {name:'malformed seed',bindings:{WHITELIST_USER_IDS:'710101,invalid'},status:403},
+ {name:'production ignores dev flag',bindings:{APP_ENV:'production',WHITELIST_USER_IDS:String(seedUser)},status:200},
+ {name:'disabled dev gate',bindings:{TEST_WHITELIST_ENABLED:'false',WHITELIST_USER_IDS:String(seedUser)},status:200}
+ ]){
+ const test=await fixture(scenario.bindings);
+ try {
+ assert.equal((await test.api('bootstrap',blockedUser)).status,scenario.status,scenario.name);
+ assert.equal((await test.api('bootstrap',admin)).status,200,scenario.name+' admin access');
+ if(scenario.status===200){
+ await test.message(710060,'/new',blockedUser);
+ assert.ok(await test.db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind(String(blockedUser)).first(),scenario.name+' bot access');
+ }
+ if(scenario.bindings.APP_ENV==='production' || scenario.bindings.TEST_WHITELIST_ENABLED==='false'){
+ await test.message(710061,'/whitelist add '+seedUser);
+ assert.equal(await test.setting(),null,scenario.name+' must not enable whitelist administration');
+ }
+ }finally{await test.worker.dispose();}
+ }
+ console.log('Development access integration passed: environment isolation, empty and seeded lists, private admin commands, concurrent edits, Telegram retries, route privacy, callback and link claims, checkout denial and payment/refund audit preservation. Telegram mocked.');
+}
+
const mf = new Miniflare(convertV4MiniflareOptions({
workers: [{ name: 'test',
modules: true, scriptPath: '.wrangler/build/worker.js', compatibilityDate: '2026-10-05', compatibilityFlags: ['nodejs_compat'],
@@ -884,3 +1031,4 @@ try {
await db.prepare('DELETE FROM delivery_lease WHERE owner=?').bind(invitationDeliveryOwner).run();
console.log('Worker integration passed: fixed personal attendee counts and live count button captions, invitation response summaries, add/revoke with notify or silent removal, exact durable notifications, native button event creation, profiles, private photos, ticket bookings, personal RSVP links, multiple tagged co-host links and isolated revocation, legacy co-host migration, approvals, payments, durable refunds and media. Telegram mocked.');
} finally { await mf.dispose(); }
+await checkTestAccess();
diff --git a/scripts/deployment-config.mjs b/scripts/deployment-config.mjs
index 908c17b..0a03c28 100644
--- a/scripts/deployment-config.mjs
+++ b/scripts/deployment-config.mjs
@@ -41,7 +41,12 @@ export async function buildConfig(privateConfig, environment) {
return {
...base,
name: privateConfig.name,
- vars: { BOT_USERNAME: privateConfig.vars.BOT_USERNAME, APP_URL: privateConfig.vars.APP_URL, APP_ENV: environment },
+ vars: {
+ BOT_USERNAME: privateConfig.vars.BOT_USERNAME,
+ APP_URL: privateConfig.vars.APP_URL,
+ APP_ENV: environment,
+ TEST_WHITELIST_ENABLED: environment === 'development' && String(privateConfig.vars.TEST_WHITELIST_ENABLED).trim() === 'true' ? 'true' : 'false'
+ },
d1_databases: [{ binding: 'DB', database_name: privateConfig.d1_databases[0].database_name, database_id: privateConfig.d1_databases[0].database_id, migrations_dir: 'migrations' }],
triggers: { crons: privateConfig.triggers?.crons ?? (environment === 'development' ? [] : base.triggers.crons) }
};
diff --git a/src/mini-api.js b/src/mini-api.js
index fee25ca..7c3674b 100644
--- a/src/mini-api.js
+++ b/src/mini-api.js
@@ -6,6 +6,7 @@ import {invitationMode,invitationSettings,oneTimeInvites,invitationAvailable,rec
import {managedInvitations,invitationsVersion,addInvitations,removeInvitation} from './invitation-management.js';
import {isManager,cohostEntries,cohostLink,cohostVersion,createCohostInvite,revokeCohost} from './cohosts.js';
import { authenticate } from './mini-auth.js';
+import { mayUseTestApp, testAccessMessage } from './test-access.js';
import { parsePricing, currencyCodes, localCurrency } from './pricing.js';
import { mediaApi } from './media-api.js';
import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
@@ -118,6 +119,7 @@ export async function miniApi(request, env) {
const respond = (data, status = 200) => Response.json(data, { status, headers });
const user = authenticate(request.headers.get('Authorization')?.replace(/^tma /, ''), env.TELEGRAM_BOT_TOKEN);
if (!user) return respond({ error: 'Open the planner inside Telegram. If it was open for a while, close and reopen it.' }, 401);
+ if (!await mayUseTestApp(env, user)) return respond({ error: testAccessMessage }, 403);
const path = new URL(request.url).pathname;
const profileResponse=await profilePhotoApi(request,env,user);if(profileResponse)return profileResponse;
const eventMatch=path.match(/^\/api\/events\/([a-f0-9]{16})$/);
diff --git a/src/test-access.js b/src/test-access.js
new file mode 100644
index 0000000..47d2caa
--- /dev/null
+++ b/src/test-access.js
@@ -0,0 +1,82 @@
+import { isSuperAdmin } from './admin.js';
+import { mutateState } from './worker-store.js';
+
+export const testAccessMessage = 'This test app is limited to approved testers.';
+export const whitelistEnabled = env => env.APP_ENV === 'development' && String(env.TEST_WHITELIST_ENABLED).trim() === 'true';
+
+function validId(value) {
+ const text = String(value);
+ return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(Number(text));
+}
+
+function parseIds(value) {
+ if (typeof value !== 'string') return null;
+ if (!value.trim()) return [];
+ const ids = value.split(/[,\s]+/).filter(Boolean);
+ return ids.length && ids.every(validId) ? [...new Set(ids)] : null;
+}
+
+export async function accessPolicy(env) {
+ if (!whitelistEnabled(env)) return { enabled: false, ids: [], invalid: false };
+ const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first();
+ let ids;
+ if (stored) {
+ try {
+ const value = JSON.parse(stored.value);
+ ids = Array.isArray(value) && value.every(id => typeof id === 'string' && validId(id)) ? [...new Set(value)] : null;
+ } catch { ids = null; }
+ } else ids = parseIds(env.WHITELIST_USER_IDS ?? '');
+ return { enabled: true, ids: ids ?? [], invalid: ids === null };
+}
+
+export async function mayUseTestApp(env, user) {
+ if (!whitelistEnabled(env) || isSuperAdmin(user, env)) return true;
+ if (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot) return false;
+ const policy = await accessPolicy(env);
+ return !policy.invalid && (!policy.ids.length || policy.ids.includes(String(user.id)));
+}
+
+function statusText(ids) {
+ return ids.length ? `Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'}. Super admin always has access.` : 'Whitelist empty. Open to everyone.';
+}
+
+export async function handleWhitelistCommand(env, update) {
+ const message = update.message;
+ if (message?.chat?.type !== 'private' || typeof message.text !== 'string') return null;
+ const command = message.text.trim().match(/^\/whitelist(?:@([A-Za-z0-9_]+))?(?:\s+([\s\S]*))?$/i);
+ if (!command || (command[1] && command[1].toLowerCase() !== String(env.BOT_USERNAME).toLowerCase())) return null;
+ await mutateState(env, async (_data, bot, settings) => {
+ const reply = text => bot.send(message.chat.id, text);
+ if (!isSuperAdmin(message.from, env)) return reply('Only the super admin can manage test access.');
+ if (!whitelistEnabled(env)) return reply('Test whitelist is disabled in this environment.');
+ const input = (command[2] || 'list').trim();
+ const [operation, ...rest] = input.split(/\s+/);
+ const action = operation.toLowerCase();
+ if (!['list', 'add', 'remove', 'clear'].includes(action) || (['list', 'clear'].includes(action) && rest.length)) {
+ return reply('Use /whitelist, /whitelist add ID, /whitelist remove ID, or /whitelist clear.');
+ }
+ const policy = await accessPolicy(env);
+ if (action !== 'clear' && policy.invalid) return reply('Whitelist configuration is invalid. Access is restricted. Use /whitelist clear to reset it.');
+ if (action === 'list') {
+ await reply(statusText(policy.ids));
+ let page = '';
+ for (const id of policy.ids) {
+ if (page.length + id.length + 1 > 3500) { await reply(page); page = ''; }
+ page += `${page ? '\n' : ''}${id}`;
+ }
+ if (page) await reply(page);
+ return;
+ }
+ let ids = [];
+ if (action !== 'clear') {
+ const requested = parseIds(rest.join(' '));
+ if (!requested?.length) return reply('Enter numeric Telegram user IDs, separated by spaces or commas.');
+ const current = new Set(policy.ids);
+ for (const id of requested) action === 'add' ? current.add(id) : current.delete(id);
+ ids = [...current];
+ }
+ settings.setAppSetting('test-whitelist', JSON.stringify(ids));
+ await reply(statusText(ids));
+ }, update.update_id);
+ return new Response('OK');
+}
diff --git a/src/worker-store.js b/src/worker-store.js
index 9c166fb..d20259a 100644
--- a/src/worker-store.js
+++ b/src/worker-store.js
@@ -16,8 +16,13 @@ export async function mutateState(env, action, updateId) {
const messages = [];
const bot = new Bot({ data }, async (method, params) => { messages.push({ method, params }); return {}; }, env.BOT_USERNAME, env.APP_URL);
bot.pricing=await readOnlinePricing(env,data.preferences._pricing);
- const value = await action(data, bot);
- const batch = [env.DB.prepare('INSERT INTO commits(owner) VALUES (?)').bind(owner)];
+ const settings = [];
+ const value = await action(data, bot, {
+ setAppSetting(key, value) {
+ settings.push(env.DB.prepare('INSERT INTO app_settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(key, value));
+ }
+ });
+ const batch = [env.DB.prepare('INSERT INTO commits(owner) VALUES (?)').bind(owner), ...settings];
for (const kind of ['events', 'sessions', 'preferences']) {
for (const [id, record] of Object.entries(data[kind])) {
const serialized = JSON.stringify(record);
diff --git a/src/worker.js b/src/worker.js
index 753f7f0..4a9ef8a 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -1,10 +1,11 @@
import { mutateState, BusyError } from './worker-store.js';
import { miniApi } from './mini-api.js';
-import { rememberUser } from './admin.js';
+import { isSuperAdmin, rememberUser } from './admin.js';
import { sendDueReminders } from './reminders.js';
import { checkout, refundResult } from './payments.js';
import {refreshOnlineRates} from './exchange.js';
import { botCommands, botCommandsVersion } from './telegram-menu.js';
+import { handleWhitelistCommand, mayUseTestApp, testAccessMessage, whitelistEnabled } from './test-access.js';
export async function authorized(request, secret) {
if (!secret) return false;
@@ -26,12 +27,25 @@ async function telegram(env, method, params) {
export async function processUpdate(env, update) {
try {
+ const user = update.pre_checkout_query?.from || update.callback_query?.from || (update.message?.chat?.type === 'private' ? update.message.from : null);
+ // Payments already charged must still be recorded or refunded after access changes.
+ if (user && !update.message?.successful_payment && !update.message?.refunded_payment && !await mayUseTestApp(env, user)) {
+ if (update.pre_checkout_query) {
+ const result = await telegram(env, 'answerPreCheckoutQuery', { pre_checkout_query_id: update.pre_checkout_query.id, ok: false, error_message: testAccessMessage });
+ return new Response(result.ok ? 'OK' : 'Retry', { status: result.ok ? 200 : 503 });
+ }
+ await mutateState(env, (_data, bot) => update.callback_query
+ ? bot.api('answerCallbackQuery', { callback_query_id: update.callback_query.id, text: testAccessMessage, show_alert: true })
+ : bot.send(update.message.chat.id, testAccessMessage), update.update_id);
+ return new Response('OK');
+ }
+ const whitelistResponse = await handleWhitelistCommand(env, update);
+ if (whitelistResponse) return whitelistResponse;
if (update.pre_checkout_query) {
const decision = await mutateState(env, (data, bot) => checkout(bot, update.pre_checkout_query));
const result = await telegram(env, 'answerPreCheckoutQuery', decision);
return new Response(result.ok ? 'OK' : 'Retry', {status: result.ok ? 200 : 503});
}
- const user = update.callback_query?.from || (update.message?.chat?.type === 'private' ? update.message.from : null);
if (user) await rememberUser(env, user);
await mutateState(env, (data, bot) => bot.handle(update), update.update_id);
return new Response('OK');
@@ -157,6 +171,19 @@ export async function configureMiniApp(env) {
const result = await telegram(env, 'setMyCommands', { commands: botCommands });
if (result.ok) await env.DB.prepare("INSERT INTO app_settings(key,value) VALUES ('bot-commands',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(botCommandsVersion).run();
}
+ const adminId = Number(env.SUPER_ADMIN_ID);
+ if (isSuperAdmin({ id: adminId }, env)) {
+ const adminCommands = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-admin-commands'").first();
+ const enabled = whitelistEnabled(env);
+ const version = `${botCommandsVersion}:${adminId}:${enabled}`;
+ if ((enabled || adminCommands) && adminCommands?.value !== version) {
+ const result = await telegram(env, 'setMyCommands', {
+ scope: { type: 'chat', chat_id: adminId },
+ commands: enabled ? [...botCommands, { command: 'whitelist', description: 'Manage test access' }] : botCommands
+ });
+ if (result.ok) await env.DB.prepare("INSERT INTO app_settings(key,value) VALUES ('test-admin-commands',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(version).run();
+ }
+ }
const setting = await env.DB.prepare("SELECT value FROM app_settings WHERE key='mini-menu'").first();
const menuVersion=`App:${env.APP_URL}`;
if (setting?.value === menuVersion) return;
diff --git a/test/deployment.test.js b/test/deployment.test.js
index 2baff44..6eddffd 100644
--- a/test/deployment.test.js
+++ b/test/deployment.test.js
@@ -35,14 +35,28 @@ test('invalid configuration reports no private input or parser excerpts', () =>
});
test('generated config takes application and assets from source and never includes credentials', async () => {
- const dev = development(); dev.main = 'untrusted.js'; dev.assets = { directory: '../private' }; dev.vars.TELEGRAM_BOT_TOKEN = 'fictional-secret'; dev.triggers = { crons: [] }; dev.env = { production: production() };
+ const dev = development(); dev.main = 'untrusted.js'; dev.assets = { directory: '../private' }; dev.vars.TELEGRAM_BOT_TOKEN = 'fictional-secret'; dev.vars.WHITELIST_USER_IDS = '900000001,900000002'; dev.triggers = { crons: [] }; dev.env = { production: production() };
const config = await buildConfig(dev, 'development');
assert.equal(config.main, 'src/worker.js'); assert.equal(config.assets.directory, './public');
assert.equal(config.assets.binding, 'ASSETS'); assert.equal(config.d1_databases[0].binding, 'DB');
assert.equal(config.d1_databases[0].migrations_dir, 'migrations');
assert.equal(config.vars.APP_ENV, 'development'); assert.deepEqual(config.triggers.crons, []);
- assert.equal(config.vars.TELEGRAM_BOT_TOKEN, undefined); assert.equal(config.env, undefined);
- assert.doesNotMatch(JSON.stringify(config), /fictional-secret|untrusted|private/);
+ assert.equal(config.vars.TELEGRAM_BOT_TOKEN, undefined); assert.equal(config.vars.WHITELIST_USER_IDS, undefined); assert.equal(config.env, undefined);
+ assert.doesNotMatch(JSON.stringify(config), /fictional-secret|900000001|900000002|untrusted|private/);
+});
+
+test('test whitelist enablement is explicit and limited to development', async () => {
+ assert.equal((await buildConfig(development(), 'development')).vars.TEST_WHITELIST_ENABLED, 'false');
+ for (const value of ['true', ' true ', true]) {
+ const dev = development(); dev.vars.TEST_WHITELIST_ENABLED = value;
+ assert.equal((await buildConfig(dev, 'development')).vars.TEST_WHITELIST_ENABLED, 'true');
+ const prod = production(); prod.vars.TEST_WHITELIST_ENABLED = value;
+ assert.equal((await buildConfig(prod, 'production')).vars.TEST_WHITELIST_ENABLED, 'false');
+ }
+ for (const value of ['false', 'TRUE', '1', '', false, null]) {
+ const dev = development(); dev.vars.TEST_WHITELIST_ENABLED = value;
+ assert.equal((await buildConfig(dev, 'development')).vars.TEST_WHITELIST_ENABLED, 'false');
+ }
});
test('development cron stays disabled until explicitly configured', async () => {
diff --git a/test/telegram-menu.test.js b/test/telegram-menu.test.js
index a885db3..f260f17 100644
--- a/test/telegram-menu.test.js
+++ b/test/telegram-menu.test.js
@@ -59,3 +59,25 @@ test('new bot configuration keeps the authenticated built-in App button alongsid
assert.equal(f.settings.get('stars-webhook'), f.env.APP_URL);
assert.equal(f.settings.get('bot-commands'), botCommandsVersion);
});
+
+test('the test whitelist command appears only in the configured admin private chat', async t => {
+ const f = fixture(t, { commandVersion: botCommandsVersion });
+ Object.assign(f.env, { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true', SUPER_ADMIN_ID: '900000001' });
+ await configureMiniApp(f.env);
+ assert.equal(f.calls.length, 1);
+ assert.deepEqual(f.calls[0].scope, { type: 'chat', chat_id: 900000001 });
+ assert.deepEqual(f.calls[0].commands.map(item => item.command), ['help', 'cancel', 'paysupport', 'whitelist']);
+ await configureMiniApp(f.env);
+ assert.equal(f.calls.length, 1);
+ f.env.TEST_WHITELIST_ENABLED = 'false';
+ await configureMiniApp(f.env);
+ assert.deepEqual(f.calls.at(-1).commands, botCommands);
+ assert.deepEqual(f.calls.at(-1).scope, { type: 'chat', chat_id: 900000001 });
+});
+
+test('production never registers the test command even when its flag is enabled', async t => {
+ const f = fixture(t, { commandVersion: botCommandsVersion });
+ Object.assign(f.env, { APP_ENV: 'production', TEST_WHITELIST_ENABLED: 'true', SUPER_ADMIN_ID: '900000001' });
+ await configureMiniApp(f.env);
+ assert.deepEqual(f.calls, []);
+});
diff --git a/test/test-access.test.js b/test/test-access.test.js
new file mode 100644
index 0000000..8608c7b
--- /dev/null
+++ b/test/test-access.test.js
@@ -0,0 +1,131 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { accessPolicy, mayUseTestApp, whitelistEnabled } from '../src/test-access.js';
+
+const development = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true' };
+
+function fixture({ stored = null, seed = '', ...variables } = {}) {
+ let reads = 0;
+ const env = {
+ ...development,
+ WHITELIST_USER_IDS: seed,
+ ...variables,
+ DB: {
+ prepare(sql) {
+ assert.equal(sql, "SELECT value FROM app_settings WHERE key='test-whitelist'");
+ return { async first() { reads++; return stored; } };
+ }
+ }
+ };
+ return { env, reads: () => reads };
+}
+
+test('test access requires both the development environment and its explicit switch', () => {
+ assert.equal(whitelistEnabled(development), true);
+ assert.equal(whitelistEnabled({ ...development, TEST_WHITELIST_ENABLED: ' true ' }), true);
+ for (const env of [
+ {},
+ { APP_ENV: 'development' },
+ { ...development, TEST_WHITELIST_ENABLED: 'false' },
+ { ...development, TEST_WHITELIST_ENABLED: '' },
+ { ...development, APP_ENV: 'production' },
+ { TEST_WHITELIST_ENABLED: 'true' }
+ ]) assert.equal(whitelistEnabled(env), false);
+});
+
+test('production ignores the test list without reading its database', async () => {
+ const env = {
+ ...development,
+ APP_ENV: 'production',
+ WHITELIST_USER_IDS: 'malformed',
+ DB: { prepare() { assert.fail('Production must not read the test whitelist.'); } }
+ };
+ assert.deepEqual(await accessPolicy(env), { enabled: false, ids: [], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
+});
+
+test('disabling the test access switch preserves open development access', async () => {
+ const env = { ...development, TEST_WHITELIST_ENABLED: 'false', WHITELIST_USER_IDS: '111002' };
+ assert.deepEqual(await accessPolicy(env), { enabled: false, ids: [], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
+});
+
+test('an absent or blank seed opens access to verified human users', async () => {
+ for (const seed of ['', ' \n\t ', undefined, null]) {
+ const { env } = fixture({ seed });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
+ }
+});
+
+test('seed IDs accept commas and whitespace and remove duplicates', async () => {
+ const { env } = fixture({ seed: '111001, 111002\n111001\t111003' });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: ['111001', '111002', '111003'], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111002 }), true);
+ assert.equal(await mayUseTestApp(env, { id: 111004 }), false);
+});
+
+test('an explicitly cleared stored list overrides a populated or invalid seed', async () => {
+ for (const seed of ['111002', 'malformed']) {
+ const { env } = fixture({ stored: { value: '[]' }, seed });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
+ }
+});
+
+test('stored tester IDs override the seed and remove duplicates', async () => {
+ const { env } = fixture({ stored: { value: '["111002","111002","111003"]' }, seed: '111001' });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: ['111002', '111003'], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ assert.equal(await mayUseTestApp(env, { id: 111003 }), true);
+});
+
+test('malformed nonempty seeds fail closed', async () => {
+ for (const seed of [',,', '111001;111002', '111001,invalid', '0', '-1', '00111001', '1.1', '1e5', '9007199254740992', 111001, ['111001']]) {
+ const { env } = fixture({ seed });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ }
+});
+
+test('malformed stored values fail closed rather than falling back to an open seed', async () => {
+ for (const value of ['not-json', '', 'null', '{}', '"111001"', '[111001]', '["111001",null]', '["0"]', '["00111001"]', '["9007199254740992"]', null]) {
+ const { env } = fixture({ stored: { value }, seed: '' });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ }
+});
+
+test('the configured super admin bypasses invalid policy and database failure', async () => {
+ const env = {
+ ...development,
+ SUPER_ADMIN_ID: '999001\n',
+ WHITELIST_USER_IDS: 'malformed',
+ DB: { prepare() { assert.fail('Super admin access must not depend on the whitelist database.'); } }
+ };
+ assert.equal(await mayUseTestApp(env, { id: 999001 }), true);
+});
+
+test('client flags or malformed administrator configuration cannot bypass the list', async () => {
+ for (const SUPER_ADMIN_ID of ['', 'invalid', '0999001', '9007199254740992']) {
+ const { env } = fixture({ seed: '111002', SUPER_ADMIN_ID });
+ assert.equal(await mayUseTestApp(env, { id: 999001, isSuperAdmin: true, username: 'admin' }), false);
+ }
+});
+
+test('development access rejects unverified identity shapes before reading the policy', async () => {
+ const { env, reads } = fixture();
+ for (const user of [null, {}, { id: '111001' }, { id: 0 }, { id: -1 }, { id: 1.5 }, { id: Number.MAX_SAFE_INTEGER + 1 }, { id: 111001, is_bot: true }]) {
+ assert.equal(await mayUseTestApp(env, user), false);
+ }
+ assert.equal(reads(), 0);
+});
+
+test('database failure cannot silently open development access', async () => {
+ const env = {
+ ...development,
+ DB: { prepare() { return { async first() { throw new Error('Database unavailable'); } }; } }
+ };
+ await assert.rejects(accessPolicy(env), /Database unavailable/);
+ await assert.rejects(mayUseTestApp(env, { id: 111001 }), /Database unavailable/);
+});
From e080706efe244b67af583e724a68b32b9772f50b Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:51:04 +1100
Subject: [PATCH 03/12] Require release tests on main and keep development
deployments fast
---
.github/workflows/check-and-deploy.yml | 14 ++--
.github/workflows/sync-main-to-dev.yml | 6 +-
AGENTS.md | 16 +++--
docs/development.md | 10 +--
scripts/deploy-environment.mjs | 2 +-
test/mini-auth.test.js | 89 ++++++++++++++++++++++++++
6 files changed, 119 insertions(+), 18 deletions(-)
create mode 100644 test/mini-auth.test.js
diff --git a/.github/workflows/check-and-deploy.yml b/.github/workflows/check-and-deploy.yml
index 5cd6000..b01a56c 100644
--- a/.github/workflows/check-and-deploy.yml
+++ b/.github/workflows/check-and-deploy.yml
@@ -13,6 +13,8 @@ permissions:
jobs:
checks:
name: Regression and Worker checks
+ # Validate releases, including dev -> main PRs. Dev stays fast for manual testing.
+ if: github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -33,12 +35,14 @@ jobs:
run: npm run test:worker
deploy:
- name: Deploy checked source
+ name: Deploy selected environment
needs: checks
if: >-
+ !cancelled() &&
github.event_name != 'pull_request' &&
- (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') &&
- vars.XEVENTS_DEPLOY_ENABLED == 'true'
+ vars.XEVENTS_DEPLOY_ENABLED == 'true' &&
+ ((github.ref == 'refs/heads/main' && needs.checks.result == 'success') ||
+ (github.ref == 'refs/heads/dev' && needs.checks.result == 'skipped'))
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
@@ -48,7 +52,7 @@ jobs:
group: ${{ github.ref_name == 'main' && 'xevents-production' || 'xevents-development' }}
cancel-in-progress: false
steps:
- - name: Check out the tested commit
+ - name: Check out the selected commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
@@ -57,7 +61,7 @@ jobs:
run: |
REMOTE_COMMIT="$(git ls-remote origin "refs/heads/$GITHUB_REF_NAME" | cut -f1)"
if [ "$REMOTE_COMMIT" != "$GITHUB_SHA" ]; then
- echo 'Deployment superseded by a newer branch commit. Run checks for the latest commit.' >&2
+ echo 'Deployment superseded by a newer branch commit. Deploy the latest commit instead.' >&2
exit 1
fi
- name: Set up Node.js
diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml
index 0ecffe6..1d8377b 100644
--- a/.github/workflows/sync-main-to-dev.yml
+++ b/.github/workflows/sync-main-to-dev.yml
@@ -91,8 +91,8 @@ jobs:
PRODUCTION_CONFIG_JSON: ${{ secrets.PRODUCTION_CONFIG_JSON }}
run: node scripts/refresh-development.mjs
- check-development:
- name: Start development checks and deployment
+ deploy-development:
+ name: Start development deployment
needs: [sync-code, refresh-schema]
if: >-
always() && needs.sync-code.result == 'success' &&
@@ -105,7 +105,7 @@ jobs:
contents: read
steps:
# A push made with GITHUB_TOKEN does not trigger another push workflow.
- - name: Explicitly dispatch development checks
+ - name: Explicitly dispatch development deployment
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run check-and-deploy.yml --ref dev --repo "$GITHUB_REPOSITORY"
diff --git a/AGENTS.md b/AGENTS.md
index 6ba8aff..62f7571 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,18 +45,24 @@ existing invitations, and users' private event data when making changes.
- Test with fictional events, mocked Telegram calls, and local database fixtures.
Never send real notifications, make real payments, or use production guest data
as a test fixture.
-- Run regression tests appropriate to the change and the project's required
- checks. Invitation, visibility, or schema changes need coverage for existing
+- Maintain regression tests appropriate to the change and the project's required
+ production checks. Invitation, visibility, or schema changes need coverage for existing
events, legacy modes, privacy, and stored response preservation.
-- Run Worker integration and static-import checks when changing Worker modules,
- bindings, API behavior, or asset imports. Include every new imported asset in
+- Keep development deployments fast: automated test jobs run for PRs targeting
+ `main` and for `main` pushes/manual runs, not for `dev`. Local tests during
+ development are optional when useful; require the full regression and Worker
+ checks before production promotion and deployment. Keep development builds,
+ environment isolation validation and deployment health checks enabled.
+- Run Worker integration and static-import checks before production promotion
+ when changing Worker modules, bindings, API behavior, or asset imports.
+ Include every new imported asset in
deployment and verify that it can load.
- Check important Mini App flows on mobile when changing forms or navigation.
Keep visible errors, keyboard access, and understandable button states working.
## Publishing and deployment
-- Work on `dev` and verify changes there before promoting them to `main`.
+- Work on `dev` and verify the affected flows there before promoting to `main`.
`main` deploys production; `dev` deploys the separate development Worker and D1.
Keep all bot credentials, app URLs and database bindings isolated by environment.
- The production-to-dev workflow merges code without force-pushing. Optional D1
diff --git a/docs/development.md b/docs/development.md
index 14d9a71..437174d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -12,14 +12,16 @@ The development bot runs the same bot, Mini App and API code as production. Tele
## Day-to-day workflow
1. Switch to `dev` and make a focused change. Short-lived feature branches can merge into `dev` when useful.
-2. Run `npm test` and `npm run test:worker`. Use fictional events and mocked payments in automated tests.
-3. Push `dev`. When deployment is enabled and its credentials are configured, GitHub checks and deploys the development environment.
+2. Push `dev`. When deployment is enabled and its credentials are configured, GitHub builds and deploys development without running the automated test suite. Dependency installation, configuration isolation, migrations and the service health check still apply.
+3. Run local tests when useful while developing; they are not required before every development push. Use fictional events and mocked payments in automated tests.
4. Test the relevant Telegram and Mini App flows through the development bot.
-5. Open a pull request from `dev` to `main`. Merge after the checks and development testing pass. The `main` deployment uses production configuration and applies compatible production migrations.
+5. Open a pull request from `dev` to `main`. The required **Regression and Worker checks** job runs `npm test` and `npm run test:worker` on every PR targeting `main`, and again on `main` pushes or manual runs. Merge after those checks and development testing pass. Production deployment requires successful checks for that exact commit, uses production configuration and applies compatible production migrations.
6. After a production-only fix, run **Sync production code to development** to bring `main` changes back into `dev`.
Keep changes small so that `main` remains releasable. Do not treat development database contents as something to promote: promotion moves code and migrations, not test events or users.
+Automated test jobs are skipped for `dev` pushes, PRs targeting `dev`, and manual workflow runs on `dev`. Production checks include authentication, access and permission boundaries, invitation compatibility, payment/refund handling and local Worker/D1 integration. A failed, skipped or cancelled production check blocks production deployment; PRs never deploy either environment.
+
## Enable GitHub deployment
The canonical deployment repository is [ehsan0921/XEvents](https://github.com/ehsan0921/XEvents). Keep mirrored repositories in checks-only mode so they cannot race to deploy the same Worker or run its migrations twice.
@@ -120,7 +122,7 @@ This restriction applies to the Cloudflare Worker, including its webhook and Min
## Sync production to dev
-Run **Sync production code to development** manually from GitHub Actions on `main`. It merges production code into `dev` without resetting the branch or force-pushing. It preserves development work and stops if there is a merge conflict. Resolve that conflict in a normal checkout and push the reviewed result. After the merge, it explicitly starts development checks because a push using GitHub's workflow token does not start another push workflow.
+Run **Sync production code to development** manually from GitHub Actions on `main`. It merges production code into `dev` without resetting the branch or force-pushing. It preserves development work and stops if there is a merge conflict. Resolve that conflict in a normal checkout and push the reviewed result. After the merge, it explicitly starts the development deployment workflow because a push using GitHub's workflow token does not start another push workflow. That development run skips automated tests.
The `copy_database_schema` input defaults to `false`. With that default, the workflow syncs code only and leaves the development database unchanged.
diff --git a/scripts/deploy-environment.mjs b/scripts/deploy-environment.mjs
index b169e91..2d0e2b8 100644
--- a/scripts/deploy-environment.mjs
+++ b/scripts/deploy-environment.mjs
@@ -14,7 +14,7 @@ try {
await wrangler(['deploy', '--config', generated.path, '--keep-vars']);
const health = await fetch(new URL('/', config.vars.APP_URL), { signal: AbortSignal.timeout(15000) });
if (!health.ok || (await health.json()).status !== 'running') throw new Error('Deployment completed, but the service health check failed.');
- console.log('Checks passed, D1 migrations applied and ' + environment + ' deployed.');
+ console.log('D1 migrations applied and ' + environment + ' deployed.');
} finally { await generated.cleanup(); }
} catch (error) {
console.error(error instanceof TypeError ? 'Deployment failed. Verify the private environment configuration and network connection.' : error.message);
diff --git a/test/mini-auth.test.js b/test/mini-auth.test.js
new file mode 100644
index 0000000..ea87298
--- /dev/null
+++ b/test/mini-auth.test.js
@@ -0,0 +1,89 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createHmac } from 'node:crypto';
+import { authenticate } from '../src/mini-auth.js';
+
+const productionToken = 'fictional-production-bot-token';
+const developmentToken = 'fictional-development-bot-token';
+const now = 2_000_000_000;
+const guest = { id: 111001, first_name: 'Test guest' };
+
+function signed({ token = productionToken, date = now, user = guest, entries = [] } = {}) {
+ const params = new URLSearchParams([
+ ['query_id', 'fictional-query'],
+ ['auth_date', String(date)],
+ ['user', typeof user === 'string' ? user : JSON.stringify(user)],
+ ...entries
+ ]);
+ const data = [...params.entries()]
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
+ .map(([key, value]) => `${key}=${value}`)
+ .join('\n');
+ const key = createHmac('sha256', 'WebAppData').update(token).digest();
+ params.set('hash', createHmac('sha256', key).update(data).digest('hex'));
+ return params.toString();
+}
+
+test('production accepts its signed session and rejects a development bot session', () => {
+ assert.deepEqual(authenticate(signed(), productionToken, now), guest);
+ assert.equal(authenticate(signed({ token: developmentToken }), productionToken, now), null);
+ assert.equal(authenticate(signed(), developmentToken, now), null);
+});
+
+test('authentication accepts the freshness and clock-skew limits, then rejects expired or future sessions', () => {
+ assert.deepEqual(authenticate(signed({ date: now - 3600 }), productionToken, now), guest);
+ assert.equal(authenticate(signed({ date: now - 3601 }), productionToken, now), null);
+ assert.deepEqual(authenticate(signed({ date: now + 30 }), productionToken, now), guest);
+ assert.equal(authenticate(signed({ date: now + 31 }), productionToken, now), null);
+ for (const date of ['not-a-date', now + 0.5, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.equal(authenticate(signed({ date }), productionToken, now), null);
+ }
+});
+
+test('signed user names and launch parameters survive Unicode, URL encoding and query ordering', () => {
+ const user = { id: 111002, first_name: 'Zoë & علی + 🎉', last_name: 'Guest\nName' };
+ const data = signed({ user, entries: [['start_param', 'event_alpha+beta'], ['chat_instance', 'fixture-chat']] });
+ const reversed = new URLSearchParams([...new URLSearchParams(data).entries()].reverse()).toString();
+ assert.deepEqual(authenticate(reversed, productionToken, now), user);
+
+ const changed = new URLSearchParams(reversed);
+ changed.set('start_param', 'event_other');
+ assert.equal(authenticate(changed.toString(), productionToken, now), null);
+});
+
+test('duplicate query parameters are rejected even when their complete contents were signed', () => {
+ for (const entries of [
+ [['auth_date', String(now)]],
+ [['user', JSON.stringify({ id: 111003 })]],
+ [['query_id', 'second-query']],
+ [['start_param', 'first'], ['start_param', 'second']]
+ ]) assert.equal(authenticate(signed({ entries }), productionToken, now), null);
+
+ const params = new URLSearchParams(signed());
+ params.append('hash', params.get('hash'));
+ assert.equal(authenticate(params.toString(), productionToken, now), null);
+});
+
+test('a valid signature cannot turn a bot or malformed user identity into a human session', () => {
+ for (const user of [
+ null, {}, [], 'invalid-json',
+ { id: '111001' }, { id: 0 }, { id: -1 }, { id: 1.5 },
+ { id: Number.MAX_SAFE_INTEGER + 1 },
+ { ...guest, is_bot: true }
+ ]) assert.equal(authenticate(signed({ user }), productionToken, now), null);
+});
+
+test('missing credentials and malformed or oversized requests fail without throwing', () => {
+ for (const input of [undefined, null, {}, 123, '', 'user=invalid', 'hash=short']) {
+ assert.equal(authenticate(input, productionToken, now), null);
+ }
+ assert.equal(authenticate(signed(), undefined, now), null);
+ assert.equal(authenticate(signed(), '', now), null);
+
+ const oversized = signed({ entries: [['start_param', 'a'.repeat(12001)]] });
+ assert.equal(authenticate(oversized, productionToken, now), null);
+
+ const malformedHash = new URLSearchParams(signed());
+ malformedHash.set('hash', 'z'.repeat(64));
+ assert.equal(authenticate(malformedHash.toString(), productionToken, now), null);
+});
From 4942dda0ddae810a60f989d973f4be444b54e900 Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:18:51 +1100
Subject: [PATCH 04/12] Add manual production-to-development database refresh
---
.../workflows/copy-production-db-to-dev.yml | 54 ++++
AGENTS.md | 11 +-
docs/development.md | 22 +-
scripts/copy-production-database.mjs | 73 +++++
scripts/database-snapshot.mjs | 201 ++++++++++++
test/copy-production-database.test.js | 191 ++++++++++++
test/database-snapshot.test.js | 288 ++++++++++++++++++
7 files changed, 834 insertions(+), 6 deletions(-)
create mode 100644 .github/workflows/copy-production-db-to-dev.yml
create mode 100644 scripts/copy-production-database.mjs
create mode 100644 scripts/database-snapshot.mjs
create mode 100644 test/copy-production-database.test.js
create mode 100644 test/database-snapshot.test.js
diff --git a/.github/workflows/copy-production-db-to-dev.yml b/.github/workflows/copy-production-db-to-dev.yml
new file mode 100644
index 0000000..2ee0cab
--- /dev/null
+++ b/.github/workflows/copy-production-db-to-dev.yml
@@ -0,0 +1,54 @@
+name: Copy production database to dev
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ refresh-database:
+ name: Replace dev data with a production snapshot
+ if: vars.XEVENTS_DATABASE_REFRESH_ENABLED == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ # This environment is restricted to main. It has its own private credentials.
+ environment:
+ name: development-refresh
+ concurrency:
+ group: xevents-development
+ cancel-in-progress: false
+ steps:
+ - name: Require the trusted workflow branch
+ env:
+ SELECTED_REF: ${{ github.ref }}
+ run: |
+ if [ "$SELECTED_REF" != 'refs/heads/main' ]; then
+ echo 'Run this database-only workflow from main.' >&2
+ exit 1
+ fi
+ - name: Check out the database-copy tools
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+ - name: Read the current development migrations
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: dev
+ path: .wrangler/development-code
+ persist-credentials: false
+ - name: Set up Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 22
+ cache: npm
+ - name: Install database tools
+ run: npm ci
+ - name: Copy production data into development only
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ WRANGLER_CONFIG_JSON: ${{ secrets.WRANGLER_CONFIG_JSON }}
+ PRODUCTION_CONFIG_JSON: ${{ secrets.PRODUCTION_CONFIG_JSON }}
+ run: node scripts/copy-production-database.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 62f7571..e78368f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,9 +65,14 @@ existing invitations, and users' private event data when making changes.
- Work on `dev` and verify the affected flows there before promoting to `main`.
`main` deploys production; `dev` deploys the separate development Worker and D1.
Keep all bot credentials, app URLs and database bindings isolated by environment.
-- The production-to-dev workflow merges code without force-pushing. Optional D1
- copying is schema-only into an empty development database. Never copy real
- guest records, media references, payment state or delivery queues into tests.
+- Keep code synchronization and database refresh separate. The user explicitly
+ authorizes the manual production-to-dev database snapshot workflow to copy real
+ business records into the private development D1. Replace development data,
+ preserve its settings/whitelist, clear sessions and delivery/coordination state,
+ and require the development snapshot access/payment/notification safeguards.
+ Never merge code, deploy Workers, write production data or publish database
+ exports as part of this database-only workflow. Automated tests still use
+ fictional local fixtures, never actual production snapshots.
- Run deployment automation from one canonical repository; mirrors run checks
only. Store private configuration in GitHub environment secrets and ignored files.
diff --git a/docs/development.md b/docs/development.md
index 437174d..e3fa46f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -37,7 +37,7 @@ Create GitHub environments named `production` and `development`. Configure these
| `WRANGLER_CONFIG_JSON` | The complete private Wrangler configuration for that environment, as valid JSON. Use different Worker names, D1 IDs, bot usernames and app URLs. |
| `PRODUCTION_CONFIG_JSON` | Development only: the private production configuration used to reject production resource collisions. It is a workflow reference, never the development Worker's runtime configuration. |
-`PRODUCTION_CONFIG_JSON` must match the actual production target. Also create the `development-refresh` environment with the same four secrets as development; restrict this environment to `main`. It is used only for the optional schema-copy job. Production needs only the first three secrets. Keep private configuration in GitHub secrets, never repository variables, checked-in files or workflow text.
+`PRODUCTION_CONFIG_JSON` must match the actual production target. Also create the `development-refresh` environment with the same four secrets as development; restrict this environment to `main`. It is used for manual database refreshes. Production needs only the first three secrets. Keep private configuration in GitHub secrets, never repository variables, checked-in files or workflow text.
The deployment helper, `scripts/deploy-environment.mjs`, applies migrations to the selected environment and deploys with `--keep-vars`. The private configuration is materialized for that run and must not become a build artifact or committed file. Keep existing production bindings and secrets intact.
@@ -120,7 +120,23 @@ Removing the final tester also opens access to everyone; every change confirms w
This restriction applies to the Cloudflare Worker, including its webhook and Mini App APIs. Use `wrangler dev` for local access-control testing; the standalone `npm start` polling process does not enforce this hosted feature. The whitelist command is advertised only in the configured superadmin's private chat.
-## Sync production to dev
+## Copy production data to dev
+
+Open **Actions → Copy production database to dev → Run workflow**, select `main`, and run it whenever you need the latest production records in development. This replaces the existing development database contents. It does not merge either branch, deploy a Worker, change bot credentials, or write to production.
+
+Enable this action only in the canonical repository by setting the repository variable `XEVENTS_DATABASE_REFRESH_ENABLED=true`. Leave it unset or `false` in mirrors. This switch is independent of `XEVENTS_DEPLOY_ENABLED`. The `development-refresh` environment needs all four secrets listed above, including its own `CLOUDFLARE_API_TOKEN` with D1 export and import access. A missing token must be configured before the action can run.
+
+The action exports production, prepares the snapshot privately, and imports one replacement SQL file into the verified development D1. It copies events, users, profiles, guest responses, invitation links, ticket/check-in records, payment audits and media references. Development settings, including the test whitelist, are retained; production settings are not copied. Sessions, queued deliveries, processed-update markers and coordination locks are cleared. Already-applied migrations are preserved, and pending migrations from the current `dev` checkout are applied offline before import. An incompatible migration set stops the refresh before writing development data.
+
+The development Worker must already include the database-refresh safeguards; the action checks its health capability before exporting. A copied database is restricted to the superadmin and explicitly allowed testers, even if the normal whitelist switch is disabled. An empty whitelist means **admin only** for a production snapshot; `/whitelist clear` cannot expose it to everyone. Add testers with `/whitelist add ID...` as usual. Copied reminders are paused, outbound messages are limited to these permitted accounts, and Stars invoices, checkout and refunds are disabled while the snapshot marker is present. Stored financial audit records remain intact.
+
+Telegram media references are copied, but their `file_id` values belong to the production bot. Some existing images or files will need to be uploaded again through the development bot. Never use the production bot token to work around this isolation.
+
+Exports and prepared SQL files are temporary, private files deleted after the run. They are never printed or uploaded as artifacts. D1 export can briefly block production database requests; import can briefly block development requests. A failed D1 file import rolls back the replacement. Both development deployment and database refresh use the same concurrency group. See Cloudflare's [D1 import and export guide](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
+
+Automated tests continue to use fictional local fixtures and mocked Telegram calls. Production snapshots are private operational data, never test fixtures or public artifacts.
+
+## Sync production code to dev
Run **Sync production code to development** manually from GitHub Actions on `main`. It merges production code into `dev` without resetting the branch or force-pushing. It preserves development work and stops if there is a merge conflict. Resolve that conflict in a normal checkout and push the reviewed result. After the merge, it explicitly starts the development deployment workflow because a push using GitHub's workflow token does not start another push workflow. That development run skips automated tests.
@@ -128,7 +144,7 @@ The `copy_database_schema` input defaults to `false`. With that default, the wor
Select `copy_database_schema` only when the development database is empty and you want to initialize it from production's schema. `scripts/refresh-development.mjs` verifies the separate targets and refuses a nonempty development database, preserving existing test data. It exports **schema only**, imports it into development, and applies reviewed `main` migrations; the subsequent development deployment applies any additional `dev` migrations. Schema export can briefly block production database requests, so use it deliberately. Cloudflare documents the supported [D1 schema export and import](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
-Only schema and applied migration filenames are transferred, so already-applied schema changes are not replayed. This workflow never copies production users, events, invitations, sessions, payment or refund records, queued notifications, reminders, configuration rows or media references. It does not promote development data to production. Create fictional test events in the development bot after initialization. A production database backup is private operational data and must never be uploaded as a public workflow artifact or committed as a test fixture.
+Only schema and applied migration filenames are transferred by that optional initialization step, so already-applied schema changes are not replayed. For actual records, use the separate **Copy production database to dev** action above. Neither workflow promotes development data to production.
## Before promoting a change
diff --git a/scripts/copy-production-database.mjs b/scripts/copy-production-database.mjs
new file mode 100644
index 0000000..fcb23e0
--- /dev/null
+++ b/scripts/copy-production-database.mjs
@@ -0,0 +1,73 @@
+import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
+import { resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseConfig, validateSeparation, buildConfig } from './deployment-config.mjs';
+import { temporaryConfig, wrangler, jsonOutput } from './deployment-tools.mjs';
+import { prepareSnapshot } from './database-snapshot.mjs';
+
+const safeError = message => Object.assign(new Error(message), { safe: true });
+function rows(output) {
+ const result = jsonOutput(output);
+ if (!Array.isArray(result) || !result.length || result.some(statement => statement.success !== true || !Array.isArray(statement.results))) throw safeError('The database response could not be verified. Development was not changed.');
+ return result.flatMap(statement => statement.results);
+}
+
+export async function copyProductionDatabase({ env = process.env, run = wrangler, fetcher = fetch, migrationsDirectory = '.wrangler/development-code/migrations' } = {}) {
+ if (env.GITHUB_REF_NAME !== 'main') throw safeError('Run the database refresh workflow from main.');
+ if (!env.CLOUDFLARE_API_TOKEN || !env.CLOUDFLARE_ACCOUNT_ID) throw safeError('Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in the development-refresh GitHub environment.');
+ const dev = parseConfig(env.WRANGLER_CONFIG_JSON), prod = parseConfig(env.PRODUCTION_CONFIG_JSON);
+ validateSeparation(dev, prod);
+ const health = await fetcher(new URL('/', dev.vars.APP_URL), { signal: AbortSignal.timeout(15000) });
+ const status = health.ok && await health.json();
+ if (status?.status !== 'running' || status.databaseRefreshProtection !== 1) throw safeError('Deploy the development database-refresh protection before copying production data. Development was not changed.');
+
+ const root = await realpath('.');
+ const migrationPath = await realpath(migrationsDirectory);
+ if (!migrationPath.startsWith(root + sep)) throw safeError('Development migrations must come from the checked-out development source.');
+ const names = (await readdir(migrationPath, { withFileTypes: true })).filter(entry => entry.isFile() && /^\d+_[A-Za-z0-9_-]+\.sql$/.test(entry.name)).map(entry => entry.name).sort();
+ if (!names.length) throw safeError('The development migration set is missing. Development was not changed.');
+ const migrations = await Promise.all(names.map(async name => ({ name, sql: await readFile(resolve(migrationPath, name), 'utf8') })));
+
+ await mkdir(resolve('.wrangler'), { recursive: true });
+ const directory = await mkdtemp(resolve('.wrangler/database-copy-'));
+ let production, development;
+ try {
+ production = await temporaryConfig(await buildConfig(prod, 'production'), 'database-copy-production');
+ development = await temporaryConfig(await buildConfig(dev, 'development'), 'database-copy-development');
+ const query = command => run(['d1', 'execute', 'DB', '--remote', '--config', development.path, '--json', '--command', command]).then(rows);
+ const schemaQuery = "SELECT type,name FROM sqlite_schema WHERE type IN ('table','view') AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' ORDER BY type,name";
+ const exported = resolve(directory, 'production.sql');
+ await run(['d1', 'export', 'DB', '--remote', '--config', production.path, '--output', exported, '--skip-confirmation']);
+ const developmentSchema = await query(schemaQuery);
+ const settingsQuery = 'SELECT key,value FROM app_settings ORDER BY key';
+ const developmentSettings = developmentSchema.some(table => table.name === 'app_settings') ? await query(settingsQuery) : [];
+ const copiedAt = new Date().toISOString();
+ const snapshot = prepareSnapshot({ sourceSql: await readFile(exported, 'utf8'), developmentSchema, developmentSettings, migrations, copiedAt });
+ const prepared = resolve(directory, 'development.sql');
+ await writeFile(prepared, snapshot.sql, { mode: 0o600 });
+ const latestSchema = await query(schemaQuery);
+ const latestSettings = latestSchema.some(table => table.name === 'app_settings') ? await query(settingsQuery) : [];
+ if (JSON.stringify(latestSchema) !== JSON.stringify(developmentSchema) || JSON.stringify(latestSettings) !== JSON.stringify(developmentSettings)) throw safeError('Development schema or settings changed while preparing the snapshot. Run the refresh again; development was not changed.');
+ // The only remote write targets the verified development binding. D1 imports
+ // the replacement as one file; failed imports roll back instead of leaving
+ // a separate reset operation committed ahead of the copied data.
+ await run(['d1', 'execute', 'DB', '--remote', '--config', development.path, '--file', prepared, '--yes']);
+ console.log('Production database snapshot copied to development. Development settings retained; delivery state cleared. Code branches and production data were not changed.');
+ return snapshot.counts;
+ } finally {
+ try {
+ await Promise.all([production?.cleanup(), development?.cleanup()]);
+ } finally {
+ // mkdtemp creates this exact workspace-owned directory; never delete a
+ // caller-provided location when disposing of private database exports.
+ if (!directory.startsWith(root + sep) || !directory.startsWith(resolve('.wrangler/database-copy-'))) throw safeError('Private export cleanup path could not be verified.');
+ await rm(directory, { recursive: true, force: true });
+ }
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ process.umask(0o077);
+ try { await copyProductionDatabase(); }
+ catch (error) { console.error(error.safe ? error.message : 'Database refresh failed. Check the private environment configuration, Cloudflare permissions, migration compatibility and network connection. No database contents were logged.'); process.exitCode = 1; }
+}
diff --git a/scripts/database-snapshot.mjs b/scripts/database-snapshot.mjs
new file mode 100644
index 0000000..44bb4f5
--- /dev/null
+++ b/scripts/database-snapshot.mjs
@@ -0,0 +1,201 @@
+import { DatabaseSync } from 'node:sqlite';
+import { randomUUID } from 'node:crypto';
+
+const runtimeTables = ['outbox', 'processed', 'lease', 'delivery_lease', 'commits'];
+const columnsRequired = {
+ records: ['kind', 'id', 'data'],
+ processed: ['id', 'at'],
+ lease: ['id', 'owner', 'expires'],
+ delivery_lease: ['id', 'owner', 'expires'],
+ commits: ['owner'],
+ outbox: ['id', 'method', 'params', 'due', 'attempts'],
+ app_settings: ['key', 'value']
+};
+const invalid = () => new Error('The database snapshot could not be prepared safely. Development was not changed.');
+const internal = name => /^(?:sqlite_|_cf_)/i.test(name);
+const identifier = name => '"' + name.replaceAll('"', '""') + '"';
+const validName = name => typeof name === 'string' && name.length > 0 && !name.includes('\0');
+
+function rows(db, sql) {
+ const statement = db.prepare(sql);
+ statement.setReadBigInts(true);
+ return statement.all();
+}
+
+function schema(db) {
+ const attached = rows(db, 'PRAGMA database_list');
+ if (attached.some(row => row.name !== 'main' && row.name !== 'temp')) throw invalid();
+ const tables = rows(db, 'PRAGMA table_list');
+ // SQL dumps cannot faithfully restore virtual tables and their shadow state.
+ if (tables.some(row => !internal(row.name) && (row.schema !== 'main' || !['table', 'view'].includes(row.type)))) throw invalid();
+ const objects = rows(db, "SELECT type,name,tbl_name,sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").filter(row => !internal(row.name));
+ if (objects.some(row => !['table', 'view', 'index', 'trigger'].includes(row.type) || !validName(row.name) || typeof row.sql !== 'string')) throw invalid();
+ return objects;
+}
+
+function validateRecords(db, objects) {
+ const tableNames = new Set(objects.filter(row => row.type === 'table').map(row => row.name));
+ for (const [table, names] of Object.entries(columnsRequired)) {
+ if (!tableNames.has(table)) throw invalid();
+ const columns = rows(db, `PRAGMA table_info(${identifier(table)})`);
+ if (names.some(name => !columns.some(column => column.name === name))) throw invalid();
+ }
+ const columns = rows(db, 'PRAGMA table_info(records)');
+ if (['kind', 'id', 'data'].some(name => !columns.some(column => column.name === name && column.type.toUpperCase() === 'TEXT'))) throw invalid();
+ for (const record of rows(db, 'SELECT kind,id,data FROM records')) {
+ if (![record.kind, record.id, record.data].every(value => typeof value === 'string') || !record.kind || !record.id) throw invalid();
+ if (['events', 'users', 'preferences'].includes(record.kind)) {
+ const value = JSON.parse(record.data);
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid();
+ }
+ }
+}
+
+function applyPendingMigrations(db, available) {
+ if (!Array.isArray(available)) throw invalid();
+ const byName = new Map();
+ for (const migration of available) {
+ if (!migration || !validName(migration.name) || typeof migration.sql !== 'string' || !migration.sql.trim() || byName.has(migration.name)) throw invalid();
+ byName.set(migration.name, migration.sql);
+ }
+ const exists = db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='d1_migrations'").get();
+ const applied = exists ? rows(db, 'SELECT name FROM d1_migrations') : [];
+ if (applied.some(row => !byName.has(row.name))) throw new Error('Production uses migrations missing from development. Synchronize the reviewed migrations before copying the database.');
+ if (!exists) db.exec('CREATE TABLE d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)');
+ const completed = new Set(applied.map(row => row.name));
+ const pending = [...byName.keys()].sort().filter(name => !completed.has(name));
+ const record = db.prepare('INSERT INTO d1_migrations(name) VALUES (?)');
+ for (const name of pending) {
+ db.exec(byName.get(name));
+ record.run(name);
+ }
+ return pending.length;
+}
+
+function literal(value, storageType) {
+ // Read TEXT as bytes: Node 22's SQLite string conversion truncates embedded NULs.
+ if (storageType === 'text' && value instanceof Uint8Array) return "CAST(X'" + Buffer.from(value).toString('hex') + "' AS TEXT)";
+ if (value === null) return 'NULL';
+ if (typeof value === 'bigint') return value.toString();
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ const number = value.toString();
+ // Integers are read as BigInt. A Number must retain SQLite's REAL storage class.
+ return /[.e]/i.test(number) ? number : number + '.0';
+ }
+ if (typeof value === 'string') {
+ // SQLite's SQL parser cannot accept a literal containing a NUL character.
+ if (value.includes('\0')) return "CAST(X'" + Buffer.from(value, 'utf8').toString('hex') + "' AS TEXT)";
+ return "'" + value.replaceAll("'", "''") + "'";
+ }
+ if (value instanceof Uint8Array) return "X'" + Buffer.from(value).toString('hex') + "'";
+ throw invalid();
+}
+
+function dump(db, objects) {
+ const sql = [];
+ const tables = objects.filter(row => row.type === 'table');
+ for (const table of tables) sql.push(table.sql + ';');
+ for (const table of tables) {
+ const allColumns = rows(db, `PRAGMA table_xinfo(${identifier(table.name)})`);
+ const columns = allColumns.filter(column => column.hidden === 0n).map(column => column.name);
+ if (!columns.length) throw invalid();
+ const info = rows(db, 'PRAGMA table_list').find(row => row.schema === 'main' && row.name === table.name);
+ // Keep accessible rowids too; they may be referenced by existing code or indexes.
+ const rowid = info.wr === 0n ? ['_rowid_', 'rowid', 'oid'].find(name => !allColumns.some(column => column.name.toLowerCase() === name)) : undefined;
+ const selected = rowid ? [rowid, ...columns] : columns;
+ const names = selected.map(identifier).join(',');
+ const expressions = selected.map(name => {
+ const column = identifier(name);
+ return `typeof(${column}),CASE WHEN typeof(${column})='text' THEN CAST(${column} AS BLOB) ELSE ${column} END`;
+ }).join(',');
+ const statement = db.prepare(`SELECT ${expressions} FROM ${identifier(table.name)}`);
+ statement.setReadBigInts(true);
+ statement.setReturnArrays(true);
+ for (const row of statement.iterate()) {
+ const values = selected.map((_, index) => literal(row[index * 2 + 1], row[index * 2]));
+ sql.push(`INSERT INTO ${identifier(table.name)}(${names}) VALUES (${values.join(',')});`);
+ }
+ }
+ if (db.prepare("SELECT 1 FROM sqlite_schema WHERE name='sqlite_sequence' AND type='table'").get()) {
+ sql.push('DELETE FROM sqlite_sequence;');
+ for (const row of rows(db, 'SELECT name,seq FROM sqlite_sequence ORDER BY name')) {
+ if (!internal(row.name)) sql.push(`INSERT INTO sqlite_sequence(name,seq) VALUES (${literal(row.name)},${literal(row.seq)});`);
+ }
+ }
+ // Guards, audit triggers, and indexes must be installed after copying table data.
+ for (const type of ['index', 'view', 'trigger']) for (const object of objects.filter(row => row.type === type)) sql.push(object.sql + ';');
+ return sql.join('\n');
+}
+
+function resetSql(developmentSchema, settings) {
+ if (!Array.isArray(developmentSchema)) throw invalid();
+ const objects = developmentSchema.filter(object => {
+ if (!object || !['table', 'view'].includes(object.type) || !validName(object.name)) throw invalid();
+ return !internal(object.name);
+ });
+ const unique = new Set();
+ if (!objects.some(object => object.type === 'table' && object.name === 'app_settings')) throw invalid();
+ const guard = identifier('_xevents_snapshot_guard_' + randomUUID().replaceAll('-', ''));
+ const bytes = value => "X'" + Buffer.from(value, 'utf8').toString('hex') + "'";
+ const exactSettings = [...settings].map(([key, value]) => `EXISTS (SELECT 1 FROM "app_settings" WHERE typeof("key")='text' AND CAST("key" AS BLOB)=${bytes(key)} AND typeof("value")='text' AND CAST("value" AS BLOB)=${bytes(value)})`);
+ // Check again inside the same D1 import transaction, before resetting any data.
+ // An external whitelist/settings edit after the API re-read aborts the import.
+ const matches = [`(SELECT count(*) FROM "app_settings")=${settings.size}`, ...exactSettings].join(' AND ');
+ const result = [
+ 'PRAGMA defer_foreign_keys=ON;',
+ `CREATE TABLE ${guard}(ok INTEGER NOT NULL CHECK(ok=1));`,
+ `INSERT INTO ${guard}(ok) SELECT CASE WHEN ${matches} THEN 1 ELSE 0 END;`,
+ `DROP TABLE ${guard};`
+ ];
+ for (const type of ['view', 'table']) for (const object of objects.filter(row => row.type === type)) {
+ const key = object.name.toLowerCase();
+ if (unique.has(key)) throw invalid();
+ unique.add(key);
+ result.push(`DROP ${type.toUpperCase()} IF EXISTS ${identifier(object.name)};`);
+ }
+ return result.join('\n');
+}
+
+/** Build an atomic D1 replacement import entirely offline; never print SQL/data. */
+export function prepareSnapshot({ sourceSql, developmentSchema, developmentSettings, migrations, copiedAt }) {
+ let db;
+ try {
+ if (typeof sourceSql !== 'string' || !sourceSql.trim() || !Array.isArray(developmentSettings) || typeof copiedAt !== 'string' || !Number.isFinite(Date.parse(copiedAt))) throw invalid();
+ const settings = new Map();
+ for (const setting of developmentSettings) {
+ if (!setting || !validName(setting.key) || typeof setting.value !== 'string' || settings.has(setting.key)) throw invalid();
+ settings.set(setting.key, setting.value);
+ }
+ const reset = resetSql(developmentSchema, settings);
+ db = new DatabaseSync(':memory:', { allowExtension: false });
+ db.exec(sourceSql);
+ schema(db);
+ const appliedMigrations = applyPendingMigrations(db, migrations);
+ const objects = schema(db);
+ validateRecords(db, objects);
+ // Cleanup must not run production audit/business triggers in the private staging DB.
+ for (const trigger of objects.filter(row => row.type === 'trigger')) db.exec(`DROP TRIGGER ${identifier(trigger.name)}`);
+ const removedSessions = Number(rows(db, "SELECT count(*) AS n FROM records WHERE kind='sessions'")[0].n);
+ db.exec("DELETE FROM records WHERE kind='sessions'");
+ for (const table of runtimeTables) db.exec(`DELETE FROM ${identifier(table)}`);
+ db.exec('DELETE FROM app_settings');
+ const set = db.prepare('INSERT INTO app_settings(key,value) VALUES (?,?)');
+ settings.set('production-snapshot', JSON.stringify({ version: 1, copiedAt }));
+ for (const [key, value] of settings) set.run(key, value);
+ if (rows(db, 'PRAGMA foreign_key_check').length || rows(db, 'PRAGMA integrity_check').some(row => row.integrity_check !== 'ok')) throw invalid();
+ const grouped = rows(db, 'SELECT kind,count(*) AS n FROM records GROUP BY kind');
+ const count = kind => Number(grouped.find(row => row.kind === kind)?.n || 0n);
+ const counts = {
+ records: grouped.reduce((total, row) => total + Number(row.n), 0),
+ events: count('events'), users: count('users'), preferences: count('preferences'),
+ removedSessions, tables: objects.filter(row => row.type === 'table').length, appliedMigrations
+ };
+ return { sql: reset + '\n' + dump(db, objects) + '\n', counts };
+ } catch (error) {
+ // SQLite errors can quote private values, SQL excerpts, or file paths.
+ if (error?.message === 'Production uses migrations missing from development. Synchronize the reviewed migrations before copying the database.') throw error;
+ throw invalid();
+ } finally {
+ db?.close();
+ }
+}
diff --git a/test/copy-production-database.test.js b/test/copy-production-database.test.js
new file mode 100644
index 0000000..79d6472
--- /dev/null
+++ b/test/copy-production-database.test.js
@@ -0,0 +1,191 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, resolve, sep } from 'node:path';
+import { copyProductionDatabase } from '../scripts/copy-production-database.mjs';
+
+const production = () => ({ name: 'fictional-production', vars: { BOT_USERNAME: 'FictionalProductionBot', APP_URL: 'https://production.test/app' }, d1_databases: [{ binding: 'DB', database_name: 'fictional-production-db', database_id: '11111111-1111-4111-8111-111111111111' }] });
+const development = () => ({ name: 'fictional-development', vars: { BOT_USERNAME: 'FictionalDevelopmentBot', APP_URL: 'https://development.test/app', TEST_WHITELIST_ENABLED: 'true' }, d1_databases: [{ binding: 'DB', database_name: 'fictional-development-db', database_id: '22222222-2222-4222-8222-222222222222' }] });
+const encoded = records => JSON.stringify([{ success: true, results: records }]);
+const argument = (args, flag) => args[args.indexOf(flag) + 1];
+const copyFiles = names => names.filter(name => /^wrangler\.database-copy-.*\.jsonc$/.test(name)).sort();
+const copyDirectories = names => names.filter(name => name.startsWith('database-copy-')).sort();
+
+async function fixture(t, { failAt, settingsRace = false, schemaRace = false, unhealthy } = {}) {
+ await mkdir(resolve('.wrangler'), { recursive: true });
+ const directory = await mkdtemp(resolve('.wrangler/test-database-copy-'));
+ const migrationsDirectory = resolve(directory, 'migrations');
+ await mkdir(migrationsDirectory);
+ t.after(async () => {
+ assert.ok(directory.startsWith(resolve('.wrangler') + sep));
+ await rm(directory, { recursive: true, force: true });
+ });
+ const names = ['0001_initial.sql', '0002_delivery_lease.sql', '0003_mini_app.sql'];
+ const migrations = await Promise.all(names.map(async name => {
+ const sql = await readFile(new URL('../migrations/' + name, import.meta.url), 'utf8');
+ await writeFile(resolve(migrationsDirectory, name), sql);
+ return { name, sql };
+ }));
+ const sourceSql = migrations.map(migration => migration.sql).join('\n') + '\n' +
+ 'CREATE TABLE d1_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,applied_at TEXT DEFAULT CURRENT_TIMESTAMP);\n' +
+ names.map(name => `INSERT INTO d1_migrations(name) VALUES('${name}');`).join('\n') + '\n' +
+ "INSERT INTO records VALUES('events','fixture-event','{\"title\":\"Fictional fixture event\"}');\n" +
+ "INSERT INTO app_settings VALUES('mini-menu','App:https://production.test/app');";
+ const settings = [{ key: 'test-whitelist', value: '["900000001"]' }, { key: 'mini-menu', value: 'App:https://development.test/app' }];
+ const schema = ['records', 'outbox', 'processed', 'lease', 'commits', 'delivery_lease', 'app_settings', 'd1_migrations', 'dev_only'].map(name => ({ type: 'table', name }));
+ const calls = [], healthCalls = [], paths = new Set();
+ let settingReads = 0, schemaReads = 0, imported;
+ const beforeFiles = copyFiles(await readdir('.'));
+ const beforeDirectories = copyDirectories(await readdir('.wrangler'));
+ const env = {
+ GITHUB_REF_NAME: 'main',
+ CLOUDFLARE_API_TOKEN: 'fictional-test-token',
+ CLOUDFLARE_ACCOUNT_ID: 'fictional-account',
+ WRANGLER_CONFIG_JSON: JSON.stringify(development()),
+ PRODUCTION_CONFIG_JSON: JSON.stringify(production())
+ };
+ const run = async args => {
+ const path = argument(args, '--config');
+ paths.add(path);
+ const config = JSON.parse(await readFile(path, 'utf8'));
+ calls.push({ args, config });
+ if (args[1] === 'export') {
+ const output = argument(args, '--output');
+ paths.add(output);
+ paths.add(dirname(output));
+ // Also write before the synthetic failure to verify private export cleanup.
+ await writeFile(output, sourceSql);
+ if (failAt === 'export') throw new Error('Mock export failure.');
+ return 'Mock export completed.';
+ }
+ if (args.includes('--file')) {
+ const path = argument(args, '--file');
+ paths.add(path);
+ imported = await readFile(path, 'utf8');
+ if (failAt === 'import') throw new Error('Mock import failure.');
+ return 'Mock import completed.';
+ }
+ const command = argument(args, '--command');
+ if (command.includes('sqlite_schema')) {
+ schemaReads++;
+ if (failAt === 'schema') throw new Error('Mock schema query failure.');
+ if (failAt === 'response') return encoded([]).replace('true', 'false');
+ return encoded(schemaRace && schemaReads > 1 ? [...schema, { type: 'table', name: 'new_dev_table' }] : schema);
+ }
+ if (command.includes('app_settings')) {
+ settingReads++;
+ if (failAt === 'settings') throw new Error('Mock settings query failure.');
+ return encoded(settingsRace && settingReads > 1 ? settings.map(setting => setting.key === 'test-whitelist' ? { ...setting, value: '[]' } : setting) : settings);
+ }
+ throw new Error('Unexpected mocked database command.');
+ };
+ const fetcher = async (url, options) => {
+ healthCalls.push({ url: String(url), options });
+ if (unhealthy === 'network') throw new Error('Mock network failure.');
+ if (unhealthy === 'invalid-json') return new Response('invalid JSON');
+ return Response.json(unhealthy === 'missing-protection' ? { status: 'running' } : unhealthy === 'stopped' ? { status: 'stopped', databaseRefreshProtection: 1 } : { status: 'running', databaseRefreshProtection: 1 }, { status: unhealthy === 'http-error' ? 503 : 200 });
+ };
+ const assertCleaned = async () => {
+ for (const path of paths) await assert.rejects(access(path), { code: 'ENOENT' });
+ assert.deepEqual(copyFiles(await readdir('.')), beforeFiles);
+ assert.deepEqual(copyDirectories(await readdir('.wrangler')), beforeDirectories);
+ };
+ return { env, run, fetcher, migrationsDirectory, calls, healthCalls, assertCleaned, get imported() { return imported; } };
+}
+
+test('DB refresh exports production read-only and imports exactly one replacement file into dev', async t => {
+ const f = await fixture(t);
+ const counts = await copyProductionDatabase(f);
+ assert.equal(counts.events, 1);
+ assert.equal(counts.appliedMigrations, 0);
+ assert.equal(f.healthCalls.length, 1);
+ assert.equal(f.healthCalls[0].url, 'https://development.test/');
+ assert.ok(f.healthCalls[0].options.signal instanceof AbortSignal);
+ const prodCalls = f.calls.filter(call => call.config.name === 'fictional-production');
+ assert.equal(prodCalls.length, 1);
+ assert.equal(prodCalls[0].args[1], 'export');
+ assert.equal(prodCalls[0].config.vars.APP_ENV, 'production');
+ assert.ok(prodCalls[0].args.includes('--remote'));
+ assert.ok(!prodCalls[0].args.includes('--no-data'));
+ const imports = f.calls.filter(call => call.args.includes('--file'));
+ assert.equal(imports.length, 1);
+ assert.equal(imports[0].config.name, 'fictional-development');
+ assert.equal(imports[0].config.vars.APP_ENV, 'development');
+ assert.ok(imports[0].args.includes('--yes'));
+ assert.ok(f.calls.every(call => !['deploy', 'migrations'].includes(call.args[0]) && call.args[1] !== 'migrations'));
+ const queries = f.calls.filter(call => call.args.includes('--command'));
+ assert.equal(queries.length, 4);
+ assert.ok(queries.every(call => call.config.name === 'fictional-development' && /^SELECT\b/.test(argument(call.args, '--command'))));
+ assert.match(f.imported, /^PRAGMA defer_foreign_keys=ON;/);
+ assert.match(f.imported, /DROP TABLE IF EXISTS "dev_only"/);
+ await f.assertCleaned();
+});
+
+test('DB refresh refuses non-main runs and missing credentials before network or database calls', async t => {
+ const f = await fixture(t);
+ for (const env of [{ ...f.env, GITHUB_REF_NAME: 'dev' }, { ...f.env, CLOUDFLARE_API_TOKEN: '' }, { ...f.env, CLOUDFLARE_ACCOUNT_ID: '' }]) {
+ await assert.rejects(copyProductionDatabase({ ...f, env }), /main|CLOUDFLARE/);
+ }
+ assert.equal(f.calls.length, 0);
+ assert.equal(f.healthCalls.length, 0);
+ await f.assertCleaned();
+});
+
+test('DB refresh refuses shared production/dev resources before health checks or exports', async t => {
+ const f = await fixture(t);
+ for (const change of [
+ (dev, prod) => { dev.d1_databases[0].database_id = prod.d1_databases[0].database_id; },
+ (dev, prod) => { dev.name = prod.name; },
+ (dev, prod) => { dev.vars.APP_URL = prod.vars.APP_URL; },
+ (dev, prod) => { dev.vars.BOT_USERNAME = prod.vars.BOT_USERNAME; }
+ ]) {
+ const dev = development(), prod = production();
+ change(dev, prod);
+ await assert.rejects(copyProductionDatabase({ ...f, env: { ...f.env, WRANGLER_CONFIG_JSON: JSON.stringify(dev) } }), /different Worker/);
+ }
+ assert.equal(f.calls.length, 0);
+ assert.equal(f.healthCalls.length, 0);
+ await f.assertCleaned();
+});
+
+test('DB refresh requires reachable dev health and deployed snapshot protection', async t => {
+ for (const unhealthy of ['missing-protection', 'stopped', 'http-error', 'network', 'invalid-json']) {
+ const f = await fixture(t, { unhealthy });
+ await assert.rejects(copyProductionDatabase(f));
+ assert.equal(f.healthCalls.length, 1);
+ assert.equal(f.calls.length, 0);
+ await f.assertCleaned();
+ }
+});
+
+test('DB refresh refuses a changing whitelist before the sole dev import', async t => {
+ const f = await fixture(t, { settingsRace: true });
+ await assert.rejects(copyProductionDatabase(f), /settings changed/);
+ assert.equal(f.calls.filter(call => call.args[1] === 'export').length, 1);
+ assert.equal(f.calls.filter(call => call.args.includes('--file')).length, 0);
+ await f.assertCleaned();
+});
+
+test('DB refresh refuses a changing dev schema before the sole dev import', async t => {
+ const f = await fixture(t, { schemaRace: true });
+ await assert.rejects(copyProductionDatabase(f), /schema or settings changed/);
+ assert.equal(f.calls.filter(call => call.args.includes('--file')).length, 0);
+ await f.assertCleaned();
+});
+
+test('DB refresh removes temporary configs and private snapshot files after operation failures', async t => {
+ for (const failAt of ['export', 'schema', 'settings', 'response', 'import']) {
+ const f = await fixture(t, { failAt });
+ await assert.rejects(copyProductionDatabase(f));
+ assert.equal(f.calls.filter(call => call.args.includes('--file')).length, failAt === 'import' ? 1 : 0);
+ assert.ok(f.calls.filter(call => call.config.name === 'fictional-production').every(call => call.args[1] === 'export'));
+ await f.assertCleaned();
+ }
+});
+
+test('DB refresh refuses migration directories outside the checked-out workspace', async t => {
+ const f = await fixture(t);
+ await assert.rejects(copyProductionDatabase({ ...f, migrationsDirectory: resolve('..') }), /checked-out development source/);
+ assert.equal(f.calls.length, 0);
+ await f.assertCleaned();
+});
diff --git a/test/database-snapshot.test.js b/test/database-snapshot.test.js
new file mode 100644
index 0000000..326688c
--- /dev/null
+++ b/test/database-snapshot.test.js
@@ -0,0 +1,288 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { DatabaseSync } from 'node:sqlite';
+import { prepareSnapshot } from '../scripts/database-snapshot.mjs';
+
+const copiedAt = '2026-10-08T04:30:00.000Z';
+const migrations = ['0001_initial.sql', '0002_delivery_lease.sql', '0003_mini_app.sql'].map(name => ({ name, sql: readFileSync(new URL('../migrations/' + name, import.meta.url), 'utf8') }));
+const core = migrations.map(migration => migration.sql).join('\n');
+const metadata = 'CREATE TABLE d1_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\n' + migrations.map(({ name }) => `INSERT INTO d1_migrations(name,applied_at) VALUES ('${name}','2026-01-01');`).join('\n');
+const source = extra => core + '\n' + metadata + '\n' + (extra || '');
+const initialSchema = ['records', 'processed', 'lease', 'commits', 'outbox', 'delivery_lease', 'app_settings'].map(name => ({ type: 'table', name }));
+const options = extra => ({ sourceSql: source(), developmentSchema: initialSchema, developmentSettings: [], migrations, copiedAt, ...extra });
+
+function open(sql = '') {
+ const db = new DatabaseSync(':memory:');
+ if (sql) db.exec(sql);
+ return db;
+}
+
+function developmentSchema(db) {
+ return db.prepare("SELECT type,name FROM sqlite_schema WHERE type IN ('table','view')").all();
+}
+
+function apply(db, sql) {
+ try { db.exec('BEGIN;\n' + sql + '\nCOMMIT;'); }
+ catch (error) { db.exec('ROLLBACK'); throw error; }
+}
+
+function restore(sql) {
+ const db = open(core);
+ apply(db, sql);
+ return db;
+}
+
+function value(db, sql) {
+ const statement = db.prepare(sql);
+ statement.setReadBigInts(true);
+ return statement.get();
+}
+
+test('snapshot replaces nonempty development and can be copied repeatedly', () => {
+ const db = open(core + "INSERT INTO records VALUES('events','old','{\"title\":\"Old test event\"}'); CREATE TABLE dev_only(value TEXT); INSERT INTO dev_only VALUES('old'); CREATE VIEW old_view AS SELECT value FROM dev_only;");
+ try {
+ const sourceSql = source("INSERT INTO records VALUES('events','source-event','{\"title\":\"Future gathering\",\"invitationMode\":\"legacy\"}');");
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const prepared = prepareSnapshot(options({ sourceSql, developmentSchema: developmentSchema(db), developmentSettings: db.prepare('SELECT key,value FROM app_settings ORDER BY key').all() }));
+ apply(db, prepared.sql);
+ assert.equal(db.prepare('SELECT count(*) AS n FROM records').get().n, 1);
+ assert.equal(db.prepare('SELECT id FROM records').get().id, 'source-event');
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_schema WHERE name IN ('dev_only','old_view')").get().n, 0);
+ assert.equal(prepared.counts.events, 1);
+ assert.equal(prepared.counts.appliedMigrations, 0);
+ assert.equal(db.prepare('SELECT count(*) AS n FROM d1_migrations').get().n, 3);
+ }
+ } finally { db.close(); }
+});
+
+test('snapshot clears sessions and delivery state while retaining the dev whitelist and settings', () => {
+ const sourceSql = source(`
+ INSERT INTO records VALUES('sessions','900000001','{"step":"upload","eventId":"source-event"}');
+ INSERT INTO records VALUES('events','source-event','{"title":"Fixture event"}');
+ INSERT INTO outbox(id,method,params) VALUES('queued','sendMessage','{"chat_id":900000001,"text":"Fixture"}');
+ INSERT INTO processed VALUES(100,1);
+ INSERT INTO lease VALUES(1,'fixture-owner',9223372036854775807);
+ INSERT INTO delivery_lease VALUES(1,'fixture-owner',9223372036854775807);
+ INSERT INTO commits VALUES('fixture-owner');
+ INSERT INTO app_settings VALUES('test-whitelist','["900000002"]');
+ INSERT INTO app_settings VALUES('mini-menu','App:https://production.test/app');
+ `);
+ const developmentSettings = [{ key: 'test-whitelist', value: '["900000003"]' }, { key: 'mini-menu', value: 'App:https://development.test/app' }, { key: 'empty-setting', value: '' }];
+ const { sql, counts } = prepareSnapshot(options({ sourceSql, developmentSettings }));
+ const db = open(core);
+ try {
+ for (const { key, value } of developmentSettings) db.prepare('INSERT INTO app_settings VALUES(?,?)').run(key, value);
+ apply(db, sql);
+ for (const table of ['outbox', 'processed', 'lease', 'delivery_lease', 'commits']) assert.equal(db.prepare(`SELECT count(*) AS n FROM ${table}`).get().n, 0);
+ assert.equal(db.prepare("SELECT count(*) AS n FROM records WHERE kind='sessions'").get().n, 0);
+ for (const setting of developmentSettings) assert.equal(db.prepare('SELECT value FROM app_settings WHERE key=?').get(setting.key).value, setting.value);
+ assert.deepEqual(JSON.parse(db.prepare("SELECT value FROM app_settings WHERE key='production-snapshot'").get().value), { version: 1, copiedAt });
+ assert.equal(counts.removedSessions, 1);
+ assert.equal(counts.records, 1);
+ assert.doesNotMatch(sql, /production\.test|fixture-owner|900000001|900000002/);
+ } finally { db.close(); }
+});
+
+test('empty development settings do not inherit a production whitelist or endpoint', () => {
+ const { sql } = prepareSnapshot(options({ sourceSql: source("INSERT INTO app_settings VALUES('test-whitelist','[\"900000002\"]'); INSERT INTO app_settings VALUES('stars-webhook','https://production.test/telegram');") }));
+ const db = restore(sql);
+ try {
+ assert.equal(db.prepare('SELECT count(*) AS n FROM app_settings').get().n, 1);
+ assert.equal(db.prepare('SELECT key FROM app_settings').get().key, 'production-snapshot');
+ assert.doesNotMatch(sql, /900000002|production\.test/);
+ } finally { db.close(); }
+});
+
+test('event ownership, invitation, media, payment, refund and user JSON stay byte-for-byte unchanged', () => {
+ const raw = '{ "id":"event-one", "owner":900000001, "invitationMode":"named", "guests":{"900000002":{"status":"yes","participants":2,"ticketCode":"fixture-ticket","payment":{"status":"paid","chargeId":"fixture-charge","refund":{"status":"complete"}},"checkedInAt":"2026-10-07"}}, "invitations":[{"token":"fixture-token","claimedBy":900000002,"oneTime":true}], "media":[{"fileId":"fixture-file","name":"Photo.jpg"}], "location":"Guest location", "title":"L’été — میهمانی" }';
+ const user = '{ "id":900000002,"username":"fixture_user","firstName":"O\'Brien","phone":"fictional optional phone" }';
+ const preference = '{ "timezone":"Australia/Sydney","photo":"fixture-profile-file" }';
+ const sourceSql = source() + [
+ ['events', 'event-one', raw], ['users', '900000002', user], ['preferences', '900000002', preference], ['future-business', 'other', 'arbitrary legacy data']
+ ].map(row => 'INSERT INTO records VALUES(' + row.map(text => "'" + text.replaceAll("'", "''") + "'").join(',') + ');').join('\n');
+ const { sql, counts } = prepareSnapshot(options({ sourceSql }));
+ const db = restore(sql);
+ try {
+ for (const [kind, expected] of [['events', raw], ['users', user], ['preferences', preference], ['future-business', 'arbitrary legacy data']]) assert.equal(db.prepare('SELECT data FROM records WHERE kind=?').get(kind).data, expected);
+ assert.equal(counts.records, 4);
+ assert.equal(counts.events, 1);
+ assert.equal(counts.users, 1);
+ assert.equal(counts.preferences, 1);
+ } finally { db.close(); }
+});
+
+test('SQL literals preserve quoted Unicode, NUL text, blobs, REAL values and exact 64-bit integers', () => {
+ const sourceSql = source(`CREATE TABLE payload(id INTEGER PRIMARY KEY, text_value TEXT, bytes BLOB, exact INTEGER, floating, nullable);
+ INSERT INTO payload VALUES(7,'O''Brien — میهمانی\nnext line',X'00ff1027',9223372036854775807,1.0,NULL);
+ INSERT INTO payload VALUES(8,CAST(X'610062' AS TEXT),X'',-9223372036854775808,-1.25,NULL);`);
+ const { sql } = prepareSnapshot(options({ sourceSql }));
+ const db = restore(sql);
+ try {
+ const first = value(db, 'SELECT * FROM payload WHERE id=7');
+ assert.equal(first.text_value, "O'Brien — میهمانی\nnext line");
+ assert.deepEqual(first.bytes, new Uint8Array([0, 255, 16, 39]));
+ assert.equal(first.exact, 9223372036854775807n);
+ assert.equal(first.floating, 1);
+ assert.equal(db.prepare('SELECT typeof(floating) AS kind FROM payload WHERE id=7').get().kind, 'real');
+ assert.equal(first.nullable, null);
+ const second = value(db, 'SELECT * FROM payload WHERE id=8');
+ // Node 22's SQLite TEXT reader stops at NUL; compare the actual stored bytes.
+ assert.equal(db.prepare('SELECT hex(CAST(text_value AS BLOB)) AS bytes FROM payload WHERE id=8').get().bytes, '610062');
+ assert.deepEqual(second.bytes, new Uint8Array());
+ assert.equal(second.exact, -9223372036854775808n);
+ } finally { db.close(); }
+});
+
+test('pending development migrations run once and keep original applied metadata', () => {
+ const pending = { name: '0004_example.sql', sql: 'ALTER TABLE records ADD COLUMN extra TEXT DEFAULT \'example\'; CREATE INDEX records_extra ON records(extra);' };
+ const { sql, counts } = prepareSnapshot(options({ migrations: [...migrations, pending], sourceSql: source("INSERT INTO records VALUES('events','fixture','{}');") }));
+ const db = restore(sql);
+ try {
+ assert.equal(db.prepare('SELECT extra FROM records').get().extra, 'example');
+ assert.equal(db.prepare("SELECT applied_at FROM d1_migrations WHERE name='0001_initial.sql'").get().applied_at, '2026-01-01');
+ assert.equal(db.prepare("SELECT count(*) AS n FROM d1_migrations WHERE name='0004_example.sql'").get().n, 1);
+ assert.equal(counts.appliedMigrations, 1);
+ // Treat the completed snapshot as the next source; ALTER must not run again.
+ const repeatSource = source("INSERT INTO records VALUES('events','fixture','{}');" + pending.sql + "INSERT INTO d1_migrations(name) VALUES('0004_example.sql');");
+ const repeat = prepareSnapshot(options({ sourceSql: repeatSource, migrations: [...migrations, pending], developmentSchema: developmentSchema(db), developmentSettings: db.prepare('SELECT key,value FROM app_settings ORDER BY key').all() }));
+ apply(db, repeat.sql);
+ assert.equal(repeat.counts.appliedMigrations, 0);
+ assert.equal(db.prepare('SELECT count(*) AS n FROM d1_migrations').get().n, 4);
+ } finally { db.close(); }
+});
+
+test('missing migration fails before returning an import and keeps private names out of errors', () => {
+ assert.throws(() => prepareSnapshot(options({ sourceSql: source("INSERT INTO d1_migrations(name) VALUES('9999_private_example.sql');") })), error => /migrations missing/.test(error.message) && !error.message.includes('9999_private_example'));
+});
+
+test('unrecorded initial migrations can initialize metadata without duplicating application rows', () => {
+ const { sql, counts } = prepareSnapshot(options({ sourceSql: core + "INSERT INTO records VALUES('events','fixture','{}');" }));
+ const db = restore(sql);
+ try {
+ assert.equal(db.prepare('SELECT count(*) AS n FROM d1_migrations').get().n, 3);
+ assert.equal(db.prepare('SELECT count(*) AS n FROM records').get().n, 1);
+ assert.equal(counts.appliedMigrations, 3);
+ } finally { db.close(); }
+});
+
+test('development identifiers are quoted safely and platform internals are never dropped', () => {
+ const unusual = 'unusual"; DROP TABLE records;--';
+ const db = open(core + 'CREATE TABLE "' + unusual.replaceAll('"', '""') + '"(value TEXT); CREATE TABLE _cf_KV(key TEXT,value TEXT); INSERT INTO _cf_KV VALUES(\'fixture\',\'platform state\');');
+ try {
+ const { sql } = prepareSnapshot(options({ developmentSchema: developmentSchema(db) }));
+ assert.doesNotMatch(sql, /DROP TABLE IF EXISTS "(?:_cf_KV|sqlite_sequence)"/i);
+ apply(db, sql);
+ assert.equal(db.prepare('SELECT value FROM _cf_KV').get().value, 'platform state');
+ assert.equal(db.prepare('SELECT count(*) AS n FROM sqlite_schema WHERE name=?').get(unusual).n, 0);
+ assert.equal(db.prepare('SELECT count(*) AS n FROM records').get().n, 0);
+ } finally { db.close(); }
+});
+
+test('auto-increment high-water marks and generated columns survive a snapshot', () => {
+ const { sql } = prepareSnapshot(options({ sourceSql: source(`CREATE TABLE sequence_example(id INTEGER PRIMARY KEY AUTOINCREMENT,value TEXT,length INTEGER GENERATED ALWAYS AS (length(value)) STORED);
+ INSERT INTO sequence_example(id,value) VALUES(5,'fixture'); INSERT INTO sequence_example(id,value) VALUES(99,'deleted'); DELETE FROM sequence_example WHERE id=99;`) }));
+ const db = restore(sql);
+ try {
+ assert.equal(db.prepare('SELECT length FROM sequence_example').get().length, 7);
+ db.prepare('INSERT INTO sequence_example(value) VALUES (?)').run('next');
+ assert.equal(db.prepare("SELECT id FROM sequence_example WHERE value='next'").get().id, 100);
+ } finally { db.close(); }
+});
+
+test('rowids and WITHOUT ROWID tables survive while generated aliases remain read-only', () => {
+ const { sql } = prepareSnapshot(options({ sourceSql: source(`
+ CREATE TABLE rowid_example(value TEXT,"_rowid_" INTEGER GENERATED ALWAYS AS (length(value)) VIRTUAL);
+ INSERT INTO rowid_example(rowid,value) VALUES(45,'fixture');
+ CREATE TABLE natural_key(a TEXT,b INTEGER,PRIMARY KEY(a,b)) WITHOUT ROWID;
+ INSERT INTO natural_key VALUES('fixture',9223372036854775807);
+ `) }));
+ const db = restore(sql);
+ try {
+ assert.equal(value(db, 'SELECT rowid FROM rowid_example').rowid, 45n);
+ assert.equal(db.prepare('SELECT "_rowid_" AS n FROM rowid_example').get().n, 7);
+ assert.equal(value(db, 'SELECT b FROM natural_key').b, 9223372036854775807n);
+ } finally { db.close(); }
+});
+
+test('indexes, views and lease guards are restored after data without replaying business triggers', () => {
+ const { sql } = prepareSnapshot(options({ sourceSql: source(`CREATE TABLE audit(value TEXT); CREATE INDEX audit_value ON audit(value);
+ CREATE TRIGGER record_audit AFTER INSERT ON records BEGIN INSERT INTO audit VALUES(NEW.id); END;
+ CREATE VIEW event_titles AS SELECT id FROM records WHERE kind='events';
+ INSERT INTO records VALUES('events','fixture-event','{}');
+ CREATE TRIGGER delete_audit AFTER DELETE ON records BEGIN INSERT INTO audit VALUES('deleted'); END;
+ INSERT INTO records VALUES('sessions','fixture-session','{}');`) }));
+ const db = restore(sql);
+ try {
+ assert.deepEqual(db.prepare('SELECT value FROM audit ORDER BY value').all().map(row => row.value), ['fixture-event', 'fixture-session']);
+ assert.equal(db.prepare('SELECT id FROM event_titles').get().id, 'fixture-event');
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_schema WHERE type='index' AND name='audit_value'").get().n, 1);
+ assert.throws(() => db.prepare("INSERT INTO commits VALUES('lost')").run(), /lease lost/);
+ db.prepare("INSERT INTO records VALUES('events','new-event','{}')").run();
+ assert.equal(db.prepare("SELECT count(*) AS n FROM audit WHERE value='new-event'").get().n, 1);
+ assert.ok(sql.lastIndexOf('CREATE TRIGGER') > sql.lastIndexOf('INSERT INTO'));
+ } finally { db.close(); }
+});
+
+test('virtual tables and unsupported extensions fail safely without emitting SQL or private values', () => {
+ for (const extra of ["CREATE VIRTUAL TABLE private_search USING fts5(private_text);", "SELECT load_extension('private-extension-path');", "INSERT INTO missing_private_table VALUES('private-fixture-value');"]) {
+ assert.throws(() => prepareSnapshot(options({ sourceSql: source(extra) })), error => /could not be prepared safely/.test(error.message) && !/private|load_extension|missing_private_table/.test(error.message));
+ }
+});
+
+test('malformed core records and schema fail before any replacement SQL is returned', () => {
+ for (const extra of [
+ "INSERT INTO records VALUES('events','fixture','not valid JSON');",
+ "INSERT INTO records VALUES('users','fixture','null');",
+ "INSERT INTO records VALUES('preferences','fixture','[]');",
+ 'DROP TABLE outbox;',
+ 'DROP TABLE records; CREATE TABLE records(kind INTEGER,id TEXT,data TEXT);'
+ ]) assert.throws(() => prepareSnapshot(options({ sourceSql: source(extra) })), /could not be prepared safely/);
+});
+
+test('invalid development settings, schema and timestamps produce generic errors', () => {
+ for (const change of [
+ { developmentSettings: [{ key: 'key', value: 'one' }, { key: 'key', value: 'two' }] },
+ { developmentSettings: [{ key: 'key', value: null }] },
+ { developmentSchema: [{ type: 'table', name: 'invalid\0name' }] },
+ { developmentSchema: [{ type: 'trigger', name: 'invalid' }] },
+ { developmentSchema: [{ type: 'table', name: 'records' }, { type: 'table', name: 'records' }] },
+ { copiedAt: 'private invalid value' }
+ ]) assert.throws(() => prepareSnapshot(options(change)), error => /could not be prepared safely/.test(error.message) && !error.message.includes('private'));
+});
+
+test('atomic settings guard aborts a stale snapshot without resetting data or overwriting current settings', () => {
+ for (const change of [
+ db => db.exec("UPDATE app_settings SET value='[]' WHERE key='test-whitelist'"),
+ db => db.exec("INSERT INTO app_settings VALUES('new-setting','current')"),
+ db => db.exec("DELETE FROM app_settings WHERE key='test-whitelist'"),
+ db => db.exec("UPDATE app_settings SET key='renamed-setting' WHERE key='test-whitelist'"),
+ db => db.exec("UPDATE app_settings SET value=CAST(value AS BLOB) WHERE key='test-whitelist'"),
+ db => db.exec("UPDATE app_settings SET key=CAST(key AS BLOB) WHERE key='test-whitelist'")
+ ]) {
+ const db = open(core + "INSERT INTO records VALUES('events','current-dev-event','{\"title\":\"Keep current dev data\"}'); INSERT INTO app_settings VALUES('test-whitelist','[\"900000001\"]');");
+ try {
+ const developmentSettings = db.prepare('SELECT key,value FROM app_settings').all();
+ const { sql } = prepareSnapshot(options({ sourceSql: source("INSERT INTO records VALUES('events','new-snapshot-event','{}');"), developmentSchema: developmentSchema(db), developmentSettings }));
+ change(db);
+ const current = db.prepare('SELECT hex(CAST(key AS BLOB)) AS keyBytes,hex(CAST(value AS BLOB)) AS valueBytes,typeof(key) AS keyType,typeof(value) AS valueType FROM app_settings ORDER BY key').all();
+ assert.throws(() => apply(db, sql), /CHECK constraint failed/);
+ assert.equal(db.prepare('SELECT id FROM records').get().id, 'current-dev-event');
+ assert.deepEqual(db.prepare('SELECT hex(CAST(key AS BLOB)) AS keyBytes,hex(CAST(value AS BLOB)) AS valueBytes,typeof(key) AS keyType,typeof(value) AS valueType FROM app_settings ORDER BY key').all(), current);
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_schema WHERE name GLOB '_xevents_snapshot_guard_*'").get().n, 0);
+ } finally { db.close(); }
+ }
+});
+
+test('atomic settings guard compares exact text bytes and requires an initialized dev settings table', () => {
+ assert.throws(() => prepareSnapshot(options({ developmentSchema: [{ type: 'table', name: 'records' }] })), /could not be prepared safely/);
+ const db = open(core);
+ try {
+ const developmentSettings = [{ key: 'quote\' — نام', value: 'a\0b' }];
+ db.prepare('INSERT INTO app_settings VALUES(?,?)').run(developmentSettings[0].key, developmentSettings[0].value);
+ const { sql } = prepareSnapshot(options({ developmentSettings }));
+ assert.ok(sql.indexOf('CHECK(ok=1)') < sql.indexOf('DROP TABLE IF EXISTS "records"'));
+ apply(db, sql);
+ assert.equal(db.prepare('SELECT hex(CAST(value AS BLOB)) AS bytes FROM app_settings WHERE key=?').get(developmentSettings[0].key).bytes, '610062');
+ } finally { db.close(); }
+});
From 7dcdd1864062bd4013d79fd5f9e1e42a35056cad Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:18:51 +1100
Subject: [PATCH 05/12] Protect development production snapshots and payment
audits
---
scripts/check-worker.mjs | 36 ++++++++
src/mini-api.js | 27 +++++-
src/payments.js | 4 +
src/snapshot-mode.js | 27 ++++++
src/test-access.js | 29 +++---
src/worker-store.js | 4 +
src/worker.js | 22 +++--
test/snapshot-mode.test.js | 177 +++++++++++++++++++++++++++++++++++++
test/test-access.test.js | 27 +++++-
9 files changed, 333 insertions(+), 20 deletions(-)
create mode 100644 src/snapshot-mode.js
create mode 100644 test/snapshot-mode.test.js
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index 1280253..9328782 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -159,6 +159,42 @@ async function checkTestAccess() {
}
}finally{await test.worker.dispose();}
}
+ const snapshot=await fixture({TEST_WHITELIST_ENABLED:'false',WHITELIST_USER_IDS:''});
+ try {
+ const {worker,db,calls,api,message,setting}=snapshot;
+ await db.prepare("INSERT INTO app_settings(key,value) VALUES ('production-snapshot','not-json')").run();
+ const health=await (await worker.dispatchFetch('https://access.test/')).json();
+ assert.equal(health.databaseRefreshProtection,1);
+ assert.equal((await api('bootstrap',blockedUser)).status,403,'A production copy must stay restricted even with the dev whitelist switch disabled.');
+ assert.equal((await api('bootstrap',admin)).status,200);
+ await message(710070,'/whitelist add '+seedUser);
+ assert.deepEqual(await setting(),[String(seedUser)]);
+ assert.equal((await api('bootstrap',seedUser)).status,200);
+ await message(710071,'/whitelist clear');
+ assert.deepEqual(await setting(),[]);
+ assert.equal((await api('bootstrap',seedUser)).status,403,'Clearing testers must not expose a production copy.');
+ await message(710072,'/whitelist add '+seedUser);
+ const audit={starOrders:{'snapshot-order':{id:'snapshot-order',owner:admin,title:'Copied financial audit',amount:10,status:'paid',charge:'snapshot-charge'}}};
+ await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(seedUser),JSON.stringify(audit)).run();
+ for(const [id,method,params] of [
+ ['snapshot-foreign','sendMessage',{chat_id:blockedUser,text:'Copied recipient fixture'}],
+ ['snapshot-invoice','sendInvoice',{chat_id:seedUser,title:'Blocked invoice fixture'}],
+ ['snapshot-refund','refundStarPayment',{user_id:seedUser,telegram_payment_charge_id:'snapshot-charge'}],
+ ['snapshot-permitted','sendMessage',{chat_id:seedUser,text:'Allowed tester fixture'}]
+ ])await db.prepare('INSERT INTO outbox(id,method,params) VALUES (?,?,?)').bind(id,method,JSON.stringify(params)).run();
+ await message(710073,'/whitelist list');
+ for(let attempt=0;attempt<100;attempt++){
+ if(!(await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE id LIKE 'snapshot-%'").first()).n)break;
+ await new Promise(resolve=>setTimeout(resolve,25));
+ }
+ assert.equal((await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE id LIKE 'snapshot-%'").first()).n,0);
+ assert.equal(calls.some(call=>call.params?.text==='Copied recipient fixture'),false);
+ assert.equal(calls.some(call=>['sendInvoice','refundStarPayment'].includes(call.method)),false);
+ assert.ok(calls.some(call=>call.params?.text==='Allowed tester fixture'));
+ const preserved=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(seedUser)).first()).data);
+ assert.deepEqual(preserved,audit,'Suppressing copied refunds must preserve the imported payment audit.');
+ assert.ok(calls.some(call=>/Only the super admin has access/.test(call.params?.text || '')),'The clear command must describe snapshot admin-only access.');
+ }finally{await snapshot.worker.dispose();}
console.log('Development access integration passed: environment isolation, empty and seeded lists, private admin commands, concurrent edits, Telegram retries, route privacy, callback and link claims, checkout denial and payment/refund audit preservation. Telegram mocked.');
}
diff --git a/src/mini-api.js b/src/mini-api.js
index 7c3674b..8e05977 100644
--- a/src/mini-api.js
+++ b/src/mini-api.js
@@ -6,7 +6,7 @@ import {invitationMode,invitationSettings,oneTimeInvites,invitationAvailable,rec
import {managedInvitations,invitationsVersion,addInvitations,removeInvitation} from './invitation-management.js';
import {isManager,cohostEntries,cohostLink,cohostVersion,createCohostInvite,revokeCohost} from './cohosts.js';
import { authenticate } from './mini-auth.js';
-import { mayUseTestApp, testAccessMessage } from './test-access.js';
+import { mayUseTestApp, testAccessMessage, TestAccessError } from './test-access.js';
import { parsePricing, currencyCodes, localCurrency } from './pricing.js';
import { mediaApi } from './media-api.js';
import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
@@ -120,6 +120,25 @@ export async function miniApi(request, env) {
const user = authenticate(request.headers.get('Authorization')?.replace(/^tma /, ''), env.TELEGRAM_BOT_TOKEN);
if (!user) return respond({ error: 'Open the planner inside Telegram. If it was open for a while, close and reopen it.' }, 401);
if (!await mayUseTestApp(env, user)) return respond({ error: testAccessMessage }, 403);
+ // The identity comes only from verified Telegram data. A database refresh can
+ // finish between authorization and a later route read or mutation.
+ const invocation = { ...env, snapshotUser: user };
+ try {
+ const response = await miniApiForUser(request, invocation, user);
+ if (!await mayUseTestApp(invocation, user)) {
+ if (response.body) await response.body.cancel().catch(() => {});
+ return respond({ error: testAccessMessage }, 403);
+ }
+ return response;
+ } catch (error) {
+ if (error instanceof TestAccessError || !await mayUseTestApp(invocation, user)) return respond({ error: testAccessMessage }, 403);
+ throw error;
+ }
+}
+
+async function miniApiForUser(request, env, user) {
+ const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
+ const respond = (data, status = 200) => Response.json(data, { status, headers });
const path = new URL(request.url).pathname;
const profileResponse=await profilePhotoApi(request,env,user);if(profileResponse)return profileResponse;
const eventMatch=path.match(/^\/api\/events\/([a-f0-9]{16})$/);
@@ -147,7 +166,7 @@ export async function miniApi(request, env) {
const settings=parsePricing(JSON.parse(raw));
await mutateState(env,data=>{data.preferences._pricing=settings;});
return respond({settings});
- }catch(e){return respond({error:e instanceof InputError ? e.message : 'Could not save pricing settings.'},e instanceof BusyError ? 503 : 400);}
+ }catch(e){if(e instanceof TestAccessError)throw e;return respond({error:e instanceof InputError ? e.message : 'Could not save pricing settings.'},e instanceof BusyError ? 503 : 400);}
}
if (path !== '/api/admin/overview' || request.method !== 'GET') return respond({ error: 'Not found.' }, 404);
await rememberUser(env, user);
@@ -177,6 +196,7 @@ export async function miniApi(request, env) {
const form = await new Response(bytes, { headers: { 'Content-Type': request.headers.get('Content-Type') || '' } }).formData();
const photo = form.get('photo');
if (!(photo instanceof File) || !photo.size || photo.size > 5 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp'].includes(photo.type)) return respond({ error: 'Choose a JPG, PNG, or WebP photo smaller than 5 MB.' }, 400);
+ if (!await mayUseTestApp(env, user)) throw new TestAccessError();
const upload = new FormData(); upload.set('chat_id', String(user.id)); upload.set('photo', photo); upload.set('caption', `Banner for ${event.title}`);
const result = await (await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendPhoto`, { method: 'POST', body: upload, signal: AbortSignal.timeout(20000) })).json();
const fileId = result.result?.photo?.at(-1)?.file_id;
@@ -188,7 +208,7 @@ export async function miniApi(request, env) {
return { event: publicEvent(current, user.id, env.BOT_USERNAME) };
});
return respond(value);
- } catch (error) { console.error('banner_failed', error.name); return respond({ error: error instanceof BusyError ? error.message : 'Could not load or save the banner. Please try again.' }, 503); }
+ } catch (error) { if(error instanceof TestAccessError)throw error;console.error('banner_failed', error.name); return respond({ error: error instanceof BusyError ? error.message : 'Could not load or save the banner. Please try again.' }, 503); }
}
if (path === '/api/bootstrap' && request.method === 'GET') {
await rememberUser(env, user);
@@ -332,6 +352,7 @@ export async function miniApi(request, env) {
});
return respond(value);
} catch (e) {
+ if (e instanceof TestAccessError) throw e;
if (e instanceof BusyError) return respond({ error: e.message }, 503);
// Validation errors are controlled strings; never expose database or fetch errors.
if (e instanceof InputError) return respond({ error: e.message }, 400);
diff --git a/src/payments.js b/src/payments.js
index 84573b5..c0ccc77 100644
--- a/src/payments.js
+++ b/src/payments.js
@@ -1,5 +1,6 @@
import { randomBytes } from 'node:crypto';
import { participantCount,requiresApproval } from './permissions.js';
+import { snapshotPaymentMessage } from './snapshot-mode.js';
export const starTotal = (e, g) => e.starPrice * (e.starPricing === 'person' ? participantCount(e, g) : 1);
export function orderFor(data, uid, token) { return data.preferences[uid]?.starOrders?.[token]; }
@@ -8,6 +9,7 @@ export function validOrder(data, uid, order) {
return !!(e && !e.cancelled && e.owner === order.owner && e.starPrice > 0 && e.starPrice===order.unitPrice && e.starPricing===order.pricing && e.paymentTerms===order.terms && g?.status === 'yes' && (!requiresApproval(e) || g.approval === 'approved') && g.payment?.status !== 'paid' && order.status === 'pending' && g.payment?.order === order.id && starTotal(e,g) === order.amount && participantCount(e,g) === order.participants && (!e.startsAt || Date.parse(e.startsAt) > Date.now()));
}
export async function invoice(bot, uid, e, consent = false) {
+ if (bot.productionSnapshot) return bot.send(uid, snapshotPaymentMessage);
const g=e.guests[uid];
if (!e.starPrice || e.cancelled || g?.status !== 'yes' || (requiresApproval(e) && g.approval !== 'approved')) return bot.send(uid,'Payment is available after your acceptance is approved.');
if (g.payment?.status === 'paid') return bot.send(uid,'Your payment is already confirmed.');
@@ -29,6 +31,7 @@ export async function invoice(bot, uid, e, consent = false) {
return bot.api('sendInvoice',{chat_id:uid,title:e.title.slice(0,32),description:`Event admission · ${order.participants} participant(s)`.slice(0,255),payload:order.id,provider_token:'',currency:'XTR',prices:[{label:'Event admission',amount:order.amount}],start_parameter:`e_${e.id}`});
}
export function checkout(bot,q) {
+ if (bot.productionSnapshot) return {pre_checkout_query_id:q.id,ok:false,error_message:snapshotPaymentMessage};
const order=orderFor(bot.db,q.from.id,q.invoice_payload);
const ok=q.currency==='XTR' && validOrder(bot.db,q.from.id,order) && q.total_amount===order.amount && (!order.checkoutId || order.checkoutId===q.id);
if(ok) { order.checkoutId=q.id; order.checkoutAt ||= new Date().toISOString(); bot.db.events[order.event].guests[q.from.id].payment.status='processing'; }
@@ -51,6 +54,7 @@ export async function successful(bot,m) {
await bot.ticket(uid,e);
}
export async function requestRefund(bot,uid,order) {
+ if (bot.productionSnapshot) return bot.send(uid, snapshotPaymentMessage);
if(!order?.charge || !['paid','refund_failed'].includes(order.status))return;
order.status='refund_pending';
const g=bot.db.events[order.event]?.guests[uid];
diff --git a/src/snapshot-mode.js b/src/snapshot-mode.js
new file mode 100644
index 0000000..36c9989
--- /dev/null
+++ b/src/snapshot-mode.js
@@ -0,0 +1,27 @@
+import { isSuperAdmin } from './admin.js';
+import { accessPolicy } from './test-access.js';
+
+export const snapshotPaymentMessage = 'Payments and refunds are disabled for this development database copy.';
+
+export async function snapshotActive(env) {
+ if (env.APP_ENV !== 'development') return false;
+ // Presence is intentional: malformed or empty metadata must not disable protection.
+ const row = await env.DB.prepare("SELECT value FROM app_settings WHERE key='production-snapshot'").first();
+ return row !== null && row !== undefined;
+}
+
+export async function snapshotDeliveryAllowed(env, method, params = {}) {
+ if (!await snapshotActive(env)) return true;
+ if (['sendInvoice', 'createInvoiceLink', 'refundStarPayment'].includes(method)) return false;
+ const recipients = ['chat_id', 'user_id'].filter(key => params[key] !== undefined).map(key => params[key]);
+ if (!recipients.length) return true;
+ let policy;
+ for (const value of recipients) {
+ const text = String(value);
+ if (!/^[1-9]\d*$/.test(text) || !Number.isSafeInteger(Number(text))) return false;
+ if (isSuperAdmin({ id: Number(text) }, env)) continue;
+ policy ||= await accessPolicy(env, true);
+ if (policy.invalid || !policy.ids.includes(text)) return false;
+ }
+ return true;
+}
diff --git a/src/test-access.js b/src/test-access.js
index 47d2caa..68d8c49 100644
--- a/src/test-access.js
+++ b/src/test-access.js
@@ -1,7 +1,11 @@
import { isSuperAdmin } from './admin.js';
import { mutateState } from './worker-store.js';
+import { snapshotActive } from './snapshot-mode.js';
export const testAccessMessage = 'This test app is limited to approved testers.';
+export class TestAccessError extends Error {
+ constructor() { super(testAccessMessage); }
+}
export const whitelistEnabled = env => env.APP_ENV === 'development' && String(env.TEST_WHITELIST_ENABLED).trim() === 'true';
function validId(value) {
@@ -16,8 +20,8 @@ function parseIds(value) {
return ids.length && ids.every(validId) ? [...new Set(ids)] : null;
}
-export async function accessPolicy(env) {
- if (!whitelistEnabled(env)) return { enabled: false, ids: [], invalid: false };
+export async function accessPolicy(env, force = false) {
+ if (!whitelistEnabled(env) && !(force && env.APP_ENV === 'development')) return { enabled: false, ids: [], invalid: false };
const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first();
let ids;
if (stored) {
@@ -30,13 +34,17 @@ export async function accessPolicy(env) {
}
export async function mayUseTestApp(env, user) {
- if (!whitelistEnabled(env) || isSuperAdmin(user, env)) return true;
+ if (env.APP_ENV !== 'development' || isSuperAdmin(user, env)) return true;
+ if (whitelistEnabled(env) && (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot)) return false;
+ const snapshot = await snapshotActive(env);
+ if (!whitelistEnabled(env) && !snapshot) return true;
if (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot) return false;
- const policy = await accessPolicy(env);
- return !policy.invalid && (!policy.ids.length || policy.ids.includes(String(user.id)));
+ const policy = await accessPolicy(env, snapshot);
+ return !policy.invalid && ((!snapshot && !policy.ids.length) || policy.ids.includes(String(user.id)));
}
-function statusText(ids) {
+function statusText(ids, snapshot = false) {
+ if (snapshot) return ids.length ? `Database copy protected. Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'} and the super admin.` : 'Database copy protected. Only the super admin has access.';
return ids.length ? `Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'}. Super admin always has access.` : 'Whitelist empty. Open to everyone.';
}
@@ -48,17 +56,18 @@ export async function handleWhitelistCommand(env, update) {
await mutateState(env, async (_data, bot, settings) => {
const reply = text => bot.send(message.chat.id, text);
if (!isSuperAdmin(message.from, env)) return reply('Only the super admin can manage test access.');
- if (!whitelistEnabled(env)) return reply('Test whitelist is disabled in this environment.');
+ const snapshot = !!bot.productionSnapshot;
+ if (!whitelistEnabled(env) && !snapshot) return reply('Test whitelist is disabled in this environment.');
const input = (command[2] || 'list').trim();
const [operation, ...rest] = input.split(/\s+/);
const action = operation.toLowerCase();
if (!['list', 'add', 'remove', 'clear'].includes(action) || (['list', 'clear'].includes(action) && rest.length)) {
return reply('Use /whitelist, /whitelist add ID, /whitelist remove ID, or /whitelist clear.');
}
- const policy = await accessPolicy(env);
+ const policy = await accessPolicy(env, snapshot);
if (action !== 'clear' && policy.invalid) return reply('Whitelist configuration is invalid. Access is restricted. Use /whitelist clear to reset it.');
if (action === 'list') {
- await reply(statusText(policy.ids));
+ await reply(statusText(policy.ids, snapshot));
let page = '';
for (const id of policy.ids) {
if (page.length + id.length + 1 > 3500) { await reply(page); page = ''; }
@@ -76,7 +85,7 @@ export async function handleWhitelistCommand(env, update) {
ids = [...current];
}
settings.setAppSetting('test-whitelist', JSON.stringify(ids));
- await reply(statusText(ids));
+ await reply(statusText(ids, snapshot));
}, update.update_id);
return new Response('OK');
}
diff --git a/src/worker-store.js b/src/worker-store.js
index d20259a..fae4d09 100644
--- a/src/worker-store.js
+++ b/src/worker-store.js
@@ -1,5 +1,7 @@
import { Bot } from './bot.js';
import {readOnlinePricing} from './exchange.js';
+import { snapshotActive } from './snapshot-mode.js';
+import { mayUseTestApp, TestAccessError } from './test-access.js';
export class BusyError extends Error {}
@@ -8,6 +10,7 @@ export async function mutateState(env, action, updateId) {
const lock = await env.DB.prepare('INSERT INTO lease (id, owner, expires) VALUES (1, ?, unixepoch()+60) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, expires=excluded.expires WHERE lease.expires < unixepoch() RETURNING owner').bind(owner).first();
if (!lock) throw new BusyError('Please try again in a moment.');
try {
+ if (env.snapshotUser && !await mayUseTestApp(env, env.snapshotUser)) throw new TestAccessError();
if (updateId !== undefined && await env.DB.prepare('SELECT id FROM processed WHERE id=?').bind(updateId).first()) return;
const { results } = await env.DB.prepare("SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')").all();
const data = { events: {}, sessions: {}, preferences: {} };
@@ -15,6 +18,7 @@ export async function mutateState(env, action, updateId) {
const before = new Map(results.map(r => [`${r.kind}:${r.id}`, r.data]));
const messages = [];
const bot = new Bot({ data }, async (method, params) => { messages.push({ method, params }); return {}; }, env.BOT_USERNAME, env.APP_URL);
+ bot.productionSnapshot = await snapshotActive(env);
bot.pricing=await readOnlinePricing(env,data.preferences._pricing);
const settings = [];
const value = await action(data, bot, {
diff --git a/src/worker.js b/src/worker.js
index 4a9ef8a..8789dd1 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -6,6 +6,7 @@ import { checkout, refundResult } from './payments.js';
import {refreshOnlineRates} from './exchange.js';
import { botCommands, botCommandsVersion } from './telegram-menu.js';
import { handleWhitelistCommand, mayUseTestApp, testAccessMessage, whitelistEnabled } from './test-access.js';
+import { snapshotActive, snapshotDeliveryAllowed } from './snapshot-mode.js';
export async function authorized(request, secret) {
if (!secret) return false;
@@ -16,6 +17,7 @@ export async function authorized(request, secret) {
}
async function telegram(env, method, params) {
+ if (!await snapshotDeliveryAllowed(env, method, params)) return { ok: false, error_code: 403, snapshotBlocked: true };
let response;
try {
response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, {
@@ -47,7 +49,14 @@ export async function processUpdate(env, update) {
return new Response(result.ok ? 'OK' : 'Retry', {status: result.ok ? 200 : 503});
}
if (user) await rememberUser(env, user);
- await mutateState(env, (data, bot) => bot.handle(update), update.update_id);
+ await mutateState(env, async (data, bot) => {
+ if (user && !update.message?.successful_payment && !update.message?.refunded_payment && !await mayUseTestApp(env, user)) {
+ return update.callback_query
+ ? bot.api('answerCallbackQuery', { callback_query_id: update.callback_query.id, text: testAccessMessage, show_alert: true })
+ : bot.send(update.message.chat.id, testAccessMessage);
+ }
+ return bot.handle(update);
+ }, update.update_id);
return new Response('OK');
} catch (e) {
if (e instanceof BusyError) return new Response('Busy; retry', { status: 503 });
@@ -67,7 +76,7 @@ export async function drainOutbox(env) {
const row = await env.DB.prepare('UPDATE outbox SET due=unixepoch()+60, attempts=attempts+1 WHERE id=? AND due<=unixepoch() RETURNING *').bind(id).first();
if (!row) continue;
const result = await telegram(env, row.method, JSON.parse(row.params));
- if (row.method === 'refundStarPayment' && (result.ok || [400,403].includes(result.error_code))) {
+ if (row.method === 'refundStarPayment' && !result.snapshotBlocked && (result.ok || [400,403].includes(result.error_code))) {
const params=JSON.parse(row.params);
await mutateState(env, (data,bot) => refundResult(bot,params.user_id,params.telegram_payment_charge_id,result.ok));
}
@@ -122,7 +131,7 @@ export default {
}
return response;
}
- if (request.method === 'GET' && url.pathname === '/') return Response.json({ service: 'XEvents', status: 'running' });
+ if (request.method === 'GET' && url.pathname === '/') return Response.json({ service: 'XEvents', status: 'running', ...(env.APP_ENV === 'development' ? { databaseRefreshProtection: 1 } : {}) });
if (url.pathname === '/setup' && request.method === 'POST') {
if (!await authorized(request, env.TELEGRAM_WEBHOOK_SECRET)) return new Response('Unauthorized', { status: 401 });
const me = await telegram(env, 'getMe', {});
@@ -154,7 +163,10 @@ export default {
async scheduled(controller, env, ctx) {
ctx.waitUntil(refreshOnlineRates(env).catch(()=>console.error('online_rates_storage_failed')));
ctx.waitUntil(configureMiniApp(env));
- ctx.waitUntil(mutateState(env, (data, bot) => sendDueReminders(data, bot)).then(() => drainOutbox(env)).catch(error => { if (!(error instanceof BusyError)) console.error('reminder_processing_failed'); }));
+ ctx.waitUntil((async () => {
+ if (!await snapshotActive(env)) await mutateState(env, (data, bot) => bot.productionSnapshot ? undefined : sendDueReminders(data, bot));
+ await drainOutbox(env);
+ })().catch(error => { if (!(error instanceof BusyError)) console.error('reminder_processing_failed'); }));
ctx.waitUntil(env.DB.prepare('DELETE FROM processed WHERE at < unixepoch()-604800').run());
}
};
@@ -174,7 +186,7 @@ export async function configureMiniApp(env) {
const adminId = Number(env.SUPER_ADMIN_ID);
if (isSuperAdmin({ id: adminId }, env)) {
const adminCommands = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-admin-commands'").first();
- const enabled = whitelistEnabled(env);
+ const enabled = whitelistEnabled(env) || await snapshotActive(env);
const version = `${botCommandsVersion}:${adminId}:${enabled}`;
if ((enabled || adminCommands) && adminCommands?.value !== version) {
const result = await telegram(env, 'setMyCommands', {
diff --git a/test/snapshot-mode.test.js b/test/snapshot-mode.test.js
new file mode 100644
index 0000000..69450a2
--- /dev/null
+++ b/test/snapshot-mode.test.js
@@ -0,0 +1,177 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import worker, { drainOutbox, processUpdate } from '../src/worker.js';
+import { snapshotActive, snapshotDeliveryAllowed, snapshotPaymentMessage } from '../src/snapshot-mode.js';
+import { invoice, checkout, requestRefund } from '../src/payments.js';
+import { miniApi } from '../src/mini-api.js';
+import { createHmac } from 'node:crypto';
+
+function apiRequest(path, input) {
+ const params = new URLSearchParams({ auth_date: String(Math.floor(Date.now() / 1000)), user: JSON.stringify({ id: 111001, first_name: 'Fictional tester' }), query_id: 'fictional-snapshot-query' });
+ const text = [...params.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join('\n');
+ const key = createHmac('sha256', 'WebAppData').update('snapshot-test-token').digest();
+ params.set('hash', createHmac('sha256', key).update(text).digest('hex'));
+ return new Request('https://fictional.test/api/' + path, { method: input ? 'POST' : 'GET', headers: { Authorization: 'tma ' + params.toString(), 'Content-Type': 'application/json' }, ...(input ? { body: JSON.stringify(input) } : {}) });
+}
+
+function fixture({ snapshot = { value: '{}' }, stored = { value: '["111002"]' }, ...variables } = {}) {
+ return {
+ APP_ENV: 'development', SUPER_ADMIN_ID: '999001', ...variables,
+ DB: {
+ prepare(sql) {
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return stored; } };
+ assert.fail('Unexpected database query.');
+ }
+ }
+ };
+}
+
+test('only development activates the snapshot marker and any marker value protects data', async () => {
+ for (const value of ['', 'null', 'false', 'not-json', null]) assert.equal(await snapshotActive(fixture({ snapshot: { value } })), true);
+ assert.equal(await snapshotActive(fixture({ snapshot: null })), false);
+ for (const APP_ENV of ['production', undefined, 'preview']) {
+ const env = { APP_ENV, DB: { prepare() { assert.fail('Production must ignore the marker.'); } } };
+ assert.equal(await snapshotActive(env), false);
+ assert.equal(await snapshotDeliveryAllowed(env, 'refundStarPayment', { user_id: 111001 }), true);
+ }
+});
+
+test('copied database deliveries only reach admin and explicitly listed testers', async () => {
+ const env = fixture({ TEST_WHITELIST_ENABLED: 'false' });
+ for (const chat_id of [999001, '999001', 111002, '111002']) assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id }), true);
+ for (const chat_id of [111001, -111002, 0, '@FictionalChannel', '00111002', Number.MAX_SAFE_INTEGER + 1]) assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id }), false);
+ assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { chat_id: 999001, user_id: 111001 }), false);
+ assert.equal(await snapshotDeliveryAllowed(env, 'sendMessage', { user_id: 111002 }), true);
+ assert.equal(await snapshotDeliveryAllowed(env, 'setMyCommands', { scope: { type: 'chat', chat_id: 999001 } }), true);
+ assert.equal(await snapshotDeliveryAllowed(env, 'answerCallbackQuery', { callback_query_id: 'fictional-callback' }), true);
+});
+
+test('empty and malformed tester lists fail closed for outbound snapshot delivery', async () => {
+ for (const stored of [{ value: '[]' }, { value: 'not-json' }, null]) {
+ const env = fixture({ stored });
+ assert.equal(await snapshotDeliveryAllowed(env, 'sendPhoto', { chat_id: 111002 }), false);
+ assert.equal(await snapshotDeliveryAllowed(env, 'sendPhoto', { chat_id: 999001 }), true);
+ }
+});
+
+test('snapshot financial methods are blocked for every recipient', async () => {
+ for (const method of ['sendInvoice', 'createInvoiceLink', 'refundStarPayment']) {
+ assert.equal(await snapshotDeliveryAllowed(fixture(), method, { chat_id: 999001, user_id: 999001 }), false);
+ assert.equal(await snapshotDeliveryAllowed(fixture({ snapshot: null }), method, { chat_id: 111001 }), true);
+ }
+});
+
+test('payment entry points leave imported orders and guest payment audit unchanged', async () => {
+ const event = { id: 'fictional', guests: { 111002: { payment: { status: 'paid', order: 'fictional-order' } } } };
+ const order = { id: 'fictional-order', charge: 'fictional-charge', status: 'paid' };
+ const before = JSON.stringify({ event, order });
+ const sent = [];
+ const bot = { productionSnapshot: true, send: async (...args) => sent.push(args), api() { assert.fail('No financial API request is permitted.'); } };
+ await invoice(bot, 111002, event, true);
+ assert.deepEqual(checkout(bot, { id: 'fictional-checkout' }), { pre_checkout_query_id: 'fictional-checkout', ok: false, error_message: snapshotPaymentMessage });
+ await requestRefund(bot, 111002, order);
+ assert.equal(JSON.stringify({ event, order }), before);
+ assert.deepEqual(sent, [[111002, snapshotPaymentMessage], [111002, snapshotPaymentMessage]]);
+});
+
+test('development health advertises database refresh safeguards without exposing configuration', async () => {
+ for (const APP_ENV of ['production', 'development']) {
+ const response = await worker.fetch(new Request('https://fictional.test/'), { APP_ENV }, {});
+ const body = await response.json();
+ assert.equal(body.databaseRefreshProtection, APP_ENV === 'development' ? 1 : undefined);
+ assert.deepEqual(Object.keys(body).sort(), APP_ENV === 'development' ? ['databaseRefreshProtection', 'service', 'status'] : ['service', 'status']);
+ }
+});
+
+test('suppressed refund deliveries do not contact Telegram or rewrite imported audit state', async t => {
+ const env = fixture();
+ const base = env.DB.prepare.bind(env.DB), removed = [];
+ const refund = { id: 'fictional-refund', due: 0, method: 'refundStarPayment', params: JSON.stringify({ user_id: 111002, telegram_payment_charge_id: 'fictional-charge' }) };
+ env.DB.prepare = sql => {
+ if (sql.startsWith('INSERT INTO delivery_lease')) return { bind() { return this; }, async first() { return { owner: 'fictional-lease' }; } };
+ if (sql === 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20') return { async all() { return { results: [refund] }; } };
+ if (sql.startsWith('UPDATE outbox SET due=unixepoch()+60')) return { bind() { return this; }, async first() { return refund; } };
+ if (sql === 'DELETE FROM outbox WHERE id=?') return { bind(id) { removed.push(id); return this; }, async run() {} };
+ if (sql === 'DELETE FROM delivery_lease WHERE owner=?') return { bind() { return this; }, async run() {} };
+ return base(sql);
+ };
+ t.mock.method(globalThis, 'fetch', () => assert.fail('Suppressed refunds must never reach Telegram.'));
+ await drainOutbox(env);
+ assert.deepEqual(removed, ['fictional-refund']);
+});
+
+test('snapshot scheduler preserves reminder state while configuration and rates tasks remain available', async () => {
+ const env = fixture(), base = env.DB.prepare.bind(env.DB), pending = [];
+ env.DB.prepare = sql => {
+ if (sql === 'SELECT value FROM app_settings WHERE key=?') return { bind() { return this; }, async first() { return { value: JSON.stringify({ retrievedAt: new Date().toISOString() }) }; } };
+ if (sql.startsWith('INSERT INTO delivery_lease')) return { bind() { return this; }, async first() { return null; } };
+ if (sql === 'DELETE FROM processed WHERE at < unixepoch()-604800') return { async run() {} };
+ return base(sql);
+ };
+ await worker.scheduled({}, env, { waitUntil(promise) { pending.push(promise); } });
+ await Promise.all(pending);
+ assert.equal(pending.length, 4);
+});
+
+test('a Mini App read crossing the database refresh cannot return copied private event data', async () => {
+ let copied = false;
+ const event = { id: '1110000000000001', owner: 111001, title: 'Private copied event fixture', when: 'Future', guests: {}, startsAt: '2099-01-01T00:00:00Z', location: 'Private copied venue fixture' };
+ const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', BOT_USERNAME: 'FictionalSnapshotBot', DB: {
+ prepare(sql) {
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
+ if (sql === "SELECT data FROM records WHERE kind='events' AND id=?") return { bind() { return this; }, async first() { copied = true; return { data: JSON.stringify(event) }; } };
+ assert.fail('Unexpected database query.');
+ }
+ } };
+ const response = await miniApi(apiRequest('events/' + event.id), env);
+ assert.equal(response.status, 403);
+ assert.equal(response.headers.get('Cache-Control'), 'no-store');
+ assert.doesNotMatch(await response.text(), /Private copied|venue|1110000000000001/);
+});
+
+test('a Mini App mutation rechecks access under its lease before loading copied records', async () => {
+ let copied = false, released = false;
+ const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', snapshotUser: { id: 999001 }, SUPER_ADMIN_ID: '999001', DB: {
+ prepare(sql) {
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
+ if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } };
+ if (sql === 'DELETE FROM lease WHERE owner=?') return { bind() { return this; }, async run() { released = true; } };
+ assert.fail('Blocked mutations must not read or write copied application records.');
+ }
+ } };
+ const response = await miniApi(apiRequest('preferences', { timezone: 'UTC', snapshotUser: { id: 999001 } }), env);
+ assert.equal(response.status, 403);
+ assert.equal(released, true);
+ assert.match((await response.json()).error, /test|access/i);
+});
+
+test('a Telegram callback crossing a refresh does not render the copied event', async () => {
+ let copied = false;
+ const queued = [];
+ const event = { id: '1110000000000001', owner: 111001, title: 'Private copied Telegram fixture', guests: {}, location: 'Private copied venue' };
+ const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', BOT_USERNAME: 'FictionalSnapshotBot', DB: {
+ prepare(sql) {
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
+ if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } };
+ if (sql.startsWith("INSERT INTO records(kind,id,data) VALUES ('users'")) return { bind() { return this; }, async run() {} };
+ if (sql === 'SELECT id FROM processed WHERE id=?') return { bind() { return this; }, async first() { return null; } };
+ if (sql === "SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')") return { async all() { return { results: [{ kind: 'events', id: event.id, data: JSON.stringify(event) }] }; } };
+ if (sql === 'SELECT value FROM app_settings WHERE key=?') return { bind() { return this; }, async first() { return null; } };
+ if (sql === 'INSERT INTO outbox(id,method,params) VALUES (?,?,?)') return { bind(id, method, params) { queued.push({ id, method, params: JSON.parse(params) }); return this; } };
+ if (['INSERT INTO commits(owner) VALUES (?)', 'INSERT INTO processed(id,at) VALUES (?,unixepoch())', 'DELETE FROM commits WHERE owner=?'].includes(sql)) return { bind() { return this; } };
+ if (sql === 'DELETE FROM lease WHERE owner=?') return { bind() { return this; }, async run() {} };
+ assert.fail('The blocked callback must not mutate copied application records.');
+ },
+ async batch() {}
+ } };
+ const response = await processUpdate(env, { update_id: 1111001, callback_query: { id: 'fictional-racing-callback', from: { id: 111001, first_name: 'Fictional tester' }, data: 'v:' + event.id } });
+ assert.equal(response.status, 200);
+ assert.equal(queued.length, 1);
+ assert.equal(queued[0].method, 'answerCallbackQuery');
+ assert.match(queued[0].params.text, /test|access/i);
+ assert.doesNotMatch(JSON.stringify(queued), /Private copied/);
+});
diff --git a/test/test-access.test.js b/test/test-access.test.js
index 8608c7b..6dbf6d6 100644
--- a/test/test-access.test.js
+++ b/test/test-access.test.js
@@ -4,7 +4,7 @@ import { accessPolicy, mayUseTestApp, whitelistEnabled } from '../src/test-acces
const development = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true' };
-function fixture({ stored = null, seed = '', ...variables } = {}) {
+function fixture({ stored = null, snapshot = null, seed = '', ...variables } = {}) {
let reads = 0;
const env = {
...development,
@@ -12,6 +12,7 @@ function fixture({ stored = null, seed = '', ...variables } = {}) {
...variables,
DB: {
prepare(sql) {
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } };
assert.equal(sql, "SELECT value FROM app_settings WHERE key='test-whitelist'");
return { async first() { reads++; return stored; } };
}
@@ -45,11 +46,33 @@ test('production ignores the test list without reading its database', async () =
});
test('disabling the test access switch preserves open development access', async () => {
- const env = { ...development, TEST_WHITELIST_ENABLED: 'false', WHITELIST_USER_IDS: '111002' };
+ const { env } = fixture({ TEST_WHITELIST_ENABLED: 'false', seed: '111002' });
assert.deepEqual(await accessPolicy(env), { enabled: false, ids: [], invalid: false });
assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
});
+test('database copies require explicit testers even when the whitelist switch is disabled', async () => {
+ const { env } = fixture({ snapshot: { value: '' }, TEST_WHITELIST_ENABLED: 'false', seed: '111002', SUPER_ADMIN_ID: '999001' });
+ assert.equal(await mayUseTestApp(env, { id: 111002 }), true);
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ assert.equal(await mayUseTestApp(env, { id: 999001 }), true);
+ assert.deepEqual(await accessPolicy(env, true), { enabled: true, ids: ['111002'], invalid: false });
+});
+
+test('an empty or malformed list never opens a production database copy', async () => {
+ for (const stored of [null, { value: '[]' }, { value: 'not-json' }]) {
+ const { env } = fixture({ snapshot: { value: 'not-json' }, stored, SUPER_ADMIN_ID: '999001' });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ assert.equal(await mayUseTestApp(env, { id: 999001 }), true);
+ }
+});
+
+test('forcing test access policy still cannot enable a production restriction', async () => {
+ const env = { APP_ENV: 'production', DB: { prepare() { assert.fail('Production must not read copied database restrictions.'); } } };
+ assert.deepEqual(await accessPolicy(env, true), { enabled: false, ids: [], invalid: false });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), true);
+});
+
test('an absent or blank seed opens access to verified human users', async () => {
for (const seed of ['', ' \n\t ', undefined, null]) {
const { env } = fixture({ seed });
From 5fa2c342a6815f18dbf7983d63ffdc61fb47bcf6 Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:36:22 +1100
Subject: [PATCH 06/12] Add development whitelist controls to the superadmin
panel
---
docs/development.md | 4 ++
public/app.js | 84 ++++++++++++++++++++++--
public/index.html | 19 +++++-
public/style.css | 1 +
scripts/check-worker.mjs | 49 +++++++++++++-
src/mini-api.js | 19 +++++-
src/test-access.js | 92 ++++++++++++++++++++++----
src/worker.js | 5 +-
test/snapshot-mode.test.js | 4 ++
test/telegram-menu.test.js | 3 +-
test/test-access.test.js | 130 ++++++++++++++++++++++++++++++++++++-
11 files changed, 385 insertions(+), 25 deletions(-)
diff --git a/docs/development.md b/docs/development.md
index e3fa46f..e8c4437 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -118,6 +118,10 @@ Keep real IDs in private Worker secrets or bot commands. `WHITELIST_USER_IDS` su
Removing the final tester also opens access to everyone; every change confirms whether access is restricted or open. Invalid configuration restricts access until the superadmin resets it. Payment and refund confirmations already issued by Telegram are still recorded after a tester is removed. Existing queued notifications are preserved.
+In the development Mini App, open **Profile → Super admin → Dev access** to turn the whitelist on or off and add or remove numeric Telegram user IDs. The panel warns before enabling restrictions or removing the last allowed user. The superadmin always retains access. Turning it off keeps the saved IDs and permits everyone; turning it on restricts access when the list contains IDs. An empty ordinary whitelist still permits everyone. These changes apply immediately to the development bot and Mini App and survive redeployment. The `TEST_WHITELIST_ENABLED` variable is the initial default; the saved panel setting takes precedence. Production does not expose these controls or honor the development setting.
+
+When development contains a production snapshot, its protection takes priority: the panel cannot turn access off, and removing the last tester leaves admin-only access. Database refresh retains the saved switch and whitelist together with the other development settings.
+
This restriction applies to the Cloudflare Worker, including its webhook and Mini App APIs. Use `wrangler dev` for local access-control testing; the standalone `npm start` polling process does not enforce this hosted feature. The whitelist command is advertised only in the configured superadmin's private chat.
## Copy production data to dev
diff --git a/public/app.js b/public/app.js
index 44d6577..201c388 100644
--- a/public/app.js
+++ b/public/app.js
@@ -37,6 +37,7 @@ function updatePricePreview(){
}
let listFilter = 'all';
let adminData = null, adminMode = 'events';
+let adminWhitelist = null, whitelistBusy = false;
let bannerPreviewUrl,bannerLoadGeneration=0;
let profilePhotoUrl,profilePhotoGeneration=0;
let botIconUrl,botIconGeneration=0;
@@ -216,7 +217,7 @@ const {cancelEvent,deleteEvent}=setupEventActions({$,api,refresh,notice,onEnded:
function confirmAction(message, operation) {
const dialog = $('confirm-dialog');
if (dialog.open) return Promise.resolve(false);
- const labels={delete:['Delete event?','Delete event','Keep event'],cancel:['Cancel event?','Cancel event','Keep event'],'cohost-revoke':['Revoke co-host access?','Revoke access','Keep access'],'cohost-cancel-link':['Cancel co-host invite?','Cancel invite link','Keep link'],'cohost-rotate':['Replace co-host invite?','Create new link','Keep current link']}[operation] || ['Confirm action','Confirm','Back'];
+ const labels={delete:['Delete event?','Delete event','Keep event'],cancel:['Cancel event?','Cancel event','Keep event'],'cohost-revoke':['Revoke co-host access?','Revoke access','Keep access'],'cohost-cancel-link':['Cancel co-host invite?','Cancel invite link','Keep link'],'cohost-rotate':['Replace co-host invite?','Create new link','Keep current link'],'whitelist-enable':['Restrict dev access?','Turn on whitelist','Keep current access'],'whitelist-first':['Restrict dev access?','Add user and restrict','Back'],'whitelist-disable':['Open dev access?','Turn off whitelist','Keep restricted'],'whitelist-last':['Remove the last user?','Remove user','Keep user'],'whitelist-repair':['Repair access setting?','Repair and turn on','Back']}[operation] || ['Confirm action','Confirm','Back'];
$('confirm-title').textContent = labels[0];
$('confirm-message').textContent = message;
$('confirm-proceed').textContent = labels[1];$('confirm-back').textContent=labels[2];
@@ -645,12 +646,85 @@ function filterExplore(){const text=$('explore-search').value.trim().toLowerCase
$('explore-search').oninput=filterExplore;
async function loadAdmin() {
$('admin-refresh').disabled = true; $('admin-error').hidden = true;
- try { adminData = await api('admin/overview');
- $('owner-default-price').value=adminData.pricing?.defaultStarPrice || 0; $('owner-default-unit').value=adminData.pricing?.defaultStarPricing || 'person';
-renderAdmin(); }
- catch (error) { $('admin-error').textContent=error.message; $('admin-error').hidden=false; $('admin-list').replaceChildren(); }
+ const results=await Promise.allSettled([api('admin/overview'),loadAdminWhitelist()]);
+ try {
+ if(results[0].status==='rejected')throw results[0].reason;
+ adminData=results[0].value;
+ $('owner-default-price').value=adminData.pricing?.defaultStarPrice || 0; $('owner-default-unit').value=adminData.pricing?.defaultStarPricing || 'person';
+ renderAdmin();
+ if(results[1].status==='rejected')throw new Error('Dev access: '+results[1].reason.message);
+ }
+ catch (error) { $('admin-error').textContent=error.message; $('admin-error').hidden=false;if(results[0].status==='rejected')$('admin-list').replaceChildren(); }
finally { $('admin-refresh').disabled=false; }
}
+function setWhitelistBusy(busy) {
+ whitelistBusy=busy;
+ $('admin-whitelist').setAttribute('aria-busy',String(busy));
+ for(const control of $('admin-whitelist').querySelectorAll('input,button'))control.disabled=busy;
+ $('whitelist-enabled').disabled=busy || !!adminWhitelist?.snapshotProtected;
+}
+function renderAdminWhitelist() {
+ const data=adminWhitelist,section=$('admin-whitelist');
+ section.hidden=!state.user?.isSuperAdmin || data?.supported!==true;
+ if(section.hidden)return;
+ const ids=Array.isArray(data.ids)?data.ids:[];
+ $('whitelist-enabled').checked=!!data.enabled || !!data.snapshotProtected;
+ $('whitelist-toggle-note').textContent=data.snapshotProtected ? 'Copied production data stays restricted to listed testers and the super admin.' : 'Off allows everyone. On allows listed users; an empty list also allows everyone.';
+ $('whitelist-summary').textContent=data.snapshotProtected ? '🔒 Dev access · protected snapshot' : data.restricted ? '🔒 Dev access · restricted' : '🔒 Dev access · open';
+ $('whitelist-access').textContent=data.invalid ? (data.restricted ? 'The saved access settings need attention. Access is restricted for safety; repair the setting and update the user IDs below.' : 'Anyone can access the dev bot and Mini App. The saved whitelist needs attention; repair the access setting before using it.') : data.snapshotProtected ? (ids.length ? 'Only the listed users and the super admin can use this development snapshot.' : 'This development snapshot is available to the super admin only. Add user IDs to invite testers.') : data.restricted ? 'Only the listed users and the super admin can access the dev bot and Mini App.' : data.enabled ? 'The list is empty, so anyone can access the dev bot and Mini App. Add a user ID to restrict access.' : 'Whitelist is off. Anyone can access the dev bot and Mini App.';
+ $('whitelist-snapshot-note').hidden=!data.snapshotProtected;
+ $('whitelist-repair-options').hidden=!data.invalid;
+ $('whitelist-repair-note').hidden=!data.invalid;
+ $('whitelist-empty').hidden=ids.length>0;
+ $('whitelist-empty').textContent=data.invalid ? 'No valid user IDs are shown. Repair the access setting, then add a valid user ID.' : data.snapshotProtected ? 'No testers added yet.' : 'No user IDs added yet. An empty whitelist leaves access open.';
+ const list=$('whitelist-users');list.replaceChildren();
+ for(const id of ids){
+ const row=element('li','','whitelist-user'),label=element('code',String(id));
+ const remove=action('✕ Remove',()=>changeAdminWhitelist({action:'remove',ids:String(id)}));
+ remove.className='secondary';remove.setAttribute('aria-label','Remove Telegram user ID '+String(id));
+ row.append(label,remove);list.append(row);
+ }
+ setWhitelistBusy(whitelistBusy);
+}
+async function loadAdminWhitelist() {
+ if(!state.user?.isSuperAdmin){adminWhitelist=null;$('admin-whitelist').hidden=true;return;}
+ setWhitelistBusy(true);
+ try { adminWhitelist=await api('admin/whitelist');renderAdminWhitelist(); }
+ finally { setWhitelistBusy(false); }
+}
+async function changeAdminWhitelist(body,repair=false) {
+ if(whitelistBusy || !adminWhitelist?.supported || !state.user?.isSuperAdmin)return;
+ const data=adminWhitelist;
+ setWhitelistBusy(true);$('whitelist-status').textContent='';
+ try {
+ let warning='',operation='';
+ if(repair){
+ warning='Repair turns on a valid whitelist access setting. When valid user IDs are saved, only listed users and the super admin can enter. If the user list still needs attention after repair, add a valid user ID below. Your super admin access is always kept.'+(data.snapshotProtected ? ' Copied production data stays protected.' : ' An empty valid list leaves access open.');operation='whitelist-repair';
+ }else if(body.action==='set-enabled'){
+ warning=body.enabled ? 'When the whitelist is on and has user IDs, other users cannot access the dev bot or Mini App. Only listed users and the super admin can enter. An empty list leaves access open. Your super admin access is always kept.' : 'Turning off the whitelist allows everyone to access the dev bot and Mini App. Saved user IDs will stay in the list.';
+ operation=body.enabled?'whitelist-enable':'whitelist-disable';
+ }else if(body.action==='remove' && data.ids?.length===1 && data.enabled && !data.snapshotProtected){
+ warning='Removing the last user ID leaves the whitelist empty. Anyone will be able to access the dev bot and Mini App.';operation='whitelist-last';
+ }else if(body.action==='add' && !data.ids?.length && data.enabled && !data.snapshotProtected){
+ warning='Adding the first user ID will restrict access. Other users cannot access the dev bot or Mini App. Only listed users and the super admin can enter.';operation='whitelist-first';
+ }
+ if(warning && !await confirmAction(warning,operation))return;
+ adminWhitelist=await api('admin/whitelist',body);renderAdminWhitelist();
+ if(body.action==='add')$('whitelist-user-id').value='';
+ $('whitelist-status').textContent=repair ? (adminWhitelist.invalid ? 'Access setting repaired. Add a valid user ID to repair the saved list.' : 'Access setting repaired and whitelist turned on.') : body.action==='set-enabled' ? (body.enabled?'Whitelist turned on.':'Whitelist turned off.') : body.action==='add' ? 'User added to the whitelist.' : 'User removed from the whitelist.';
+ }catch(error){$('whitelist-status').textContent=error.message;}
+ finally {renderAdminWhitelist();setWhitelistBusy(false);}
+}
+$('whitelist-enabled').onchange=event=>{
+ const enabled=event.target.checked;event.target.checked=!!adminWhitelist?.enabled;
+ changeAdminWhitelist({action:'set-enabled',enabled});
+};
+$('whitelist-repair').onclick=()=>changeAdminWhitelist({action:'set-enabled',enabled:true},true);
+$('whitelist-add-form').onsubmit=event=>{
+ event.preventDefault();const input=$('whitelist-user-id'),id=input.value.trim();
+ if(!/^[1-9]\d{0,15}$/.test(id)){$('whitelist-status').textContent='Enter a valid numeric Telegram user ID.';input.focus();return;}
+ changeAdminWhitelist({action:'add',ids:id});
+};
function renderAdmin() {
if (!adminData) return;
$('admin-summary').textContent=`${adminData.events.length} events · ${adminData.users.length} users · ${adminData.events.filter(e=>e.group==='Upcoming events').length} upcoming`;
diff --git a/public/index.html b/public/index.html
index c8f75b2..35b2568 100644
--- a/public/index.html
+++ b/public/index.html
@@ -79,7 +79,24 @@
IT’S A DATE
Your event is ready.
- SUPER ADMIN
All plans. All people.
Browse every event and everyone who has used XEvents.
Owner fee settings
+ SUPER ADMIN
All plans. All people.
Browse every event and everyone who has used XEvents.
+
+ 🔒 Dev access
+
+
Restricting access: when the whitelist is on and has user IDs, other users cannot use the dev bot or Mini App. The super admin always keeps access.
+
+
Access restrictions stay on for copied production data. Add or remove testers below; this protection cannot be turned off here.
+
+
Repair turns on a valid whitelist setting. If the saved user list still needs attention, add a valid user ID below to replace it.
+
+
No user IDs added yet.
+
+
+
+ Owner fee settings
YOUR PROFILE
Your profile.
Your name, optional photo and phone number, and local timezone.
diff --git a/public/style.css b/public/style.css
index e0a7bc7..735e63e 100644
--- a/public/style.css
+++ b/public/style.css
@@ -9,6 +9,7 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter
.admin-guest { white-space:pre-wrap; overflow-wrap:anywhere; border-top:1px solid #dce5df; padding:14px 0; }
#admin-view p { white-space:pre-wrap; overflow-wrap:anywhere; }
#admin-view summary { cursor:pointer; padding:12px 0; font-weight:700; }
+#admin-whitelist summary{padding:0;line-height:1.5}#admin-whitelist[open] summary{margin-bottom:16px}.whitelist-warning{padding:12px 14px;border:1px solid #e7c78d;border-radius:10px;background:#fff4de;color:#72531e;font-size:13px}.whitelist-warning strong{font-weight:750}#admin-whitelist .check-option{margin-top:16px}#admin-whitelist .check-option .check-note{float:none}#whitelist-access,#whitelist-status{font-size:13px}#whitelist-status:empty{display:none}.whitelist-add-row{display:flex;gap:8px;align-items:stretch}.whitelist-add-row input{flex:1;min-width:0}.whitelist-add-row button{flex-shrink:0}.whitelist-users{list-style:none;padding:0;margin:12px 0 0}.whitelist-user{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding:10px 0}.whitelist-user code{min-width:0;overflow-wrap:anywhere;font-size:15px}.whitelist-user button{margin:0;flex-shrink:0;font-size:12px}#admin-whitelist input:disabled{cursor:not-allowed}#admin-whitelist button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}body.dark .whitelist-warning{background:#3e3323;color:#f6deac;border-color:#6b5938}
#confirm-dialog { width:calc(100% - 36px); max-width:460px; padding:24px; border:1px solid var(--line); border-radius:18px; background:var(--panel); color:var(--text); }
#confirm-dialog::backdrop { background:rgba(0,0,0,.5); }
#confirm-dialog p { font-size:14px; overflow-wrap:anywhere; }
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index 9328782..a5e0c24 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -153,12 +153,59 @@ async function checkTestAccess() {
await test.message(710060,'/new',blockedUser);
assert.ok(await test.db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind(String(blockedUser)).first(),scenario.name+' bot access');
}
- if(scenario.bindings.APP_ENV==='production' || scenario.bindings.TEST_WHITELIST_ENABLED==='false'){
+ if(scenario.bindings.APP_ENV==='production'){
await test.message(710061,'/whitelist add '+seedUser);
assert.equal(await test.setting(),null,scenario.name+' must not enable whitelist administration');
}
}finally{await test.worker.dispose();}
}
+ const controls=await fixture({WHITELIST_USER_IDS:String(seedUser)});
+ try {
+ const {db,api,message,setting,calls}=controls;
+ assert.equal((await api('admin/whitelist',seedUser)).status,403,'Permitted testers cannot read admin access settings.');
+ const initial=await api('admin/whitelist',admin);
+ assert.equal(initial.status,200);
+ assert.deepEqual(await initial.json(),{supported:true,enabled:true,restricted:true,ids:[String(seedUser)],snapshotProtected:false,invalid:false});
+ const change=async input=>{
+ const response=await api('admin/whitelist',admin,'POST',input);
+ assert.equal(response.status,200,JSON.stringify(await response.clone().json()));
+ return response.json();
+ };
+ const initialCalls=calls.length;
+ const added=await change({action:'add',ids:`${otherUser}, ${otherUser}`});
+ assert.deepEqual([...added.ids].sort(),[seedUser,otherUser].map(String).sort(),'Panel additions preserve the secret seed and deduplicate IDs.');
+ assert.equal(calls.length,initialCalls,'Panel edits must not send bot messages.');
+ for(const input of [{action:'add',ids:'invalid'},{action:'remove',ids:otherUser},{action:'set-enabled',enabled:'false'},{action:'unknown'}]){
+ assert.equal((await api('admin/whitelist',admin,'POST',input)).status,400,'Invalid access edits must be rejected.');
+ assert.deepEqual((await setting()).sort(),[seedUser,otherUser].map(String).sort());
+ }
+ assert.equal((await change({action:'set-enabled',enabled:false})).restricted,false);
+ assert.equal((await api('bootstrap',blockedUser)).status,200,'Saved off switch opens normal development without discarding its IDs.');
+ await message(710080,'/new',blockedUser);
+ assert.ok(await db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind(String(blockedUser)).first(),'Bot messages obey the saved switch too.');
+ const disabledAdd=await change({action:'add',ids:String(blockedUser)});
+ assert.equal(disabledAdd.enabled,false);assert.ok(disabledAdd.ids.includes(String(blockedUser)));
+ await change({action:'remove',ids:String(otherUser)});
+ assert.equal((await change({action:'set-enabled',enabled:true})).restricted,true);
+ assert.equal((await api('bootstrap',otherUser)).status,403);
+ assert.equal((await api('bootstrap',seedUser)).status,200);
+ await change({action:'remove',ids:`${seedUser} ${blockedUser}`});
+ assert.equal((await api('bootstrap',otherUser)).status,200,'An empty ordinary list preserves the existing open-access rule.');
+ await change({action:'add',ids:String(seedUser)});
+ await db.prepare("INSERT INTO app_settings(key,value) VALUES ('production-snapshot','{}')").run();
+ const copied=await (await api('admin/whitelist',admin)).json();assert.equal(copied.snapshotProtected,true);assert.equal(copied.restricted,true);
+ assert.equal((await api('admin/whitelist',admin,'POST',{action:'set-enabled',enabled:false})).status,400,'Panel cannot disable production-snapshot protection.');
+ const emptyCopy=await change({action:'remove',ids:String(seedUser)});assert.equal(emptyCopy.restricted,true);assert.deepEqual(emptyCopy.ids,[]);
+ assert.equal((await api('bootstrap',seedUser)).status,403,'Removing the final copied-data tester keeps admin-only access.');
+ assert.equal((await api('bootstrap',admin)).status,200);
+ }finally{await controls.worker.dispose();}
+ const productionControls=await fixture({APP_ENV:'production',WHITELIST_USER_IDS:String(seedUser)});
+ try {
+ assert.equal((await (await productionControls.api('admin/whitelist',admin)).json()).supported,false);
+ assert.equal((await productionControls.api('admin/whitelist',admin,'POST',{action:'set-enabled',enabled:true})).status,403);
+ assert.equal(await productionControls.setting(),null);
+ assert.equal((await productionControls.api('bootstrap',blockedUser)).status,200,'Production access cannot be restricted by the development panel.');
+ }finally{await productionControls.worker.dispose();}
const snapshot=await fixture({TEST_WHITELIST_ENABLED:'false',WHITELIST_USER_IDS:''});
try {
const {worker,db,calls,api,message,setting}=snapshot;
diff --git a/src/mini-api.js b/src/mini-api.js
index 8e05977..0452815 100644
--- a/src/mini-api.js
+++ b/src/mini-api.js
@@ -6,7 +6,7 @@ import {invitationMode,invitationSettings,oneTimeInvites,invitationAvailable,rec
import {managedInvitations,invitationsVersion,addInvitations,removeInvitation} from './invitation-management.js';
import {isManager,cohostEntries,cohostLink,cohostVersion,createCohostInvite,revokeCohost} from './cohosts.js';
import { authenticate } from './mini-auth.js';
-import { mayUseTestApp, testAccessMessage, TestAccessError } from './test-access.js';
+import { mayUseTestApp, testAccessMessage, TestAccessError, whitelistStatus, applyWhitelistChange } from './test-access.js';
import { parsePricing, currencyCodes, localCurrency } from './pricing.js';
import { mediaApi } from './media-api.js';
import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
@@ -160,6 +160,23 @@ async function miniApiForUser(request, env, user) {
if (mediaResponse) return mediaResponse;
if (path.startsWith('/api/admin')) {
if (!isSuperAdmin(user, env)) return respond({ error: 'Super admin access required.' }, 403);
+ if (path === '/api/admin/whitelist') {
+ if (request.method === 'GET') return respond(await whitelistStatus(env));
+ if (request.method === 'POST') {
+ if (env.APP_ENV !== 'development') return respond({ error: 'The test whitelist is available only in development.' }, 403);
+ const raw = await request.text();
+ if (raw.length > 14000) return respond({ error: 'Too much text.' }, 413);
+ try {
+ const input = JSON.parse(raw);
+ const status = await mutateState(env, (_data, _bot, settings) => applyWhitelistChange(env, input, settings));
+ return respond(status);
+ } catch (error) {
+ if (error instanceof TestAccessError) throw error;
+ return respond({ error: error instanceof InputError || error instanceof BusyError ? error.message : 'Could not save test access settings.' }, error instanceof BusyError ? 503 : 400);
+ }
+ }
+ return respond({ error: 'Not found.' }, 404);
+ }
if(path==='/api/admin/pricing' && request.method==='POST') {
const raw=await request.text();if(raw.length>12000)return respond({error:'Too much text.'},413);
try {
diff --git a/src/test-access.js b/src/test-access.js
index 68d8c49..280264f 100644
--- a/src/test-access.js
+++ b/src/test-access.js
@@ -1,6 +1,7 @@
import { isSuperAdmin } from './admin.js';
import { mutateState } from './worker-store.js';
import { snapshotActive } from './snapshot-mode.js';
+import { InputError } from './time.js';
export const testAccessMessage = 'This test app is limited to approved testers.';
export class TestAccessError extends Error {
@@ -20,8 +21,18 @@ function parseIds(value) {
return ids.length && ids.every(validId) ? [...new Set(ids)] : null;
}
-export async function accessPolicy(env, force = false) {
- if (!whitelistEnabled(env) && !(force && env.APP_ENV === 'development')) return { enabled: false, ids: [], invalid: false };
+async function whitelistSwitch(env) {
+ const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist-enabled'").first();
+ if (!stored) return { enabled: whitelistEnabled(env), invalid: false };
+ try {
+ const enabled = JSON.parse(stored.value);
+ if (typeof enabled === 'boolean') return { enabled, invalid: false };
+ } catch {}
+ // An invalid override must never quietly open a restricted test environment.
+ return { enabled: true, invalid: true };
+}
+
+async function whitelistIds(env) {
const stored = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-whitelist'").first();
let ids;
if (stored) {
@@ -30,21 +41,73 @@ export async function accessPolicy(env, force = false) {
ids = Array.isArray(value) && value.every(id => typeof id === 'string' && validId(id)) ? [...new Set(value)] : null;
} catch { ids = null; }
} else ids = parseIds(env.WHITELIST_USER_IDS ?? '');
- return { enabled: true, ids: ids ?? [], invalid: ids === null };
+ return { ids: ids ?? [], invalid: ids === null };
+}
+
+export async function accessPolicy(env, force = false) {
+ if (env.APP_ENV !== 'development') return { enabled: false, ids: [], invalid: false };
+ const option = await whitelistSwitch(env);
+ if (!option.enabled && !force) return { enabled: false, ids: [], invalid: false };
+ const list = await whitelistIds(env);
+ return { enabled: true, ids: list.ids, invalid: option.invalid || list.invalid };
+}
+
+function statusFrom(option, list, snapshot) {
+ return {
+ supported: true,
+ enabled: option.enabled,
+ restricted: snapshot || (option.enabled && (option.invalid || list.invalid || list.ids.length > 0)),
+ ids: list.ids,
+ snapshotProtected: snapshot,
+ invalid: option.invalid || list.invalid
+ };
+}
+
+export async function whitelistStatus(env) {
+ if (env.APP_ENV !== 'development') return { supported: false, enabled: false, restricted: false, ids: [], snapshotProtected: false, invalid: false };
+ const snapshot = await snapshotActive(env);
+ return statusFrom(await whitelistSwitch(env), await whitelistIds(env), snapshot);
+}
+
+// Run inside mutateState, so simultaneous admin edits share its lease and batch.
+export async function applyWhitelistChange(env, input, settings) {
+ if (env.APP_ENV !== 'development') throw new InputError('The test whitelist is available only in development.');
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !['set-enabled', 'add', 'remove'].includes(input.action)) throw new InputError('Choose a whitelist action.');
+ const snapshot = await snapshotActive(env);
+ const option = await whitelistSwitch(env);
+ const list = await whitelistIds(env);
+ if (input.action === 'set-enabled') {
+ if (typeof input.enabled !== 'boolean') throw new InputError('Choose whether the whitelist is on or off.');
+ if (snapshot && !input.enabled) throw new InputError('The whitelist must stay on while development contains copied production data.');
+ option.enabled = input.enabled;
+ option.invalid = false;
+ settings.setAppSetting('test-whitelist-enabled', JSON.stringify(input.enabled));
+ } else {
+ const requested = typeof input.ids === 'string' && input.ids.length <= 12000 ? parseIds(input.ids) : null;
+ if (!requested?.length || requested.length > 100) throw new InputError('Enter up to 100 numeric Telegram user IDs, separated by spaces or commas.');
+ if (list.invalid && input.action !== 'add') throw new InputError('The whitelist is invalid. Add valid user IDs to reset it.');
+ const ids = new Set(list.ids);
+ for (const id of requested) input.action === 'add' ? ids.add(id) : ids.delete(id);
+ if (input.action === 'add' && ids.size > 1000) throw new InputError('The whitelist can contain at most 1000 users.');
+ list.ids = [...ids];
+ list.invalid = false;
+ settings.setAppSetting('test-whitelist', JSON.stringify(list.ids));
+ }
+ return statusFrom(option, list, snapshot);
}
export async function mayUseTestApp(env, user) {
if (env.APP_ENV !== 'development' || isSuperAdmin(user, env)) return true;
- if (whitelistEnabled(env) && (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot)) return false;
- const snapshot = await snapshotActive(env);
- if (!whitelistEnabled(env) && !snapshot) return true;
if (!Number.isSafeInteger(user?.id) || user.id <= 0 || user.is_bot) return false;
+ const snapshot = await snapshotActive(env);
const policy = await accessPolicy(env, snapshot);
+ if (!policy.enabled) return true;
return !policy.invalid && ((!snapshot && !policy.ids.length) || policy.ids.includes(String(user.id)));
}
-function statusText(ids, snapshot = false) {
+function statusText(ids, snapshot = false, enabled = true) {
if (snapshot) return ids.length ? `Database copy protected. Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'} and the super admin.` : 'Database copy protected. Only the super admin has access.';
+ if (!enabled) return 'Whitelist off. Open to everyone. Saved tester IDs are preserved.';
return ids.length ? `Restricted to ${ids.length} tester${ids.length === 1 ? '' : 's'}. Super admin always has access.` : 'Whitelist empty. Open to everyone.';
}
@@ -57,17 +120,18 @@ export async function handleWhitelistCommand(env, update) {
const reply = text => bot.send(message.chat.id, text);
if (!isSuperAdmin(message.from, env)) return reply('Only the super admin can manage test access.');
const snapshot = !!bot.productionSnapshot;
- if (!whitelistEnabled(env) && !snapshot) return reply('Test whitelist is disabled in this environment.');
+ if (env.APP_ENV !== 'development') return reply('Test whitelist is disabled in this environment.');
const input = (command[2] || 'list').trim();
const [operation, ...rest] = input.split(/\s+/);
const action = operation.toLowerCase();
if (!['list', 'add', 'remove', 'clear'].includes(action) || (['list', 'clear'].includes(action) && rest.length)) {
return reply('Use /whitelist, /whitelist add ID, /whitelist remove ID, or /whitelist clear.');
}
- const policy = await accessPolicy(env, snapshot);
- if (action !== 'clear' && policy.invalid) return reply('Whitelist configuration is invalid. Access is restricted. Use /whitelist clear to reset it.');
+ const status = await whitelistStatus(env);
+ const policy = await accessPolicy(env, true);
+ if (action !== 'clear' && policy.invalid) return reply('Whitelist configuration is invalid. Use /whitelist clear to reset it.');
if (action === 'list') {
- await reply(statusText(policy.ids, snapshot));
+ await reply(statusText(policy.ids, snapshot, status.enabled));
let page = '';
for (const id of policy.ids) {
if (page.length + id.length + 1 > 3500) { await reply(page); page = ''; }
@@ -84,8 +148,12 @@ export async function handleWhitelistCommand(env, update) {
for (const id of requested) action === 'add' ? current.add(id) : current.delete(id);
ids = [...current];
}
+ if (action === 'clear' && (await whitelistSwitch(env)).invalid) {
+ status.enabled = whitelistEnabled(env);
+ settings.setAppSetting('test-whitelist-enabled', JSON.stringify(status.enabled));
+ }
settings.setAppSetting('test-whitelist', JSON.stringify(ids));
- await reply(statusText(ids, snapshot));
+ await reply(statusText(ids, snapshot, status.enabled));
}, update.update_id);
return new Response('OK');
}
diff --git a/src/worker.js b/src/worker.js
index 8789dd1..433f0a6 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -5,7 +5,7 @@ import { sendDueReminders } from './reminders.js';
import { checkout, refundResult } from './payments.js';
import {refreshOnlineRates} from './exchange.js';
import { botCommands, botCommandsVersion } from './telegram-menu.js';
-import { handleWhitelistCommand, mayUseTestApp, testAccessMessage, whitelistEnabled } from './test-access.js';
+import { handleWhitelistCommand, mayUseTestApp, testAccessMessage } from './test-access.js';
import { snapshotActive, snapshotDeliveryAllowed } from './snapshot-mode.js';
export async function authorized(request, secret) {
@@ -186,7 +186,8 @@ export async function configureMiniApp(env) {
const adminId = Number(env.SUPER_ADMIN_ID);
if (isSuperAdmin({ id: adminId }, env)) {
const adminCommands = await env.DB.prepare("SELECT value FROM app_settings WHERE key='test-admin-commands'").first();
- const enabled = whitelistEnabled(env) || await snapshotActive(env);
+ // The administrator can edit saved tester IDs even while the switch is off.
+ const enabled = env.APP_ENV === 'development';
const version = `${botCommandsVersion}:${adminId}:${enabled}`;
if ((enabled || adminCommands) && adminCommands?.value !== version) {
const result = await telegram(env, 'setMyCommands', {
diff --git a/test/snapshot-mode.test.js b/test/snapshot-mode.test.js
index 69450a2..741d1ab 100644
--- a/test/snapshot-mode.test.js
+++ b/test/snapshot-mode.test.js
@@ -20,6 +20,7 @@ function fixture({ snapshot = { value: '{}' }, stored = { value: '["111002"]' },
DB: {
prepare(sql) {
if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } };
if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return stored; } };
assert.fail('Unexpected database query.');
}
@@ -120,6 +121,7 @@ test('a Mini App read crossing the database refresh cannot return copied private
const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', BOT_USERNAME: 'FictionalSnapshotBot', DB: {
prepare(sql) {
if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } };
if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
if (sql === "SELECT data FROM records WHERE kind='events' AND id=?") return { bind() { return this; }, async first() { copied = true; return { data: JSON.stringify(event) }; } };
assert.fail('Unexpected database query.');
@@ -136,6 +138,7 @@ test('a Mini App mutation rechecks access under its lease before loading copied
const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', TELEGRAM_BOT_TOKEN: 'snapshot-test-token', snapshotUser: { id: 999001 }, SUPER_ADMIN_ID: '999001', DB: {
prepare(sql) {
if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } };
if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } };
if (sql === 'DELETE FROM lease WHERE owner=?') return { bind() { return this; }, async run() { released = true; } };
@@ -155,6 +158,7 @@ test('a Telegram callback crossing a refresh does not render the copied event',
const env = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'false', BOT_USERNAME: 'FictionalSnapshotBot', DB: {
prepare(sql) {
if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return copied ? { value: '{}' } : null; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return null; } };
if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist'") return { async first() { return { value: '[]' }; } };
if (sql.startsWith('INSERT INTO lease')) return { bind() { return this; }, async first() { copied = true; return { owner: 'fictional-lease' }; } };
if (sql.startsWith("INSERT INTO records(kind,id,data) VALUES ('users'")) return { bind() { return this; }, async run() {} };
diff --git a/test/telegram-menu.test.js b/test/telegram-menu.test.js
index f260f17..1828d91 100644
--- a/test/telegram-menu.test.js
+++ b/test/telegram-menu.test.js
@@ -71,7 +71,8 @@ test('the test whitelist command appears only in the configured admin private ch
assert.equal(f.calls.length, 1);
f.env.TEST_WHITELIST_ENABLED = 'false';
await configureMiniApp(f.env);
- assert.deepEqual(f.calls.at(-1).commands, botCommands);
+ assert.equal(f.calls.length, 1, 'The admin can manage saved dev IDs while the access switch is off.');
+ assert.ok(f.calls.at(-1).commands.some(item => item.command === 'whitelist'));
assert.deepEqual(f.calls.at(-1).scope, { type: 'chat', chat_id: 900000001 });
});
diff --git a/test/test-access.test.js b/test/test-access.test.js
index 6dbf6d6..d1fc428 100644
--- a/test/test-access.test.js
+++ b/test/test-access.test.js
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { accessPolicy, mayUseTestApp, whitelistEnabled } from '../src/test-access.js';
+import { accessPolicy, mayUseTestApp, whitelistEnabled, whitelistStatus, applyWhitelistChange, handleWhitelistCommand } from '../src/test-access.js';
const development = { APP_ENV: 'development', TEST_WHITELIST_ENABLED: 'true' };
-function fixture({ stored = null, snapshot = null, seed = '', ...variables } = {}) {
+function fixture({ stored = null, snapshot = null, override = null, seed = '', ...variables } = {}) {
let reads = 0;
const env = {
...development,
@@ -13,6 +13,7 @@ function fixture({ stored = null, snapshot = null, seed = '', ...variables } = {
DB: {
prepare(sql) {
if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return { async first() { return snapshot; } };
+ if (sql === "SELECT value FROM app_settings WHERE key='test-whitelist-enabled'") return { async first() { return override; } };
assert.equal(sql, "SELECT value FROM app_settings WHERE key='test-whitelist'");
return { async first() { reads++; return stored; } };
}
@@ -152,3 +153,128 @@ test('database failure cannot silently open development access', async () => {
await assert.rejects(accessPolicy(env), /Database unavailable/);
await assert.rejects(mayUseTestApp(env, { id: 111001 }), /Database unavailable/);
});
+
+test('a stored switch overrides environment defaults without redeployment', async () => {
+ const disabled = fixture({ override: { value: 'false' }, seed: '111002' }).env;
+ assert.equal(await mayUseTestApp(disabled, { id: 111001 }), true);
+ assert.deepEqual(await accessPolicy(disabled), { enabled: false, ids: [], invalid: false });
+ assert.deepEqual(await whitelistStatus(disabled), { supported: true, enabled: false, restricted: false, ids: ['111002'], snapshotProtected: false, invalid: false });
+ const enabled = fixture({ TEST_WHITELIST_ENABLED: 'false', override: { value: 'true' }, seed: '111002' }).env;
+ assert.equal(await mayUseTestApp(enabled, { id: 111001 }), false);
+ assert.equal(await mayUseTestApp(enabled, { id: 111002 }), true);
+});
+
+test('malformed stored switches fail closed, including with an empty tester list', async () => {
+ for (const value of ['', 'null', '"false"', '0', '{}', 'not-json']) {
+ const { env } = fixture({ override: { value }, TEST_WHITELIST_ENABLED: 'false', SUPER_ADMIN_ID: '999001' });
+ assert.deepEqual(await accessPolicy(env), { enabled: true, ids: [], invalid: true });
+ assert.equal(await mayUseTestApp(env, { id: 111001 }), false);
+ assert.equal(await mayUseTestApp(env, { id: 999001 }), true);
+ assert.equal((await whitelistStatus(env)).restricted, true);
+ }
+});
+
+test('production admin whitelist status never reads stored development settings', async () => {
+ const env = { APP_ENV: 'production', DB: { prepare() { assert.fail('No production whitelist reads.'); } } };
+ assert.deepEqual(await whitelistStatus(env), { supported: false, enabled: false, restricted: false, ids: [], snapshotProtected: false, invalid: false });
+ await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: true }, {}), /only in development/);
+});
+
+test('admin whitelist changes preserve seed IDs, de-duplicate and remove exact IDs', async () => {
+ const { env } = fixture({ seed: '111002 111003' });
+ const writes = [];
+ const settings = { setAppSetting: (key, value) => writes.push([key, value]) };
+ const added = await applyWhitelistChange(env, { action: 'add', ids: '111004, 111003' }, settings);
+ assert.deepEqual(added.ids, ['111002', '111003', '111004']);
+ assert.deepEqual(writes, [['test-whitelist', '["111002","111003","111004"]']]);
+ const removed = await applyWhitelistChange(env, { action: 'remove', ids: '111002' }, settings);
+ assert.deepEqual(removed.ids, ['111003']);
+ assert.equal(removed.restricted, true);
+ const emptied = await applyWhitelistChange(fixture({ seed: '111002' }).env, { action: 'remove', ids: '111002' }, settings);
+ assert.equal(emptied.enabled, true);
+ assert.equal(emptied.restricted, false);
+});
+
+test('switching access off retains tester IDs and validates boolean input', async () => {
+ const { env } = fixture({ stored: { value: '["111002"]' } });
+ const writes = [];
+ const settings = { setAppSetting: (key, value) => writes.push([key, value]) };
+ assert.deepEqual(await applyWhitelistChange(env, { action: 'set-enabled', enabled: false }, settings), { supported: true, enabled: false, restricted: false, ids: ['111002'], snapshotProtected: false, invalid: false });
+ assert.deepEqual(writes, [['test-whitelist-enabled', 'false']]);
+ await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: 'false' }, settings), /on or off/);
+});
+
+test('admin edits cannot turn off snapshot restrictions, even with an empty list', async () => {
+ const { env } = fixture({ snapshot: { value: '' }, override: { value: 'false' } });
+ const writes = [];
+ const settings = { setAppSetting: (...value) => writes.push(value) };
+ assert.deepEqual(await whitelistStatus(env), { supported: true, enabled: false, restricted: true, ids: [], snapshotProtected: true, invalid: false });
+ await assert.rejects(applyWhitelistChange(env, { action: 'set-enabled', enabled: false }, settings), /copied production data/);
+ assert.deepEqual(writes, []);
+ const added = await applyWhitelistChange(env, { action: 'add', ids: '111002' }, settings);
+ assert.equal(added.snapshotProtected, true);
+ assert.equal(added.restricted, true);
+ assert.deepEqual(added.ids, ['111002']);
+});
+
+test('admin can recover malformed policy explicitly without accepting invalid IDs', async () => {
+ const { env } = fixture({ stored: { value: 'not-json' }, override: { value: 'not-json' } });
+ const writes = [];
+ const settings = { setAppSetting: (...value) => writes.push(value) };
+ for (const ids of ['invalid', '0', '00111002', '1e5', '9007199254740992', '', ['111002'], 111002, '1 '.repeat(7000)]) await assert.rejects(applyWhitelistChange(env, { action: 'add', ids }, settings), /numeric Telegram user IDs/);
+ await assert.rejects(applyWhitelistChange(env, { action: 'remove', ids: '111002' }, settings), /invalid/);
+ const resetList = await applyWhitelistChange(env, { action: 'add', ids: '111002' }, settings);
+ assert.deepEqual(resetList.ids, ['111002']);
+ assert.equal(resetList.invalid, true); // The invalid switch still needs its own explicit reset.
+ assert.equal(resetList.restricted, true);
+ const resetSwitch = await applyWhitelistChange(env, { action: 'set-enabled', enabled: true }, settings);
+ assert.equal(resetSwitch.enabled, true);
+ assert.deepEqual(writes, [['test-whitelist', '["111002"]'], ['test-whitelist-enabled', 'true']]);
+});
+
+test('admin whitelist list size limits do not discard existing entries', async () => {
+ const ids = Array.from({ length: 1000 }, (_, index) => String(111000 + index));
+ const { env } = fixture({ stored: { value: JSON.stringify(ids) } });
+ const settings = { setAppSetting() { assert.fail('Invalid updates must not write settings.'); } };
+ await assert.rejects(applyWhitelistChange(env, { action: 'add', ids: '119999' }, settings), /at most 1000/);
+ await assert.rejects(applyWhitelistChange(env, { action: 'add', ids: ids.slice(0, 101).join(' ') }, settings), /up to 100/);
+ for (const input of [null, [], {}, { action: 'clear' }]) await assert.rejects(applyWhitelistChange(env, input, settings), /action/);
+});
+
+test('the legacy clear command repairs an invalid switch and retains valid switches', async () => {
+ for (const [override, expected] of [['not-json', 'true'], ['false', 'false'], ['true', 'true']]) {
+ const saved = new Map([['test-whitelist-enabled', override], ['test-whitelist', 'not-json']]);
+ const replies = [];
+ const env = { ...development, SUPER_ADMIN_ID: '999001', BOT_USERNAME: 'FictionalTesterBot', DB: {
+ prepare(sql) {
+ let values = [];
+ return {
+ bind(...input) { values = input; return this; },
+ async first() {
+ if (sql.startsWith('INSERT INTO lease')) return { owner: 'fictional-lease' };
+ if (sql === "SELECT value FROM app_settings WHERE key='production-snapshot'") return null;
+ const key = sql.match(/^SELECT value FROM app_settings WHERE key='([^']+)'$/)?.[1];
+ if (key) return saved.has(key) ? { value: saved.get(key) } : null;
+ if (sql === 'SELECT value FROM app_settings WHERE key=?' || sql === 'SELECT id FROM processed WHERE id=?') return null;
+ assert.fail('Unexpected command fixture read.');
+ },
+ async all() {
+ assert.equal(sql, "SELECT kind,id,data FROM records WHERE kind IN ('events','sessions','preferences')");
+ return { results: [] };
+ },
+ async run() { assert.equal(sql, 'DELETE FROM lease WHERE owner=?'); },
+ apply() {
+ if (sql.startsWith('INSERT INTO app_settings')) saved.set(values[0], values[1]);
+ else if (sql === 'INSERT INTO outbox(id,method,params) VALUES (?,?,?)') replies.push(JSON.parse(values[2]).text);
+ }
+ };
+ },
+ async batch(statements) { for (const statement of statements) statement.apply(); }
+ } };
+ const response = await handleWhitelistCommand(env, { update_id: 111001, message: { chat: { id: 999001, type: 'private' }, from: { id: 999001 }, text: '/whitelist clear' } });
+ assert.equal(response.status, 200);
+ assert.equal(saved.get('test-whitelist-enabled'), expected);
+ assert.equal(saved.get('test-whitelist'), '[]');
+ assert.match(replies[0], /Open to everyone/);
+ }
+});
From d7f98fe351f96c5338217289bc904029f8f79cfe Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:42:43 +1100
Subject: [PATCH 07/12] Limit Actions to production tests and database copy
---
.github/workflows/check-and-deploy.yml | 56 +--------
.../workflows/copy-production-db-to-dev.yml | 24 +++-
.github/workflows/sync-main-to-dev.yml | 111 ------------------
AGENTS.md | 11 +-
docs/development.md | 69 +++++------
5 files changed, 69 insertions(+), 202 deletions(-)
delete mode 100644 .github/workflows/sync-main-to-dev.yml
diff --git a/.github/workflows/check-and-deploy.yml b/.github/workflows/check-and-deploy.yml
index b01a56c..84ab56e 100644
--- a/.github/workflows/check-and-deploy.yml
+++ b/.github/workflows/check-and-deploy.yml
@@ -1,10 +1,10 @@
-name: Check and deploy
+name: Production tests
on:
push:
- branches: [main, dev]
+ branches: [main]
pull_request:
- branches: [main, dev]
+ branches: [main]
workflow_dispatch:
permissions:
@@ -13,7 +13,7 @@ permissions:
jobs:
checks:
name: Regression and Worker checks
- # Validate releases, including dev -> main PRs. Dev stays fast for manual testing.
+ # Preserve this job name: main branch protection requires it.
if: github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main')
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -33,51 +33,3 @@ jobs:
run: npm test
- name: Check Worker bundle and local database integration
run: npm run test:worker
-
- deploy:
- name: Deploy selected environment
- needs: checks
- if: >-
- !cancelled() &&
- github.event_name != 'pull_request' &&
- vars.XEVENTS_DEPLOY_ENABLED == 'true' &&
- ((github.ref == 'refs/heads/main' && needs.checks.result == 'success') ||
- (github.ref == 'refs/heads/dev' && needs.checks.result == 'skipped'))
- runs-on: ubuntu-latest
- timeout-minutes: 20
- environment:
- name: ${{ github.ref_name == 'main' && 'production' || 'development' }}
- # The development schema-refresh job uses this same resource lock.
- concurrency:
- group: ${{ github.ref_name == 'main' && 'xevents-production' || 'xevents-development' }}
- cancel-in-progress: false
- steps:
- - name: Check out the selected commit
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: ${{ github.sha }}
- persist-credentials: false
- - name: Reject a superseded deployment
- run: |
- REMOTE_COMMIT="$(git ls-remote origin "refs/heads/$GITHUB_REF_NAME" | cut -f1)"
- if [ "$REMOTE_COMMIT" != "$GITHUB_SHA" ]; then
- echo 'Deployment superseded by a newer branch commit. Deploy the latest commit instead.' >&2
- exit 1
- fi
- - name: Set up Node.js
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- with:
- node-version: 22
- cache: npm
- - name: Install dependencies
- run: npm ci
- - name: Apply migrations and deploy to the selected environment
- env:
- DEPLOY_ENVIRONMENT: ${{ github.ref_name == 'main' && 'production' || 'development' }}
- GH_REF_NAME: ${{ github.ref_name }}
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- WRANGLER_CONFIG_JSON: ${{ secrets.WRANGLER_CONFIG_JSON }}
- # Used only to reject an accidental production binding in development.
- PRODUCTION_CONFIG_JSON: ${{ github.ref_name == 'dev' && secrets.PRODUCTION_CONFIG_JSON || '' }}
- run: node scripts/deploy-environment.mjs
diff --git a/.github/workflows/copy-production-db-to-dev.yml b/.github/workflows/copy-production-db-to-dev.yml
index 2ee0cab..95c9b37 100644
--- a/.github/workflows/copy-production-db-to-dev.yml
+++ b/.github/workflows/copy-production-db-to-dev.yml
@@ -9,7 +9,6 @@ permissions:
jobs:
refresh-database:
name: Replace dev data with a production snapshot
- if: vars.XEVENTS_DATABASE_REFRESH_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
# This environment is restricted to main. It has its own private credentials.
@@ -19,12 +18,31 @@ jobs:
group: xevents-development
cancel-in-progress: false
steps:
- - name: Require the trusted workflow branch
+ - name: Check database-copy setup
env:
SELECTED_REF: ${{ github.ref }}
+ DATABASE_REFRESH_ENABLED: ${{ vars.XEVENTS_DATABASE_REFRESH_ENABLED }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ WRANGLER_CONFIG_JSON: ${{ secrets.WRANGLER_CONFIG_JSON }}
+ PRODUCTION_CONFIG_JSON: ${{ secrets.PRODUCTION_CONFIG_JSON }}
run: |
if [ "$SELECTED_REF" != 'refs/heads/main' ]; then
- echo 'Run this database-only workflow from main.' >&2
+ echo '::error::Run this database-only workflow from main.'
+ exit 1
+ fi
+ if [ "$DATABASE_REFRESH_ENABLED" != 'true' ]; then
+ echo '::error::Database copying is disabled in this mirror. Run Copy production database to dev in ehsan0921/Telegram-event-management on main.'
+ exit 1
+ fi
+ MISSING=0
+ for SECRET_NAME in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID WRANGLER_CONFIG_JSON PRODUCTION_CONFIG_JSON; do
+ if [ -z "${!SECRET_NAME}" ]; then
+ echo "::error::Missing $SECRET_NAME. Add it to Settings > Environments > development-refresh > Environment secrets, then run again."
+ MISSING=1
+ fi
+ done
+ if [ "$MISSING" = '1' ]; then
exit 1
fi
- name: Check out the database-copy tools
diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml
deleted file mode 100644
index 1d8377b..0000000
--- a/.github/workflows/sync-main-to-dev.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-name: Sync production code to development
-
-on:
- workflow_dispatch:
- inputs:
- copy_database_schema:
- description: Also copy production schema into an empty development database (no user data)
- type: boolean
- required: true
- default: false
-
-permissions:
- contents: read
-
-concurrency:
- group: xevents-sync-main-to-dev
- cancel-in-progress: false
-
-jobs:
- sync-code:
- name: Merge main into dev
- runs-on: ubuntu-latest
- timeout-minutes: 10
- permissions:
- contents: write
- steps:
- - name: Require the main branch
- env:
- SELECTED_REF: ${{ github.ref }}
- COPY_DATABASE_SCHEMA: ${{ inputs.copy_database_schema }}
- DEPLOY_ENABLED: ${{ vars.XEVENTS_DEPLOY_ENABLED }}
- run: |
- if [ "$SELECTED_REF" != 'refs/heads/main' ]; then
- echo 'Run this workflow from main. Other source branches are not allowed.' >&2
- exit 1
- fi
- if [ "$COPY_DATABASE_SCHEMA" = 'true' ] && [ "$DEPLOY_ENABLED" != 'true' ]; then
- echo 'Schema copying is available only in the repository enabled for deployment.' >&2
- exit 1
- fi
- - name: Check out production source
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: main
- fetch-depth: 0
- persist-credentials: false
- - name: Merge production changes without discarding development work
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- gh auth setup-git
- git config user.name 'github-actions[bot]'
- git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- git fetch origin main dev
- git switch --create dev --track origin/dev
- git merge --no-edit origin/main
- git push origin HEAD:refs/heads/dev
-
- refresh-schema:
- name: Copy schema to empty development D1
- needs: sync-code
- if: inputs.copy_database_schema
- runs-on: ubuntu-latest
- timeout-minutes: 20
- # Restrict this environment to main in the repository environment settings.
- environment:
- name: development-refresh
- concurrency:
- group: xevents-development
- cancel-in-progress: false
- steps:
- - name: Check out the trusted refresh script
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: main
- persist-credentials: false
- - name: Set up Node.js
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- with:
- node-version: 22
- cache: npm
- - name: Install dependencies
- run: npm ci
- - name: Copy schema without copying production records
- env:
- DEPLOY_ENVIRONMENT: development
- GH_REF_NAME: ${{ github.ref_name }}
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- WRANGLER_CONFIG_JSON: ${{ secrets.WRANGLER_CONFIG_JSON }}
- PRODUCTION_CONFIG_JSON: ${{ secrets.PRODUCTION_CONFIG_JSON }}
- run: node scripts/refresh-development.mjs
-
- deploy-development:
- name: Start development deployment
- needs: [sync-code, refresh-schema]
- if: >-
- always() && needs.sync-code.result == 'success' &&
- ((!inputs.copy_database_schema && needs.refresh-schema.result == 'skipped') ||
- (inputs.copy_database_schema && needs.refresh-schema.result == 'success'))
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- actions: write
- contents: read
- steps:
- # A push made with GITHUB_TOKEN does not trigger another push workflow.
- - name: Explicitly dispatch development deployment
- env:
- GH_TOKEN: ${{ github.token }}
- run: gh workflow run check-and-deploy.yml --ref dev --repo "$GITHUB_REPOSITORY"
diff --git a/AGENTS.md b/AGENTS.md
index e78368f..3e60218 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,7 +49,9 @@ existing invitations, and users' private event data when making changes.
production checks. Invitation, visibility, or schema changes need coverage for existing
events, legacy modes, privacy, and stored response preservation.
- Keep development deployments fast: automated test jobs run for PRs targeting
- `main` and for `main` pushes/manual runs, not for `dev`. Local tests during
+ `main` and for `main` pushes/manual runs, not for `dev`. GitHub Actions contains
+ only production tests and the manual production-to-dev database copy; do not add
+ deployment or code-sync workflows. Local tests during
development are optional when useful; require the full regression and Worker
checks before production promotion and deployment. Keep development builds,
environment isolation validation and deployment health checks enabled.
@@ -73,8 +75,11 @@ existing invitations, and users' private event data when making changes.
Never merge code, deploy Workers, write production data or publish database
exports as part of this database-only workflow. Automated tests still use
fictional local fixtures, never actual production snapshots.
-- Run deployment automation from one canonical repository; mirrors run checks
- only. Store private configuration in GitHub environment secrets and ignored files.
+- Enable database-copy automation in one repository only, currently
+ `ehsan0921/Telegram-event-management`. Mirrors run production checks and report
+ an actionable error if database copying is requested there. Never silently skip
+ a requested database copy. Store private configuration in GitHub environment
+ secrets and ignored files. Deploy separately with the CLI after required checks.
- Inspect the final diff and stage explicit intended files. Keep local credentials,
generated private data, deployment logs, and machine-specific helpers untracked.
diff --git a/docs/development.md b/docs/development.md
index e8c4437..25aca85 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -2,49 +2,62 @@
XEvents uses one application with two deployment branches. Work on `dev`, test with a separate Telegram bot and Cloudflare D1 database, then promote the reviewed code to `main`. Production invitations, payments and guest data stay in the production database.
-| Branch | Purpose | Cloudflare target | GitHub environment |
+| Branch | Purpose | Cloudflare target | Private local configuration |
| --- | --- | --- | --- |
-| `main` | Production releases | Production Worker and production D1 | `production` |
-| `dev` | Development and testing | Development Worker and `xevents-dev` D1 | `development` |
+| `main` | Production releases | Production Worker and production D1 | `wrangler.production.jsonc` |
+| `dev` | Development and testing | Development Worker and `xevents-dev` D1 | `wrangler.development.jsonc` |
The development bot runs the same bot, Mini App and API code as production. Telegram verifies Mini App launches using that bot's own token. A person can use the same Telegram account in both bots, but their events, profiles, invitations and uploads are stored separately. Start the development bot before testing private messages or uploads.
## Day-to-day workflow
1. Switch to `dev` and make a focused change. Short-lived feature branches can merge into `dev` when useful.
-2. Push `dev`. When deployment is enabled and its credentials are configured, GitHub builds and deploys development without running the automated test suite. Dependency installation, configuration isolation, migrations and the service health check still apply.
+2. Push `dev`, then deploy it explicitly using the private development configuration. GitHub does not deploy either environment. Dependency installation, configuration isolation, migrations and the service health check still apply.
3. Run local tests when useful while developing; they are not required before every development push. Use fictional events and mocked payments in automated tests.
4. Test the relevant Telegram and Mini App flows through the development bot.
-5. Open a pull request from `dev` to `main`. The required **Regression and Worker checks** job runs `npm test` and `npm run test:worker` on every PR targeting `main`, and again on `main` pushes or manual runs. Merge after those checks and development testing pass. Production deployment requires successful checks for that exact commit, uses production configuration and applies compatible production migrations.
-6. After a production-only fix, run **Sync production code to development** to bring `main` changes back into `dev`.
+5. Open a pull request from `dev` to `main`. The **Production tests** workflow runs the required **Regression and Worker checks** job: `npm test` and `npm run test:worker` on every PR targeting `main`, and again on `main` pushes or manual runs from `main`. Merge after those checks and development testing pass. Deploy production explicitly only after checks pass for that exact commit, using production configuration and compatible production migrations.
+6. After a production-only fix, merge `main` back into `dev` in a normal checkout. Resolve conflicts and review the result before pushing.
Keep changes small so that `main` remains releasable. Do not treat development database contents as something to promote: promotion moves code and migrations, not test events or users.
-Automated test jobs are skipped for `dev` pushes, PRs targeting `dev`, and manual workflow runs on `dev`. Production checks include authentication, access and permission boundaries, invitation compatibility, payment/refund handling and local Worker/D1 integration. A failed, skipped or cancelled production check blocks production deployment; PRs never deploy either environment.
+Development pushes and PRs targeting `dev` do not trigger automated test jobs. Run manual production tests from `main`. Production checks include authentication, access and permission boundaries, invitation compatibility, payment/refund handling and local Worker/D1 integration. A failed, skipped or cancelled production check must be resolved before production deployment.
-## Enable GitHub deployment
+## GitHub Actions
-The canonical deployment repository is [ehsan0921/XEvents](https://github.com/ehsan0921/XEvents). Keep mirrored repositories in checks-only mode so they cannot race to deploy the same Worker or run its migrations twice.
+There are two workflows:
-In the canonical repository, set the GitHub Actions repository variable `XEVENTS_DEPLOY_ENABLED` to `true` once Cloudflare credentials and both environment configurations are ready. Leave it unset or `false` in mirrors. Without the Cloudflare token and environment configuration, checks can run but hosted deployment is not ready.
+- **Production tests** validates pull requests targeting `main`, pushes to `main`, and manual runs from `main`. It requires no Cloudflare secrets and does not deploy.
+- **Copy production database to dev** is a manual database-only operation from `main`. It does not synchronize code or deploy Workers.
-Create GitHub environments named `production` and `development`. Configure these **environment secrets** separately for each:
+The canonical database-refresh repository is [ehsan0921/Telegram-event-management](https://github.com/ehsan0921/Telegram-event-management). Set its repository variable `XEVENTS_DATABASE_REFRESH_ENABLED` to `true`. The [ehsan0921/XEvents](https://github.com/ehsan0921/XEvents) mirror runs production checks only; leave its database-refresh variable unset or `false`. A database action attempted in a disabled mirror reports an error directing you to the canonical repository instead of silently skipping.
+
+In the canonical repository, create the GitHub environment `development-refresh`, restrict it to `main`, and configure these four **environment secrets**:
| Secret | Value |
| --- | --- |
-| `CLOUDFLARE_API_TOKEN` | A Cloudflare API token with the permissions needed to deploy the intended Worker and manage its D1 migrations. Use a dedicated, suitably scoped token. |
-| `CLOUDFLARE_ACCOUNT_ID` | The private Cloudflare account identifier for that environment. |
-| `WRANGLER_CONFIG_JSON` | The complete private Wrangler configuration for that environment, as valid JSON. Use different Worker names, D1 IDs, bot usernames and app URLs. |
-| `PRODUCTION_CONFIG_JSON` | Development only: the private production configuration used to reject production resource collisions. It is a workflow reference, never the development Worker's runtime configuration. |
-
-`PRODUCTION_CONFIG_JSON` must match the actual production target. Also create the `development-refresh` environment with the same four secrets as development; restrict this environment to `main`. It is used for manual database refreshes. Production needs only the first three secrets. Keep private configuration in GitHub secrets, never repository variables, checked-in files or workflow text.
+| `CLOUDFLARE_API_TOKEN` | A dedicated Cloudflare API token permitting production D1 export and development D1 import. |
+| `CLOUDFLARE_ACCOUNT_ID` | The private Cloudflare account identifier. |
+| `WRANGLER_CONFIG_JSON` | The complete private development Wrangler configuration as valid JSON. |
+| `PRODUCTION_CONFIG_JSON` | The complete private production configuration as valid JSON, used for read-only export and to reject resource collisions. |
-The deployment helper, `scripts/deploy-environment.mjs`, applies migrations to the selected environment and deploys with `--keep-vars`. The private configuration is materialized for that run and must not become a build artifact or committed file. Keep existing production bindings and secrets intact.
+The two configurations must use different Worker names, D1 identifiers, bot usernames and app URLs. `PRODUCTION_CONFIG_JSON` must match the actual production target. Keep private configuration in GitHub secrets, never repository variables, checked-in files or workflow text. The action checks required secrets before installing database tools and reports missing secret names without their values.
-Branch policies should keep `main` changes going through pull requests and successful checks. Restrict the production environment to `main` and development deployment to `dev`; allow the guarded refresh job from `main` to target development. Workflow branch checks also enforce their intended targets.
+Branch policies should keep `main` changes going through pull requests and the successful **Regression and Worker checks** job. The database-copy workflow and its environment are restricted to `main`.
See [GitHub environment secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) and [Cloudflare API tokens](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/).
+## Explicit deployments
+
+Deployment is separate from GitHub Actions and database refresh. Install dependencies with `npm ci`, then use the existing CLI helper from a private authenticated shell:
+
+```sh
+node scripts/deploy-environment.mjs
+```
+
+Supply `DEPLOY_ENVIRONMENT=development` and `GH_REF_NAME=dev` for development, or `DEPLOY_ENVIRONMENT=production` and `GH_REF_NAME=main` for production. Load `WRANGLER_CONFIG_JSON`, `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` privately; development also requires `PRODUCTION_CONFIG_JSON` to validate resource isolation. Do not put secret values in shell history or committed scripts.
+
+The helper validates the environment, applies its migrations, builds and deploys with `--keep-vars`, then checks the service health endpoint. Production requires successful regression and Worker checks for the selected commit before running this command. The temporary private configuration must never become a build artifact or committed file. Keep existing production bindings and secrets intact, and avoid deploying development while its database refresh is running.
+
## Separate development configuration
Keep the real development configuration in an ignored local file, such as `wrangler.development.jsonc`. The following values are examples and placeholders:
@@ -93,7 +106,7 @@ Store these secrets on the **development Cloudflare Worker**, using private inte
- `SUPER_ADMIN_ID`: your administrator Telegram identity, if needed.
- `WHITELIST_USER_IDS`: optional initial allowed Telegram IDs for development, separated by commas or whitespace. Leave it blank to permit everyone.
-These bot secrets do not belong in GitHub source or `WRANGLER_CONFIG_JSON`, and the deployment workflow does not need copies of them in GitHub. Set `vars.BOT_USERNAME` to the development bot and `vars.APP_URL` to the development Mini App URL. The [self-hosting guide](self-hosting.md#3-set-worker-secrets) explains interactive secret setup and webhook registration.
+These bot secrets do not belong in GitHub source or `WRANGLER_CONFIG_JSON`, and GitHub Actions does not need copies of them. Set `vars.BOT_USERNAME` to the development bot and `vars.APP_URL` to the development Mini App URL. The [self-hosting guide](self-hosting.md#3-set-worker-secrets) explains interactive secret setup and webhook registration.
Do not connect production's bot to the development Worker. Telegram uses a single webhook per bot, so doing that would redirect production updates. A separate bot is also necessary for authentication and media isolation: Telegram [`file_id` values are specific to each bot](https://core.telegram.org/bots/api#sending-files).
@@ -126,9 +139,9 @@ This restriction applies to the Cloudflare Worker, including its webhook and Min
## Copy production data to dev
-Open **Actions → Copy production database to dev → Run workflow**, select `main`, and run it whenever you need the latest production records in development. This replaces the existing development database contents. It does not merge either branch, deploy a Worker, change bot credentials, or write to production.
+In [ehsan0921/Telegram-event-management](https://github.com/ehsan0921/Telegram-event-management/actions/workflows/copy-production-db-to-dev.yml), open **Actions → Copy production database to dev → Run workflow**, select `main`, and run it whenever you need the latest production records in development. This replaces the existing development database contents. It does not merge either branch, deploy a Worker, change bot credentials, or write to production.
-Enable this action only in the canonical repository by setting the repository variable `XEVENTS_DATABASE_REFRESH_ENABLED=true`. Leave it unset or `false` in mirrors. This switch is independent of `XEVENTS_DEPLOY_ENABLED`. The `development-refresh` environment needs all four secrets listed above, including its own `CLOUDFLARE_API_TOKEN` with D1 export and import access. A missing token must be configured before the action can run.
+Enable this action only in the canonical repository by setting the repository variable `XEVENTS_DATABASE_REFRESH_ENABLED=true`. Leave it unset or `false` in mirrors. The `development-refresh` environment needs all four secrets listed above, including its own `CLOUDFLARE_API_TOKEN` with D1 export and import access. A disabled repository or missing secret produces an actionable failure before database operations; it does not silently skip the copy.
The action exports production, prepares the snapshot privately, and imports one replacement SQL file into the verified development D1. It copies events, users, profiles, guest responses, invitation links, ticket/check-in records, payment audits and media references. Development settings, including the test whitelist, are retained; production settings are not copied. Sessions, queued deliveries, processed-update markers and coordination locks are cleared. Already-applied migrations are preserved, and pending migrations from the current `dev` checkout are applied offline before import. An incompatible migration set stops the refresh before writing development data.
@@ -136,20 +149,10 @@ The development Worker must already include the database-refresh safeguards; the
Telegram media references are copied, but their `file_id` values belong to the production bot. Some existing images or files will need to be uploaded again through the development bot. Never use the production bot token to work around this isolation.
-Exports and prepared SQL files are temporary, private files deleted after the run. They are never printed or uploaded as artifacts. D1 export can briefly block production database requests; import can briefly block development requests. A failed D1 file import rolls back the replacement. Both development deployment and database refresh use the same concurrency group. See Cloudflare's [D1 import and export guide](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
+Exports and prepared SQL files are temporary, private files deleted after the run. They are never printed or uploaded as artifacts. D1 export can briefly block production database requests; import can briefly block development requests. A failed D1 file import rolls back the replacement. Database-copy runs share a concurrency group so copies cannot overlap. Keep explicit development deployments separate from an active refresh. See Cloudflare's [D1 import and export guide](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
Automated tests continue to use fictional local fixtures and mocked Telegram calls. Production snapshots are private operational data, never test fixtures or public artifacts.
-## Sync production code to dev
-
-Run **Sync production code to development** manually from GitHub Actions on `main`. It merges production code into `dev` without resetting the branch or force-pushing. It preserves development work and stops if there is a merge conflict. Resolve that conflict in a normal checkout and push the reviewed result. After the merge, it explicitly starts the development deployment workflow because a push using GitHub's workflow token does not start another push workflow. That development run skips automated tests.
-
-The `copy_database_schema` input defaults to `false`. With that default, the workflow syncs code only and leaves the development database unchanged.
-
-Select `copy_database_schema` only when the development database is empty and you want to initialize it from production's schema. `scripts/refresh-development.mjs` verifies the separate targets and refuses a nonempty development database, preserving existing test data. It exports **schema only**, imports it into development, and applies reviewed `main` migrations; the subsequent development deployment applies any additional `dev` migrations. Schema export can briefly block production database requests, so use it deliberately. Cloudflare documents the supported [D1 schema export and import](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
-
-Only schema and applied migration filenames are transferred by that optional initialization step, so already-applied schema changes are not replayed. For actual records, use the separate **Copy production database to dev** action above. Neither workflow promotes development data to production.
-
## Before promoting a change
- Verify that development uses its own bot, Worker and D1 before testing.
From 49988aca12bf5616bf5ebe40f00a2294ab021faa Mon Sep 17 00:00:00 2001
From: Ehsan <15076484+ehsan0921@users.noreply.github.com>
Date: Thu, 8 Oct 2026 14:07:02 +1100
Subject: [PATCH 08/12] Simplify guest invitation actions and add response
management history
---
docs/invitations.md | 12 +-
public/app.js | 115 ++++++++++----
public/index.html | 2 +-
public/style.css | 6 +-
scripts/check-worker.mjs | 126 ++++++++++++++-
src/bot.js | 16 +-
src/invitation-history.js | 36 +++++
src/invitation-management.js | 181 ++++++++++++++++++++--
src/invitations.js | 18 ++-
src/mini-api.js | 23 ++-
test/cohost-ui.test.js | 223 +++++++++++++++++++++------
test/invitation-history-flow.test.js | 102 ++++++++++++
test/invitation-management.test.js | 205 +++++++++++++++++++++++-
13 files changed, 953 insertions(+), 112 deletions(-)
create mode 100644 src/invitation-history.js
create mode 100644 test/invitation-history-flow.test.js
diff --git a/docs/invitations.md b/docs/invitations.md
index d9ee7b3..b3acf9a 100644
--- a/docs/invitations.md
+++ b/docs/invitations.md
@@ -77,15 +77,19 @@ A guest's selected count survives reopening the invitation. Editable counts can
In the bot, open **Manage → Invitation links** to see guest-name buttons, with ten guests per page and two buttons per row. When the complete invitation and link fit Telegram's 256-character copy-button limit, tapping the name copies them immediately. Longer invitations open that guest in **App**; tap their name there to copy the full message and personal link. Invitation details offer **Share invite**, **Copy invite** (or **Copy invite in App** for longer text), and **Copy link only**. Without App, the bot displays the full invitation so you can select and copy it. Messages are not truncated to fit the button.
-The Mini App's **Guest invitations** view shows each invitation's responses, comments and attendee counts, plus totals for responses and accepted people. Filter by **Accepted**, **Declined**, **Maybe**, **Later** or **Unanswered**, and combine the filter with a guest-name search. Unanswered includes invitations with no response and guests who chose Later. A reusable invitation can show several responses and appear in more than one filter.
+The Mini App's **Guest invitations** view shows each invitation's current response, comments and attendee counts, plus totals for responses and accepted people. Recorded response times appear in your timezone. Opening an invite without responding is shown as awaiting a response; it does not count as choosing Respond later. Filter by **Accepted**, **Declined**, **Maybe**, **Later**, **Unanswered** or **Revoked**, and combine the filter with a guest-name search. Unanswered includes invitations with no response and guests who chose Later. A reusable invitation can show several responses and appear in more than one filter. Older responses may have no recorded timestamp.
-Each guest has **Share invite**, **Copy invite**, and **Copy link** actions. **Copy invite** includes the personal message and link; **Copy link** copies only that guest's URL. Guest-name buttons also copy the full invitation, and previews are expandable. If clipboard access fails, the requested text or URL is selected for manual copying. Guest names and unused links are private to the event hosts. Send personal links privately: possession of a link does not prove someone's real-world identity.
+Each guest card keeps **Copy invite** and **Copy link** visible. **Copy invite** includes the personal message and link; **Copy link** copies only that guest's URL. Guest-name buttons also copy the full invitation, and previews are expandable. The three-dot menu contains **Share invite**, **Edit invite**, **Change response**, **Revoke invite** and **See history**. If clipboard access fails, the requested text or URL is selected for manual copying. Guest names and unused links are private to the event hosts. Send personal links privately: possession of a link does not prove someone's real-world identity.
Owners and co-hosts can expand **Add guests** and choose **Add one guest** or **Add a list**, without replacing existing links or responses. The individual form provides a name, a count from 1 to 10, and plain-language choices for one person, asking for a count, an editable preset, count confirmation or a fixed count. The same individual controls are available beside the text list when creating or editing an event. Adding a guest to an unsaved event appends a line to that draft; it does not send an invitation. Duplicate names need a distinguishing label.
-The **Guest list** also has **Add guest** and **Invitations** shortcuts. Each named guest has **Invitation** and **Revoke invitation** actions; revocation opens the matching personal invitation's removal choices. **Remove invite** asks whether to **Notify guest** or **Remove silently**, with **Keep invite** to cancel. Notification is available only after a Telegram account has opened the invitation. Removing an invitation revokes its link and ticket, removes its responses from attendance totals, and clears related reminders and unfinished response steps. For reusable invitations, this affects every response associated with that link. Payment records remain available for separate refund handling; removal does not issue a refund. These named-invitation controls are not shown for shared ticket-booking links.
+The **Guest list** also has **Add guest** and **Invitations** shortcuts. Each named guest has **Invitation** and **Revoke invitation** actions. **Revoke invite** offers an optional notification, off by default. Revocation disables the link and ticket, removes its responses from attendance totals, and clears related reminders and unfinished response steps. For reusable invitations, this affects every response associated with that link. Revoked cards remain available through the **Revoked** filter, with their history and a separate **Delete invite** confirmation. Deleting removes the card from the invitation list while retaining private audit, check-in and payment records. Neither action issues a refund. These named-invitation controls are not shown for shared ticket-booking links.
-Links stay stable when you save the same guest names and count settings. The general event editor can remove unopened names, revoking their links. Once an invitation has been opened, that editor cannot remove or rename it, or change its count or selection mode. Use the dedicated **Remove invite** action above to revoke an opened or answered invitation. Changes are checked against the current guest list and responses; if another host or guest changes them first, refresh and review before retrying. This edit protection is separate from the one-time-use lock described below.
+**Edit invite** changes the name, attendee count or attendee setting without replacing its personal link. Linked guest responses use the updated name and count rules. Attendance changes are blocked for checked-in guests and while protected payment or refund states apply; a name-only correction remains possible. The general event editor still protects opened invitations from bulk removal or attendee changes: use the dedicated per-invitation actions instead. Changes are checked against the current guest list and responses; if another host or guest changes them first, refresh and review before retrying. This edit protection is separate from the one-time-use lock described below.
+
+**Change response** records Accept, Reject, Maybe or Respond later for a guest who has already opened the invitation in Telegram. For reusable links, select the actual guest response to change. Choose whether to notify that guest; silent saving is the default. Unopened invitations cannot create a response or ticket for an unknown Telegram account. Acceptance still follows payment requirements and attendee rules. Checked-in guests and protected payments cannot have their attendance changed here.
+
+**See history** shows recorded creation, first opening by each guest, responses, response changes, edits and revocation with timestamps in your saved timezone. Organiser changes include whether notification was requested. History starts when activity is recorded by this version; older invitations may have incomplete history, and missing past timestamps are not invented. History is visible only to event hosts.
Editing loads the latest event details, including the invitation message, guest list, payment settings and existing banner preview. Saving without selecting a replacement image preserves the current banner.
diff --git a/public/app.js b/public/app.js
index 201c388..3f0b3a1 100644
--- a/public/app.js
+++ b/public/app.js
@@ -423,28 +423,31 @@ async function openNamedLinks(event,selectedGuest,options={}){
try{
let {event:e}=await api(`events/${event.id}`);
if(generation!==invitationLinksGeneration)return;
- if(!isManager(e))throw Error('Only event managers can see invitation links.');
+ if(!isManager(e)){$('invitation-links-list').replaceChildren();if($('invitation-links-dialog').open)$('invitation-links-dialog').close();throw Error('Only event managers can see invitation links.');}
const search=$('invitation-links-search'),filter=$('invitation-links-filter'),status=$('invitation-links-status');
search.value='';search.hidden=false;filter.value='all';status.textContent='';
$('invitation-add').open=options.add===true;$('invitation-add-names').value='';$('invitation-single').open=options.add===true;$('invitation-bulk').open=false;
- let busy=false,mutationButtons=[],addRequest,singleRequest,focusSelected=true,singleEditor;
+ let busy=false,mutationButtons=[],mutationInputs=[],addRequest,singleRequest,focusSelected=true,singleEditor,panelState=null;
+ const mutationRequests=new Map();
const current=()=>generation===invitationLinksGeneration;
const readOnly=()=>e.cancelled || e.group==='Past events';
const responses=guest=>Array.isArray(guest.responses) ? guest.responses : ['yes','no','maybe','later'].includes(guest.status) ? [{name:guest.name,status:guest.status,participants:guest.participants || 1}] : [];
- const matches=(guest,value)=>value==='all' || (value==='unanswered' ? !responses(guest).length || responses(guest).some(g=>g.status==='later') : responses(guest).some(g=>g.status===value));
- const setBusy=value=>{busy=value;$('invitation-add-submit').disabled=value || readOnly();$('invitation-add-names').disabled=value || readOnly();singleEditor?.setDisabled(value || readOnly());for(const b of mutationButtons)b.disabled=value || readOnly() || b.dataset.unavailable==='true';};
+ const matches=(guest,value)=>value==='all' || (value==='revoked' ? guest.revoked===true : !guest.revoked && (value==='unanswered' ? !responses(guest).length || responses(guest).some(g=>g.status==='later') : responses(guest).some(g=>g.status===value && (value!=='later' || g.responded!==false))));
+ const setBusy=value=>{busy=value;$('invitation-add-submit').disabled=value || readOnly();$('invitation-add-names').disabled=value || readOnly();singleEditor?.setDisabled(value || readOnly());for(const b of [...mutationButtons,...mutationInputs])b.disabled=value || readOnly() || b.dataset.unavailable==='true';};
const updateEvent=value=>{e=value;const index=state.events.findIndex(item=>item.id===e.id);if(index>=0)state.events[index]=e;renderEvents();renderHome();};
- const refreshList=async()=>{const result=await api(`events/${e.id}`);if(!current())return;if(!isManager(result.event))throw Error('Your event management access has changed.');updateEvent(result.event);render();};
+ const refreshList=async()=>{const result=await api(`events/${e.id}`);if(!current())return;if(!isManager(result.event)){$('invitation-links-list').replaceChildren();$('invitation-links-dialog').close();notice('Your event management access has changed.');return;}updateEvent(result.event);render();};
+ const mutationRequest=(operation,payload)=>{const key=operation+':'+payload.token,value=JSON.stringify(payload),previous=mutationRequests.get(key);if(previous?.value===value)return {...payload,requestId:previous.id};const id=crypto.randomUUID();mutationRequests.set(key,{value,id});return {...payload,requestId:id};};
const mutate=async(operation,payload,guestName='Guest',source='bulk')=>{
if(busy || !current())return;
setBusy(true);status.textContent='Saving…';
try{
const result=await api(`events/${e.id}/invitations/${operation}`,{...payload,version:e.invitationsVersion});
if(!current())return;
- updateEvent(result.event);
+ updateEvent(result.event);panelState=null;
if(operation==='add'){if(source==='bulk'){$('invitation-add-names').value='';$('invitation-add').open=false;addRequest=null;}else singleRequest=null;}
render();
- status.textContent=operation==='add' ? 'Guests added. Their links are ready to share.' : result.alreadyRemoved ? guestName+'’s invitation was already removed.' : payload.notify ? result.notifyCount ? guestName+'’s invitation removed. Guest notified.' : guestName+'’s invitation removed. No Telegram account was available to notify.' : guestName+'’s invitation removed silently.';
+ const done={edit:'Invitation updated.',response:'Response updated.',revoke:'Invitation revoked. Its link and ticket are disabled.',delete:'Invitation deleted.'};
+ status.textContent=operation==='add' ? 'Guests added. Their links are ready to share.' : (done[operation] || 'Invitation updated.')+(payload.notify ? result.notifyCount ? ' Guest notified.' : ' No Telegram notification was sent.' : ['response','revoke'].includes(operation) ? ' Guest not notified.' : '');
return true;
}catch(error){
if(!current())return;
@@ -457,46 +460,96 @@ async function openNamedLinks(event,selectedGuest,options={}){
const render=()=>{
if(!current())return;
$('invitation-links-note').textContent=oneTimeInviteEnabled(e) ? 'Send each link only to its named guest. Accept, Decline or Maybe locks it to that guest; Respond later does not. The same guest can change their RSVP.' : 'These links can be used by more than one guest. Each response is shown below.';
- const list=$('invitation-links-list');list.replaceChildren();mutationButtons=[];
- const guests=[...(e.invitees || [])],selected=selectedGuest && guests.find(guest=>guest.token===selectedGuest || (()=>{try{return new URL(guest.url).searchParams.get('start')==='i_'+e.id+'_'+selectedGuest;}catch{return false;}})());
+ const list=$('invitation-links-list');list.replaceChildren();mutationButtons=[];mutationInputs=[];
+ const guests=[...(e.invitees || []),...(e.revokedInvitees || []).map(guest=>({...guest,revoked:true}))],selected=selectedGuest && guests.find(guest=>guest.token===selectedGuest || (()=>{try{return new URL(guest.url).searchParams.get('start')==='i_'+e.id+'_'+selectedGuest;}catch{return false;}})());
if(selected){guests.splice(guests.indexOf(selected),1);guests.unshift(selected);}
else if(selectedGuest)status.textContent='That invitation is unavailable. Choose a guest below.';
- const replies=guests.flatMap(responses),finalReplies=replies.filter(g=>['yes','no','maybe'].includes(g.status));
- $('invitation-links-summary').textContent=`${guests.length} invitations · ${finalReplies.length} responses · ${replies.filter(g=>g.status==='yes').reduce((sum,g)=>sum+(g.participants || 1),0)} people accepted`;
+ const activeGuests=guests.filter(guest=>!guest.revoked),replies=activeGuests.flatMap(responses),finalReplies=replies.filter(g=>['yes','no','maybe'].includes(g.status)),revokedCount=guests.length-activeGuests.length;
+ $('invitation-links-summary').textContent=`${activeGuests.length} invitations · ${finalReplies.length} responses · ${replies.filter(g=>g.status==='yes').reduce((sum,g)=>sum+(g.participants || 1),0)} people accepted${revokedCount?' · '+revokedCount+' revoked':''}`;
const selection=filter.value || 'all';filter.replaceChildren();
- for(const [value,label] of [['all','All'],['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Later'],['unanswered','Unanswered']]){const option=document.createElement('option');option.value=value;option.textContent=label+' ('+guests.filter(guest=>matches(guest,value)).length+')';filter.append(option);}filter.value=selection;
- const rows=[];let selectedButton;
+ for(const [value,label] of [['all','All'],['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Later'],['unanswered','Unanswered'],['revoked','Revoked']]){const option=document.createElement('option');option.value=value;option.textContent=label+' ('+guests.filter(guest=>matches(guest,value)).length+')';filter.append(option);}filter.value=selection;
+ const rows=[];let selectedButton,panelFocus;
for(const guest of guests){
const count=guest.participants || 1,askCount=guest.participantMode==='ask' || (!guest.participants && e.askParticipantCount),countLabel=askCount ? 'Guest chooses attendee count' : count+' '+(count===1?'attendee':'attendees'),countRule=!askCount && guest.participants ? guest.participantMode==='confirm' ? ' · Confirm count on acceptance' : guest.participantMode==='fixed' ? ' · Fixed count' : ' · Guest can change count' : '';
- const replyList=responses(guest),responseLabel=replyList.length>1 ? replyList.length+' responses' : {yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Awaiting RSVP'}[replyList[0]?.status] || 'Not opened';
- const linkLabel=oneTimeInviteEnabled(e) ? guest.claimed ? 'Locked to one guest' : 'One-time link' : 'Reusable link';
- const text=inviteText(e,guest)+'\n\n'+guest.url,row=element('article','','panel invitation-entry'+(guest===selected?' invitation-selected':''));
+ const replyList=responses(guest),responseStates=[...new Set(replyList.map(reply=>reply.responded===false?'unanswered':reply.status))],responseLabel=guest.revoked ? 'Revoked' : responseStates.length>1 ? 'Mixed responses' : {yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later',unanswered:'Awaiting response'}[responseStates[0]] || 'Not opened';
+ const linkLabel=guest.revoked ? 'Link disabled' : oneTimeInviteEnabled(e) ? guest.claimed ? 'Locked to one guest' : 'One-time link' : 'Reusable link';
+ const text=inviteText(e,guest)+'\n\n'+guest.url,row=element('article','','panel invitation-entry'+(guest===selected?' invitation-selected':'')+(guest.revoked?' invitation-revoked':''));row.dataset.token=guest.token || '';
const preview=element('details','','invitation-preview'),fullText=document.createElement('textarea'),linkText=document.createElement('input');fullText.value=text;fullText.readOnly=true;fullText.rows=9;fullText.setAttribute('aria-label','Full invitation for '+guest.name);linkText.value=guest.url;linkText.readOnly=true;linkText.setAttribute('aria-label','Personal link for '+guest.name);preview.append(element('summary','Preview invitation'),fullText,linkText);
const copyInvite=async()=>{try{await navigator.clipboard.writeText(text);if(generation===invitationLinksGeneration)$('invitation-links-status').textContent='Invitation copied for '+guest.name+'.';}catch{if(generation!==invitationLinksGeneration)return;preview.open=true;fullText.focus();fullText.select();$('invitation-links-status').textContent='Could not copy. Select and copy the full invitation shown for '+guest.name+'.';}};
const copyLink=async()=>{try{await navigator.clipboard.writeText(guest.url);if(generation===invitationLinksGeneration)$('invitation-links-status').textContent='Link copied for '+guest.name+'.';}catch{if(generation!==invitationLinksGeneration)return;preview.open=true;linkText.focus();linkText.select();$('invitation-links-status').textContent='Could not copy. Select and copy the personal link shown for '+guest.name+'.';}};
- const copy=action(guest.name,copyInvite,'primary invitation-copy');copy.setAttribute('aria-label','Copy invitation for '+guest.name);
- const actions=element('div','','event-actions invitation-actions');actions.append(action('Share invite',()=>openTelegram(`https://t.me/share/url?url=${encodeURIComponent(guest.url)}&text=${encodeURIComponent(inviteText(e,guest))}`)),action('Copy invite',copyInvite),action('Copy link',copyLink));
- const invitationState=element('p','','small muted');invitationState.append(element(askCount?'span':'strong',countLabel),element('span',countRule+' · '+responseLabel+' · '+linkLabel));row.append(copy,invitationState);
+ const copy=action(guest.name,copyInvite,'primary invitation-copy');copy.setAttribute('aria-label',guest.revoked ? guest.name+' · invitation revoked' : 'Copy invitation for '+guest.name);copy.disabled=guest.revoked===true;
+ const actions=element('div','','event-actions invitation-actions');const copyInviteButton=action('Copy invite',copyInvite),copyLinkButton=action('Copy link',copyLink);copyInviteButton.disabled=copyLinkButton.disabled=guest.revoked===true;if(!guest.revoked)actions.append(copyInviteButton,copyLinkButton);
+ const invitationState=element('p','','small muted');invitationState.append(element(askCount?'span':'strong',countLabel),element('span',countRule+' · '+linkLabel));
+ const currentStatus=element('div','','invitation-current-status'),statusBadge=element('strong',responseLabel+(replyList.length>1?' · '+replyList.length+' guests':''),'invitation-status');statusBadge.dataset.status=guest.revoked?'revoked':responseStates.length>1?'mixed':responseStates[0] || 'unopened';statusBadge.setAttribute('aria-label','Current response: '+statusBadge.textContent);currentStatus.append(element('span','Current response','small muted'),statusBadge);
+ const responseDates=[...(guest.history || []).filter(entry=>['responded','changed'].includes(entry.type)).map(entry=>entry.at),...replyList.map(reply=>reply.respondedAt)].filter(value=>typeof value==='string' && Number.isFinite(new Date(value).getTime())).sort((a,b)=>new Date(a)-new Date(b));
+ if(responseDates.length){const instant=new Date(responseDates.at(-1)),time=element('time','Last response: '+new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant),'small muted invitation-last-response');time.setAttribute('datetime',instant.toISOString());currentStatus.append(time);}row.append(copy,currentStatus,invitationState);
if(replyList.length){
const replyDetails=element('details','','invitation-responses');replyDetails.open=replyList.length===1;
replyDetails.append(element('summary',replyList.length>1 ? 'View '+replyList.length+' responses' : 'Response details'));
- for(const g of replyList){const label={yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Later'}[g.status] || g.status;replyDetails.append(element('p',`${g.name || guest.name}: ${label}${g.status==='yes'?' · '+(g.participants || 1)+' '+((g.participants || 1)===1?'person':'people'):''}${g.status==='yes' && g.paymentStatus && g.paymentStatus!=='paid'?' · Awaiting payment':''}${g.comment?'\n'+g.comment:''}`,'small invitation-response'));}
+ for(const g of replyList){const label=g.responded===false?'Awaiting response':{yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later'}[g.status] || g.status,line=element('p',`${g.name || guest.name}: ${label}${g.status==='yes'?' · '+(g.participants || 1)+' '+((g.participants || 1)===1?'person':'people'):''}${g.status==='yes' && g.paymentStatus && g.paymentStatus!=='paid'?' · Awaiting payment':''}${g.comment?'\n'+g.comment:''}`,'small invitation-response');if(typeof g.respondedAt==='string' && Number.isFinite(new Date(g.respondedAt).getTime())){const instant=new Date(g.respondedAt),time=element('time','Last response: '+new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant),'small muted');time.setAttribute('datetime',instant.toISOString());line.append(time);}replyDetails.append(line);}
row.append(replyDetails);
}
- const removePanel=element('div','','invitation-remove');removePanel.hidden=true;
- const notify=action('Notify guest',()=>mutate('remove',{token:guest.token,notify:true,confirm:true},guest.name));notify.dataset.unavailable=String(guest.canNotify===false);
- const silent=action('Remove silently',()=>mutate('remove',{token:guest.token,notify:false,confirm:true},guest.name));
- const keep=action('Keep invite',()=>{removePanel.hidden=true;});
- const remove=action('Remove invite',()=>{if(busy)return;for(const item of rows)item.removePanel.hidden=true;removePanel.hidden=false;keep.focus();},'text-button invitation-remove-button');
- remove.setAttribute('aria-label','Remove invite for '+guest.name);
- if(guest===selected && options.revoke && focusSelected)removePanel.hidden=false;
- const removalActions=element('div','','event-actions');removalActions.append(notify,silent,keep);
- removePanel.append(element('p',`Remove ${guest.name}’s invite? Its link and ticket will stop working.${replyList.length>1?' All responses using this link will be removed.':''}${guest.hasPayments?' Payment records remain; handle refunds separately in Payments.':''}${guest.canNotify===false?' No Telegram account is available to notify yet.':''}`,'small'),removalActions);
- mutationButtons.push(remove,notify,silent,keep);row.append(actions,preview,remove,removePanel);list.append(row);rows.push({row,guest,removePanel,name:[guest.name,...replyList.map(g=>g.name || '')].join(' ').toLocaleLowerCase()});if(guest===selected)selectedButton=options.revoke?keep:copy;
+ const more=element('details','','event-more invitation-more'),toggle=element('summary','⋯');toggle.setAttribute('aria-label','More options for '+guest.name);const menu=element('div','','event-more-panel');more.append(toggle,menu);
+ const panel=element('section','','invitation-management');panel.hidden=true;panel.setAttribute('aria-label','Manage invitation for '+guest.name);
+ const showPanel=kind=>{if(busy)return;panelState={token:guest.token,kind,focus:true};render();};
+ const menuAction=(label,kind,unavailable=false,danger=false)=>{const button=action(label,()=>showPanel(kind),danger?'secondary invitation-danger':'secondary');button.dataset.unavailable=String(unavailable);mutationButtons.push(button);menu.append(button);return button;};
+ const editableReplies=replyList.filter(reply=>Number.isSafeInteger(reply.id) && reply.id>0);
+ if(!guest.revoked){menu.append(action('📤 Share invite',()=>{more.open=false;openTelegram(`https://t.me/share/url?url=${encodeURIComponent(guest.url)}&text=${encodeURIComponent(inviteText(e,guest))}`);}));menuAction('✏️ Edit invite','edit');menuAction('↻ Change response','response',!editableReplies.length);if(!editableReplies.length)menu.append(element('p','The guest must open their invitation first.','small muted invitation-menu-note'));menuAction('🚫 Revoke invite','revoke',false,true);}
+ else menuAction('🗑 Delete invite','delete',false,true);
+ menu.append(action('🕘 See history',()=>showPanel('history')));
+ if(guest===selected && options.revoke && focusSelected && !guest.revoked)panelState={token:guest.token,kind:'revoke',focus:true};
+ if(panelState && panelState.token===guest.token && (guest.revoked && !['history','delete'].includes(panelState.kind) || panelState.kind==='response' && !editableReplies.length))panelState=null;
+ if(panelState && panelState.token===guest.token){
+ const kind=panelState.kind,draft=panelState.draft ||= {};panel.hidden=false;panel.dataset.kind=kind;
+ const close=action('Back',()=>{panelState=null;render();});
+ const title={edit:'Edit invite',response:'Change response',revoke:'Revoke invitation?',delete:'Delete invitation?',history:'Invitation history'}[kind] || 'Invitation',heading=element('h3',title);heading.tabIndex=-1;panel.append(heading);
+ const field=(label,type,key,value)=>{const input=document.createElement(type==='select'?'select':'input');if(type!=='select')input.type=type;input.dataset.field=key;input.value=draft[key] ?? value;const wrapper=element('label',label);wrapper.append(input);panel.append(wrapper);mutationInputs.push(input);return {input,wrapper};};
+ const selectValues=(input,values,initial)=>{for(const [value,label] of values){const option=element('option',label);option.value=String(value);input.append(option);}input.value=String(initial);};
+ const checkbox=(label,key,description='',unavailable=false)=>{const input=document.createElement('input');input.type='checkbox';input.dataset.field=key;input.checked=draft[key]===true;input.dataset.unavailable=String(unavailable);const wrapper=element('label','','check-option'),caption=element('span',label);if(description)caption.append(element('span',description,'check-note'));wrapper.append(input,caption);panel.append(wrapper);mutationInputs.push(input);input.onchange=()=>{draft[key]=input.checked;};return input;};
+ const footer=element('div','','event-actions invitation-panel-actions');
+ if(kind==='edit'){
+ const {input:name}=field('Guest name','text','name',guest.name);name.maxLength=100;name.autocomplete='off';name.oninput=()=>{draft.name=name.value;};
+ const modeValue=guest.participantMode==='ask'?'ask':['confirm','fixed'].includes(guest.participantMode)?guest.participantMode:guest.participants?'editable':'one';
+ const {input:mode}=field('Attendee setting','select','mode',modeValue);selectValues(mode,[['one','One person'],['editable','Set a count · guest can change'],['ask','Ask guest how many'],['confirm','Ask guest to confirm count'],['fixed','Fixed count · no changes']],draft.mode ?? modeValue);
+ const {input:count,wrapper:countField}=field('Number of people','select','count',guest.participants || 1);selectValues(count,Array.from({length:10},(_,i)=>[i+1,String(i+1)]),draft.count ?? guest.participants ?? 1);
+ const update=()=>{draft.mode=mode.value;draft.count=count.value;countField.hidden=!['editable','confirm','fixed'].includes(mode.value);};mode.onchange=count.onchange=update;update();
+ if(guest.hasPayments)panel.append(element('p','Paid tickets may prevent attendee changes. Manage refunds in Payments.','small muted'));
+ const save=action('Save invite',async()=>{const value=name.value.trim();if(!value || value.length>100 || /[=\r\n]/.test(value)){status.textContent='Enter a name of up to 100 characters, without = or line breaks.';name.focus();return;}if((e.invitees || []).some(invite=>invite.token!==guest.token && invite.name.toLocaleLowerCase()===value.toLocaleLowerCase())){status.textContent='That name is already in the list.';name.focus();return;}const participantMode={one:'default',editable:'preset',ask:'ask',confirm:'confirm',fixed:'fixed'}[mode.value],participants=['one','ask'].includes(mode.value)?null:Number(count.value);if(!participantMode || participants!==null && (!Number.isInteger(participants) || participants<1 || participants>10)){status.textContent='Choose a count from 1 to 10.';return;}draft.name=name.value;await mutate('edit',mutationRequest('edit',{token:guest.token,name:value,participants,participantMode}),guest.name);},'primary');mutationButtons.push(save);footer.append(save,close);panelFocus=name;
+ }else if(kind==='response'){
+ const initial=editableReplies.length===1?editableReplies[0].id:'';
+ const {input:person}=field('Guest response','select','userId',initial);selectValues(person,[['','Choose a guest'],...editableReplies.map(reply=>[reply.id,reply.name || guest.name])],draft.userId ?? initial);
+ const responseValue=draft.status || editableReplies.find(reply=>String(reply.id)===person.value)?.status || 'yes';
+ const {input:answer}=field('Response','select','status',responseValue);selectValues(answer,[['yes','Accepted'],['no','Declined'],['maybe','Maybe'],['later','Respond later']],responseValue);
+ const selectedReply=editableReplies.find(reply=>String(reply.id)===person.value),responseCount=draft.count || selectedReply?.selectedParticipants || selectedReply?.participants || guest.participants || 1;
+ const {input:count,wrapper:countField}=field('Number of people','select','count',responseCount);selectValues(count,Array.from({length:10},(_,i)=>[i+1,String(i+1)]),responseCount);count.dataset.unavailable=String(guest.participantMode==='fixed' || !guest.participants && !guest.participantMode);
+ const update=()=>{draft.userId=person.value;draft.status=answer.value;draft.count=count.value;countField.hidden=answer.value!=='yes';};
+ person.onchange=()=>{const reply=editableReplies.find(item=>String(item.id)===person.value);answer.value=reply?.status || 'yes';count.value=String(reply?.selectedParticipants || reply?.participants || guest.participants || 1);update();};answer.onchange=count.onchange=update;update();
+ if(guest.participantMode==='fixed')panel.append(element('p','The invitation’s attendee count is fixed.','small muted'));else if(!guest.participants && !guest.participantMode)panel.append(element('p','This invitation is for one person. Edit its attendee setting to change that.','small muted'));
+ if(guest.hasPayments)panel.append(element('p','Payment or check-in records may prevent this response change. Manage refunds in Payments.','small muted'));
+ const notify=checkbox('Notify guest','notify','Send the updated response to their Telegram chat.',guest.canNotify===false);
+ const save=action('Save response',async()=>{const reply=editableReplies.find(item=>String(item.id)===person.value),participants=Number(count.value);if(!reply){status.textContent='Choose the guest whose response you want to change.';person.focus();return;}if(answer.value==='yes' && (!Number.isInteger(participants) || participants<1 || participants>10)){status.textContent='Choose a count from 1 to 10.';return;}await mutate('response',mutationRequest('response',{token:guest.token,userId:reply.id,status:answer.value,...(answer.value==='yes'?{participants}:{}),notify:notify.checked && guest.canNotify!==false}),guest.name);},'primary');mutationButtons.push(save);footer.append(save,close);panelFocus=person;
+ }else if(kind==='revoke'){
+ panel.append(element('p',`Revoke ${guest.name}’s invitation? Its link and ticket will stop working.${replyList.length>1?' This affects every response using this link.':''} You can still see its history in Revoked invitations.`,'small'));
+ const notify=checkbox('Notify guest','notify',guest.canNotify===false?'No Telegram account is available to notify yet.':'Send a revocation message in Telegram.',guest.canNotify===false),remove=checkbox('Delete after revoking','deleteAfter','Remove it from the invitation list. Audit records are retained. You’ll confirm next.');
+ if(guest.hasPayments)panel.append(element('p','Payment records remain. Handle refunds separately in Payments.','small muted'));
+ const revoke=action('Revoke invite',async()=>{const deleteAfter=remove.checked,done=await mutate('revoke',mutationRequest('revoke',{token:guest.token,notify:notify.checked && guest.canNotify!==false,confirm:true}),guest.name);if(done && deleteAfter && current()){panelState={token:guest.token,kind:'delete',focus:true};filter.value='revoked';render();status.textContent='Invitation revoked. Confirm deletion below.';}},'danger-button');mutationButtons.push(revoke);footer.append(revoke,close);panelFocus=close;
+ }else if(kind==='delete'){
+ panel.append(element('p',`Delete ${guest.name}’s revoked invitation from this list? This cannot be undone here. Its link stays disabled; audit records are retained.`,'small'));
+ const remove=action('Delete permanently',()=>mutate('delete',mutationRequest('delete',{token:guest.token,confirm:true}),guest.name),'danger-button');mutationButtons.push(remove);footer.append(remove,close);panelFocus=close;
+ }else if(kind==='history'){
+ const history=Array.isArray(guest.history)?guest.history:[],labels={created:'Invite created',opened:'Invitation opened',responded:'Response received',changed:'Response changed',edited:'Invite edited',revoked:'Invitation revoked',deleted:'Invitation deleted'},statuses={yes:'Accepted',no:'Declined',maybe:'Maybe',later:'Respond later'},modes={default:'One person',preset:'Guest can change count',ask:'Guest chooses count',confirm:'Guest confirms count',fixed:'Fixed count'};
+ panel.append(element('p','Times shown in '+selectedZone()+'.','small muted'));
+ if(!history.some(entry=>entry.type==='created'))panel.append(element('p',history.length?'Earlier activity may not be recorded. History tracking starts with this update.':'No recorded history yet. Earlier activity isn’t available.','small muted'));
+ const timeline=element('ol','','invitation-history');
+ for(const entry of history){const item=element('li'),title=labels[entry.type] || 'Invitation updated',details=[];if(entry.name)details.push(entry.previousName && entry.previousName!==entry.name?entry.previousName+' → '+entry.name:entry.name);if(entry.status)details.push(entry.previousStatus && entry.previousStatus!==entry.status?(statuses[entry.previousStatus] || entry.previousStatus)+' → '+(statuses[entry.status] || entry.status):statuses[entry.status] || entry.status);if(entry.participants)details.push((entry.previousParticipants && entry.previousParticipants!==entry.participants?entry.previousParticipants+' → ':'')+entry.participants+' '+(entry.participants===1?'person':'people'));if(entry.participantMode)details.push(entry.previousParticipantMode && entry.previousParticipantMode!==entry.participantMode?(modes[entry.previousParticipantMode] || 'Previous attendee setting')+' → '+(modes[entry.participantMode] || 'Attendee setting updated'):modes[entry.participantMode] || 'Attendee setting updated');if(entry.actorRole==='organiser')details.push('By organiser');if(entry.notify===true)details.push('Notification requested');else if(entry.notify===false)details.push('Guest not notified');item.append(element('strong',title));if(details.length)item.append(element('p',details.join(' · '),'small'));const instant=new Date(typeof entry.at==='string'?entry.at:NaN),time=element('time',Number.isFinite(instant.getTime())?new Intl.DateTimeFormat(undefined,{timeZone:selectedZone(),dateStyle:'medium',timeStyle:'short'}).format(instant):'Date unavailable','small muted');if(Number.isFinite(instant.getTime()))time.setAttribute('datetime',instant.toISOString());item.append(time);timeline.append(item);}panel.append(timeline);footer.append(close);panelFocus=heading;
+ }
+ panel.append(footer);if(!panelState.focus)panelFocus=null;panelState.focus=false;
+ }
+ row.append(more,actions,...(guest.revoked?[]:[preview]),panel);list.append(row);rows.push({row,guest,name:[guest.name,...replyList.map(g=>g.name || '')].join(' ').toLocaleLowerCase()});if(guest===selected)selectedButton=copy;
}
const applyFilter=()=>{const term=search.value.trim().toLocaleLowerCase();for(const item of rows)item.row.hidden=!item.name.includes(term) || !matches(item.guest,filter.value);$('invitation-links-empty').hidden=rows.some(item=>!item.row.hidden);};
search.oninput=applyFilter;filter.onchange=applyFilter;applyFilter();setBusy(busy);
- if(focusSelected && selectedButton && !rows.find(item=>item.guest===selected)?.row.hidden){selectedButton.focus();selectedButton.scrollIntoView?.({block:'nearest'});}focusSelected=false;
+ if(panelFocus){panelFocus.focus();panelFocus.scrollIntoView?.({block:'nearest'});}else if(focusSelected && selectedButton && !rows.find(item=>item.guest===selected)?.row.hidden){selectedButton.focus();selectedButton.scrollIntoView?.({block:'nearest'});}focusSelected=false;
};
singleEditor=setupGuestEditor('invitation-single',async guest=>{
validateGuestAddition(guest.name,(e.invitees || []).map(invite=>invite.name));
diff --git a/public/index.html b/public/index.html
index 35b2568..3be89f7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -112,7 +112,7 @@