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 9eedd0b..e70ae1c 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. @@ -97,7 +99,7 @@ Stars payments use Telegram's real Bot API endpoints in the current application. ## 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); +});