Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/check-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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 }}
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/sync-main-to-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand All @@ -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"
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion scripts/deploy-environment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions test/mini-auth.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading