diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..8952c6d
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,132 @@
+name: Deploy ATAuth to DO
+# CI/CD: Gitea Actions โ DigitalOcean managed k8s
+
+on:
+ push:
+ branches: [main]
+ paths-ignore:
+ - '*.md'
+ - 'docs/**'
+
+env:
+ REGISTRY: registry.digitalocean.com/ghostmesh-registry
+ IMAGE_NAME: atauth
+ DEPLOYMENT: atauth
+ NAMESPACE: atauth
+
+jobs:
+ test:
+ name: Test Gateway
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: gateway
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Type check
+ run: npm run typecheck
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Run tests
+ run: npm run test:run
+
+ - name: Notify test failure
+ if: failure()
+ run: |
+ SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
+ FIRST_LINE=$(echo "${{ github.event.head_commit.message }}" | head -1)
+ MSG=$(jq -n --arg body "๐งช ${{ github.repository }}: tests FAILED @ ${SHORT_SHA}
+ โ ${FIRST_LINE}" '{msgtype: "m.text", body: $body}')
+ curl -sf -X PUT \
+ "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \
+ -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ -d "$MSG" || true
+
+ build-and-deploy:
+ name: Build, Push, Deploy
+ runs-on: host
+ needs: [test]
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set short SHA
+ id: sha
+ run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT"
+
+ - name: Notify build started
+ env:
+ COMMIT_MSG: ${{ github.event.head_commit.message }}
+ run: |
+ FIRST_LINE=$(echo "$COMMIT_MSG" | head -1)
+ MSG=$(jq -n --arg body "๐จ ${{ github.repository }}: build started @ ${{ steps.sha.outputs.short }}
+ โ Commit: $FIRST_LINE
+ โ Triggered by: ${{ github.actor }}" '{msgtype: "m.text", body: $body}')
+ curl -sf -X PUT \
+ "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \
+ -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ -d "$MSG" || true
+
+ - name: Login to DO Container Registry
+ run: echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
+
+ - name: Build Docker image
+ run: |
+ docker build \
+ --platform linux/amd64 \
+ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \
+ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
+ gateway/
+
+ - name: Push Docker image
+ run: |
+ docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}
+ docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+
+ - name: Deploy to DO Kubernetes
+ run: |
+ echo "${{ secrets.DO_KUBECONFIG }}" | base64 -d > /tmp/kubeconfig
+ KUBECONFIG=/tmp/kubeconfig kubectl set image deployment/${{ env.DEPLOYMENT }} \
+ ${{ env.DEPLOYMENT }}=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \
+ -n ${{ env.NAMESPACE }}
+ KUBECONFIG=/tmp/kubeconfig kubectl rollout status deployment/${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }} --timeout=120s
+ rm -f /tmp/kubeconfig
+
+ - name: Notify build result
+ if: always()
+ env:
+ COMMIT_MSG: ${{ github.event.head_commit.message }}
+ run: |
+ if [ "${{ job.status }}" = "success" ]; then
+ EMOJI="โ
"; STATUS="deployed"
+ else
+ EMOJI="โ"; STATUS="FAILED"
+ fi
+ FIRST_LINE=$(echo "$COMMIT_MSG" | head -1)
+ MSG=$(jq -n --arg body "${EMOJI} ${{ github.repository }}: ${STATUS} @ ${{ steps.sha.outputs.short }}
+ โ $FIRST_LINE" '{msgtype: "m.text", body: $body}')
+ curl -sf -X PUT \
+ "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \
+ -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ -d "$MSG" || true
+
+ - name: Clean up Docker images
+ if: always()
+ run: |
+ docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} 2>/dev/null || true
+ docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 2>/dev/null || true
diff --git a/gateway/src/routes/admin-dashboard.ts b/gateway/src/routes/admin-dashboard.ts
index ce41d10..5679063 100644
--- a/gateway/src/routes/admin-dashboard.ts
+++ b/gateway/src/routes/admin-dashboard.ts
@@ -212,7 +212,7 @@ export function createAdminDashboardRoutes(
router.post('/origins/:id/delete', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- db.removeProxyAllowedOrigin(parseInt(req.params.id, 10));
+ db.removeProxyAllowedOrigin(parseInt(String(req.params.id), 10));
res.redirect('/admin/dashboard/origins?msg=Origin+deleted');
});
@@ -347,7 +347,7 @@ export function createAdminDashboardRoutes(
router.post('/access/:id/delete', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- db.deleteProxyAccessRule(parseInt(req.params.id, 10));
+ db.deleteProxyAccessRule(parseInt(String(req.params.id), 10));
res.redirect('/admin/dashboard/access?msg=Rule+deleted');
});
@@ -408,7 +408,7 @@ export function createAdminDashboardRoutes(
router.post('/sessions/:id/delete', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- db.deleteProxySession(req.params.id);
+ db.deleteProxySession(String(req.params.id));
res.redirect('/admin/dashboard/sessions?msg=Session+revoked');
});
@@ -700,7 +700,7 @@ export function createAdminDashboardRoutes(
// --- Edit Client ---
router.get('/clients/:id/edit', (req: Request, res: Response) => {
- const client = db.getOIDCClient(req.params.id);
+ const client = db.getOIDCClient(String(req.params.id));
if (!client) {
return res.redirect('/admin/dashboard/clients?msg=Client+not+found');
}
@@ -798,7 +798,7 @@ export function createAdminDashboardRoutes(
router.post('/clients/:id/edit', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- const clientId = req.params.id;
+ const clientId = String(req.params.id);
const existing = db.getOIDCClient(clientId);
if (!existing) {
return res.redirect('/admin/dashboard/clients?msg=Client+not+found');
@@ -838,7 +838,7 @@ export function createAdminDashboardRoutes(
router.post('/clients/:id/rotate-secret', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- const clientId = req.params.id;
+ const clientId = String(req.params.id);
const existing = db.getOIDCClient(clientId);
if (!existing) {
return res.redirect('/admin/dashboard/clients?msg=Client+not+found');
@@ -857,7 +857,7 @@ export function createAdminDashboardRoutes(
router.post('/clients/:id/delete', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- db.deleteApp(req.params.id);
+ db.deleteApp(String(req.params.id));
res.redirect('/admin/dashboard/clients?msg=Client+deleted');
});
@@ -886,7 +886,7 @@ export function createAdminDashboardRoutes(
});
router.get('/clients/wizard/:preset', (req: Request, res: Response) => {
- const presetData = getPresetByKey(req.params.preset);
+ const presetData = getPresetByKey(String(req.params.preset));
if (!presetData) {
return res.status(404).setHeader('Content-Type', 'text/html; charset=utf-8').send(
layout('Not Found', '
')
@@ -979,7 +979,8 @@ export function createAdminDashboardRoutes(
router.post('/clients/wizard/:preset', (req: Request, res: Response) => {
if (!requireCsrf(req, res)) return;
- const presetData = getPresetByKey(req.params.preset);
+ const presetKey = String(req.params.preset);
+ const presetData = getPresetByKey(presetKey);
if (!presetData) {
return res.redirect('/admin/dashboard/clients/wizard');
}
@@ -990,15 +991,15 @@ export function createAdminDashboardRoutes(
const require_pkce = req.body.require_pkce === 'on';
if (!id || !name || !domain) {
- return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`);
+ return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(presetKey)}`);
}
if (!/^[a-z0-9][a-z0-9_-]*$/.test(id) || id.length > 64) {
- return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`);
+ return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(presetKey)}`);
}
const existing = db.getOIDCClient(id);
if (existing) {
- return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`);
+ return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(presetKey)}`);
}
// Build redirect URIs from template
diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts
index 3b6039e..ddcf47c 100644
--- a/gateway/src/routes/admin.ts
+++ b/gateway/src/routes/admin.ts
@@ -188,9 +188,10 @@ export function createAdminRoutes(
* Get application configuration (without secret)
*/
router.get('/apps/:id', requireAdmin, async (req: Request, res: Response) => {
- const app = db.getApp(req.params.id);
+ const id = String(req.params.id);
+ const app = db.getApp(id);
if (!app) {
- throw httpError.notFound('app_not_found', `Application '${req.params.id}' not found`);
+ throw httpError.notFound('app_not_found', `Application '${id}' not found`);
}
res.json({
@@ -206,15 +207,16 @@ export function createAdminRoutes(
* Update application configuration
*/
router.put('/apps/:id', requireAdmin, async (req: Request, res: Response) => {
- const existing = db.getApp(req.params.id);
+ const id = String(req.params.id);
+ const existing = db.getApp(id);
if (!existing) {
- throw httpError.notFound('app_not_found', `Application '${req.params.id}' not found`);
+ throw httpError.notFound('app_not_found', `Application '${id}' not found`);
}
const { name, token_ttl_seconds, callback_url, rotate_secret } = req.body;
const updated = {
- id: req.params.id,
+ id,
name: name || existing.name,
hmac_secret: rotate_secret ? generateHmacSecret() : existing.hmac_secret,
token_ttl_seconds: token_ttl_seconds || existing.token_ttl_seconds,
@@ -224,9 +226,9 @@ export function createAdminRoutes(
db.upsertApp(updated);
if (rotate_secret) {
- db.logAuditEvent('app.secret_rotate', 'admin', req.params.id, 'HMAC secret rotated', clientIp(req));
+ db.logAuditEvent('app.secret_rotate', 'admin', id, 'HMAC secret rotated', clientIp(req));
} else {
- db.logAuditEvent('app.update', 'admin', req.params.id, `Updated app config`, clientIp(req));
+ db.logAuditEvent('app.update', 'admin', id, `Updated app config`, clientIp(req));
}
const response: Record = {
@@ -360,9 +362,10 @@ export function createAdminRoutes(
* Get OIDC client details
*/
router.get('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => {
- const client = db.getOIDCClient(req.params.id);
+ const id = String(req.params.id);
+ const client = db.getOIDCClient(id);
if (!client) {
- throw httpError.notFound('client_not_found', `OIDC client '${req.params.id}' not found`);
+ throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`);
}
res.json({
@@ -386,9 +389,10 @@ export function createAdminRoutes(
* Update OIDC client
*/
router.put('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => {
- const existing = db.getOIDCClient(req.params.id);
+ const id = String(req.params.id);
+ const existing = db.getOIDCClient(id);
if (!existing) {
- throw httpError.notFound('client_not_found', `OIDC client '${req.params.id}' not found`);
+ throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`);
}
const {
@@ -403,7 +407,7 @@ export function createAdminRoutes(
refresh_token_ttl_seconds,
} = req.body;
- db.updateOIDCClient(req.params.id, {
+ db.updateOIDCClient(id, {
redirect_uris: redirect_uris ?? existing.redirect_uris,
grant_types: grant_types ?? existing.grant_types,
allowed_scopes: allowed_scopes ?? existing.allowed_scopes,
@@ -416,16 +420,16 @@ export function createAdminRoutes(
// Update app name if provided
if (name) {
- const app = db.getApp(req.params.id);
+ const app = db.getApp(id);
if (app) {
db.upsertApp({ ...app, name });
}
}
- db.logAuditEvent('oidc_client.update', 'admin', req.params.id, 'Updated OIDC client config', clientIp(req));
+ db.logAuditEvent('oidc_client.update', 'admin', id, 'Updated OIDC client config', clientIp(req));
res.json({
- id: req.params.id,
+ id,
message: 'OIDC client updated',
});
});
@@ -435,13 +439,14 @@ export function createAdminRoutes(
* Delete OIDC client
*/
router.delete('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => {
- const existing = db.getOIDCClient(req.params.id);
+ const id = String(req.params.id);
+ const existing = db.getOIDCClient(id);
if (!existing) {
- throw httpError.notFound('client_not_found', `OIDC client '${req.params.id}' not found`);
+ throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`);
}
- db.deleteApp(req.params.id);
- db.logAuditEvent('oidc_client.delete', 'admin', req.params.id, `Deleted OIDC client "${existing.name}"`, clientIp(req));
+ db.deleteApp(id);
+ db.logAuditEvent('oidc_client.delete', 'admin', id, `Deleted OIDC client "${existing.name}"`, clientIp(req));
res.json({
message: 'OIDC client deleted',
@@ -453,19 +458,20 @@ export function createAdminRoutes(
* Rotate OIDC client secret
*/
router.post('/oidc/clients/:id/rotate-secret', requireAdmin, async (req: Request, res: Response) => {
- const existing = db.getOIDCClient(req.params.id);
+ const id = String(req.params.id);
+ const existing = db.getOIDCClient(id);
if (!existing) {
- throw httpError.notFound('client_not_found', `OIDC client '${req.params.id}' not found`);
+ throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`);
}
const clientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex');
- db.updateOIDCClientSecret(req.params.id, clientSecretHash);
- db.logAuditEvent('oidc_client.secret_rotate', 'admin', req.params.id, 'Client secret rotated', clientIp(req));
+ db.updateOIDCClientSecret(id, clientSecretHash);
+ db.logAuditEvent('oidc_client.secret_rotate', 'admin', id, 'Client secret rotated', clientIp(req));
res.json({
- id: req.params.id,
+ id,
client_secret: clientSecret,
message: 'Client secret rotated. Update your application configuration!',
});
@@ -504,8 +510,9 @@ export function createAdminRoutes(
* Revoke a specific session
*/
router.delete('/sessions/:id', requireAdmin, async (req: Request, res: Response) => {
- db.deleteSession(req.params.id);
- db.logAuditEvent('session.revoke', 'admin', req.params.id, 'Session revoked', clientIp(req));
+ const id = String(req.params.id);
+ db.deleteSession(id);
+ db.logAuditEvent('session.revoke', 'admin', id, 'Session revoked', clientIp(req));
res.json({ message: 'Session revoked' });
});
@@ -575,7 +582,7 @@ export function createAdminRoutes(
* Get user details including MFA status and passkeys
*/
router.get('/users/:did', requireAdmin, async (req: Request, res: Response) => {
- const { did } = req.params;
+ const did = String(req.params.did);
const passkeys = passkeyService?.listPasskeys(did) || [];
const mfaStatus = mfaService?.getMFAStatus(did, passkeys.length) || {
@@ -610,7 +617,7 @@ export function createAdminRoutes(
* Reset MFA for a user (admin override)
*/
router.delete('/users/:did/mfa', requireAdmin, async (req: Request, res: Response) => {
- const { did } = req.params;
+ const did = String(req.params.did);
if (mfaService) {
mfaService.disableTOTP(did);
@@ -628,7 +635,8 @@ export function createAdminRoutes(
* Delete a passkey for a user (admin override)
*/
router.delete('/users/:did/passkeys/:passkeyId', requireAdmin, async (req: Request, res: Response) => {
- const { did, passkeyId } = req.params;
+ const did = String(req.params.did);
+ const passkeyId = String(req.params.passkeyId);
if (passkeyService) {
const success = passkeyService.deletePasskey(did, passkeyId);
@@ -699,8 +707,9 @@ export function createAdminRoutes(
* Remove an allowed origin
*/
router.delete('/proxy/origins/:id', requireAdmin, async (req: Request, res: Response) => {
- db.removeProxyAllowedOrigin(parseInt(req.params.id, 10));
- db.logAuditEvent('proxy.origin_remove', 'admin', req.params.id, 'Removed proxy origin', clientIp(req));
+ const id = String(req.params.id);
+ db.removeProxyAllowedOrigin(parseInt(id, 10));
+ db.logAuditEvent('proxy.origin_remove', 'admin', id, 'Removed proxy origin', clientIp(req));
res.json({ message: 'Origin removed' });
});
@@ -722,8 +731,9 @@ export function createAdminRoutes(
* Revoke a proxy session
*/
router.delete('/proxy/sessions/:id', requireAdmin, async (req: Request, res: Response) => {
- db.deleteProxySession(req.params.id);
- db.logAuditEvent('proxy.session_revoke', 'admin', req.params.id, 'Proxy session revoked', clientIp(req));
+ const id = String(req.params.id);
+ db.deleteProxySession(id);
+ db.logAuditEvent('proxy.session_revoke', 'admin', id, 'Proxy session revoked', clientIp(req));
res.json({ message: 'Proxy session revoked' });
});
@@ -806,8 +816,9 @@ export function createAdminRoutes(
* Delete an access rule.
*/
router.delete('/proxy/access/:id', requireAdmin, async (req: Request, res: Response) => {
- db.deleteProxyAccessRule(parseInt(req.params.id, 10));
- db.logAuditEvent('proxy.access_rule_delete', 'admin', req.params.id, 'Deleted access rule', clientIp(req));
+ const id = String(req.params.id);
+ db.deleteProxyAccessRule(parseInt(id, 10));
+ db.logAuditEvent('proxy.access_rule_delete', 'admin', id, 'Deleted access rule', clientIp(req));
res.json({ message: 'Access rule deleted' });
});
diff --git a/gateway/src/routes/email.ts b/gateway/src/routes/email.ts
index 507d17e..97cd826 100644
--- a/gateway/src/routes/email.ts
+++ b/gateway/src/routes/email.ts
@@ -128,7 +128,7 @@ export function createEmailRouter(
throw new HttpError(401, 'unauthorized', 'Authentication required');
}
- const { email } = req.params;
+ const email = String(req.params.email);
if (!email || !isValidEmail(email)) {
throw new HttpError(400, 'invalid_request', 'Invalid email address');
diff --git a/gateway/src/routes/passkey.ts b/gateway/src/routes/passkey.ts
index 963a07f..b8a7f9d 100644
--- a/gateway/src/routes/passkey.ts
+++ b/gateway/src/routes/passkey.ts
@@ -196,7 +196,7 @@ export function createPasskeyRouter(
throw new HttpError(401, 'unauthorized', 'Authentication required');
}
- const { id } = req.params;
+ const id = String(req.params.id);
const { name } = req.body as { name: string };
if (!name) {
@@ -227,7 +227,7 @@ export function createPasskeyRouter(
throw new HttpError(401, 'unauthorized', 'Authentication required');
}
- const { id } = req.params;
+ const id = String(req.params.id);
// Prevent deleting last passkey if MFA is required
// (This is a safety check - can be configured based on policy)