Remove secrets from env files

This commit is contained in:
Arthur Belleville 2025-11-04 10:53:31 +01:00
parent f3a2e46116
commit 4777e3dc03
No known key found for this signature in database
25 changed files with 3023 additions and 2342 deletions

4
.gitignore vendored
View file

@ -32,6 +32,10 @@ __pycache__/
.coverage .coverage
htmlcov/ htmlcov/
# Environment files
.env*
!.env.example
.turbo .turbo
dist dist
.wrangler .wrangler

175
SECURITY_NOTICE.md Normal file
View file

@ -0,0 +1,175 @@
# ⚠️ SECURITY NOTICE - .env Files Removed from Git
## What Happened
Multiple `.env` files containing potentially sensitive credentials were being tracked in git. These files have now been **removed from version control** but remain on your local filesystem.
## Files Removed from Git
The following files were removed from git tracking:
- `api/.env.development`
- `api/.env.production`
- `api/.env.staging`
- `apps/external/.env.production`
- `apps/main/.env.production`
- `apps/main/.env.staging`
- `backend/app/.env`
- `xtablo-expo/.env`
**Note:** The files still exist locally - they're just no longer tracked by git.
## Updated .gitignore
Both `.gitignore` files have been updated to prevent this in the future:
```gitignore
# Environment files
.env*
!.env.example
```
This will:
- ✅ Ignore all `.env*` files (`.env`, `.env.development`, `.env.production`, etc.)
- ✅ Allow `.env.example` files to be committed (they should contain no real secrets)
## ⚠️ IMPORTANT: Security Actions Required
### 1. Review Git History
The `.env` files may have been committed in the past with sensitive credentials. Check the git history:
```bash
# See when .env files were last committed
git log --all --full-history -- "**/.env*"
# View the contents of a specific commit (replace COMMIT_HASH)
git show COMMIT_HASH:api/.env.production
```
### 2. Rotate Compromised Credentials
If any of these files were committed with real credentials, you should **rotate those credentials immediately**:
#### For API secrets in `api/.env.*`:
- [ ] **Supabase**: Regenerate service role key (Supabase Dashboard → Settings → API)
- [ ] **Stripe**: Regenerate secret keys (Stripe Dashboard → Developers → API keys)
- [ ] **Stream Chat**: Regenerate API secret (Stream Dashboard)
- [ ] **Email OAuth**: Revoke and regenerate OAuth tokens (Google Cloud Console)
- [ ] **Cloudflare R2**: Regenerate access keys (Cloudflare Dashboard → R2 → Manage R2 API Tokens)
#### For frontend env files:
- [ ] Check if any sensitive keys were in `apps/main/.env.*` or `apps/external/.env.*`
- [ ] Regenerate any exposed publishable keys if necessary
### 3. Use Google Secret Manager for Production
Since you've just set up Google Secret Manager, move your production secrets there:
```bash
# Migrate production secrets to Google Secret Manager
cd api
./scripts/migrate-env-to-secrets.sh .env.production your-gcp-project-id
# Verify they were created
./scripts/verify-secrets.sh your-gcp-project-id
```
After migrating:
- Delete the local `.env.production` file (or remove all sensitive values)
- Use `.env.example` as a template for what should be configured
### 4. Clean Git History (Optional but Recommended)
If sensitive credentials were committed, consider cleaning the git history. **Warning: This is destructive and requires team coordination.**
```bash
# Option A: Using BFG Repo-Cleaner (recommended)
# Download from: https://rtyley.github.io/bfg-repo-cleaner/
java -jar bfg.jar --delete-files .env.* --no-blob-protection
git reflog expire --expire=now --all
git gc --prune=now --aggressive
# Option B: Using git-filter-repo
# Install: pip install git-filter-repo
git filter-repo --path-glob '**/.env.*' --invert-paths
# After either option, force push (coordinate with team first!)
git push origin --force --all
```
**Important:** Cleaning git history will:
- Rewrite all commit hashes
- Require all team members to re-clone the repository
- Break any external references to commits (PRs, issues, etc.)
Only do this if:
1. You've confirmed sensitive credentials were committed
2. You've rotated all those credentials
3. You've coordinated with your team
### 5. Prevent Future Issues
**Best Practices:**
1. **Always use `.env.example`** files (committed) with placeholder values:
```bash
# .env.example
STRIPE_SECRET_KEY=sk_test_REPLACE_ME
SUPABASE_SERVICE_ROLE_KEY=REPLACE_WITH_YOUR_KEY
```
2. **Never commit actual `.env` files** - they're now in `.gitignore`
3. **Use Google Secret Manager** for production/staging environments
4. **Review files before committing:**
```bash
git status
git diff --cached
```
5. **Use pre-commit hooks** to prevent accidental commits:
```bash
# Install pre-commit: https://pre-commit.com/
# Add a hook to check for secrets
```
## Current Status
`.env` files removed from git tracking
`.gitignore` updated to prevent future commits
✅ Local `.env` files preserved (still work for development)
⚠️ Files staged for removal (need to commit)
## Next Steps
1. **Review this security notice carefully**
2. **Check git history** for exposed credentials
3. **Rotate any exposed credentials**
4. **Commit the changes:**
```bash
git add .gitignore
git commit -m "security: Remove .env files from git and update .gitignore"
```
5. **Push the changes** (after rotating credentials if needed)
6. **Migrate production secrets** to Google Secret Manager
## Questions or Concerns?
If you have questions about:
- What credentials might be exposed
- How to rotate specific credentials
- Cleaning git history
- Setting up Google Secret Manager
Please refer to:
- `docs/GOOGLE_SECRET_MANAGER_SETUP.md` - For Secret Manager setup
- `api/GOOGLE_SECRET_MANAGER.md` - Quick reference
- Your cloud provider's documentation for credential rotation
---
**Generated:** 2025-11-03
**Action Required:** See checklist above

View file

@ -1,25 +1,13 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4
SUPABASE_CONNECTION_STRING=postgresql://postgres:mke0dwp@cnv.MFZ@mpa@db.mhcafqvzbrrwvahpvvzd.supabase.co:5432/postgres
SUPABASE_CA_CERT="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVYkx4TW9kNjJQMmt0Q2lBa3huS0p3dEU5VlBZd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2F6RUxNQWtHQTFVRUJoTUNWVk14RURBT0JnTlZCQWdNQjBSbGJIZGhjbVV4RXpBUkJnTlZCQWNNQ2s1bApkeUJEWVhOMGJHVXhGVEFUQmdOVkJBb01ERk4xY0dGaVlYTmxJRWx1WXpFZU1Cd0dBMVVFQXd3VlUzVndZV0poCmMyVWdVbTl2ZENBeU1ESXhJRU5CTUI0WERUSXhNRFF5T0RFd05UWTFNMW9YRFRNeE1EUXlOakV3TlRZMU0xb3cKYXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBZ01CMFJsYkhkaGNtVXhFekFSQmdOVkJBY01DazVsZHlCRApZWE4wYkdVeEZUQVRCZ05WQkFvTURGTjFjR0ZpWVhObElFbHVZekVlTUJ3R0ExVUVBd3dWVTNWd1lXSmhjMlVnClVtOXZkQ0F5TURJeElFTkJNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXFRWFcKUXlIT0IrcVIyR0pvYkNxL0NCbVE0MEcwb0RtQ0MzbXpWbm44c3Y0WE5lV3RFNVhjRUwwdVZpaDdKbzREa3gxUQpEbUdIQkgxekRmZ3MycVhpTGI2eHB3L0NLUVB5cFpXMUpzc09UTUlmUXBwTlE4N0s3NVlhMHAyNVkzZVBTMnQyCkd0dkh4TmpVVjZrak9aakVuMnlXRWNCZHBPVkNVWUJWRkJOTUI0WUJIa05SRGEvK1M0dXl3QW9hVFduQ0pMVWkKY3ZUbEhtTXc2eFNRUW4xVWZSUUhrNTBETUNFSjdDeTFSeHJaSnJrWFhSUDNMcVFMMmlqSjZGNHlNZmgrR3liNApPNFhham9Wai8rUjRHd3l3S1lyclM4UHJTTnR3eHI1U3RsUU84eklRVVNNaXEyNndNOG1nRUxGbFMvMzJVY2x0Ck5hUTF4QlJpemt6cFpjdDlEd0lEQVFBQm8yQXdYakFMQmdOVkhROEVCQU1DQVFZd0hRWURWUjBPQkJZRUZLalgKdVhZMzJDenRraEltbmc0eUpOVXRhVVlzTUI4R0ExVWRJd1FZTUJhQUZLalh1WFkzMkN6dGtoSW1uZzR5Sk5VdAphVVlzTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUI4c3B6Tm4rNFZVCnRWeGJkTWFYKzM5WjUwc2M3dUFUbXVzMTZqbW1IamhJSHorbC85R2xKNUtxQU1PeDI2bVBaZ2Z6RzdvbmVMMmIKVlcrV2dZVWtUVDNYRVBGV25UcDJSSndRYW84L3RZUFhXRUpEYzBXVlFIcnBtbldPRktVL2QzTXFCZ0JtNXkrNgpqQjgxVFUvUkcyclZlclBEV1ArMU1NY05OeTA0OTFDVEw1WFFaN0pmREpKOUNDbVhTZHRUbDR1VVFuU3V2L1F4CkNlYTEzQlgyWmdKYzdBdTMwdmloTGh1YjUyRGU0UC80Z29uS3NOSFlkYldqZzdPV0t3TnYveml0R0RWREI5WTIKQ01UeVpLRzNYRXU1R2hsMUxFbkkzUW1FS3NxYUNMdjEyQm5WamJrU2Vac01uZXZKUHMxWWU2VGpqSndkaWs1UApvL2JLaUl6K0ZxOD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="
STREAM_CHAT_API_KEY=t5vvvddteapa STREAM_CHAT_API_KEY=t5vvvddteapa
STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj
STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33
STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02
XTABLO_URL="https://app-staging.xtablo.com" XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="http://localhost:5173,http://localhost:5174" CORS_ORIGIN="http://localhost:5173,http://localhost:5174"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
TASKS_SECRET="hello" TASKS_SECRET="hello"
EMAIL_USER="baptiste@xtablo.com" EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
EMAIL_CLIENT_SECRET="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"
EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk"

View file

@ -1,19 +1,13 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4
STREAM_CHAT_API_KEY=v4yf8rs94aa8 STREAM_CHAT_API_KEY=v4yf8rs94aa8
STREAM_CHAT_API_SECRET=jq2szvv73ua7sz9tvr9y24dxg37sw8ue8t576fu7ggr4h6wvcmunby4gvte8tm8f
XTABLO_URL=https://app.xtablo.com XTABLO_URL=https://app.xtablo.com
CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com" CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0=" TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
EMAIL_USER="baptiste@xtablo.com" EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
EMAIL_CLIENT_SECRET="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"
EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk"

View file

@ -1,22 +1,11 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4
SUPABASE_CONNECTION_STRING=postgresql://postgres:mke0dwp@cnv.MFZ@mpa@db.mhcafqvzbrrwvahpvvzd.supabase.co:5432/postgres
SUPABASE_CA_CERT="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVYkx4TW9kNjJQMmt0Q2lBa3huS0p3dEU5VlBZd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2F6RUxNQWtHQTFVRUJoTUNWVk14RURBT0JnTlZCQWdNQjBSbGJIZGhjbVV4RXpBUkJnTlZCQWNNQ2s1bApkeUJEWVhOMGJHVXhGVEFUQmdOVkJBb01ERk4xY0dGaVlYTmxJRWx1WXpFZU1Cd0dBMVVFQXd3VlUzVndZV0poCmMyVWdVbTl2ZENBeU1ESXhJRU5CTUI0WERUSXhNRFF5T0RFd05UWTFNMW9YRFRNeE1EUXlOakV3TlRZMU0xb3cKYXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBZ01CMFJsYkhkaGNtVXhFekFSQmdOVkJBY01DazVsZHlCRApZWE4wYkdVeEZUQVRCZ05WQkFvTURGTjFjR0ZpWVhObElFbHVZekVlTUJ3R0ExVUVBd3dWVTNWd1lXSmhjMlVnClVtOXZkQ0F5TURJeElFTkJNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXFRWFcKUXlIT0IrcVIyR0pvYkNxL0NCbVE0MEcwb0RtQ0MzbXpWbm44c3Y0WE5lV3RFNVhjRUwwdVZpaDdKbzREa3gxUQpEbUdIQkgxekRmZ3MycVhpTGI2eHB3L0NLUVB5cFpXMUpzc09UTUlmUXBwTlE4N0s3NVlhMHAyNVkzZVBTMnQyCkd0dkh4TmpVVjZrak9aakVuMnlXRWNCZHBPVkNVWUJWRkJOTUI0WUJIa05SRGEvK1M0dXl3QW9hVFduQ0pMVWkKY3ZUbEhtTXc2eFNRUW4xVWZSUUhrNTBETUNFSjdDeTFSeHJaSnJrWFhSUDNMcVFMMmlqSjZGNHlNZmgrR3liNApPNFhham9Wai8rUjRHd3l3S1lyclM4UHJTTnR3eHI1U3RsUU84eklRVVNNaXEyNndNOG1nRUxGbFMvMzJVY2x0Ck5hUTF4QlJpemt6cFpjdDlEd0lEQVFBQm8yQXdYakFMQmdOVkhROEVCQU1DQVFZd0hRWURWUjBPQkJZRUZLalgKdVhZMzJDenRraEltbmc0eUpOVXRhVVlzTUI4R0ExVWRJd1FZTUJhQUZLalh1WFkzMkN6dGtoSW1uZzR5Sk5VdAphVVlzTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUI4c3B6Tm4rNFZVCnRWeGJkTWFYKzM5WjUwc2M3dUFUbXVzMTZqbW1IamhJSHorbC85R2xKNUtxQU1PeDI2bVBaZ2Z6RzdvbmVMMmIKVlcrV2dZVWtUVDNYRVBGV25UcDJSSndRYW84L3RZUFhXRUpEYzBXVlFIcnBtbldPRktVL2QzTXFCZ0JtNXkrNgpqQjgxVFUvUkcyclZlclBEV1ArMU1NY05OeTA0OTFDVEw1WFFaN0pmREpKOUNDbVhTZHRUbDR1VVFuU3V2L1F4CkNlYTEzQlgyWmdKYzdBdTMwdmloTGh1YjUyRGU0UC80Z29uS3NOSFlkYldqZzdPV0t3TnYveml0R0RWREI5WTIKQ01UeVpLRzNYRXU1R2hsMUxFbkkzUW1FS3NxYUNMdjEyQm5WamJrU2Vac01uZXZKUHMxWWU2VGpqSndkaWs1UApvL2JLaUl6K0ZxOD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="
STREAM_CHAT_API_KEY=t5vvvddteapa STREAM_CHAT_API_KEY=t5vvvddteapa
STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj
STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33
STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02
XTABLO_URL="https://app-staging.xtablo.com" XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="https://app-staging.xtablo.com" CORS_ORIGIN="https://app-staging.xtablo.com"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
EMAIL_USER="baptiste@xtablo.com" EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
EMAIL_CLIENT_SECRET="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"
EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk"

1
api/.gitignore vendored
View file

@ -17,6 +17,7 @@ node_modules/
# env # env
.env.development .env.development
!.env.example
.dev.vars .dev.vars
# logs # logs

227
api/package-lock.json generated
View file

@ -7,6 +7,7 @@
"name": "xtablo-api", "name": "xtablo-api",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@google-cloud/secret-manager": "^6.1.1",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.14.4",
"@supabase/stripe-sync-engine": "^0.45.0", "@supabase/stripe-sync-engine": "^0.45.0",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
@ -2226,6 +2227,128 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@google-cloud/secret-manager": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz",
"integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==",
"dependencies": {
"google-gax": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/google-gax": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.5.tgz",
"integrity": "sha512-VuC6nVnPVfo/M1WudLoS4Y3dTepVndZatUmeb0nUNmfzft6mKSy8ffDh4h5qxR7L9lslDxNpWPYsuPrFboOmTw==",
"dependencies": {
"@grpc/grpc-js": "^1.12.6",
"@grpc/proto-loader": "^0.8.0",
"duplexify": "^4.1.3",
"google-auth-library": "^10.1.0",
"google-logging-utils": "^1.1.1",
"node-fetch": "^3.3.2",
"object-hash": "^3.0.0",
"proto3-json-serializer": "^3.0.0",
"protobufjs": "^7.5.3",
"retry-request": "^8.0.0",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/proto3-json-serializer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz",
"integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==",
"dependencies": {
"protobufjs": "^7.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/retry-request": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz",
"integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==",
"dependencies": {
"extend": "^3.0.2",
"teeny-request": "^10.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@google-cloud/secret-manager/node_modules/teeny-request": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz",
"integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==",
"dependencies": {
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
"node-fetch": "^3.3.2",
"stream-events": "^1.0.5"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@graphile/logger": { "node_modules/@graphile/logger": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz",
@ -2235,7 +2358,6 @@
"version": "1.14.0", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz",
"integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==",
"dev": true,
"dependencies": { "dependencies": {
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2" "@js-sdsl/ordered-map": "^4.4.2"
@ -2248,7 +2370,6 @@
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"dev": true,
"dependencies": { "dependencies": {
"lodash.camelcase": "^4.3.0", "lodash.camelcase": "^4.3.0",
"long": "^5.0.0", "long": "^5.0.0",
@ -2317,7 +2438,6 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0", "string-width-cjs": "npm:string-width@^4.2.0",
@ -2334,7 +2454,6 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2346,7 +2465,6 @@
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2357,14 +2475,12 @@
"node_modules/@isaacs/cliui/node_modules/emoji-regex": { "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
"dev": true
}, },
"node_modules/@isaacs/cliui/node_modules/string-width": { "node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2", "emoji-regex": "^9.2.2",
@ -2381,7 +2497,6 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^6.0.1" "ansi-regex": "^6.0.1"
}, },
@ -2396,7 +2511,6 @@
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
"string-width": "^5.0.1", "string-width": "^5.0.1",
@ -2473,7 +2587,6 @@
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"dev": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/js-sdsl" "url": "https://opencollective.com/js-sdsl"
@ -2623,7 +2736,6 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"optional": true, "optional": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@ -3492,7 +3604,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@ -3888,8 +3999,7 @@
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
"dev": true
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
@ -3960,7 +4070,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@ -4318,7 +4427,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
"shebang-command": "^2.0.0", "shebang-command": "^2.0.0",
@ -4610,7 +4718,6 @@
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"dev": true,
"dependencies": { "dependencies": {
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
"inherits": "^2.0.3", "inherits": "^2.0.3",
@ -4621,8 +4728,7 @@
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
"dev": true
}, },
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
@ -4648,7 +4754,6 @@
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
} }
@ -4993,7 +5098,6 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"dependencies": { "dependencies": {
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1" "signal-exit": "^4.0.1"
@ -5009,7 +5113,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"
}, },
@ -5945,8 +6048,7 @@
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
"dev": true
}, },
"node_modules/isomorphic-ws": { "node_modules/isomorphic-ws": {
"version": "5.0.0", "version": "5.0.0",
@ -5969,7 +6071,6 @@
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
}, },
@ -6210,8 +6311,7 @@
"node_modules/lodash.camelcase": { "node_modules/lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
"dev": true
}, },
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
@ -6379,7 +6479,6 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
@ -6636,7 +6735,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
} }
@ -6677,7 +6775,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
@ -6792,8 +6889,7 @@
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
"dev": true
}, },
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.5.0", "version": "1.5.0",
@ -6848,7 +6944,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -6857,7 +6952,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"dependencies": { "dependencies": {
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@ -7385,6 +7479,53 @@
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
}, },
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-async": { "node_modules/run-async": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
@ -7474,7 +7615,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
}, },
@ -7486,7 +7626,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -7719,7 +7858,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
"dev": true,
"dependencies": { "dependencies": {
"stubs": "^3.0.0" "stubs": "^3.0.0"
} }
@ -7727,8 +7865,7 @@
"node_modules/stream-shift": { "node_modules/stream-shift": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="
"dev": true
}, },
"node_modules/streamsearch": { "node_modules/streamsearch": {
"version": "1.1.0", "version": "1.1.0",
@ -7764,7 +7901,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0", "is-fullwidth-code-point": "^3.0.0",
@ -7790,7 +7926,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@ -7843,8 +7978,7 @@
"node_modules/stubs": { "node_modules/stubs": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="
"dev": true
}, },
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
@ -8231,7 +8365,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
}, },
@ -8269,7 +8402,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
"string-width": "^4.1.0", "string-width": "^4.1.0",
@ -8285,8 +8417,7 @@
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
"dev": true
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.2", "version": "8.18.2",

View file

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@google-cloud/secret-manager": "^6.1.1",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.14.4",
"@supabase/stripe-sync-engine": "^0.45.0", "@supabase/stripe-sync-engine": "^0.45.0",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",

View file

@ -1,4 +1,5 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import type { Secrets } from "./secrets.js";
export interface AppConfig { export interface AppConfig {
NODE_ENV: "development" | "production" | "test" | "staging"; NODE_ENV: "development" | "production" | "test" | "staging";
@ -31,12 +32,11 @@ function validateEnvVar(name: string, value: string | undefined): string {
return value; return value;
} }
function createConfig(): AppConfig { export function createConfig(secrets: Secrets): AppConfig {
const NODE_ENV = (process.env.NODE_ENV || "development") as const NODE_ENV = (process.env.NODE_ENV || "development") as
| "development" | "development"
| "production" | "production"
| "staging" | "staging";
| "test";
dotenv.config({ path: `.env.${NODE_ENV}` }); dotenv.config({ path: `.env.${NODE_ENV}` });
@ -45,31 +45,22 @@ function createConfig(): AppConfig {
NODE_ENV, NODE_ENV,
PORT: parseInt(process.env.PORT || "8080", 10), PORT: parseInt(process.env.PORT || "8080", 10),
SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL), SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL),
SUPABASE_SERVICE_ROLE_KEY: validateEnvVar( SUPABASE_SERVICE_ROLE_KEY: secrets.supabaseServiceRoleKey,
"SUPABASE_SERVICE_ROLE_KEY", SUPABASE_CONNECTION_STRING: secrets.supabaseConnectionString,
process.env.SUPABASE_SERVICE_ROLE_KEY SUPABASE_CA_CERT: secrets.supabaseCaCert,
),
SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "",
SUPABASE_CA_CERT: process.env.SUPABASE_CA_CERT || "",
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY), STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
STREAM_CHAT_API_SECRET: validateEnvVar( STREAM_CHAT_API_SECRET: secrets.streamChatApiSecret,
"STREAM_CHAT_API_SECRET", STRIPE_SECRET_KEY: secrets.stripeSecretKey,
process.env.STREAM_CHAT_API_SECRET STRIPE_WEBHOOK_SECRET: secrets.stripeWebhookSecret,
),
STRIPE_SECRET_KEY: validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY),
STRIPE_WEBHOOK_SECRET: validateEnvVar(
"STRIPE_WEBHOOK_SECRET",
process.env.STRIPE_WEBHOOK_SECRET
),
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER), EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID), EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET), EMAIL_CLIENT_SECRET: secrets.emailClientSecret,
EMAIL_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN), EMAIL_REFRESH_TOKEN: secrets.emailRefreshToken,
CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com", CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com",
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID), R2_ACCESS_KEY_ID: secrets.r2AccessKeyId,
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY), R2_SECRET_ACCESS_KEY: secrets.r2SecretAccessKey,
TASKS_SECRET: process.env.TASKS_SECRET || "", TASKS_SECRET: process.env.TASKS_SECRET || "",
LOG_LEVEL: "info", LOG_LEVEL: "info",
}; };
@ -79,26 +70,13 @@ function createConfig(): AppConfig {
baseConfig.LOG_LEVEL = "debug"; baseConfig.LOG_LEVEL = "debug";
} else if (NODE_ENV === "production") { } else if (NODE_ENV === "production") {
baseConfig.LOG_LEVEL = "info"; baseConfig.LOG_LEVEL = "info";
} else if (NODE_ENV === "test") {
baseConfig.LOG_LEVEL = "warn";
} }
// Log configuration info console.log("✓ Configuration loaded successfully");
// console.info(`Configuration loaded for ${NODE_ENV} environment:`);
// console.info(`- Port: ${baseConfig.PORT}`);
// console.info(`- CORS Origins: ${baseConfig.CORS_ORIGIN.join(", ")}`);
// console.info(`- Log Level: ${baseConfig.LOG_LEVEL}`);
// console.info(`- XTablo URL: ${baseConfig.XTABLO_URL}`);
// console.info(`- Supabase URL: ${baseConfig.SUPABASE_URL}`);
// console.info(`- Stream Chat API Key: ${baseConfig.STREAM_CHAT_API_KEY}`);
// console.info(`- Email User: ${baseConfig.EMAIL_USER}`);
// console.info(`- Email Key: ${baseConfig.EMAIL_KEY}`);
return baseConfig; return baseConfig;
} }
export const config = createConfig();
// Helper functions for common environment checks // Helper functions for common environment checks
export const isDevelopment = () => config.NODE_ENV === "development"; // export const isDevelopment = () => config.NODE_ENV === "development";
export const isProduction = () => config.NODE_ENV === "production"; // export const isProduction = () => config.NODE_ENV === "production";

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "./types.ts";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import type { EventAndTablo } from "./types.ts";
export const generateICSFromEvents = ( export const generateICSFromEvents = (
events: EventAndTablo[], events: EventAndTablo[],

View file

@ -1,13 +1,18 @@
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import tracer from "dd-trace";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import path from "path"; import path from "path";
import Stripe from "stripe";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { config } from "./config.js"; import { createConfig } from "./config.js";
import { publicRouter } from "./public.js"; import { initializeMiddlewares } from "./middleware.js";
import { mainRouter } from "./routers.js"; import { getPublicRouter } from "./public.js";
import tracer from "dd-trace"; import { getMainRouter } from "./routers.js";
import { loadSecrets, type Secrets } from "./secrets.js";
import { createStripeSync } from "./stripeSync.js";
import { createTransporter } from "./transporter.js";
tracer.init({ tracer.init({
logInjection: true, logInjection: true,
@ -16,60 +21,69 @@ tracer.init({
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory const __dirname = path.dirname(__filename); // get the name of the directory
const app = new Hono(); async function startServer(secrets: Secrets) {
// Load configuration (will load from Google Secret Manager in production/staging)
console.log("Initializing application configuration...");
const config = createConfig(secrets);
console.log(`✓ Configuration initialized for ${config.NODE_ENV} environment`);
app.use(logger()); // Initialize middlewares
const middlewares = initializeMiddlewares(config);
app.use("*", async (c, next) => { // Initialize Stripe client
const corsMiddleware = cors({ const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
origin: config.CORS_ORIGIN.split(","), apiVersion: "2025-10-29.clover",
allowHeaders: [
"Authorization",
"Content-Type",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
],
allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"],
exposeHeaders: ["set-cookie"],
credentials: true,
}); });
return corsMiddleware(c, next); // Initialize Stripe Sync
}); const stripeSync = createStripeSync(config);
app.route("/api/v1", mainRouter); // Initialize transporter
app.route("/api/public", publicRouter); const transporter = createTransporter(config);
// const worker = async () => { const app = new Hono();
// const connectionString = `${
// config.SUPABASE_CONNECTION_STRING
// }?ssl=true&sslrootcert=${path.resolve(__dirname, "..", "supabase_ca.crt")}`;
// const runner = await run({ app.use(logger());
// connectionString,
// concurrency: 1,
// pollInterval: 1000,
// taskDirectory: path.resolve(__dirname, "tasks"),
// noPreparedStatements: true,
// crontabFile: path.resolve(__dirname, "crontab"),
// });
// await runner.promise; app.use("*", async (c, next) => {
// }; const corsMiddleware = cors({
origin: config.CORS_ORIGIN.split(","),
allowHeaders: [
"Authorization",
"Content-Type",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
],
allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"],
exposeHeaders: ["set-cookie"],
credentials: true,
});
// worker().catch((err) => { return corsMiddleware(c, next);
// console.error(err); });
// process.exit(1);
// });
serve( app.route("/api/v1", getMainRouter(middlewares, config, stripe, stripeSync, transporter));
{ app.route("/api/public", getPublicRouter(middlewares));
fetch: app.fetch,
port: 8080, serve(
}, {
(info) => { fetch: app.fetch,
console.log(`Server is running on http://localhost:${info.port}`); port: 8080,
} },
); (info) => {
// TODO: Add health check endpoint console.log(`✓ Server is running on http://localhost:${info.port}`);
}
);
// TODO: Add health check endpoint
}
loadSecrets()
.then((secrets) => {
console.log("Secrets loaded successfully");
startServer(secrets);
})
.catch((error) => {
console.error("Failed to load secrets:", error);
process.exit(1);
});

View file

@ -1,73 +1,109 @@
import { S3Client } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3";
import { createClient, type User } from "@supabase/supabase-js"; import { createClient, type SupabaseClient, type User } from "@supabase/supabase-js";
import type { Context, Next } from "hono"; import type { Context, MiddlewareHandler, Next } from "hono";
import { createMiddleware } from "hono/factory";
import { StreamChat } from "stream-chat"; import { StreamChat } from "stream-chat";
import { config } from "./config.js"; import { type AppConfig } from "./config.js";
// Create authentication middleware export type Middlewares = {
export const authMiddleware = async (c: Context, next: Next) => { supabaseMiddleware: MiddlewareHandler<{
const supabase = c.get("supabase"); Variables: { supabase: SupabaseClient };
// Extract Bearer token from Authorization header Bindings: { user: User };
const authHeader = c.req.header("Authorization"); }>;
if (!authHeader || !authHeader.startsWith("Bearer ")) { authMiddleware: MiddlewareHandler<{
return c.json({ error: "Missing or invalid authorization header" }, 401); Variables: { supabase: SupabaseClient; user: User };
} Bindings: { user: User };
}>;
const token = authHeader.substring(7); // Remove "Bearer " prefix streamChatMiddleware: MiddlewareHandler<{
Variables: { streamServerClient: StreamChat };
const { }>;
data: { user }, r2Middleware: MiddlewareHandler<{
error, Variables: { s3_client: S3Client };
} = await supabase.auth.getUser(token); }>;
regularUserCheckMiddleware: MiddlewareHandler<{
if (error || !user) { Variables: { supabase: SupabaseClient; user: User };
return c.json({ error: "Invalid or expired token" }, 401); Bindings: { user: User };
} }>;
const userTyped = user as User;
c.set("user", userTyped);
await next();
}; };
export const regularUserCheckMiddleware = async (c: Context, next: Next) => { export const initializeMiddlewares = (config: AppConfig): Middlewares => {
const user = c.get("user"); const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => {
if (user.is_temporary) { const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY);
return c.json({ error: "User is read only" }, 401); c.set("supabase", supabase);
} await next();
await next();
};
export const supabaseMiddleware = async (c: Context, next: Next) => {
const supabase = createClient(
process.env.SUPABASE_URL as string,
process.env.SUPABASE_SERVICE_ROLE_KEY as string
);
c.set("supabase", supabase);
await next();
};
export const streamChatMiddleware = async (c: Context, next: Next) => {
const serverClient = StreamChat.getInstance(
process.env.STREAM_CHAT_API_KEY as string,
process.env.STREAM_CHAT_API_SECRET as string,
{
disableCache: true,
}
);
c.set("streamServerClient", serverClient);
await next();
};
export const r2Middleware = async (c: Context, next: Next) => {
const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
}); });
c.set("s3_client", s3);
await next(); const authMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>(async (c, next) => {
const supabase = c.get("supabase");
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: "Missing or invalid authorization header" }, 401);
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) {
return c.json({ error: "Invalid or expired token" }, 401);
}
c.set("user", user);
await next();
});
const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => {
const serverClient = StreamChat.getInstance(
config.STREAM_CHAT_API_KEY,
config.STREAM_CHAT_API_SECRET
);
c.set("streamServerClient", serverClient);
await next();
});
const r2Middleware = createMiddleware(async (c: Context, next: Next) => {
const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
});
c.set("s3_client", s3);
await next();
});
const regularUserCheckMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>(async (c, next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const { data: profile, error } = await supabase
.from("profiles")
.select("is_temporary")
.eq("id", user.id)
.single();
if (error || !profile) {
return c.json({ error: error.message }, 500);
}
if (profile.is_temporary) {
return c.json({ error: "User is read only" }, 401);
}
await next();
});
return {
supabaseMiddleware,
authMiddleware,
streamChatMiddleware,
r2Middleware,
regularUserCheckMiddleware,
};
}; };

View file

@ -1,54 +1,55 @@
import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono"; import { Hono } from "hono";
import { checkTabloMember } from "./helpers.js";
import type { Database } from "./database.types.js"; import type { Database } from "./database.types.js";
import { authMiddleware } from "./middleware.js"; import { checkTabloMember } from "./helpers.js";
import type { Middlewares } from "./middleware.js";
export const notesRouter = new Hono<{ export const getNotesRouter = (middlewares: Middlewares) => {
Variables: { const notesRouter = new Hono<{
user: User; Variables: {
supabase: SupabaseClient; user: User;
}; supabase: SupabaseClient;
}>(); };
}>();
type Note = Database["public"]["Tables"]["notes"]["Row"]; type Note = Database["public"]["Tables"]["notes"]["Row"];
notesRouter.use(authMiddleware); notesRouter.use(middlewares.authMiddleware);
/** /**
* Fetch notes shared with a specific tablo * Fetch notes shared with a specific tablo
*/ */
notesRouter.get("/:tabloId", checkTabloMember, async (c) => { notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
const { tabloId } = c.req.param(); const { tabloId } = c.req.param();
if (!tabloId) { if (!tabloId) {
return c.json({ error: "Tablo ID is required" }, 400); return c.json({ error: "Tablo ID is required" }, 400);
} }
const supabase = c.get("supabase"); const supabase = c.get("supabase");
// Find the tablo owner // Find the tablo owner
const { data: tabloData, error: tabloError } = await supabase const { data: tabloData, error: tabloError } = await supabase
.from("tablos") .from("tablos")
.select("owner_id") .select("owner_id")
.eq("id", tabloId) .eq("id", tabloId)
.single(); .single();
if (tabloError) { if (tabloError) {
console.error("Error fetching tablo:", tabloError); console.error("Error fetching tablo:", tabloError);
return c.json({ error: "Failed to fetch tablo" }, 500); return c.json({ error: "Failed to fetch tablo" }, 500);
} }
if (!tabloData) { if (!tabloData) {
return c.json({ error: "Tablo not found" }, 404); return c.json({ error: "Tablo not found" }, 404);
} }
const tabloOwnerId = tabloData.owner_id; const tabloOwnerId = tabloData.owner_id;
// Find notes shared with this specific tablo or all tablos // Find notes shared with this specific tablo or all tablos
const { data, error } = await supabase const { data, error } = await supabase
.from("note_access") .from("note_access")
.select(` .select(`
note_id, note_id,
notes!inner ( notes!inner (
id, id,
@ -60,23 +61,26 @@ notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
deleted_at deleted_at
) )
`) `)
.eq("is_active", true) .eq("is_active", true)
.eq("user_id", tabloOwnerId) .eq("user_id", tabloOwnerId)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`) .or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null); .is("notes.deleted_at", null);
if (error) { if (error) {
return c.json({ error: "An error occurred" }, 500); return c.json({ error: "An error occurred" }, 500);
} }
// Extract notes from the join result and remove duplicates // Extract notes from the join result and remove duplicates
type JoinedResult = { note_id: string; notes: Note[] }; type JoinedResult = { note_id: string; notes: Note[] };
const extractedNotes = (data as JoinedResult[]) const extractedNotes = (data as JoinedResult[])
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes)) .map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
.filter((note) => note !== null && note !== undefined); .filter((note) => note !== null && note !== undefined);
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo) // Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values()); const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values());
return c.json({ notes: uniqueNotes }); return c.json({ notes: uniqueNotes });
}); });
return notesRouter;
};

View file

@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Database, Tables } from "./database.types.js"; import type { Database, Tables } from "./database.types.js";
import { supabaseMiddleware } from "./middleware.js"; import type { Middlewares } from "./middleware.js";
import { import {
type EventTypeConfig, type EventTypeConfig,
type Exception, type Exception,
@ -12,119 +12,123 @@ import {
type WeeklyAvailability, type WeeklyAvailability,
} from "./slots.js"; } from "./slots.js";
export const publicRouter = new Hono<{ export const getPublicRouter = (middlewares: Middlewares) => {
Variables: { const publicRouter = new Hono<{
supabase: SupabaseClient; Variables: {
}; supabase: SupabaseClient;
}>(); };
}>();
publicRouter.use(supabaseMiddleware); publicRouter.use(middlewares.supabaseMiddleware);
publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const shortUserId = c.req.param("shortUserId"); const shortUserId = c.req.param("shortUserId");
const standardName = c.req.param("standardName"); const standardName = c.req.param("standardName");
// Get user // Get user
const { data: userData, error: userError } = await supabase const { data: userData, error: userError } = await supabase
.from("profiles") .from("profiles")
.select("*") .select("*")
.eq("short_user_id", shortUserId) .eq("short_user_id", shortUserId)
.single(); .single();
if (userError || !userData) { if (userError || !userData) {
return c.json({ error: "User not found" }, 404); return c.json({ error: "User not found" }, 404);
}
const user = userData as Tables<"profiles">;
// Get event type
const { data: eventTypeData, error: eventTypeError } = await supabase
.from("event_types")
.select("*")
.eq("user_id", user.id)
.eq("standard_name", standardName)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// Get user's availabilities
const { data: availabilitiesData, error: availabilitiesError } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
if (availabilitiesError) {
return c.json({ error: "Availabilities not found" }, 404);
}
const availabilities = availabilitiesData as Tables<"availabilities">;
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
// Use CET time for availability calculations
const now = new Date();
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 2);
const { data: eventsData, error: eventsError } = await supabase
.from("events")
.select("*")
.eq("created_by", user.id)
.gte("start_date", getDateStringCET(now))
.lte("start_date", getDateStringCET(nextMonth))
.is("deleted_at", null);
if (eventsError) {
return c.json({ error: "Failed to fetch events" }, 500);
}
const existingEvents = eventsData as Tables<"events">[];
// Generate slots for the next month
const slots: TimeSlot[] = [];
const currentDate = new Date(now);
while (currentDate <= nextMonth) {
const dayOfWeek = getDayOfWeek(currentDate);
const dayAvailability = weeklyAvailability[dayOfWeek];
if (dayAvailability) {
const daySlots = generateTimeSlots(
now, // Pass CET current time as first parameter
currentDate,
dayAvailability,
eventTypeConfig,
exceptions,
existingEvents
);
slots.push(...daySlots);
} }
currentDate.setDate(currentDate.getDate() + 1); const user = userData as Tables<"profiles">;
}
// Group slots by date for easier frontend consumption // Get event type
const slotsByDate: { [date: string]: TimeSlot[] } = {}; const { data: eventTypeData, error: eventTypeError } = await supabase
slots.forEach((slot) => { .from("event_types")
if (!slotsByDate[slot.date]) { .select("*")
slotsByDate[slot.date] = []; .eq("user_id", user.id)
.eq("standard_name", standardName)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
return c.json({ error: "Event type not found" }, 404);
} }
slotsByDate[slot.date].push(slot);
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// Get user's availabilities
const { data: availabilitiesData, error: availabilitiesError } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
if (availabilitiesError) {
return c.json({ error: "Availabilities not found" }, 404);
}
const availabilities = availabilitiesData as Tables<"availabilities">;
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
// Use CET time for availability calculations
const now = new Date();
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 2);
const { data: eventsData, error: eventsError } = await supabase
.from("events")
.select("*")
.eq("created_by", user.id)
.gte("start_date", getDateStringCET(now))
.lte("start_date", getDateStringCET(nextMonth))
.is("deleted_at", null);
if (eventsError) {
return c.json({ error: "Failed to fetch events" }, 500);
}
const existingEvents = eventsData as Tables<"events">[];
// Generate slots for the next month
const slots: TimeSlot[] = [];
const currentDate = new Date(now);
while (currentDate <= nextMonth) {
const dayOfWeek = getDayOfWeek(currentDate);
const dayAvailability = weeklyAvailability[dayOfWeek];
if (dayAvailability) {
const daySlots = generateTimeSlots(
now, // Pass CET current time as first parameter
currentDate,
dayAvailability,
eventTypeConfig,
exceptions,
existingEvents
);
slots.push(...daySlots);
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Group slots by date for easier frontend consumption
const slotsByDate: { [date: string]: TimeSlot[] } = {};
slots.forEach((slot) => {
if (!slotsByDate[slot.date]) {
slotsByDate[slot.date] = [];
}
slotsByDate[slot.date].push(slot);
});
return c.json({
user: { name: user.name, avatar_url: user.avatar_url },
eventType: eventTypeConfig,
slots: slotsByDate,
availableSlots: slots.filter((slot) => slot.available),
});
}); });
return c.json({ return publicRouter;
user: { name: user.name, avatar_url: user.avatar_url }, };
eventType: eventTypeConfig,
slots: slotsByDate,
availableSlots: slots.filter((slot) => slot.available),
});
});

View file

@ -1,42 +1,39 @@
import type { StripeSync } from "@supabase/stripe-sync-engine";
import { Hono } from "hono"; import { Hono } from "hono";
import { supabaseMiddleware } from "./middleware.js"; import type { Transporter } from "nodemailer";
import { tabloRouter } from "./tablo.js"; import type Stripe from "stripe";
import { tabloDataRouter } from "./tablo_data.js"; import type { AppConfig } from "./config.js";
import { taskRouter } from "./tasks.js"; import type { Middlewares } from "./middleware.js";
import { userRouter } from "./user.js"; import { getNotesRouter } from "./notes.js";
import { notesRouter } from "./notes.js"; import { getStripeRouter, getStripeWebhookRouter } from "./stripe.js";
import { stripeRouter, stripeWebhookRouter } from "./stripe.js"; import { getTabloRouter } from "./tablo.js";
import { getTabloDataRouter } from "./tablo_data.js";
import { getTaskRouter } from "./tasks.js";
import { getUserRouter } from "./user.js";
export const mainRouter = new Hono<{ export const getMainRouter = (
Bindings: { middlewares: Middlewares,
SESSION_ENCRYPTION_KEY: string; config: AppConfig,
}; stripe: Stripe,
}>(); stripeSync: StripeSync,
transporter: Transporter
) => {
const mainRouter = new Hono<{
Bindings: {
SESSION_ENCRYPTION_KEY: string;
};
}>();
// const store = new CookieStore(); mainRouter.use(middlewares.supabaseMiddleware);
mainRouter.use(supabaseMiddleware); mainRouter.route("/users", getUserRouter(middlewares, transporter));
// mainRouter.use("*", (c, next) => mainRouter.route("/tablos", getTabloRouter(middlewares, config, transporter));
// sessionMiddleware({ mainRouter.route("/tasks", getTaskRouter(middlewares, config, stripeSync));
// store, mainRouter.route("/tablo-data", getTabloDataRouter(middlewares));
// encryptionKey: c.env.SESSION_ENCRYPTION_KEY, mainRouter.route("/notes", getNotesRouter(middlewares));
// expireAfterSeconds: 900, // stripe routes
// sessionCookieName: "xtablo_session", mainRouter.route("/stripe", getStripeRouter(middlewares, config, stripe));
// cookieOptions: { mainRouter.route("/stripe-webhook", getStripeWebhookRouter(stripeSync));
// sameSite: "Lax",
// path: "/",
// httpOnly: true,
// secure: false,
// // secure: process.env.NODE_ENV === "production",
// },
// })(c, next)
// );
mainRouter.route("/users", userRouter); return mainRouter;
mainRouter.route("/tablos", tabloRouter); };
mainRouter.route("/tasks", taskRouter);
mainRouter.route("/tablo-data", tabloDataRouter);
mainRouter.route("/notes", notesRouter);
// stripe routes
mainRouter.route("/stripe", stripeRouter);
mainRouter.route("/stripe-webhook", stripeWebhookRouter);

51
api/src/secrets.ts Normal file
View file

@ -0,0 +1,51 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
const client = new SecretManagerServiceClient();
const SECRET_PREFIX = "projects/xtablo/secrets/";
const SECRET_SUFFIX = "/versions/latest";
/**
* fetchSecret retrieves the latest version of the secret from secret manager.
* @param {string} tokenName The name of the secret in Secret Manager
* @return {string} The sensitive value stored in Secret Manager.
*/
async function fetchSecret(tokenName) {
const [version] = await client.accessSecretVersion({
name: SECRET_PREFIX + tokenName + SECRET_SUFFIX,
});
return version.payload.data.toString();
}
export type Secrets = {
supabaseServiceRoleKey: string;
supabaseConnectionString: string;
supabaseCaCert: string;
streamChatApiSecret: string;
stripeSecretKey: string;
stripeWebhookSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
r2SecretAccessKey: string;
};
/**
* loadSecrets retrieves all the secrets needed for the program
* @return {object} The object with all of the secrets
*/
export async function loadSecrets(): Promise<Secrets> {
const secrets = {
supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"),
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
streamChatApiSecret: await fetchSecret("stream-chat-api-secret"),
stripeSecretKey: await fetchSecret("stripe-secret-key"),
stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),
r2SecretAccessKey: await fetchSecret("r2-secret-access-key"),
};
return secrets;
}

View file

@ -1,279 +1,287 @@
import { Hono } from "hono"; import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import Stripe from "stripe"; import Stripe from "stripe";
import { authMiddleware, regularUserCheckMiddleware } from "./middleware.js"; import type { AppConfig } from "./config.js";
import { config } from "./config.js"; import type { Middlewares } from "./middleware.js";
import { stripeSync } from "./stripeSync.js";
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", { export const getStripeWebhookRouter = (stripeSync: StripeSync) => {
apiVersion: "2025-10-29.clover", const stripeWebhookRouter = new Hono();
});
export const stripeRouter = new Hono<{ /**
Variables: { * Stripe webhook handler using @supabase/stripe-sync-engine
user: User; * This automatically syncs all Stripe events to Supabase tables
supabase: SupabaseClient; * Repository: https://github.com/supabase/stripe-sync-engine
}; */
}>(); stripeWebhookRouter.post("/", async (c) => {
try {
const signature = c.req.header("stripe-signature");
stripeRouter.use(authMiddleware); if (!signature) {
return c.json({ error: "No signature provided" }, 400);
}
// ============================================================================ // Get raw body for signature verification
// Webhook endpoint (no auth required - validated by signature) const rawBody = await c.req.text();
// ============================================================================
export const stripeWebhookRouter = new Hono(); // Process webhook using Stripe Sync Engine
// This handles signature verification and syncing automatically
await stripeSync.processWebhook(rawBody, signature);
/** return c.json({ received: true });
* Stripe webhook handler using @supabase/stripe-sync-engine } catch (error) {
* This automatically syncs all Stripe events to Supabase tables console.error("Webhook error:", error);
* Repository: https://github.com/supabase/stripe-sync-engine return c.json(
*/ { error: error instanceof Error ? error.message : "Webhook processing failed" },
stripeWebhookRouter.post("/", async (c) => { 400
try { );
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "No signature provided" }, 400);
} }
});
// Get raw body for signature verification return stripeWebhookRouter;
const rawBody = await c.req.text(); };
// Process webhook using Stripe Sync Engine export const getStripeRouter = (middlewares: Middlewares, config: AppConfig, stripe: Stripe) => {
// This handles signature verification and syncing automatically const stripeRouter = new Hono<{
await stripeSync.processWebhook(rawBody, signature); Variables: {
user: User;
supabase: SupabaseClient;
};
}>();
return c.json({ received: true }); stripeRouter.use(middlewares.authMiddleware);
} catch (error) {
console.error("Webhook error:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
400
);
}
});
// ============================================================================ // ============================================================================
// Authenticated endpoints // Authenticated endpoints
// ============================================================================ // ============================================================================
/** /**
* Create a Stripe Checkout Session * Create a Stripe Checkout Session
* POST /api/v1/stripe/create-checkout-session * POST /api/v1/stripe/create-checkout-session
*/ */
stripeRouter.post("/create-checkout-session", regularUserCheckMiddleware, async (c) => { stripeRouter.post(
const user = c.get("user"); "/create-checkout-session",
const supabase = c.get("supabase"); middlewares.regularUserCheckMiddleware,
const body = await c.req.json(); async (c) => {
const { priceId, successUrl, cancelUrl } = body; const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const { priceId, successUrl, cancelUrl } = body;
if (!priceId) { if (!priceId) {
return c.json({ error: "priceId is required" }, 400); return c.json({ error: "priceId is required" }, 400);
} }
try { try {
// Get or create Stripe customer // Get or create Stripe customer
let customerId: string; let customerId: string;
// Check if customer already exists by querying stripe schema with metadata filter // Check if customer already exists by querying stripe schema with metadata filter
// Note: Using service role, so we filter manually by metadata // Note: Using service role, so we filter manually by metadata
const { data: customers } = await supabase const { data: customers } = await supabase
.schema("stripe") .schema("stripe")
.from("customers") .from("customers")
.select("id, metadata") .select("id, metadata")
.limit(1000); // Get all customers to filter by metadata .limit(1000); // Get all customers to filter by metadata
const existingCustomer = customers?.find( const existingCustomer = customers?.find(
(c: Stripe.Customer) => c.metadata?.user_id === user.id (c: Stripe.Customer) => c.metadata?.user_id === user.id
); );
if (existingCustomer) { if (existingCustomer) {
customerId = existingCustomer.id; customerId = existingCustomer.id;
} else { } else {
// Create new Stripe customer with user_id in metadata // Create new Stripe customer with user_id in metadata
// stripe-sync-engine will automatically sync this to the database via webhook // stripe-sync-engine will automatically sync this to the database via webhook
const customer = await stripe.customers.create({ const customer = await stripe.customers.create({
email: user.email!, email: user.email!,
metadata: { metadata: {
user_id: user.id, // Stored in metadata for tracking user_id: user.id, // Stored in metadata for tracking
}, },
});
customerId = customer.id;
}
// Create Checkout Session
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`,
cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`,
metadata: {
user_id: user.id,
},
subscription_data: {
metadata: {
user_id: user.id,
},
},
});
return c.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error("Error creating checkout session:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to create checkout session" },
500
);
}
}
);
/**
* Create a Stripe Customer Portal Session
* POST /api/v1/stripe/create-portal-session
*/
stripeRouter.post("/create-portal-session", middlewares.regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const { returnUrl } = body;
try {
// Get Stripe customer ID by filtering metadata
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
if (!customer) {
return c.json({ error: "No Stripe customer found" }, 404);
}
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: returnUrl || `${config.XTABLO_URL}/settings`,
}); });
customerId = customer.id; return c.json({ url: session.url });
} catch (error) {
console.error("Error creating portal session:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to create portal session" },
500
);
} }
});
// Create Checkout Session // Note: Subscription status queries are handled directly from the frontend
const session = await stripe.checkout.sessions.create({ // using Supabase client with RLS policies. No API endpoints needed for reads.
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
success_url: successUrl || `${process.env.FRONTEND_URL}/settings?success=true`,
cancel_url: cancelUrl || `${process.env.FRONTEND_URL}/settings?canceled=true`,
metadata: {
user_id: user.id,
},
subscription_data: {
metadata: {
user_id: user.id,
},
},
});
return c.json({ sessionId: session.id, url: session.url }); /**
} catch (error) { * Cancel subscription at period end
console.error("Error creating checkout session:", error); * POST /api/v1/stripe/cancel-subscription
return c.json( */
{ error: error instanceof Error ? error.message : "Failed to create checkout session" }, stripeRouter.post("/cancel-subscription", middlewares.regularUserCheckMiddleware, async (c) => {
500 const user = c.get("user");
); const supabase = c.get("supabase");
}
});
/** try {
* Create a Stripe Customer Portal Session // Get user's Stripe customer first
* POST /api/v1/stripe/create-portal-session const { data: customers } = await supabase
*/ .schema("stripe")
stripeRouter.post("/create-portal-session", regularUserCheckMiddleware, async (c) => { .from("customers")
const user = c.get("user"); .select("id, metadata");
const supabase = c.get("supabase");
const body = await c.req.json();
const { returnUrl } = body;
try { const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
// Get Stripe customer ID by filtering metadata
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); if (!customer) {
return c.json({ error: "Customer not found" }, 404);
}
if (!customer) { // Get user's active subscription for this customer
return c.json({ error: "No Stripe customer found" }, 404); const { data: subscription } = await supabase
.schema("stripe")
.from("subscriptions")
.select("id, status")
.eq("customer", customer.id)
.in("status", ["active", "trialing"])
.maybeSingle();
if (!subscription) {
return c.json({ error: "No active subscription found" }, 404);
}
// Cancel subscription at period end in Stripe
// The webhook will automatically sync the change to our database
await stripe.subscriptions.cancel(subscription.id);
return c.json({
success: true,
message: "Subscription will cancel at period end",
});
} catch (error) {
console.error("Error canceling subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to cancel subscription" },
500
);
} }
});
// Create portal session /**
const session = await stripe.billingPortal.sessions.create({ * Reactivate a canceled subscription
customer: customer.id, * POST /api/v1/stripe/reactivate-subscription
return_url: returnUrl || `${process.env.FRONTEND_URL}/settings`, */
}); stripeRouter.post(
"/reactivate-subscription",
middlewares.regularUserCheckMiddleware,
async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
return c.json({ url: session.url }); try {
} catch (error) { // Get user's Stripe customer first
console.error("Error creating portal session:", error); const { data: customers } = await supabase
return c.json( .schema("stripe")
{ error: error instanceof Error ? error.message : "Failed to create portal session" }, .from("customers")
500 .select("id, metadata");
);
}
});
// Note: Subscription status queries are handled directly from the frontend const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
// using Supabase client with RLS policies. No API endpoints needed for reads.
/** if (!customer) {
* Cancel subscription at period end return c.json({ error: "No subscription found to reactivate" }, 404);
* POST /api/v1/stripe/cancel-subscription }
*/
stripeRouter.post("/cancel-subscription", regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
try { // Get user's subscription that's set to cancel
// Get user's Stripe customer first const { data: subscription } = await supabase
const { data: customers } = await supabase .schema("stripe")
.schema("stripe") .from("subscriptions")
.from("customers") .select("id, cancel_at_period_end")
.select("id, metadata"); .eq("customer", customer.id)
.eq("cancel_at_period_end", true)
.maybeSingle();
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); if (!subscription) {
return c.json({ error: "No subscription found to reactivate" }, 404);
}
if (!customer) { // Reactivate subscription in Stripe
return c.json({ error: "Customer not found" }, 404); // The webhook will automatically sync the change to our database
await stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
});
return c.json({ success: true, message: "Subscription reactivated" });
} catch (error) {
console.error("Error reactivating subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to reactivate subscription" },
500
);
}
} }
);
// Get user's active subscription for this customer return stripeRouter;
const { data: subscription } = await supabase };
.schema("stripe")
.from("subscriptions")
.select("id, status")
.eq("customer", customer.id)
.in("status", ["active", "trialing"])
.maybeSingle();
if (!subscription) {
return c.json({ error: "No active subscription found" }, 404);
}
// Cancel subscription at period end in Stripe
// The webhook will automatically sync the change to our database
await stripe.subscriptions.cancel(subscription.id);
return c.json({
success: true,
message: "Subscription will cancel at period end",
});
} catch (error) {
console.error("Error canceling subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to cancel subscription" },
500
);
}
});
/**
* Reactivate a canceled subscription
* POST /api/v1/stripe/reactivate-subscription
*/
stripeRouter.post("/reactivate-subscription", regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
try {
// Get user's Stripe customer first
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
if (!customer) {
return c.json({ error: "No subscription found to reactivate" }, 404);
}
// Get user's subscription that's set to cancel
const { data: subscription } = await supabase
.schema("stripe")
.from("subscriptions")
.select("id, cancel_at_period_end")
.eq("customer", customer.id)
.eq("cancel_at_period_end", true)
.maybeSingle();
if (!subscription) {
return c.json({ error: "No subscription found to reactivate" }, 404);
}
// Reactivate subscription in Stripe
// The webhook will automatically sync the change to our database
await stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
});
return c.json({ success: true, message: "Subscription reactivated" });
} catch (error) {
console.error("Error reactivating subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to reactivate subscription" },
500
);
}
});

View file

@ -1,19 +1,21 @@
import { StripeSync } from "@supabase/stripe-sync-engine"; import { StripeSync } from "@supabase/stripe-sync-engine";
import { config } from "./config.js"; import type { AppConfig } from "./config.js";
const ssl = { export const createStripeSync = (config: AppConfig): StripeSync => {
ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"), const ssl = {
ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"),
};
return new StripeSync({
stripeSecretKey: config.STRIPE_SECRET_KEY || "",
stripeWebhookSecret: config.STRIPE_WEBHOOK_SECRET || "",
schema: "stripe", // Use stripe schema (library default)
poolConfig: {
connectionString: config.SUPABASE_CONNECTION_STRING || "", // Direct Postgres connection string
ssl,
max: 10,
},
// Optional: force refetch from Stripe API to avoid stale data
revalidateObjectsViaStripeApi: ["subscription", "customer"],
});
}; };
export const stripeSync = new StripeSync({
stripeSecretKey: config.STRIPE_SECRET_KEY || "",
stripeWebhookSecret: config.STRIPE_WEBHOOK_SECRET || "",
schema: "stripe", // Use stripe schema (library default)
poolConfig: {
connectionString: config.SUPABASE_CONNECTION_STRING || "", // Direct Postgres connection string
ssl,
max: 10,
},
// Optional: force refetch from Stripe API to avoid stale data
revalidateObjectsViaStripeApi: ["subscription", "customer"],
});

File diff suppressed because it is too large Load diff

View file

@ -2,178 +2,177 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono"; import { Hono } from "hono";
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js"; import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js";
import { import type { Middlewares } from "./middleware.js";
authMiddleware,
r2Middleware,
regularUserCheckMiddleware,
streamChatMiddleware,
} from "./middleware.js";
export const tabloDataRouter = new Hono<{ export const getTabloDataRouter = (middlewares: Middlewares) => {
Variables: { const tabloDataRouter = new Hono<{
user: User; Variables: {
supabase: SupabaseClient; user: User;
s3_client: S3Client; supabase: SupabaseClient;
}; s3_client: S3Client;
}>(); };
}>();
tabloDataRouter.use(authMiddleware); tabloDataRouter.use(middlewares.authMiddleware);
tabloDataRouter.use(streamChatMiddleware); tabloDataRouter.use(middlewares.streamChatMiddleware);
tabloDataRouter.use(r2Middleware); tabloDataRouter.use(middlewares.r2Middleware);
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo // GET /tablo-data/:tabloId/filenames - Get all files for a tablo
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => { tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId"); const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client"); const s3_client = c.get("s3_client");
try { try {
const fileNames = await getTabloFileNames(s3_client, tabloId); const fileNames = await getTabloFileNames(s3_client, tabloId);
return c.json({ fileNames: fileNames || [] }); return c.json({ fileNames: fileNames || [] });
} catch (error) { } catch (error) {
console.error("Error fetching tablo files:", error); console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500); return c.json({ error: "Failed to fetch tablo files" }, 500);
}
});
// GET /tablo-data/:tabloId/:fileName - Get a specific file
tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
} }
});
const content = await response.Body.transformToString(); // GET /tablo-data/:tabloId/:fileName - Get a specific file
tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => {
return c.json({
fileName,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
});
} catch (error) {
console.error("Error fetching file:", error);
return c.json({ error: "Failed to fetch file" }, 500);
}
});
// POST /tablo-data/:tabloId/:fileName - Create or update a file
tabloDataRouter.post(
"/:tabloId/:fileName",
regularUserCheckMiddleware,
checkTabloMember,
async (c) => {
const tabloId = c.req.param("tabloId"); const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName"); const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client"); const s3_client = c.get("s3_client");
try { try {
const body = await c.req.json(); const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const { content, contentType = "text/plain" } = body;
if (!content) { const response = await s3_client.send(
return c.json({ error: "Content is required" }, 400); new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
} }
await s3_client.send( const content = await response.Body.transformToString();
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({ return c.json({
message: "File uploaded successfully",
fileName, fileName,
tabloId, content,
contentType: response.ContentType,
lastModified: response.LastModified,
}); });
} catch (error) { } catch (error) {
console.error("Error uploading file:", error); console.error("Error fetching file:", error);
return c.json({ error: "Failed to upload file" }, 500); return c.json({ error: "Failed to fetch file" }, 500);
} }
} });
);
// // PUT /tablo-data/:tabloId/:fileName - Update a file // POST /tablo-data/:tabloId/:fileName - Create or update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => { tabloDataRouter.post(
// const tabloId = c.req.param("tabloId"); "/:tabloId/:fileName",
// const fileName = c.req.param("fileName"); middlewares.regularUserCheckMiddleware,
// const s3_client = c.get("s3_client"); checkTabloMember,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
// try { const s3_client = c.get("s3_client");
// const body = await c.req.json();
// const { content, contentType = "text/plain" } = body;
// if (!content) { try {
// return c.json({ error: "Content is required" }, 400); const body = await c.req.json();
// } const { content, contentType = "text/plain" } = body;
// const { PutObjectCommand } = await import("@aws-sdk/client-s3"); if (!content) {
return c.json({ error: "Content is required" }, 400);
}
// await s3_client.send( await s3_client.send(
// new PutObjectCommand({ new PutObjectCommand({
// Bucket: "tablo-data", Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`, Key: `${tabloId}/${fileName}`,
// Body: content, Body: content,
// ContentType: contentType, ContentType: contentType,
// }) })
// ); );
// return c.json({ return c.json({
// message: "File updated successfully", message: "File uploaded successfully",
// fileName, fileName,
// tabloId, tabloId,
// }); });
// } catch (error) { } catch (error) {
// console.error("Error updating file:", error); console.error("Error uploading file:", error);
// return c.json({ error: "Failed to update file" }, 500); return c.json({ error: "Failed to upload file" }, 500);
// } }
// });
// DELETE /tablo-data/:tabloId/:fileName - Delete a file
tabloDataRouter.delete(
"/:tabloId/:fileName",
regularUserCheckMiddleware,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
} }
} );
);
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
// const tabloId = c.req.param("tabloId");
// const fileName = c.req.param("fileName");
// const s3_client = c.get("s3_client");
// try {
// const body = await c.req.json();
// const { content, contentType = "text/plain" } = body;
// if (!content) {
// return c.json({ error: "Content is required" }, 400);
// }
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
// await s3_client.send(
// new PutObjectCommand({
// Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`,
// Body: content,
// ContentType: contentType,
// })
// );
// return c.json({
// message: "File updated successfully",
// fileName,
// tabloId,
// });
// } catch (error) {
// console.error("Error updating file:", error);
// return c.json({ error: "Failed to update file" }, 500);
// }
// });
// DELETE /tablo-data/:tabloId/:fileName - Delete a file
tabloDataRouter.delete(
"/:tabloId/:fileName",
middlewares.regularUserCheckMiddleware,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
}
);
return tabloDataRouter;
};

View file

@ -1,105 +1,113 @@
import { S3Client } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { Hono, type Context } from "hono"; import { type Context, Hono } from "hono";
import { config } from "./config.js";
import { writeCalendarFileToR2 } from "./helpers.js";
import { streamChatMiddleware } from "./middleware.js";
import type { StreamChat } from "stream-chat"; import type { StreamChat } from "stream-chat";
import { stripeSync } from "./stripeSync.js"; import type { AppConfig } from "./config.js";
import { writeCalendarFileToR2 } from "./helpers.js";
import type { Middlewares } from "./middleware.js";
export const taskRouter = new Hono<{ export const getTaskRouter = (
Variables: { supabase: SupabaseClient }; middlewares: Middlewares,
}>(); config: AppConfig,
stripeSync: StripeSync
) => {
const taskRouter = new Hono<{
Variables: { supabase: SupabaseClient };
}>();
taskRouter.post("/sync-calendars", async (c) => { taskRouter.post("/sync-calendars", async (c) => {
const supabase = c.get("supabase");
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
});
const { data, error } = await supabase
.from("calendar_subscriptions")
.select("token, tablo_id, tablos(name)");
if (error) {
return c.json({ error: error.message }, 500);
}
const calendarSubscriptionsData = data as unknown as [
{
token: string;
tablo_id: string;
tablos: { name: string };
},
];
calendarSubscriptionsData.forEach(async (subscription) => {
const tabloName = subscription.tablos.name.replace(/ /g, "_");
await writeCalendarFileToR2(s3, supabase, {
tabloName,
token: subscription.token,
tablo_id: subscription.tablo_id,
});
});
return c.json({ message: "Synced calendars" });
});
taskRouter.post(
"/sync-tablo-names",
streamChatMiddleware,
async (
c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }>
) => {
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401); return c.json({ error: "Unauthorized" }, 401);
} }
const fifteenMinutesInMilliseconds = 1000 * 60 * 15; const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
});
const { data, error } = await supabase const { data, error } = await supabase
.from("tablos") .from("calendar_subscriptions")
.select("id, name") .select("token, tablo_id, tablos(name)");
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
if (error) { if (error) {
return c.json({ error: error.message }, 500); return c.json({ error: error.message }, 500);
} }
const tablosData = data as { id: string; name: string }[]; const calendarSubscriptionsData = data as unknown as [
{
token: string;
tablo_id: string;
tablos: { name: string };
},
];
tablosData.forEach(async (tablo) => { calendarSubscriptionsData.forEach(async (subscription) => {
const channel = streamServerClient.channel("messaging", tablo.id); const tabloName = subscription.tablos.name.replace(/ /g, "_");
try { await writeCalendarFileToR2(s3, supabase, {
await channel.update({ tabloName,
// @ts-ignore token: subscription.token,
name: tablo.name, tablo_id: subscription.tablo_id,
}); });
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
}); });
return c.json({ message: `Synced ${tablosData.length} tablo names` }); return c.json({ message: "Synced calendars" });
} });
);
taskRouter.post("/sync-stripe-subscriptions", async (c) => { taskRouter.post(
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { "/sync-tablo-names",
return c.json({ error: "Unauthorized" }, 401); middlewares.streamChatMiddleware,
} async (
c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }>
) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await stripeSync.syncBackfill({ object: "all" }); if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` }); const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
});
const { data, error } = await supabase
.from("tablos")
.select("id, name")
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
if (error) {
return c.json({ error: error.message }, 500);
}
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
}
);
taskRouter.post("/sync-stripe-subscriptions", async (c) => {
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const data = await stripeSync.syncBackfill({ object: "all" });
return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` });
});
return taskRouter;
};

View file

@ -1,10 +1,10 @@
import { google } from "googleapis"; import { google } from "googleapis";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { config } from "./config.js"; import type { AppConfig } from "./config.js";
const OAuth2 = google.auth.OAuth2; const OAuth2 = google.auth.OAuth2;
export const createTransporter = () => { export const createTransporter = (config: AppConfig) => {
const oauth2Client = new OAuth2( const oauth2Client = new OAuth2(
config.EMAIL_CLIENT_ID, config.EMAIL_CLIENT_ID,
config.EMAIL_CLIENT_SECRET, config.EMAIL_CLIENT_SECRET,
@ -30,5 +30,3 @@ export const createTransporter = () => {
return transporter; return transporter;
}; };
export const transporter = createTransporter();

View file

@ -9,96 +9,95 @@ import { Hono } from "hono";
import type { Transporter } from "nodemailer"; import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat"; import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts"; import type { Tables } from "./database.types.ts";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; import type { Middlewares } from "./middleware.js";
import { transporter } from "./transporter.js";
export const userRouter = new Hono<{ export const getUserRouter = (middlewares: Middlewares, transporter: Transporter) => {
Variables: { const userRouter = new Hono<{
user: User; Variables: {
supabase: SupabaseClient; user: User;
transporter: Transporter; supabase: SupabaseClient;
streamServerClient: StreamChat; streamServerClient: StreamChat;
s3_client: S3Client; s3_client: S3Client;
}; };
}>(); }>();
userRouter.use(authMiddleware); userRouter.use(middlewares.authMiddleware);
userRouter.use(streamChatMiddleware); userRouter.use(middlewares.streamChatMiddleware);
userRouter.use(r2Middleware); userRouter.use(middlewares.r2Middleware);
userRouter.post("/sign-up-to-stream", async (c) => { userRouter.post("/sign-up-to-stream", async (c) => {
const { id } = c.get("user"); const { id } = c.get("user");
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single(); const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">; const user = data as Tables<"profiles">;
const streamServerClient = c.get("streamServerClient"); const streamServerClient = c.get("streamServerClient");
await streamServerClient.upsertUser({ await streamServerClient.upsertUser({
id, id,
name: user.name ?? "", name: user.name ?? "",
language: "fr", language: "fr",
});
return c.json({
message: "User signed up to stream",
});
}); });
return c.json({ userRouter.get("/me", async (c) => {
message: "User signed up to stream", const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const userData = data as Tables<"profiles">;
if (!userData) {
return c.json({ error: "User not found" }, 404);
}
if (error) {
return c.json({ error: error.message }, 500);
}
const user_id = data.id;
const token = streamServerClient.createToken(user_id);
return c.json({
...userData,
streamToken: token,
});
}); });
});
userRouter.get("/me", async (c) => { userRouter.post("/mark-temporary", async (c) => {
const user = c.get("user"); const user = c.get("user");
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); const body = await c.req.json();
const { temporary_password } = body;
const userData = data as Tables<"profiles">; const { data: profile, error } = await supabase
.from("profiles")
.update({
is_temporary: true,
})
.eq("id", user.id)
.select()
.single();
if (!userData) { if (error) {
return c.json({ error: "User not found" }, 404); return c.json({ error: error.message }, 500);
} }
if (error) { try {
return c.json({ error: error.message }, 500); if (profile?.email && transporter) {
} const mailOptions = {
from: "Xtablo <noreply@xtablo.com>",
const user_id = data.id; to: profile.email,
const token = streamServerClient.createToken(user_id); subject: "Bienvenue sur XTablo - Votre mot de passe temporaire",
text: `Bienvenue sur XTablo !
return c.json({
...userData,
streamToken: token,
});
});
userRouter.post("/mark-temporary", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const { temporary_password } = body;
const { data: profile, error } = await supabase
.from("profiles")
.update({
is_temporary: true,
})
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
try {
if (profile?.email && transporter) {
const mailOptions = {
from: "Xtablo <noreply@xtablo.com>",
to: profile.email,
subject: "Bienvenue sur XTablo - Votre mot de passe temporaire",
text: `Bienvenue sur XTablo !
Votre compte a é créé avec succès. Voici vos informations de connexion : Votre compte a é créé avec succès. Voici vos informations de connexion :
@ -111,7 +110,7 @@ Connectez-vous sur : ${process.env.FRONTEND_URL || "https://app.xtablo.com"}
Cordialement, Cordialement,
L'équipe XTablo`, L'équipe XTablo`,
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bienvenue sur XTablo !</h2> <h2 style="color: #333;">Bienvenue sur XTablo !</h2>
@ -137,143 +136,146 @@ L'équipe XTablo`,
</p> </p>
</div> </div>
`, `,
}; };
await transporter.sendMail(mailOptions); await transporter.sendMail(mailOptions);
}
} catch (error) {
console.error("Failed to send welcome email:", error);
} }
} catch (error) {
console.error("Failed to send welcome email:", error);
}
return c.json({ return c.json({
message: "User marked as temporary", message: "User marked as temporary",
});
}); });
});
// userRouter.put("/profile", async (c) => { // userRouter.put("/profile", async (c) => {
// const user = c.get("user"); // const user = c.get("user");
// const supabase = c.get("supabase"); // const supabase = c.get("supabase");
// const body = await c.req.json(); // const body = await c.req.json();
// const { first_name, last_name } = body; // const { first_name, last_name } = body;
// // Deprecated: name field is deprecated, use first_name and last_name instead // // Deprecated: name field is deprecated, use first_name and last_name instead
// // Combine first_name and last_name into a single name field // // Combine first_name and last_name into a single name field
// const name = [first_name, last_name].filter(Boolean).join(" "); // const name = [first_name, last_name].filter(Boolean).join(" ");
// const updateData = // const updateData =
// first_name && last_name // first_name && last_name
// ? { // ? {
// name, // name,
// first_name, // first_name,
// last_name, // last_name,
// } // }
// : {}; // : {};
// const { data: profile, error } = await supabase // const { data: profile, error } = await supabase
// .from("profiles") // .from("profiles")
// .update(updateData) // .update(updateData)
// .eq("id", user.id) // .eq("id", user.id)
// .select() // .select()
// .single(); // .single();
// if (error) { // if (error) {
// return c.json({ error: error.message }, 500); // return c.json({ error: error.message }, 500);
// } // }
// return c.json({ // return c.json({
// message: "Profile updated successfully", // message: "Profile updated successfully",
// profile, // profile,
// }); // });
// }); // });
userRouter.post("/profile/avatar", async (c) => { userRouter.post("/profile/avatar", async (c) => {
const user = c.get("user"); const user = c.get("user");
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const s3Client = c.get("s3_client"); const s3Client = c.get("s3_client");
const body = await c.req.json(); const body = await c.req.json();
const { content, contentType = "image/jpeg" } = body; const { content, contentType = "image/jpeg" } = body;
if (!content) { if (!content) {
return c.json({ error: "Content is required" }, 400); return c.json({ error: "Content is required" }, 400);
} }
const randomString = Math.random().toString(36).substring(2, 15); const randomString = Math.random().toString(36).substring(2, 15);
const base64Content = Buffer.from(content, "base64"); const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`; const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
try { try {
await s3Client.send( await s3Client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: "web-assets", Bucket: "web-assets",
Key: key, Key: key,
Body: base64Content, Body: base64Content,
ContentType: contentType, ContentType: contentType,
ContentEncoding: "base64", ContentEncoding: "base64",
}) })
); );
} catch (error) { } catch (error) {
console.error("Failed to upload avatar:", error); console.error("Failed to upload avatar:", error);
return c.json({ error: "Failed to upload avatar" }, 500); return c.json({ error: "Failed to upload avatar" }, 500);
} }
const avatarUrl = `https://assets.xtablo.com/${key}`; const avatarUrl = `https://assets.xtablo.com/${key}`;
const { data, error } = await supabase const { data, error } = await supabase
.from("profiles") .from("profiles")
.update({ avatar_url: avatarUrl }) .update({ avatar_url: avatarUrl })
.eq("id", user.id) .eq("id", user.id)
.select() .select()
.single(); .single();
if (error) { if (error) {
return c.json({ error: error.message }, 500); return c.json({ error: error.message }, 500);
} }
return c.json({ return c.json({
message: "Avatar updated successfully", message: "Avatar updated successfully",
profile: data, profile: data,
});
}); });
});
userRouter.delete("/profile/avatar", async (c) => { userRouter.delete("/profile/avatar", async (c) => {
const user = c.get("user"); const user = c.get("user");
const supabase = c.get("supabase"); const supabase = c.get("supabase");
const s3Client = c.get("s3_client"); const s3Client = c.get("s3_client");
try { try {
const listedObjects = await s3Client.send( const listedObjects = await s3Client.send(
new ListObjectsV2Command({ new ListObjectsV2Command({
Bucket: "web-assets", Bucket: "web-assets",
Prefix: `${user.id}/`, Prefix: `${user.id}/`,
}) })
); );
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404); if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
await s3Client.send( await s3Client.send(
new DeleteObjectsCommand({ new DeleteObjectsCommand({
Bucket: "web-assets", Bucket: "web-assets",
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) }, Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
}) })
); );
} catch (error) { } catch (error) {
console.error("Failed to delete avatar:", error); console.error("Failed to delete avatar:", error);
return c.json({ error: "Failed to delete avatar" }, 500); return c.json({ error: "Failed to delete avatar" }, 500);
} }
const { error } = await supabase const { error } = await supabase
.from("profiles") .from("profiles")
.update({ avatar_url: null }) .update({ avatar_url: null })
.eq("id", user.id) .eq("id", user.id)
.select() .select()
.single(); .single();
if (error) { if (error) {
return c.json({ error: error.message }, 500); return c.json({ error: error.message }, 500);
} }
return c.json({ return c.json({
message: "Avatar deleted successfully", message: "Avatar deleted successfully",
});
}); });
});
return userRouter;
};

View file

@ -0,0 +1,299 @@
# Google Secret Manager Setup
This guide explains how to set up and use Google Secret Manager for storing application secrets in production and staging environments.
## Overview
The application uses **Google Secret Manager** to securely store sensitive configuration values (API keys, database credentials, etc.) in production and staging environments, while using local `.env` files for development.
### Environment-based Configuration
- **Development/Test**: Uses `.env.development` or `.env.test` files (via dotenv)
- **Production/Staging**: Uses Google Secret Manager
## Prerequisites
1. **Google Cloud Project**: You need a GCP project with Secret Manager API enabled
2. **Service Account**: A service account with Secret Manager access
3. **Authentication**: Proper credentials configured for your deployment environment
## Setup Steps
### 1. Enable Secret Manager API
```bash
# Enable the Secret Manager API in your GCP project
gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_ID
```
### 2. Create Secrets in Google Cloud
For each required secret, create it in Google Secret Manager:
```bash
# Example: Create SUPABASE_URL secret
echo -n "https://your-project.supabase.co" | \
gcloud secrets create SUPABASE_URL \
--data-file=- \
--project=YOUR_PROJECT_ID
# Example: Create STRIPE_SECRET_KEY secret
echo -n "sk_live_xxxxx" | \
gcloud secrets create STRIPE_SECRET_KEY \
--data-file=- \
--project=YOUR_PROJECT_ID
```
### Required Secrets
Create the following secrets in Google Secret Manager:
| Secret Name | Description | Example |
|-------------|-------------|---------|
| `SUPABASE_URL` | Supabase project URL | `https://xxx.supabase.co` |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key | `eyJxxx...` |
| `SUPABASE_CONNECTION_STRING` | PostgreSQL connection string | `postgresql://...` |
| `SUPABASE_CA_CERT` | Supabase CA certificate (optional) | `-----BEGIN CERTIFICATE-----...` |
| `STREAM_CHAT_API_KEY` | Stream Chat API key | `xxx` |
| `STREAM_CHAT_API_SECRET` | Stream Chat API secret | `xxx` |
| `STRIPE_SECRET_KEY` | Stripe secret key | `sk_live_xxx` |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_xxx` |
| `EMAIL_USER` | Gmail/email account | `yourapp@gmail.com` |
| `EMAIL_CLIENT_ID` | OAuth client ID for email | `xxx.apps.googleusercontent.com` |
| `EMAIL_CLIENT_SECRET` | OAuth client secret | `xxx` |
| `EMAIL_REFRESH_TOKEN` | OAuth refresh token | `xxx` |
| `R2_ACCOUNT_ID` | Cloudflare R2 account ID | `xxx` |
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key | `xxx` |
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key | `xxx` |
| `TASKS_SECRET` | Secret for task authentication | `xxx` |
### 3. Grant Service Account Access
Your service account needs permission to access secrets:
```bash
# Grant Secret Manager Secret Accessor role
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
```
### 4. Set Environment Variables
The application needs these environment variables to work with Google Secret Manager:
```bash
# Required in production/staging
export GCP_PROJECT=your-project-id # or GOOGLE_CLOUD_PROJECT
export NODE_ENV=production # or staging
# Optional: Use non-default port
export PORT=8080
# Optional: Override CORS origins
export CORS_ORIGIN=https://app.xtablo.com
# Optional: Override XTablo URL
export XTABLO_URL=https://app.xtablo.com
```
## Authentication
### Local Development (Testing Secret Manager)
For local testing of Secret Manager integration:
```bash
# Authenticate with gcloud
gcloud auth application-default login
# Set project
export GCP_PROJECT=your-project-id
export NODE_ENV=staging
# Run your app
npm run dev
```
### Google Cloud Run / Cloud Functions
Authentication is automatic when running on Google Cloud services. Just ensure:
1. The service account has the `roles/secretmanager.secretAccessor` role
2. The `GCP_PROJECT` environment variable is set (or use the project's default)
```yaml
# Example Cloud Run configuration (cloudbuild.yaml)
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/api:$COMMIT_SHA', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/api:$COMMIT_SHA']
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'xtablo-api'
- '--image=gcr.io/$PROJECT_ID/api:$COMMIT_SHA'
- '--region=us-central1'
- '--platform=managed'
- '--set-env-vars=NODE_ENV=production,GCP_PROJECT=$PROJECT_ID'
- '--service-account=YOUR_SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com'
```
## Usage in Code
### Accessing Configuration
The configuration is loaded asynchronously at startup:
```typescript
// In index.ts (server startup)
import { getConfig } from './config.js';
async function startServer() {
const config = await getConfig();
// Use config.STRIPE_SECRET_KEY, config.SUPABASE_URL, etc.
}
```
### Backwards Compatibility
For synchronous access (after initialization):
```typescript
import { config } from './config.js';
// This works ONLY after getConfig() has been called
// (automatically done in index.ts startup)
const stripeKey = config.STRIPE_SECRET_KEY;
```
### Manual Secret Loading
If you need to load additional secrets not in the main config:
```typescript
import { loadSecret } from './secretManager.js';
// Load a single secret
const apiKey = await loadSecret('MY_CUSTOM_SECRET');
// Load with fallback to environment variable
import { loadSecretWithFallback } from './secretManager.js';
const token = await loadSecretWithFallback('MY_TOKEN', 'MY_TOKEN_ENV_VAR');
```
## Secret Versioning
Google Secret Manager supports versioning:
```bash
# Add a new version of a secret
echo -n "new-secret-value" | \
gcloud secrets versions add SUPABASE_URL \
--data-file=- \
--project=YOUR_PROJECT_ID
# The app automatically uses the "latest" version
# You can also specify a specific version in code if needed
```
## Security Best Practices
1. **Never commit secrets** to version control
2. **Use different secrets** for each environment (dev, staging, production)
3. **Rotate secrets regularly** using Secret Manager versioning
4. **Limit access** to Secret Manager using IAM roles
5. **Audit access** using Cloud Audit Logs
6. **Use service accounts** with minimal permissions
## Troubleshooting
### "Missing required environment variable" error
**Problem**: App fails to start with validation errors
**Solution**: Ensure all required secrets are created in Secret Manager
```bash
# List all secrets in your project
gcloud secrets list --project=YOUR_PROJECT_ID
# View a specific secret's value
gcloud secrets versions access latest --secret=SUPABASE_URL --project=YOUR_PROJECT_ID
```
### "Permission denied" errors
**Problem**: App can't access secrets
**Solution**: Check IAM permissions
```bash
# Check service account permissions
gcloud projects get-iam-policy YOUR_PROJECT_ID \
--flatten="bindings[].members" \
--filter="bindings.members:serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT_ID.iam.gserviceaccount.com"
```
### "GCP_PROJECT environment variable must be set"
**Problem**: App doesn't know which GCP project to use
**Solution**: Set the environment variable
```bash
export GCP_PROJECT=your-project-id
# or
export GOOGLE_CLOUD_PROJECT=your-project-id
```
### Development mode still uses Secret Manager
**Problem**: Want to use dotenv in development but it's using Secret Manager
**Solution**: Ensure `NODE_ENV` is set to `development`
```bash
export NODE_ENV=development
npm run dev
```
## Cost Considerations
Google Secret Manager pricing (as of 2024):
- **Secret versions**: $0.06 per active secret version per month
- **Access operations**: $0.03 per 10,000 accesses
For typical usage:
- ~15 secrets = ~$1/month
- Caching reduces access operations (app caches secrets after first load)
## Migration from Environment Variables
If migrating from environment variables to Secret Manager:
1. **Create all secrets** in Google Secret Manager
2. **Update deployment configs** to set `NODE_ENV=production` and `GCP_PROJECT`
3. **Remove sensitive env vars** from deployment configs
4. **Keep non-sensitive configs** as env vars (CORS_ORIGIN, PORT, etc.)
5. **Test thoroughly** in staging before production
## References
- [Google Secret Manager Documentation](https://cloud.google.com/secret-manager/docs)
- [IAM Roles for Secret Manager](https://cloud.google.com/secret-manager/docs/access-control)
- [@google-cloud/secret-manager NPM](https://www.npmjs.com/package/@google-cloud/secret-manager)
## Support
For issues with Secret Manager integration:
1. Check application logs for detailed error messages
2. Verify IAM permissions in Google Cloud Console
3. Ensure all required secrets exist in Secret Manager
4. Check that service account authentication is working